Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
kardolus
GitHub Repository: kardolus/chatgpt-cli
Path: blob/main/agent/planexec/plan.go
3434 views
1
package planexec
2
3
import (
4
"context"
5
"encoding/json"
6
"errors"
7
"fmt"
8
"github.com/kardolus/chatgpt-cli/agent/core"
9
"github.com/kardolus/chatgpt-cli/agent/tools"
10
"github.com/kardolus/chatgpt-cli/agent/types"
11
"go.uber.org/zap"
12
"os"
13
"path/filepath"
14
"regexp"
15
"strconv"
16
"strings"
17
"text/template"
18
)
19
20
// matches: (index .Results 0) or (index .Results 12)
21
var reResultsIndex = regexp.MustCompile(`\( *index +\.Results +([0-9]+) *\)`)
22
23
type Planner interface {
24
Plan(ctx context.Context, goal string) (types.Plan, error)
25
}
26
27
type LoggingPlanner struct {
28
inner Planner
29
log *zap.SugaredLogger
30
dir string
31
normalizedPath string
32
}
33
34
func NewLoggingPlanner(inner Planner, logs *core.Logs) *LoggingPlanner {
35
// default: no-op logger, no files
36
lp := &LoggingPlanner{
37
inner: inner,
38
log: zap.NewNop().Sugar(),
39
}
40
41
if logs == nil {
42
return lp
43
}
44
if logs.DebugLogger != nil {
45
lp.log = logs.DebugLogger
46
}
47
if logs.Dir != "" {
48
lp.dir = logs.Dir
49
lp.normalizedPath = filepath.Join(logs.Dir, "plan.normalized.json")
50
}
51
return lp
52
}
53
54
func (p *LoggingPlanner) Plan(ctx context.Context, goal string) (types.Plan, error) {
55
g := strings.TrimSpace(goal)
56
p.log.Debugf("Planner: start goal_len=%d", len(g))
57
58
plan, err := p.inner.Plan(ctx, goal)
59
if err != nil {
60
p.log.Debugf("Planner: error=%v", err)
61
return types.Plan{}, err
62
}
63
64
// Write normalized plan
65
p.writeNormalized(plan)
66
67
p.log.Debugf("Planner: ok steps=%d", len(plan.Steps))
68
return plan, nil
69
}
70
71
func (p *LoggingPlanner) writeNormalized(plan types.Plan) {
72
if p.normalizedPath == "" {
73
return
74
}
75
b, err := json.MarshalIndent(plan, "", " ")
76
if err != nil {
77
p.log.Debugf("Planner: failed to marshal normalized plan: %v", err)
78
return
79
}
80
_ = os.WriteFile(p.normalizedPath, b, 0o644) // best-effort
81
}
82
83
type DefaultPlanner struct {
84
llm tools.LLM
85
budget core.Budget
86
clock core.Clock
87
88
onRaw func(raw string) // optional
89
}
90
91
func NewDefaultPlanner(llm tools.LLM, budget core.Budget, clock core.Clock, opts ...PlannerOption) *DefaultPlanner {
92
p := &DefaultPlanner{llm: llm, budget: budget, clock: clock}
93
for _, o := range opts {
94
o(p)
95
}
96
return p
97
}
98
99
type PlannerOption func(*DefaultPlanner)
100
101
func WithPlannerRawSink(fn func(string)) PlannerOption {
102
return func(p *DefaultPlanner) {
103
p.onRaw = fn
104
}
105
}
106
107
func (p *DefaultPlanner) Plan(ctx context.Context, goal string) (types.Plan, error) {
108
goal = strings.TrimSpace(goal)
109
if goal == "" {
110
return types.Plan{}, errors.New("missing goal")
111
}
112
113
now := p.clock.Now()
114
115
// Count this as an LLM tool call in budget.
116
if err := p.budget.AllowTool(types.ToolLLM, now); err != nil {
117
return types.Plan{}, err
118
}
119
120
prompt := buildPlanningPrompt(goal)
121
122
raw, tokens, err := p.llm.Complete(ctx, prompt)
123
if err != nil {
124
return types.Plan{}, err
125
}
126
127
if p.onRaw != nil {
128
p.onRaw(raw)
129
}
130
131
p.budget.ChargeLLMTokens(tokens, now)
132
133
plan, err := parsePlanJSON(raw, goal)
134
if err != nil {
135
return types.Plan{}, err
136
}
137
138
if err := validatePlan(plan); err != nil {
139
return types.Plan{}, err
140
}
141
142
return plan, nil
143
}
144
145
func buildPlanningPrompt(goal string) string {
146
return fmt.Sprintf(`
147
You are a planning module for a CLI agent. Convert the user's goal into an explicit plan.
148
149
CRITICAL OUTPUT RULES:
150
- Return ONLY raw JSON.
151
- Do NOT use markdown.
152
- Do NOT use code fences.
153
- Do NOT add prose, comments, or explanations.
154
- The FIRST non-whitespace character MUST be '{'.
155
- The LAST non-whitespace character MUST be '}'.
156
- If you cannot produce valid JSON, return: {"goal": "...", "steps": []}
157
158
Return JSON matching this schema:
159
160
{
161
"goal": "string",
162
"steps": [
163
{
164
"type": "%s" | "%s" | "%s",
165
"description": "string",
166
167
// %s-only:
168
"command": "string",
169
"args": ["string", "..."],
170
171
// %s-only:
172
"prompt": "string",
173
174
// %s-only:
175
"op": "read" | "write",
176
"path": "string",
177
"data": "string"
178
}
179
]
180
}
181
182
Core rules:
183
- Keep steps minimal.
184
- Prefer %s steps for concrete actions.
185
- Use %s steps for reasoning/summarization based on prior results.
186
- Use %s steps only for explicit reads/writes.
187
- Every step must have a non-empty description.
188
- You MAY include Go template expressions like {{ ... }} in any string field; they will be rendered later.
189
190
FILE TOOL SEMANTICS (IMPORTANT):
191
- "op":"read" returns the full current file content as Output.
192
- "op":"write" OVERWRITES THE ENTIRE FILE CONTENT with "data".
193
- There is NO append mode and NO in-place edit mode.
194
- Therefore, for "modify a line or two", plan MUST do:
195
1) file read the current content
196
2) llm produce the FULL updated content (include unchanged parts)
197
3) file write the FULL updated content back
198
199
FILE WRITE OUTPUT RULE (CRITICAL):
200
When you choose tool="file" with op="write", the value of "data" must be the EXACT file contents to write.
201
202
- Do NOT wrap "data" in markdown fences.
203
- Do NOT add leading/trailing backticks.
204
- Do NOT add any prose before/after the file content.
205
- For non-.md files, "data" must be plain raw text/code only.
206
207
MARKDOWN IS ONLY ALLOWED INSIDE "data" WHEN:
208
- path ends with ".md" AND the user asked for markdown formatting changes.
209
Otherwise, preserve the existing file’s formatting and do not introduce markdown syntax.
210
211
Prohibited patterns (unless the user explicitly wants to replace the whole file with only that snippet):
212
- Writing only a "diff", "patch", or partial snippet to a file.
213
- Writing only "the new paragraph" or "the new attempt" without including the existing content.
214
215
Template rules:
216
- Templates use Go text/template syntax.
217
- They are rendered at runtime with missingkey=error, so ALL referenced keys must exist.
218
- You can reference prior step outputs via:
219
- {{ (index .Results 0).Output }}
220
- {{ (index .Results 1).Output }}
221
- Prefer using .Output unless you explicitly need raw stdout/stderr.
222
223
Examples:
224
225
1) Shell + summarize:
226
{
227
"type": "%s",
228
"description": "Get git status",
229
"command": "git",
230
"args": ["status", "--porcelain"]
231
},
232
{
233
"type": "%s",
234
"description": "Summarize changes",
235
"prompt": "Summarize these changes:\n{{ (index .Results 0).Output }}"
236
}
237
238
2) Edit a file safely (read -> generate full new content -> write full file):
239
{
240
"type": "%s",
241
"description": "Read the existing report",
242
"op": "read",
243
"path": "report.txt"
244
},
245
{
246
"type": "%s",
247
"description": "Produce the full updated report text (preserve existing content, apply requested changes)",
248
"prompt": "Here is the current file content:\n---\n{{ (index .Results 0).Output }}\n---\nRewrite the ENTIRE file content with the requested changes applied. Return ONLY the full new file content."
249
},
250
{
251
"type": "%s",
252
"description": "Overwrite report with updated content",
253
"op": "write",
254
"path": "report.txt",
255
"data": "{{ (index .Results 1).Output }}"
256
}
257
258
User goal:
259
%q
260
261
SELF-CHECK BEFORE RESPONDING:
262
- Does output start with '{' and end with '}'?
263
- Is it valid JSON?
264
- Does it contain NO markdown or backticks?
265
If any answer is "no", fix it before returning.
266
`,
267
types.ToolShell, types.ToolLLM, types.ToolFiles,
268
types.ToolShell,
269
types.ToolLLM,
270
types.ToolFiles,
271
types.ToolShell,
272
types.ToolLLM,
273
types.ToolFiles,
274
types.ToolShell,
275
types.ToolLLM,
276
types.ToolFiles,
277
types.ToolLLM,
278
types.ToolFiles,
279
goal,
280
)
281
}
282
283
type planJSON struct {
284
Goal string `json:"goal"`
285
Steps []stepJSON `json:"steps"`
286
}
287
288
type stepJSON struct {
289
Type string `json:"type"`
290
Description string `json:"description"`
291
292
Command string `json:"command,omitempty"`
293
Args []string `json:"args,omitempty"`
294
295
Prompt string `json:"prompt,omitempty"`
296
297
Op string `json:"op,omitempty"`
298
Path string `json:"path,omitempty"`
299
Data string `json:"data,omitempty"`
300
}
301
302
func parsePlanJSON(raw string, fallbackGoal string) (types.Plan, error) {
303
raw = cleanPlannerOutput(raw)
304
raw = strings.TrimSpace(raw)
305
if raw == "" {
306
return types.Plan{}, errors.New("Planner returned empty response")
307
}
308
309
var pj planJSON
310
if err := json.Unmarshal([]byte(raw), &pj); err != nil {
311
return types.Plan{}, fmt.Errorf("failed to parse Planner JSON: %w", err)
312
}
313
314
goal := strings.TrimSpace(pj.Goal)
315
if goal == "" {
316
goal = fallbackGoal
317
}
318
319
out := types.Plan{Goal: goal}
320
out.Steps = make([]types.Step, 0, len(pj.Steps))
321
322
for _, s := range pj.Steps {
323
step, err := convertStepJSON(s)
324
if err != nil {
325
return types.Plan{}, err
326
}
327
out.Steps = append(out.Steps, step)
328
}
329
330
return out, nil
331
}
332
333
func cleanPlannerOutput(raw string) string {
334
raw = strings.TrimSpace(raw)
335
336
// If wrapped in ```...```
337
if strings.HasPrefix(raw, "```") {
338
raw = strings.TrimPrefix(raw, "```")
339
raw = strings.TrimSpace(raw)
340
341
// Remove optional language tag (only if the first line IS a language tag)
342
if i := strings.IndexByte(raw, '\n'); i != -1 {
343
firstLine := strings.ToLower(strings.TrimSpace(raw[:i]))
344
if firstLine == "json" || firstLine == "application/json" {
345
raw = raw[i+1:]
346
}
347
}
348
349
raw = strings.TrimSpace(raw)
350
raw = strings.TrimSuffix(raw, "```")
351
raw = strings.TrimSpace(raw)
352
}
353
354
return raw
355
}
356
357
func convertStepJSON(s stepJSON) (types.Step, error) {
358
t := strings.TrimSpace(strings.ToLower(s.Type))
359
desc := strings.TrimSpace(s.Description)
360
if desc == "" {
361
return types.Step{}, errors.New("Planner step missing description")
362
}
363
364
switch t {
365
case string(types.ToolShell): // "shell"
366
cmd := strings.TrimSpace(s.Command)
367
if cmd == "" {
368
return types.Step{}, errors.New("shell step missing command")
369
}
370
return types.Step{
371
Type: types.ToolShell,
372
Description: desc,
373
Command: cmd,
374
Args: s.Args,
375
}, nil
376
377
case string(types.ToolLLM): // "llm"
378
prompt := strings.TrimSpace(s.Prompt)
379
if prompt == "" {
380
return types.Step{}, errors.New("llm step missing prompt")
381
}
382
return types.Step{
383
Type: types.ToolLLM,
384
Description: desc,
385
Prompt: prompt,
386
}, nil
387
388
case string(types.ToolFiles): // "file"
389
op := strings.TrimSpace(strings.ToLower(s.Op))
390
path := strings.TrimSpace(s.Path)
391
if op == "" {
392
return types.Step{}, errors.New("file step missing op")
393
}
394
if path == "" {
395
return types.Step{}, errors.New("file step missing path")
396
}
397
return types.Step{
398
Type: types.ToolFiles,
399
Description: desc,
400
Op: op,
401
Path: path,
402
Data: s.Data,
403
}, nil
404
405
default:
406
return types.Step{}, fmt.Errorf("unknown step type: %q", s.Type)
407
}
408
}
409
410
func validatePlan(p types.Plan) error {
411
if strings.TrimSpace(p.Goal) == "" {
412
return errors.New("plan missing goal")
413
}
414
if len(p.Steps) == 0 {
415
return errors.New("plan has no steps")
416
}
417
418
for i, s := range p.Steps {
419
if strings.TrimSpace(s.Description) == "" {
420
return fmt.Errorf("step %d missing description", i)
421
}
422
switch s.Type {
423
case types.ToolShell:
424
if strings.TrimSpace(s.Command) == "" {
425
return fmt.Errorf("step %d shell missing command", i)
426
}
427
case types.ToolLLM:
428
if strings.TrimSpace(s.Prompt) == "" {
429
return fmt.Errorf("step %d llm missing prompt", i)
430
}
431
case types.ToolFiles:
432
if strings.TrimSpace(s.Op) == "" {
433
return fmt.Errorf("step %d files missing op", i)
434
}
435
if strings.TrimSpace(s.Path) == "" {
436
return fmt.Errorf("step %d files missing path", i)
437
}
438
default:
439
return fmt.Errorf("step %d has unknown type %q", i, s.Type)
440
}
441
}
442
443
if err := validateTemplates(p); err != nil {
444
return err
445
}
446
447
return nil
448
}
449
450
func validateTemplates(p types.Plan) error {
451
for i := range p.Steps {
452
s := p.Steps[i]
453
454
if err := validateTemplateField(i, "description", s.Description); err != nil {
455
return err
456
}
457
458
switch s.Type {
459
case types.ToolShell:
460
if err := validateTemplateField(i, "command", s.Command); err != nil {
461
return err
462
}
463
for ai, a := range s.Args {
464
if err := validateTemplateField(i, fmt.Sprintf("args[%d]", ai), a); err != nil {
465
return err
466
}
467
}
468
469
case types.ToolLLM:
470
if err := validateTemplateField(i, "prompt", s.Prompt); err != nil {
471
return err
472
}
473
474
case types.ToolFiles:
475
if err := validateTemplateField(i, "op", s.Op); err != nil {
476
return err
477
}
478
if err := validateTemplateField(i, "path", s.Path); err != nil {
479
return err
480
}
481
if err := validateTemplateField(i, "data", s.Data); err != nil {
482
return err
483
}
484
485
default:
486
return fmt.Errorf("step %d has unknown type %q", i, s.Type)
487
}
488
}
489
return nil
490
}
491
492
func validateTemplateField(stepIndex int, field string, s string) error {
493
if !strings.Contains(s, "{{") {
494
return nil
495
}
496
497
_, err := template.New("validate").Option("missingkey=error").Parse(s)
498
if err != nil {
499
return fmt.Errorf("step %d %s: invalid template: %w", stepIndex, field, err)
500
}
501
502
matches := reResultsIndex.FindAllStringSubmatch(s, -1)
503
504
if strings.Contains(s, "index .Results") && len(matches) == 0 {
505
return fmt.Errorf(
506
"step %d %s: template uses index .Results but not with a literal index",
507
stepIndex, field,
508
)
509
}
510
511
for _, m := range matches {
512
n, convErr := strconv.Atoi(m[1])
513
if convErr != nil {
514
return fmt.Errorf("step %d %s: invalid Results index %q", stepIndex, field, m[1])
515
}
516
if n >= stepIndex {
517
return fmt.Errorf(
518
"step %d %s: template references .Results[%d] but only prior results are available (max index %d)",
519
stepIndex, field, n, stepIndex-1,
520
)
521
}
522
}
523
524
return nil
525
}
526
527
type NaivePlanner struct{}
528
529
func (p *NaivePlanner) Plan(ctx context.Context, goal string) (types.Plan, error) {
530
// Stub: good enough for wiring + tests.
531
// Later: call a.client to generate this.
532
return types.Plan{
533
Goal: goal,
534
Steps: []types.Step{
535
{
536
Type: types.ToolShell,
537
Description: "Show repo status",
538
Command: "git",
539
Args: []string{"status", "--porcelain"},
540
},
541
{
542
Type: types.ToolShell,
543
Description: "Run tests",
544
Command: "go",
545
Args: []string{"test", "./..."},
546
},
547
},
548
}, nil
549
}
550
551