Skip to content

Commit 1f4f0c1

Browse files
committed
feat(autonomous): implement Phase 1 task intelligence layer
Implemented Symphony pattern for autonomous multi-step task execution: - TaskAnalyzer: Classifies intent, extracts files, scores complexity (1-10) - Movement decomposition: Complex tasks (>=7) decompose into phases - Symphony executor: Orchestrates movement execution with checkpoints - Unit tests: Verb extraction, file mentions, complexity estimation Features: - Simple tasks (complexity <7): Execute directly - Complex tasks (>=7): Decompose into 2-5 movements - Each movement: validated independently, resumable - Checkpoint system: Save progress to ~/.chuchu/symphonies/ Next: CLI integration (chu do command) Refs: docs/plans/autonomous-execution-unified.md
1 parent 39e4a80 commit 1f4f0c1

File tree

3 files changed

+602
-0
lines changed

3 files changed

+602
-0
lines changed

internal/autonomous/analyzer.go

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
package autonomous
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"regexp"
8+
"strings"
9+
10+
"chuchu/internal/agents"
11+
"chuchu/internal/llm"
12+
)
13+
14+
// TaskAnalysis represents the result of analyzing a task
15+
type TaskAnalysis struct {
16+
Intent string `json:"intent"`
17+
Verb string `json:"verb"`
18+
Complexity int `json:"complexity"`
19+
RequiredFiles []string `json:"required_files"`
20+
OutputFiles []string `json:"output_files"`
21+
Movements []Movement `json:"movements,omitempty"`
22+
}
23+
24+
// Movement represents a single phase in a complex task
25+
type Movement struct {
26+
ID string `json:"id"`
27+
Name string `json:"name"`
28+
Description string `json:"description"`
29+
Goal string `json:"goal"`
30+
Dependencies []string `json:"dependencies"`
31+
RequiredFiles []string `json:"required_files"`
32+
OutputFiles []string `json:"output_files"`
33+
SuccessCriteria []string `json:"success_criteria"`
34+
Status string `json:"status"` // "pending", "executing", "completed", "failed"
35+
}
36+
37+
// TaskAnalyzer analyzes tasks and decomposes them into movements if complex
38+
type TaskAnalyzer struct {
39+
classifier *agents.Classifier
40+
llm llm.Provider
41+
cwd string
42+
model string
43+
}
44+
45+
// NewTaskAnalyzer creates a new task analyzer
46+
func NewTaskAnalyzer(classifier *agents.Classifier, llmProvider llm.Provider, cwd string, model string) *TaskAnalyzer {
47+
return &TaskAnalyzer{
48+
classifier: classifier,
49+
llm: llmProvider,
50+
cwd: cwd,
51+
model: model,
52+
}
53+
}
54+
55+
// Analyze analyzes a task and determines if it needs decomposition
56+
func (a *TaskAnalyzer) Analyze(ctx context.Context, task string) (*TaskAnalysis, error) {
57+
analysis := &TaskAnalysis{}
58+
59+
// 1. Use existing classifier for intent
60+
intent, err := a.classifier.ClassifyIntent(ctx, task)
61+
if err != nil {
62+
return nil, fmt.Errorf("failed to classify intent: %w", err)
63+
}
64+
analysis.Intent = string(intent)
65+
66+
// 2. Extract verb and files
67+
analysis.Verb = extractVerb(task)
68+
analysis.RequiredFiles = extractFileMentions(task)
69+
70+
// 3. Estimate complexity (1-10)
71+
complexity, err := a.estimateComplexity(ctx, task)
72+
if err != nil {
73+
return nil, fmt.Errorf("failed to estimate complexity: %w", err)
74+
}
75+
analysis.Complexity = complexity
76+
77+
// 4. If complex (>= 7), decompose into movements
78+
if complexity >= 7 {
79+
movements, err := a.decomposeIntoMovements(ctx, task, analysis)
80+
if err != nil {
81+
return nil, fmt.Errorf("failed to decompose into movements: %w", err)
82+
}
83+
analysis.Movements = movements
84+
}
85+
86+
return analysis, nil
87+
}
88+
89+
// extractVerb extracts the primary verb from a task
90+
func extractVerb(task string) string {
91+
task = strings.ToLower(task)
92+
93+
verbs := []string{
94+
"create", "add", "remove", "delete", "update", "modify",
95+
"refactor", "reorganize", "unify", "split", "merge",
96+
"read", "list", "show", "explain", "analyze",
97+
}
98+
99+
for _, verb := range verbs {
100+
if strings.Contains(task, verb) {
101+
return verb
102+
}
103+
}
104+
105+
return "unknown"
106+
}
107+
108+
// extractFileMentions extracts explicit file paths from the task
109+
func extractFileMentions(task string) []string {
110+
// Match patterns like:
111+
// - docs/_posts/file.md
112+
// - src/main.go
113+
// - /absolute/path.txt
114+
// Order matters: longer extensions first to avoid .js matching before .json
115+
filePattern := regexp.MustCompile(`[a-zA-Z0-9_\-./]+\.(json|yaml|yml|md|go|js|ts|py|txt|html|css)`)
116+
matches := filePattern.FindAllString(task, -1)
117+
118+
// Deduplicate
119+
seen := make(map[string]bool)
120+
var files []string
121+
for _, match := range matches {
122+
if !seen[match] {
123+
seen[match] = true
124+
files = append(files, match)
125+
}
126+
}
127+
128+
return files
129+
}
130+
131+
// estimateComplexity uses LLM to score task complexity 1-10
132+
func (a *TaskAnalyzer) estimateComplexity(ctx context.Context, task string) (int, error) {
133+
prompt := fmt.Sprintf(`Rate the complexity of this task on a scale of 1-10.
134+
135+
Task: %s
136+
137+
Complexity scale:
138+
- 1-3: Simple (single file, clear action)
139+
Examples: "create hello.md with greeting", "read main.go"
140+
141+
- 4-6: Medium (2-5 files, straightforward)
142+
Examples: "add error handling to auth.go", "update docs/readme.md with new commands"
143+
144+
- 7-8: Complex (multiple files, requires planning)
145+
Examples: "reorganize docs files into categories", "refactor authentication system"
146+
147+
- 9-10: Very complex (many files, multiple phases)
148+
Examples: "migrate entire codebase from X to Y", "redesign application architecture"
149+
150+
Consider:
151+
- Number of files involved
152+
- Number of distinct steps required
153+
- Ambiguity in requirements
154+
- Potential for errors
155+
156+
Respond with ONLY a number 1-10, nothing else.`, task)
157+
158+
resp, err := a.llm.Chat(ctx, llm.ChatRequest{
159+
UserPrompt: prompt,
160+
Model: a.model,
161+
})
162+
if err != nil {
163+
return 0, err
164+
}
165+
166+
// Parse response
167+
scoreStr := strings.TrimSpace(resp.Text)
168+
var score int
169+
_, err = fmt.Sscanf(scoreStr, "%d", &score)
170+
if err != nil {
171+
// Fallback: try to find first number
172+
re := regexp.MustCompile(`\d+`)
173+
match := re.FindString(scoreStr)
174+
if match != "" {
175+
fmt.Sscanf(match, "%d", &score)
176+
}
177+
}
178+
179+
// Clamp to 1-10
180+
if score < 1 {
181+
score = 1
182+
}
183+
if score > 10 {
184+
score = 10
185+
}
186+
187+
return score, nil
188+
}
189+
190+
// decomposeIntoMovements breaks a complex task into movements
191+
func (a *TaskAnalyzer) decomposeIntoMovements(ctx context.Context, task string, analysis *TaskAnalysis) ([]Movement, error) {
192+
prompt := fmt.Sprintf(`Decompose this complex task into 2-5 independent movements (phases).
193+
194+
Task: %s
195+
Intent: %s
196+
Verb: %s
197+
198+
Rules:
199+
- Each movement should be independently executable
200+
- Define clear dependencies (Movement B depends on Movement A)
201+
- Each movement should have 1-3 success criteria
202+
- Movements should be sequential (not parallel)
203+
- Be specific about files to read/create
204+
205+
Example:
206+
Task: "reorganize all docs files"
207+
Response:
208+
[
209+
{
210+
"id": "movement-1",
211+
"name": "Analyze Structure",
212+
"description": "Create inventory of all documentation files",
213+
"goal": "Understand current docs organization",
214+
"dependencies": [],
215+
"required_files": ["docs/**/*.md"],
216+
"output_files": ["~/.chuchu/inventory.json"],
217+
"success_criteria": [
218+
"inventory.json exists",
219+
"all docs files are cataloged",
220+
"files are categorized by type"
221+
]
222+
},
223+
{
224+
"id": "movement-2",
225+
"name": "Split Features",
226+
"description": "Break features.md into individual feature pages",
227+
"goal": "Create separate page for each feature",
228+
"dependencies": ["movement-1"],
229+
"required_files": ["docs/features.md"],
230+
"output_files": ["docs/features/*.md"],
231+
"success_criteria": [
232+
"docs/features/ directory exists",
233+
"6+ feature files created",
234+
"each file has proper front matter"
235+
]
236+
}
237+
]
238+
239+
Return ONLY valid JSON array of movements, no explanation.`, task, analysis.Intent, analysis.Verb)
240+
241+
resp, err := a.llm.Chat(ctx, llm.ChatRequest{
242+
UserPrompt: prompt,
243+
Model: a.model,
244+
})
245+
if err != nil {
246+
return nil, err
247+
}
248+
249+
// Parse JSON
250+
var movements []Movement
251+
responseText := strings.TrimSpace(resp.Text)
252+
253+
// Remove markdown code blocks if present
254+
responseText = strings.TrimPrefix(responseText, "```json")
255+
responseText = strings.TrimPrefix(responseText, "```")
256+
responseText = strings.TrimSuffix(responseText, "```")
257+
responseText = strings.TrimSpace(responseText)
258+
259+
err = json.Unmarshal([]byte(responseText), &movements)
260+
if err != nil {
261+
return nil, fmt.Errorf("failed to parse movements JSON: %w\nResponse: %s", err, responseText)
262+
}
263+
264+
// Initialize status
265+
for i := range movements {
266+
movements[i].Status = "pending"
267+
}
268+
269+
return movements, nil
270+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package autonomous
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"chuchu/internal/agents"
8+
"chuchu/internal/llm"
9+
)
10+
11+
func TestExtractVerb(t *testing.T) {
12+
tests := []struct {
13+
task string
14+
expected string
15+
}{
16+
{"create summary.md with overview", "create"},
17+
{"remove TODO from main.go", "remove"},
18+
{"refactor authentication system", "refactor"},
19+
{"reorganize all docs files", "reorganize"},
20+
{"add error handling", "add"},
21+
{"something unknown", "unknown"},
22+
}
23+
24+
for _, tt := range tests {
25+
t.Run(tt.task, func(t *testing.T) {
26+
result := extractVerb(tt.task)
27+
if result != tt.expected {
28+
t.Errorf("extractVerb(%q) = %q, want %q", tt.task, result, tt.expected)
29+
}
30+
})
31+
}
32+
}
33+
34+
func TestExtractFileMentions(t *testing.T) {
35+
tests := []struct {
36+
task string
37+
expected []string
38+
}{
39+
{
40+
"read docs/_posts/2025-11-28-file.md and create summary",
41+
[]string{"docs/_posts/2025-11-28-file.md"},
42+
},
43+
{
44+
"modify main.go and test.go",
45+
[]string{"main.go", "test.go"},
46+
},
47+
{
48+
"create new file without mention",
49+
[]string{},
50+
},
51+
{
52+
"update config.yml and app.json",
53+
[]string{"config.yml", "app.json"},
54+
},
55+
}
56+
57+
for _, tt := range tests {
58+
t.Run(tt.task, func(t *testing.T) {
59+
result := extractFileMentions(tt.task)
60+
if len(result) != len(tt.expected) {
61+
t.Errorf("extractFileMentions(%q) returned %d files, want %d", tt.task, len(result), len(tt.expected))
62+
return
63+
}
64+
for i, file := range result {
65+
if file != tt.expected[i] {
66+
t.Errorf("extractFileMentions(%q)[%d] = %q, want %q", tt.task, i, file, tt.expected[i])
67+
}
68+
}
69+
})
70+
}
71+
}
72+
73+
// Mock provider for testing
74+
type mockProvider struct {
75+
response string
76+
}
77+
78+
func (m *mockProvider) Chat(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
79+
return &llm.ChatResponse{
80+
Text: m.response,
81+
}, nil
82+
}
83+
84+
func TestEstimateComplexity(t *testing.T) {
85+
tests := []struct {
86+
name string
87+
task string
88+
llmResp string
89+
expected int
90+
}{
91+
{
92+
name: "simple task",
93+
task: "create hello.md",
94+
llmResp: "2",
95+
expected: 2,
96+
},
97+
{
98+
name: "complex task",
99+
task: "reorganize all docs",
100+
llmResp: "8",
101+
expected: 8,
102+
},
103+
{
104+
name: "response with text",
105+
task: "some task",
106+
llmResp: "The complexity is 5 out of 10",
107+
expected: 5,
108+
},
109+
}
110+
111+
for _, tt := range tests {
112+
t.Run(tt.name, func(t *testing.T) {
113+
provider := &mockProvider{response: tt.llmResp}
114+
classifier := agents.NewClassifier(provider, "test-model")
115+
analyzer := NewTaskAnalyzer(classifier, provider, "/tmp", "test-model")
116+
117+
result, err := analyzer.estimateComplexity(context.Background(), tt.task)
118+
if err != nil {
119+
t.Fatalf("estimateComplexity() error = %v", err)
120+
}
121+
122+
if result != tt.expected {
123+
t.Errorf("estimateComplexity(%q) = %d, want %d", tt.task, result, tt.expected)
124+
}
125+
})
126+
}
127+
}

0 commit comments

Comments
 (0)