From 7f9c6e7af7b8345ba8f2dd19da4cdb6d83238497 Mon Sep 17 00:00:00 2001 From: AJ Flinton Date: Thu, 21 May 2026 13:38:09 -0400 Subject: [PATCH 1/4] feat: add file_analysis agent type to decomposer catalog and prompt The decomposer only knew about code-review agent types (static_analysis, quality_scan, synthesis). Goals referencing specific file paths got decomposed into project-discovery tasks because no catalog entry matched. Added 'file_analysis' agent type with tools (cat, head, tail, wc, grep, file, stat) and a description that says 'first step is to read the target file directly.' Updated the decompose prompt to detect file-path goals vs codebase goals. --- decomposer_llm.go | 20 ++++++++++++++------ decomposer_llm_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) 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) + } +} From 5935ff78f075e3f06e0c8734539c3841fdb78690 Mon Sep 17 00:00:00 2001 From: AJ Flinton Date: Thu, 21 May 2026 13:40:05 -0400 Subject: [PATCH 2/4] feat: detect file goals in worker prompt, skip project-discovery When context.source_path points to a file (not a directory), the worker prompt now directs the worker to read the target file directly in the first turn. File-goal prompts drop find/ls/language toolchains from available tools and cap at 10 turns instead of 20. Added looksLikeFilePath detector that checks file extensions (.md, .go, .py, etc.) to distinguish file goals from codebase goals. --- internal/worker/executor.go | 73 +++++++++++++++++++++++++++++++- internal/worker/executor_test.go | 27 ++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) 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") + } +} From 1d0436f7cbdb4466dab106f5f78908d47e184b19 Mon Sep 17 00:00:00 2001 From: AJ Flinton Date: Thu, 21 May 2026 13:45:43 -0400 Subject: [PATCH 3/4] feat: short-circuit file-goal decomposition before LLM call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the goal contains a file path (ending in a known extension like .md, .go, .py), the Decomposer pipeline now extracts the path and returns a single file_analysis task — bypassing the LLM strategy entirely. The prompt-based approach was fragile: the LLM still defaulted to code-review patterns. extractFilePathFromGoal scans goals for known file extensions and extracts the full path. fileGoalTask produces the single-task spec with source_path set to the discovered file. --- decomposer.go | 57 ++++++++++++++++++++++++++++++++++++++++++++++ decomposer_test.go | 39 +++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) 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_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"]) + } +} From 82c2d61b7eb05d76667a28afcc635b268750ae84 Mon Sep 17 00:00:00 2001 From: AJ Flinton Date: Thu, 21 May 2026 14:06:02 -0400 Subject: [PATCH 4/4] fix: add file_analysis to default agent types in CLI and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, workers polled only for static_analysis and quality_scan tasks. A file_analysis task from the decomposer short-circuit had no matching worker — the orchestrator sat waiting until timeout. Updated defaults in both parseRunFlags and parseResumeFlags to include file_analysis. Updated help text, flag descriptions, and test assertions to match. --- cmd/gaap/main.go | 12 ++++++------ cmd/gaap/main_test.go | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) 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) } }