Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions cmd/gaap/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/gaap/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
57 changes: 57 additions & 0 deletions decomposer.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,20 @@ 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)
if goal == "" {
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)
Expand Down Expand Up @@ -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 {
Expand Down
20 changes: 14 additions & 6 deletions decomposer_llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
36 changes: 36 additions & 0 deletions decomposer_llm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
39 changes: 39 additions & 0 deletions decomposer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}
}
73 changes: 72 additions & 1 deletion internal/worker/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: <shell command>
DONE: <summary of what you accomplished>
FAIL: <reason>

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
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions internal/worker/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading