diff --git a/cmd/gaap/main.go b/cmd/gaap/main.go index 00811c7..7e05774 100644 --- a/cmd/gaap/main.go +++ b/cmd/gaap/main.go @@ -12,7 +12,7 @@ // --repo string Repository path to analyze (default: current directory) // --timeout int Max wait for workers in seconds (default: 300) // --subscribe Enable push-based task updates via gRPC (falls back to polling) -// --agent-types string Comma-separated agent types (default: static_analysis,quality_scan) +// --agent-types string Comma-separated agent types (default: static_analysis,quality_scan,file_analysis) // --api-key string Vassago daemon API key (Bearer token) // --tls-cert string Path to TLS CA certificate for daemon connection // --model string LLM model name (default: glm-5.1:cloud) @@ -89,7 +89,7 @@ Run flags: --repo string Repository path to analyze (default: current directory) --timeout int Max wait for workers in seconds (default: 300) --subscribe Enable push-based task updates via gRPC (falls back to polling) - --agent-types string Comma-separated agent types (default: static_analysis,quality_scan) + --agent-types string Comma-separated agent types (default: static_analysis,quality_scan,file_analysis) --api-key string Vassago daemon API key (Bearer token) --tls-cert string Path to TLS CA certificate for daemon connection --model string LLM model name (default: glm-5.1:cloud) @@ -133,7 +133,7 @@ func parseRunFlags(name string, args []string) (*runConfig, error) { maxTokens := fs.Int("max-tokens", 0, "Max tokens for LLM responses (default: 4096)") temperature := fs.Float64("temperature", 0, "LLM temperature, 0.0-1.0 (default: 0.1)") subscribe := fs.Bool("subscribe", false, "Use gRPC subscription for push-based task updates (falls back to polling)") - agentTypes := fs.String("agent-types", "", "Comma-separated agent types to dispatch (default: static_analysis,quality_scan)") + agentTypes := fs.String("agent-types", "", "Comma-separated agent types to dispatch (default: static_analysis,quality_scan,file_analysis)") apiKey := fs.String("api-key", "", "Vassago daemon API key (Bearer token)") tlsCert := fs.String("tls-cert", "", "Path to TLS CA certificate for daemon connection") @@ -166,7 +166,7 @@ func parseRunFlags(name string, args []string) (*runConfig, error) { } else if env := os.Getenv("GAAP_AGENT_TYPES"); env != "" { cfg.AgentTypes = strings.Split(env, ",") } else { - cfg.AgentTypes = []string{"static_analysis", "quality_scan"} + cfg.AgentTypes = []string{"static_analysis", "quality_scan", "file_analysis"} } return cfg, nil } @@ -345,7 +345,7 @@ func parseResumeFlags(name string, args []string) (*resumeConfig, error) { ollamaURL := fs.String("ollama-url", "", "Ollama base URL (default: http://localhost:11434/v1)") maxTokens := fs.Int("max-tokens", 0, "Max tokens for LLM responses (default: 4096)") temperature := fs.Float64("temperature", 0, "LLM temperature, 0.0-1.0 (default: 0.1)") - agentTypes := fs.String("agent-types", "", "Comma-separated agent types to dispatch (default: static_analysis,quality_scan)") + agentTypes := fs.String("agent-types", "", "Comma-separated agent types to dispatch (default: static_analysis,quality_scan,file_analysis)") timeout := fs.Int("timeout", 0, "Max wait for workers in seconds (default: 300)") if err := fs.Parse(args); err != nil { @@ -373,7 +373,7 @@ func parseResumeFlags(name string, args []string) (*resumeConfig, error) { } else if env := os.Getenv("GAAP_AGENT_TYPES"); env != "" { cfg.AgentTypes = strings.Split(env, ",") } else { - cfg.AgentTypes = []string{"static_analysis", "quality_scan"} + cfg.AgentTypes = []string{"static_analysis", "quality_scan", "file_analysis"} } return cfg, nil } diff --git a/cmd/gaap/main_test.go b/cmd/gaap/main_test.go index 166c2fc..f2beca1 100644 --- a/cmd/gaap/main_test.go +++ b/cmd/gaap/main_test.go @@ -343,8 +343,8 @@ func TestParseResumeFlagsDefaults(t *testing.T) { if cfg.Temperature != 0.1 { t.Errorf("expected default temperature 0.1, got %v", cfg.Temperature) } - if len(cfg.AgentTypes) != 2 || cfg.AgentTypes[0] != "static_analysis" { - t.Errorf("expected default agent types, got %v", cfg.AgentTypes) + if len(cfg.AgentTypes) != 3 || cfg.AgentTypes[0] != "static_analysis" || cfg.AgentTypes[2] != "file_analysis" { + t.Errorf("expected default agent types with file_analysis, got %v", cfg.AgentTypes) } } diff --git a/decomposer.go b/decomposer.go index 9c94f0d..9db1cc0 100644 --- a/decomposer.go +++ b/decomposer.go @@ -39,6 +39,8 @@ func NewDecomposer(strategy DecomposerStrategy) *Decomposer { } // Decompose runs the full decomposition pipeline. +// If the goal references a specific file path, the pipeline short-circuits +// to a single file_analysis task — LLM-based decomposition is skipped. func (d *Decomposer) Decompose(ctx context.Context, goal, repoPath string) ([]TaskSpec, error) { // Step 1: Validate input goal = strings.TrimSpace(goal) @@ -46,6 +48,11 @@ func (d *Decomposer) Decompose(ctx context.Context, goal, repoPath string) ([]Ta return nil, fmt.Errorf("goal must not be empty") } + // Step 1.5: Short-circuit file goals — no LLM decomposition needed. + if filePath := extractFilePathFromGoal(goal); filePath != "" { + return d.fileGoalTask(goal, filePath), nil + } + // Step 2: Build prompt (embedded in strategy call) // Step 3: Call strategy (LLM or static) tasks, err := d.strategy.Decompose(ctx, goal, repoPath) @@ -97,6 +104,56 @@ func (d *Decomposer) Decompose(ctx context.Context, goal, repoPath string) ([]Ta } // validateTaskDAG checks a task list for structural validity. + +// extractFilePathFromGoal scans a goal string for a file path. +// Returns the first path ending in a known file extension, or "" if none found. +func extractFilePathFromGoal(goal string) string { + for ext := range fileExtensions { + idx := strings.Index(goal, ext) + if idx < 0 { + continue + } + // Back up to find the start of the path (whitespace-bounded) + end := idx + len(ext) + start := idx + for start > 0 && goal[start-1] != ' ' && goal[start-1] != '\t' && goal[start-1] != '"' && goal[start-1] != '\'' { + start-- + } + return goal[start:end] + } + return "" +} + +// fileGoalTask returns a single file_analysis task for file-specific goals. +// The strategy is bypassed entirely — a file goal needs no decomposition. +func (d *Decomposer) fileGoalTask(goal, filePath string) []TaskSpec { + prefix := generatePrefix() + return []TaskSpec{ + { + TaskID: prefix + "_file_analysis", + ParentIDs: nil, + Status: "ready", + Goal: goal, + AgentType: "file_analysis", + Context: map[string]any{ + "source_path": filePath, + }, + }, + } +} + +// fileExtensions is the set of extensions that indicate a file path rather +// than a directory in goals and context.source_path. +var fileExtensions = map[string]bool{ + ".md": true, ".txt": true, ".go": true, ".py": true, ".js": true, + ".ts": true, ".yaml": true, ".yml": true, ".json": true, ".toml": true, + ".cfg": true, ".ini": true, ".env": true, ".proto": true, ".rs": true, + ".c": true, ".h": true, ".cpp": true, ".java": true, ".rb": true, + ".csv": true, ".log": true, ".xml": true, ".html": true, ".css": true, + ".sql": true, ".sh": true, ".ps1": true, ".tf": true, ".hcl": true, + ".mod": true, ".sum": true, +} + func validateTaskDAG(tasks []TaskSpec) error { taskIDs := make(map[string]bool) for _, t := range tasks { diff --git a/decomposer_llm.go b/decomposer_llm.go index 8cfb0c0..4fd1e9f 100644 --- a/decomposer_llm.go +++ b/decomposer_llm.go @@ -55,6 +55,12 @@ var DefaultAgentCatalog = map[string]AgentSpec{ Languages: []string{}, Produces: "Cross-referenced synthesis report with prioritized findings and recommendations", }, + "file_analysis": { + Description: "Reads and analyzes a specific file or set of files. First step is to read the target file directly. Do NOT discover the project first.", + Tools: []string{"cat", "head", "tail", "wc", "grep", "file", "stat"}, + Languages: []string{}, + Produces: "Summary, analysis, or extraction from the specified file(s)", + }, } var decomposePrompt = `You are an orchestrator that decomposes high-level goals into concrete, executable tasks for specialized AI agents. @@ -71,12 +77,14 @@ AVAILABLE AGENT TYPES: %s RULES: -1. Leaf tasks (static_analysis, quality_scan) run in parallel. Set parent_ids=[] and status="ready". -2. The final task must be agent_type="synthesis" with parent_ids listing all leaf task_ids, status="blocked". -3. The synthesis task should NOT re-run analysis — it reads results from the blackboard. -4. context.repo_path must be the absolute path to the repository. -5. context.dependents on synthesis tasks must list the task_ids it depends on (for blackboard lookup). -6. Every task must produce results under a predictable key. Use context.output_key. +1. Detect the goal type FIRST. If the goal references a specific file path (ends in .md, .txt, .go, .py, .yaml, etc.), use file_analysis agent type. If the goal references a directory or project, use static_analysis + quality_scan + synthesis. +2. For file goals: one task, agent_type="file_analysis", context.source_path set to the exact file path. +3. For codebase goals: leaf tasks (static_analysis, quality_scan) run in parallel. Set parent_ids=[] and status="ready". +4. The final synthesis task must have agent_type="synthesis" with parent_ids listing all leaf task_ids, status="blocked". +5. The synthesis task should NOT re-run analysis — it reads results from the blackboard. +6. context.source_path must be the absolute path to the repository or target file. +7. context.dependents on synthesis tasks must list the task_ids it depends on (for blackboard lookup). +8. Every task must produce results under a predictable key. Use context.output_key. GOAL: %s diff --git a/decomposer_llm_test.go b/decomposer_llm_test.go index 91ce6f3..72ef232 100644 --- a/decomposer_llm_test.go +++ b/decomposer_llm_test.go @@ -168,3 +168,39 @@ func TestLLMDecompositionCleansFencedJSON(t *testing.T) { t.Errorf("task ID = %q, want task_static", tasks[0].TaskID) } } + +func TestLLMDecompositionAcceptsFileAnalysisAgentType(t *testing.T) { + t.Parallel() + + chatFn := func(ctx context.Context, prompt string) (string, error) { + tasks := []TaskSpec{ + { + TaskID: "task_file", + ParentIDs: []string{}, + Status: "ready", + Goal: "Read and summarize /tmp/release_review.md", + AgentType: "file_analysis", + Context: map[string]any{ + "source_path": "/tmp/release_review.md", + }, + }, + } + b, _ := json.Marshal(tasks) + return string(b), nil + } + + strategy := NewLLMDecomposition(chatFn, nil) + tasks, err := strategy.Decompose(context.Background(), "summarize /tmp/release_review.md", "/tmp") + if err != nil { + t.Fatalf("Decompose (file_analysis): %v", err) + } + if len(tasks) != 1 { + t.Fatalf("expected 1 task, got %d", len(tasks)) + } + if tasks[0].AgentType != "file_analysis" { + t.Errorf("AgentType = %q, want file_analysis", tasks[0].AgentType) + } + if tasks[0].Goal != "Read and summarize /tmp/release_review.md" { + t.Errorf("Goal = %q, want 'Read and summarize /tmp/release_review.md'", tasks[0].Goal) + } +} diff --git a/decomposer_test.go b/decomposer_test.go index ddfcb4d..fb45eca 100644 --- a/decomposer_test.go +++ b/decomposer_test.go @@ -145,3 +145,42 @@ func TestDecomposerContextPropagation(t *testing.T) { t.Errorf("source_path = %v, want /my/repo", task.Context["source_path"]) } } + +func TestDecomposerShortCircuitsFileGoal(t *testing.T) { + t.Parallel() + + // A strategy that returns code-review tasks — the decomposer + // should ignore it and short-circuit for file-goal goals. + strategy := &StaticDecomposition{ + Tasks: []TaskSpec{ + {TaskID: "audit-1", AgentType: "static_analysis", Status: "ready", + Goal: "run lint"}, + {TaskID: "audit-2", AgentType: "quality_scan", Status: "ready", + Goal: "scan quality"}, + {TaskID: "synth", AgentType: "synthesis", Status: "blocked", + Goal: "synthesize", ParentIDs: []string{"audit-1", "audit-2"}}, + }, + } + d := NewDecomposer(strategy) + + result, err := d.Decompose(context.Background(), "summarize /tmp/release_review.md", "/tmp") + if err != nil { + t.Fatalf("Decompose: %v", err) + } + + // Should return exactly 1 file_analysis task, not the 3 code-review tasks + if len(result) != 1 { + t.Fatalf("expected 1 file_analysis task for file goal, got %d", len(result)) + } + if result[0].AgentType != "file_analysis" { + t.Errorf("AgentType = %q, want file_analysis", result[0].AgentType) + } + if result[0].Status != "ready" { + t.Errorf("Status = %q, want ready", result[0].Status) + } + // source_path should be the file, not the repo path fallback + sp, ok := result[0].Context["source_path"].(string) + if !ok || sp != "/tmp/release_review.md" { + t.Errorf("source_path = %v, want /tmp/release_review.md", result[0].Context["source_path"]) + } +} diff --git a/internal/worker/executor.go b/internal/worker/executor.go index e430cd6..100090a 100644 --- a/internal/worker/executor.go +++ b/internal/worker/executor.go @@ -316,7 +316,54 @@ func runCommand(shellCmd, repoPath string) cmdOutput { } // buildExecutionPrompt constructs the system+user prompt for task execution. +// If context.source_path points to a file (not a directory), the prompt +// directs the worker to read the target file directly in the first turn. func buildExecutionPrompt(goal, contextStr string) string { + // Detect file-specific goals from context + var isFileGoal bool + if contextStr != "" { + var ctx map[string]any + if json.Unmarshal([]byte(contextStr), &ctx) == nil { + if sp, ok := ctx["source_path"].(string); ok && sp != "" { + isFileGoal = looksLikeFilePath(sp) + } + } + } + + if isFileGoal { + return fmt.Sprintf(`You are an autonomous worker agent executing a task on a specific file. + +Your job: read and process the target file. The file path is in the CONTEXT below. +Your first command MUST read or inspect the file directly — do NOT search the +filesystem, discover the project, or run find. Start with cat, head, or file. + +PROTOCOL — respond with exactly one of these on each turn: +CMD: +DONE: +FAIL: + +RULES: +- One command per CMD: line. Keep commands focused. +- First command: read the target file directly (cat, head, file, or wc). +- Use head/tail to limit large output. +- Never run destructive commands. Only analysis and inspection tools. +- Commands timeout after 60 seconds. +- You have a budget of 10 turns. After turn 7, start wrapping up. +- When you have enough data to answer, DONE: immediately. +- Analyze output before issuing the next command. +- If a command is blocked, try a different approach — don't retry the same one. + +AVAILABLE TOOLS: cat, head, tail, wc, grep, file, stat, echo, which + +CONTEXT: +%s + +GOAL: +%s + +Start now: read the target file.`, contextStr, goal) + } + return fmt.Sprintf(`You are an autonomous worker agent executing a task. Your job: accomplish the goal using shell commands. You have terminal access @@ -347,7 +394,31 @@ CONTEXT: GOAL: %s -Start now: output your first CMD: line.`, contextStr, goal) +Start now: output your first CMD: line.`, contextStr, goal) +} + +// fileExtensions is the set of extensions that indicate a file path rather +// than a directory in goals and context.source_path. +var fileExtensions = map[string]bool{ + ".md": true, ".txt": true, ".go": true, ".py": true, ".js": true, + ".ts": true, ".yaml": true, ".yml": true, ".json": true, ".toml": true, + ".cfg": true, ".ini": true, ".env": true, ".proto": true, ".rs": true, + ".c": true, ".h": true, ".cpp": true, ".java": true, ".rb": true, + ".csv": true, ".log": true, ".xml": true, ".html": true, ".css": true, + ".sql": true, ".sh": true, ".ps1": true, ".tf": true, ".hcl": true, + ".mod": true, ".sum": true, +} + +// looksLikeFilePath returns true if the path appears to reference a specific +// file rather than a directory or project root. +func looksLikeFilePath(path string) bool { + path = strings.TrimSpace(path) + for ext := range fileExtensions { + if strings.HasSuffix(path, ext) { + return true + } + } + return false } func truncate(s string, maxLen int) string { diff --git a/internal/worker/executor_test.go b/internal/worker/executor_test.go index 646dea1..754ac41 100644 --- a/internal/worker/executor_test.go +++ b/internal/worker/executor_test.go @@ -408,3 +408,30 @@ func TestCommandAllowedExactPatterns(t *testing.T) { t.Errorf("'grep -rn' should not be blocked by 'rm ' pattern: %v", err) } } + +// TestBuildExecutionPromptFileGoal verifies that when context contains +// a source_path pointing to a file, the prompt tells the worker to +// read the target file directly rather than discovering the project. +func TestBuildExecutionPromptFileGoal(t *testing.T) { + t.Parallel() + + goal := "Read and summarize /tmp/release_review.md" + contextStr := `{"source_path": "/tmp/release_review.md"}` + + prompt := buildExecutionPrompt(goal, contextStr) + + // Should tell the worker to read the file directly + if !strings.Contains(prompt, "/tmp/release_review.md") { + t.Error("prompt should mention the target file path") + } + + // Should NOT use project-discovery language when target is a file + if strings.Contains(prompt, "repository directory") { + t.Error("prompt should not mention 'repository directory' for file goals") + } + if strings.Contains(prompt, "Your first command MUST read or inspect the file directly") { + // Good — this is the correct file-goal prompt + } else { + t.Error("prompt should instruct worker to read the file directly") + } +}