Skip to content

Commit c6a0ee2

Browse files
yessGlory17okurucan
authored andcommitted
Fix planner not producing results from Claude CLI
The --json-schema flag does not enforce JSON output when used with --output-format stream-json, causing the model to return plain text instead of valid JSON. The Result field in StreamEvent was also typed as string, which fails to unmarshal when the CLI returns a JSON object. Added explicit JSON output instructions to the planner prompt and changed the Result field to json.RawMessage with a StructuredOutput fallback. Implemented a multi-strategy JSON extraction pipeline that tries direct parse, brace-depth extraction from result text, and assistant text as a last resort. The frontend now displays planner errors instead of silently swallowing them in console.error.
1 parent dae89e9 commit c6a0ee2

File tree

7 files changed

+78
-19
lines changed

7 files changed

+78
-19
lines changed

backend/claude/process.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,8 @@ func StartProcess(ctx context.Context, opts ProcessOptions) (*Process, error) {
107107

108108
// Parse stdout stream events in background
109109
go func() {
110-
defer close(p.events)
111110
defer close(p.done)
111+
defer close(p.events)
112112

113113
log.Printf("[claude] starting stream parser")
114114

backend/claude/stream.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ func ExtractTextContent(event StreamEvent) string {
4444
return extractFromContent(event.Message.Content)
4545
}
4646
case "result":
47-
return event.Result
47+
return event.ResultText()
4848
case "raw":
4949
return string(event.Raw)
5050
}

backend/claude/types.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,34 @@ type StreamEvent struct {
1616
Message *Message `json:"message,omitempty"`
1717

1818
// For result events
19-
DurationMS float64 `json:"duration_ms,omitempty"`
20-
NumTurns int `json:"num_turns,omitempty"`
21-
Result string `json:"result,omitempty"`
19+
DurationMS float64 `json:"duration_ms,omitempty"`
20+
NumTurns int `json:"num_turns,omitempty"`
21+
Result json.RawMessage `json:"result,omitempty"`
22+
StructuredOutput json.RawMessage `json:"structured_output,omitempty"` // --json-schema validated output
2223

2324
// Raw JSON for anything we don't parse
2425
Raw json.RawMessage `json:"-"`
2526
}
2627

28+
// ResultText returns the result as a usable string.
29+
// Priority: structured_output (from --json-schema) > result field.
30+
// If the value is a JSON string, it unwraps the quotes.
31+
// If it's a JSON object/array, it returns the raw JSON.
32+
func (e StreamEvent) ResultText() string {
33+
// Prefer structured_output when available (--json-schema validated output)
34+
if len(e.StructuredOutput) > 0 {
35+
return string(e.StructuredOutput)
36+
}
37+
if len(e.Result) == 0 {
38+
return ""
39+
}
40+
var s string
41+
if err := json.Unmarshal(e.Result, &s); err == nil {
42+
return s
43+
}
44+
return string(e.Result)
45+
}
46+
2747
// Message represents a Claude message within a stream event.
2848
type Message struct {
2949
Role string `json:"role,omitempty"`

backend/services/agent_runner.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,8 @@ func (ar *AgentRunner) RunTask(ctx context.Context, task *models.Task, agent *mo
274274
// Capture result text via lastText — avoid writing directly to task struct
275275
// to prevent data races. Callers apply ResultText from RunResult.LastText.
276276
if event.Type == "result" {
277-
if event.Result != "" {
278-
lastText = event.Result
277+
if text := event.ResultText(); text != "" {
278+
lastText = text
279279
}
280280
}
281281
}
@@ -438,7 +438,7 @@ func (ar *AgentRunner) emitTaskEvent(taskID string, event claude.StreamEvent) {
438438
}
439439
case "result":
440440
taskEvent.Type = "result"
441-
taskEvent.Content = event.Result
441+
taskEvent.Content = event.ResultText()
442442
taskEvent.Data = map[string]any{
443443
"duration_ms": event.DurationMS,
444444
"num_turns": event.NumTurns,

backend/services/planner.go

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"context"
77
"encoding/json"
88
"fmt"
9+
"log"
910
"strings"
1011
)
1112

@@ -105,7 +106,9 @@ Rules:
105106
- If a task depends on another, specify the dependency by title
106107
- Each task prompt should be detailed enough for an AI agent to execute without additional context
107108
- Keep task count between 2-8 tasks
108-
%s`, goal, agentInfo, agentRule)
109+
%s
110+
IMPORTANT: You MUST respond with ONLY a valid JSON object, no other text. The JSON must follow this exact structure:
111+
{"summary": "Brief description of the overall plan", "tasks": [{"title": "Short task title", "prompt": "Detailed prompt for the agent", "dependencies": ["titles of dependent tasks"], "agent_id": "ID of assigned agent"}]}`, goal, agentInfo, agentRule)
109112

110113
proc, err := claude.StartProcess(ctx, claude.ProcessOptions{
111114
WorkDir: projectPath,
@@ -122,10 +125,14 @@ Rules:
122125
// Collect all output. With --json-schema, the result event contains validated JSON.
123126
var resultJSON string
124127
var assistantText strings.Builder
128+
eventCount := 0
125129
for event := range proc.Events() {
130+
eventCount++
131+
log.Printf("[planner] event #%d: type=%s subtype=%s resultLen=%d structuredOutputLen=%d", eventCount, event.Type, event.Subtype, len(event.Result), len(event.StructuredOutput))
126132
switch event.Type {
127133
case "result":
128-
resultJSON = event.Result
134+
resultJSON = event.ResultText()
135+
log.Printf("[planner] captured result (len=%d): %.200s", len(resultJSON), resultJSON)
129136
case "assistant":
130137
text := claude.ExtractTextContent(event)
131138
if text != "" {
@@ -136,16 +143,40 @@ Rules:
136143

137144
<-proc.Done()
138145

139-
// Primary path: parse the result event (validated by --json-schema)
146+
log.Printf("[planner] stream ended: %d events, resultJSON len=%d, assistantText len=%d", eventCount, len(resultJSON), assistantText.Len())
147+
148+
// Try multiple strategies to extract the JSON result.
140149
raw := resultJSON
141-
if raw == "" {
142-
// Fallback: try assistant text with brace-depth extraction
143-
raw = extractJSON(assistantText.String())
144-
}
145150

146151
var result PlanResult
147-
if err := json.Unmarshal([]byte(raw), &result); err != nil {
148-
return nil, fmt.Errorf("parse planner response: %w (raw: %s)", err, truncate(raw, 500))
152+
parsed := false
153+
154+
// Strategy 1: Direct parse of result event
155+
if raw != "" {
156+
if err := json.Unmarshal([]byte(raw), &result); err == nil {
157+
parsed = true
158+
log.Printf("[planner] parsed result directly (len=%d)", len(raw))
159+
} else {
160+
log.Printf("[planner] direct parse failed: %v, trying extractJSON on result", err)
161+
// Strategy 2: Extract JSON from result text (model may have mixed text + JSON)
162+
extracted := extractJSON(raw)
163+
if extracted != raw {
164+
if err := json.Unmarshal([]byte(extracted), &result); err == nil {
165+
parsed = true
166+
log.Printf("[planner] parsed result via extractJSON (len=%d)", len(extracted))
167+
}
168+
}
169+
}
170+
}
171+
172+
// Strategy 3: Extract JSON from assistant text
173+
if !parsed {
174+
raw = extractJSON(assistantText.String())
175+
log.Printf("[planner] trying assistantText extractJSON (len=%d): %.200s", len(raw), raw)
176+
if err := json.Unmarshal([]byte(raw), &result); err != nil {
177+
log.Printf("[planner] all parse strategies failed: %v", err)
178+
return nil, fmt.Errorf("parse planner response: %w (raw: %s)", err, truncate(raw, 500))
179+
}
149180
}
150181

151182
if len(result.Tasks) == 0 {

backend/services/prompt_improver.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ Return the improved prompt and explanation.`, metaContext, draft)
9797
for event := range proc.Events() {
9898
switch event.Type {
9999
case "result":
100-
resultJSON = event.Result
100+
resultJSON = event.ResultText()
101101
case "assistant":
102102
text := claude.ExtractTextContent(event)
103103
if text != "" {

frontend/src/pages/SessionPlanner.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function SessionPlanner() {
3636
const [planning, setPlanning] = useState(false)
3737
const [proposedTasks, setProposedTasks] = useState<ProposedTask[]>([])
3838
const [planSummary, setPlanSummary] = useState('')
39+
const [planError, setPlanError] = useState('')
3940

4041
const [expandedTask, setExpandedTask] = useState<string | null>(null)
4142

@@ -133,12 +134,14 @@ export function SessionPlanner() {
133134
setPlanning(true)
134135
setProposedTasks([])
135136
setPlanSummary('')
137+
setPlanError('')
136138
try {
137139
const result = await window.go.main.App.PlanTasks(currentSession.project_id, goal.trim())
138140
setProposedTasks(result.tasks || [])
139141
setPlanSummary(result.summary || '')
140-
} catch (e) {
142+
} catch (e: any) {
141143
console.error('Planning failed:', e)
144+
setPlanError(e?.message || String(e) || 'Planning failed')
142145
} finally {
143146
setPlanning(false)
144147
}
@@ -290,6 +293,11 @@ export function SessionPlanner() {
290293
rows={3}
291294
className="w-full px-3 py-2 bg-white/[0.04] border border-white/[0.08] rounded-lg text-sm text-zinc-100 placeholder:text-zinc-600 input-focus resize-none transition-colors"
292295
/>
296+
{planError && (
297+
<div className="mt-3 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg">
298+
<p className="text-xs text-red-400">{planError}</p>
299+
</div>
300+
)}
293301
<div className="flex gap-2 mt-3">
294302
<button
295303
onClick={handlePlan}

0 commit comments

Comments
 (0)