Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
kardolus
GitHub Repository: kardolus/chatgpt-cli
Path: blob/main/agent/core/runner.go
3434 views
1
package core
2
3
import (
4
"context"
5
"fmt"
6
"github.com/kardolus/chatgpt-cli/agent/tools"
7
"github.com/kardolus/chatgpt-cli/agent/types"
8
"regexp"
9
"strings"
10
"time"
11
)
12
13
const transcriptMaxBytes = 64_000
14
15
type Tools struct {
16
Shell tools.Shell
17
LLM tools.LLM
18
Files tools.Files
19
}
20
21
type Runner interface {
22
RunStep(ctx context.Context, cfg types.Config, step types.Step) (types.StepResult, error)
23
}
24
25
type DefaultRunner struct {
26
tools Tools
27
clock Clock
28
budget Budget
29
policy Policy
30
}
31
32
func NewDefaultRunner(t Tools, c Clock, b Budget, p Policy) *DefaultRunner {
33
return &DefaultRunner{tools: t, clock: c, budget: b, policy: p}
34
}
35
36
func (r *DefaultRunner) RunStep(ctx context.Context, cfg types.Config, step types.Step) (types.StepResult, error) {
37
start := r.clock.Now()
38
39
// HARD STOP: budget step gate
40
if err := r.budget.AllowStep(step, start); err != nil {
41
tr := appendBudgetError(buildDryRunTranscript(cfg, step), err)
42
return types.StepResult{
43
Step: step,
44
Outcome: types.OutcomeError,
45
Transcript: limitTranscript(tr, transcriptMaxBytes),
46
Duration: r.clock.Now().Sub(start),
47
Output: err.Error(),
48
}, err
49
}
50
51
// HARD STOP: policy gate
52
if err := r.policy.AllowStep(cfg, step); err != nil {
53
var tr string
54
if cfg.DryRun {
55
tr = appendPolicyError(buildDryRunTranscript(cfg, step), err)
56
} else {
57
switch step.Type {
58
case types.ToolShell:
59
tr = appendPolicyError(buildShellStartTranscript(cfg, step), err)
60
case types.ToolLLM:
61
tr = appendPolicyError(buildLLMStartTranscript(step.Prompt), err)
62
case types.ToolFiles:
63
tr = appendPolicyError(buildFileStartTranscript(step), err)
64
default:
65
tr = appendPolicyError(buildUnsupportedStepTranscript(step), err)
66
}
67
}
68
69
return types.StepResult{
70
Step: step,
71
Outcome: types.OutcomeError,
72
Transcript: limitTranscript(tr, transcriptMaxBytes),
73
Duration: r.clock.Now().Sub(start),
74
Output: err.Error(),
75
}, err
76
}
77
78
if cfg.DryRun {
79
tr := buildDryRunTranscript(cfg, step)
80
return types.StepResult{
81
Step: step,
82
Outcome: types.OutcomeDryRun,
83
Transcript: limitTranscript(tr, transcriptMaxBytes),
84
Duration: r.clock.Now().Sub(start),
85
}, nil
86
}
87
88
switch step.Type {
89
case types.ToolShell:
90
// HARD STOP: tool budget gate
91
if err := r.budget.AllowTool(types.ToolShell, start); err != nil {
92
tr := appendBudgetError(buildShellStartTranscript(cfg, step), err)
93
return types.StepResult{
94
Step: step,
95
Outcome: types.OutcomeError,
96
Transcript: limitTranscript(tr, transcriptMaxBytes),
97
Duration: r.clock.Now().Sub(start),
98
Output: err.Error(),
99
}, err
100
}
101
102
res, err := r.tools.Shell.Run(ctx, cfg.WorkDir, step.Command, step.Args...)
103
if err != nil {
104
// SOFT FAIL: let agent recover
105
tr := buildShellStartTranscript(cfg, step)
106
return softStepError(r, start, step, tr, err), nil
107
}
108
109
outcome := types.OutcomeOK
110
if res.ExitCode != 0 {
111
outcome = types.OutcomeError // already soft: err is nil
112
}
113
114
tr := buildShellTranscript(cfg, step, res)
115
116
out := res.Stdout
117
if strings.TrimSpace(out) == "" {
118
out = res.Stderr
119
}
120
121
return types.StepResult{
122
Step: step,
123
Outcome: outcome,
124
Exec: &res,
125
Output: out,
126
Transcript: limitTranscript(tr, transcriptMaxBytes),
127
Duration: r.clock.Now().Sub(start),
128
Effects: []types.StepEffect{
129
effect("shell.exec", "", 0, map[string]any{
130
"cmd": step.Command,
131
"args": step.Args,
132
"workdir": cfg.WorkDir,
133
"exitCode": res.ExitCode,
134
}),
135
},
136
}, nil
137
138
case types.ToolLLM:
139
// SOFT FAIL: agent can correct prompt
140
if strings.TrimSpace(step.Prompt) == "" {
141
err := fmt.Errorf("llm step requires Prompt")
142
tr := buildLLMStartTranscript(step.Prompt)
143
return softStepError(r, start, step, tr, err), nil
144
}
145
146
// HARD STOP: token budget preflight
147
snap := r.budget.Snapshot(start)
148
if snap.Limits.MaxLLMTokens > 0 && snap.LLMTokensUsed >= snap.Limits.MaxLLMTokens {
149
err := BudgetExceededError{
150
Kind: BudgetKindLLMTokens,
151
Limit: snap.Limits.MaxLLMTokens,
152
Used: snap.LLMTokensUsed,
153
Message: "llm token budget exceeded",
154
}
155
tr := appendBudgetError(buildLLMStartTranscript(step.Prompt), err)
156
return types.StepResult{
157
Step: step,
158
Outcome: types.OutcomeError,
159
Transcript: limitTranscript(tr, transcriptMaxBytes),
160
Duration: r.clock.Now().Sub(start),
161
Output: err.Error(),
162
}, err
163
}
164
165
// HARD STOP: tool budget gate
166
if err := r.budget.AllowTool(types.ToolLLM, start); err != nil {
167
tr := appendBudgetError(buildLLMStartTranscript(step.Prompt), err)
168
return types.StepResult{
169
Step: step,
170
Outcome: types.OutcomeError,
171
Transcript: limitTranscript(tr, transcriptMaxBytes),
172
Duration: r.clock.Now().Sub(start),
173
Output: err.Error(),
174
}, err
175
}
176
177
out, tokens, err := r.tools.LLM.Complete(ctx, step.Prompt)
178
if err != nil {
179
// SOFT FAIL: agent can retry / simplify / etc
180
tr := buildLLMStartTranscript(step.Prompt)
181
return softStepError(r, start, step, tr, err), nil
182
}
183
184
r.budget.ChargeLLMTokens(tokens, start)
185
186
tr := buildLLMTranscript(step.Prompt, out)
187
return types.StepResult{
188
Step: step,
189
Outcome: types.OutcomeOK,
190
Output: out,
191
Transcript: limitTranscript(tr, transcriptMaxBytes),
192
Duration: r.clock.Now().Sub(start),
193
}, nil
194
195
case types.ToolFiles:
196
// SOFT FAIL: agent can correct op/path/data
197
if strings.TrimSpace(step.Op) == "" {
198
err := fmt.Errorf("file step requires Op")
199
tr := buildFileStartTranscript(step)
200
return softStepError(r, start, step, tr, err), nil
201
}
202
if strings.TrimSpace(step.Path) == "" {
203
err := fmt.Errorf("file step requires Path")
204
tr := buildFileStartTranscript(step)
205
return softStepError(r, start, step, tr, err), nil
206
}
207
208
// HARD STOP: tool budget gate
209
if err := r.budget.AllowTool(types.ToolFiles, start); err != nil {
210
tr := appendBudgetError(buildFileStartTranscript(step), err)
211
return types.StepResult{
212
Step: step,
213
Outcome: types.OutcomeError,
214
Transcript: limitTranscript(tr, transcriptMaxBytes),
215
Duration: r.clock.Now().Sub(start),
216
Output: err.Error(),
217
}, err
218
}
219
220
switch strings.ToLower(strings.TrimSpace(step.Op)) {
221
case "read":
222
b, err := r.tools.Files.ReadFile(step.Path)
223
if err != nil {
224
// SOFT FAIL
225
tr := buildFileStartTranscript(step)
226
return softStepError(r, start, step, tr, err), nil
227
}
228
229
out := string(b)
230
tr := buildFileReadTranscript(step.Path, out)
231
return types.StepResult{
232
Step: step,
233
Outcome: types.OutcomeOK,
234
Output: out,
235
Transcript: limitTranscript(tr, transcriptMaxBytes),
236
Duration: r.clock.Now().Sub(start),
237
}, nil
238
239
case "write":
240
if step.Data == "" {
241
err := fmt.Errorf("file write requires Data")
242
tr := buildFileStartTranscript(step)
243
return softStepError(r, start, step, tr, err), nil
244
}
245
246
if err := r.tools.Files.WriteFile(step.Path, []byte(step.Data)); err != nil {
247
tr := buildFileStartTranscript(step)
248
return softStepError(r, start, step, tr, err), nil
249
}
250
251
tr := buildFileWriteTranscript(step.Path, step.Data)
252
return types.StepResult{
253
Step: step,
254
Outcome: types.OutcomeOK,
255
Output: fmt.Sprintf("wrote %d bytes to %s", len(step.Data), step.Path),
256
Transcript: limitTranscript(tr, transcriptMaxBytes),
257
Duration: r.clock.Now().Sub(start),
258
Effects: []types.StepEffect{
259
effect("file.write", step.Path, len(step.Data), nil),
260
},
261
}, nil
262
263
case "patch":
264
if step.Data == "" {
265
err := fmt.Errorf("file patch requires Data (unified diff)")
266
tr := buildFileStartTranscript(step)
267
return softStepError(r, start, step, tr, err), nil
268
}
269
270
patchRes, err := r.tools.Files.PatchFile(step.Path, []byte(step.Data))
271
if err != nil {
272
tr := buildFilePatchTranscript(step.Path, patchRes, err)
273
return softStepError(r, start, step, tr, err), nil
274
}
275
276
tr := buildFilePatchTranscript(step.Path, patchRes, nil)
277
return types.StepResult{
278
Step: step,
279
Outcome: types.OutcomeOK,
280
Output: fmt.Sprintf("patched %s (hunks=%d)", step.Path, patchRes.Hunks),
281
Transcript: limitTranscript(tr, transcriptMaxBytes),
282
Duration: r.clock.Now().Sub(start),
283
Effects: []types.StepEffect{
284
effect("file.patch", step.Path, 0, map[string]any{
285
"hunks": patchRes.Hunks,
286
}),
287
},
288
}, nil
289
290
case "replace":
291
if step.Old == "" {
292
err := fmt.Errorf("file replace requires Old")
293
tr := buildFileStartTranscript(step)
294
return softStepError(r, start, step, tr, err), nil
295
}
296
297
replRes, err := r.tools.Files.ReplaceBytesInFile(step.Path, []byte(step.Old), []byte(step.New), step.N)
298
if err != nil {
299
tr := buildFileReplaceTranscript(step.Path, step.N, replRes, err)
300
return softStepError(r, start, step, tr, err), nil
301
}
302
303
tr := buildFileReplaceTranscript(step.Path, step.N, replRes, nil)
304
return types.StepResult{
305
Step: step,
306
Outcome: types.OutcomeOK,
307
Output: fmt.Sprintf(
308
"replaced %d occurrence(s) in %s (found=%d)",
309
replRes.Replaced,
310
step.Path,
311
replRes.OccurrencesFound,
312
),
313
Transcript: limitTranscript(tr, transcriptMaxBytes),
314
Duration: r.clock.Now().Sub(start),
315
Effects: []types.StepEffect{
316
effect("file.replace", step.Path, 0, map[string]any{
317
"found": replRes.OccurrencesFound,
318
"replaced": replRes.Replaced,
319
"n": step.N,
320
}),
321
},
322
}, nil
323
324
default:
325
// SOFT FAIL: agent can correct op
326
err := fmt.Errorf("unsupported file op: %s", step.Op)
327
tr := buildFileStartTranscript(step)
328
return softStepError(r, start, step, tr, err), nil
329
}
330
331
default:
332
// SOFT FAIL: agent can correct tool type
333
err := fmt.Errorf("unsupported step type: %s", step.Type)
334
tr := buildUnsupportedStepTranscript(step)
335
return softStepError(r, start, step, tr, err), nil
336
}
337
}
338
339
func softStepError(r *DefaultRunner, start time.Time, step types.Step, tr string, err error) types.StepResult {
340
// Keep transcript readable; include error in Output so agent sees it in OBSERVATION.
341
if tr != "" && !strings.HasSuffix(tr, "\n") {
342
tr += "\n"
343
}
344
tr += fmt.Sprintf("[error] %v\n", err)
345
346
return types.StepResult{
347
Step: step,
348
Outcome: types.OutcomeError,
349
Output: err.Error(),
350
Transcript: limitTranscript(tr, transcriptMaxBytes),
351
Duration: r.clock.Now().Sub(start),
352
}
353
}
354
355
func appendBudgetError(tr string, err error) string {
356
if tr != "" && !strings.HasSuffix(tr, "\n") {
357
tr += "\n"
358
}
359
return tr + fmt.Sprintf("[budget] %v\n", err)
360
}
361
362
func appendPolicyError(tr string, err error) string {
363
if tr != "" && !strings.HasSuffix(tr, "\n") {
364
tr += "\n"
365
}
366
return tr + fmt.Sprintf("[policy] %v\n", err)
367
}
368
369
func buildDryRunTranscript(cfg types.Config, step types.Step) string {
370
switch step.Type {
371
case types.ToolShell:
372
return fmt.Sprintf("[dry-run][shell] workdir=%q cmd=%q args=%v\n", cfg.WorkDir, step.Command, step.Args)
373
374
case types.ToolLLM:
375
return fmt.Sprintf("[dry-run][llm]\n%s\n", step.Prompt)
376
377
case types.ToolFiles:
378
op := strings.ToLower(strings.TrimSpace(step.Op))
379
380
switch op {
381
case "replace":
382
return fmt.Sprintf(
383
"[dry-run][file] op=%q path=%q old_len=%d new_len=%d n=%d\n",
384
step.Op, step.Path, len(step.Old), len(step.New), step.N,
385
)
386
387
case "patch":
388
return fmt.Sprintf(
389
"[dry-run][file] op=%q path=%q diff_len=%d\n",
390
step.Op, step.Path, len(step.Data),
391
)
392
393
case "write":
394
return fmt.Sprintf(
395
"[dry-run][file] op=%q path=%q data_len=%d\n",
396
step.Op, step.Path, len(step.Data),
397
)
398
399
case "read":
400
return fmt.Sprintf(
401
"[dry-run][file] op=%q path=%q\n",
402
step.Op, step.Path,
403
)
404
405
default:
406
// Unknown op, keep current behavior as a safe fallback.
407
return fmt.Sprintf("[dry-run][file] op=%q path=%q data_len=%d\n", step.Op, step.Path, len(step.Data))
408
}
409
410
default:
411
return fmt.Sprintf("[dry-run] step_type=%q\n", step.Type)
412
}
413
}
414
415
var firstMismatchLineRe = regexp.MustCompile(`\bline\s+(\d+)\b`)
416
417
func buildFilePatchTranscript(path string, res tools.PatchResult, err error) string {
418
var b strings.Builder
419
_, _ = fmt.Fprintf(&b, "[file] op=%q path=%q\n", "patch", path)
420
_, _ = fmt.Fprintf(&b, "hunks=%d\n", res.Hunks)
421
422
if err != nil {
423
_, _ = fmt.Fprintf(&b, "error=%q\n", err.Error())
424
425
if m := firstMismatchLineRe.FindStringSubmatch(err.Error()); len(m) == 2 {
426
_, _ = fmt.Fprintf(&b, "first_mismatch_line=%s\n", m[1])
427
}
428
}
429
430
return b.String()
431
}
432
433
func buildFileReplaceTranscript(path string, n int, res tools.ReplaceResult, err error) string {
434
var b strings.Builder
435
_, _ = fmt.Fprintf(&b, "[file] op=%q path=%q\n", "replace", path)
436
_, _ = fmt.Fprintf(&b, "n=%d\n", n)
437
_, _ = fmt.Fprintf(&b, "occurrences_found=%d\n", res.OccurrencesFound)
438
_, _ = fmt.Fprintf(&b, "replaced=%d\n", res.Replaced)
439
440
if err != nil {
441
_, _ = fmt.Fprintf(&b, "error=%q\n", err.Error())
442
}
443
return b.String()
444
}
445
446
func buildShellStartTranscript(cfg types.Config, step types.Step) string {
447
return fmt.Sprintf(
448
"[shell:start] workdir=%q cmd=%q args=%v\n",
449
cfg.WorkDir,
450
step.Command,
451
step.Args,
452
)
453
}
454
455
func buildShellTranscript(cfg types.Config, step types.Step, res types.Result) string {
456
var b strings.Builder
457
_, _ = fmt.Fprintf(&b, "[shell] workdir=%q cmd=%q args=%v\n", cfg.WorkDir, step.Command, step.Args)
458
_, _ = fmt.Fprintf(&b, "exit=%d\n", res.ExitCode)
459
460
if res.Stdout != "" {
461
b.WriteString("stdout:\n")
462
b.WriteString(res.Stdout)
463
if !strings.HasSuffix(res.Stdout, "\n") {
464
b.WriteString("\n")
465
}
466
}
467
if res.Stderr != "" {
468
b.WriteString("stderr:\n")
469
b.WriteString(res.Stderr)
470
if !strings.HasSuffix(res.Stderr, "\n") {
471
b.WriteString("\n")
472
}
473
}
474
return b.String()
475
}
476
477
func buildLLMStartTranscript(prompt string) string {
478
var b strings.Builder
479
b.WriteString("[llm:start]\n")
480
b.WriteString("prompt:\n")
481
b.WriteString(prompt)
482
if !strings.HasSuffix(prompt, "\n") {
483
b.WriteString("\n")
484
}
485
return b.String()
486
}
487
488
func buildLLMTranscript(prompt, output string) string {
489
var b strings.Builder
490
b.WriteString("[llm]\n")
491
b.WriteString("prompt:\n")
492
b.WriteString(prompt)
493
if !strings.HasSuffix(prompt, "\n") {
494
b.WriteString("\n")
495
}
496
b.WriteString("output:\n")
497
b.WriteString(output)
498
if output != "" && !strings.HasSuffix(output, "\n") {
499
b.WriteString("\n")
500
}
501
return b.String()
502
}
503
504
func buildFileStartTranscript(step types.Step) string {
505
return fmt.Sprintf(
506
"[file:start] op=%q path=%q data_len=%d\n",
507
step.Op,
508
step.Path,
509
len(step.Data),
510
)
511
}
512
513
func buildFileReadTranscript(path, content string) string {
514
var b strings.Builder
515
_, _ = fmt.Fprintf(&b, "[file] op=%q path=%q\n", "read", path)
516
b.WriteString("content:\n")
517
b.WriteString(content)
518
if content != "" && !strings.HasSuffix(content, "\n") {
519
b.WriteString("\n")
520
}
521
return b.String()
522
}
523
524
func buildFileWriteTranscript(path, data string) string {
525
var b strings.Builder
526
_, _ = fmt.Fprintf(&b, "[file] op=%q path=%q\n", "write", path)
527
_, _ = fmt.Fprintf(&b, "data_len=%d\n", len(data))
528
529
return b.String()
530
}
531
532
func buildUnsupportedStepTranscript(step types.Step) string {
533
return fmt.Sprintf("[unsupported] step_type=%q\n", step.Type)
534
}
535
536
func limitTranscript(s string, max int) string {
537
if max <= 0 || len(s) <= max {
538
return s
539
}
540
return s[:max] + "\n…(truncated)\n"
541
}
542
543
func effect(kind, path string, bytes int, meta map[string]any) types.StepEffect {
544
return types.StepEffect{
545
Kind: kind,
546
Path: path,
547
Bytes: bytes,
548
Meta: meta,
549
}
550
}
551
552