Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
kardolus
GitHub Repository: kardolus/chatgpt-cli
Path: blob/main/agent/react/react_agent.go
3433 views
1
package react
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
"sort"
12
"strconv"
13
"strings"
14
"unicode"
15
)
16
17
type ReActAgent struct {
18
*core.BaseAgent
19
LLM tools.LLM
20
Runner core.Runner
21
Budget core.Budget
22
effects types.Effects
23
llmCalls int
24
}
25
26
func NewReActAgent(llm tools.LLM, runner core.Runner, budget core.Budget, clock core.Clock, opts ...core.BaseOption) *ReActAgent {
27
base := core.NewBaseAgent(clock)
28
for _, o := range opts {
29
o(base)
30
}
31
32
return &ReActAgent{
33
BaseAgent: base,
34
LLM: llm,
35
Runner: runner,
36
Budget: budget,
37
}
38
}
39
40
type reActAction struct {
41
Thought string `json:"thought"`
42
ActionType string `json:"action_type"` // "tool" or "answer"
43
Tool string `json:"tool,omitempty"`
44
Command string `json:"command,omitempty"`
45
Args []string `json:"args,omitempty"`
46
Prompt string `json:"prompt,omitempty"`
47
Op string `json:"op,omitempty"`
48
Path string `json:"path,omitempty"`
49
Data string `json:"data,omitempty"`
50
Old string `json:"old,omitempty"`
51
New string `json:"new,omitempty"`
52
N int `json:"n,omitempty"`
53
FinalAnswer string `json:"final_answer,omitempty"`
54
}
55
56
func (a *ReActAgent) RunAgentGoal(ctx context.Context, goal string) (string, error) {
57
start := a.StartTimer()
58
defer a.FinishTimer(start)
59
60
a.effects = nil
61
a.llmCalls = 0
62
63
guard := newRepetitionGuard(32)
64
65
parseRecoveries := 0
66
const maxParseRecoveries = 3
67
68
a.LogMode(goal, "ReAct (iterative reasoning + acting)")
69
70
out := a.Out
71
dbg := a.Debug
72
73
if a.PromptHistory != nil {
74
a.PromptHistory.Reset()
75
}
76
if a.Transcript != nil {
77
a.Transcript.Reset()
78
}
79
80
a.AddHistory(fmt.Sprintf("USER: %s", goal))
81
a.AddTranscript(fmt.Sprintf("[goal]\n%s\n", goal))
82
83
for i := 0; ; i++ {
84
now := a.Clock.Now()
85
86
if err := a.Budget.AllowIteration(now); err != nil {
87
dbg.Errorf("iteration Budget exceeded at iteration %d: %v", i+1, err)
88
return "", err
89
}
90
91
snap := a.Budget.Snapshot(now)
92
if snap.Limits.MaxLLMTokens > 0 && snap.LLMTokensUsed >= snap.Limits.MaxLLMTokens {
93
return "", core.BudgetExceededError{
94
Kind: core.BudgetKindLLMTokens,
95
Limit: snap.Limits.MaxLLMTokens,
96
Used: snap.LLMTokensUsed,
97
Message: "LLM token Budget exceeded",
98
}
99
}
100
101
if err := a.Budget.AllowTool(types.ToolLLM, now); err != nil {
102
dbg.Errorf("Budget exceeded at iteration %d: %v", i+1, err)
103
return "", err
104
}
105
106
prompt := buildReActPromptFromHistory(a.History(), a.promptStateLine())
107
dbg.Debugf("react iteration %d prompt_len=%d", i+1, len(prompt))
108
109
a.AddTranscriptf("[iteration %d][prompt]\n%s\n", i+1, prompt)
110
111
a.llmCalls++
112
113
raw, tokens, err := a.LLM.Complete(ctx, prompt)
114
a.AddTranscriptf("[iteration %d][llm_raw]\n%s\n", i+1, strings.TrimSpace(raw))
115
if err != nil {
116
dbg.Errorf("LLM error at iteration %d: %v", i+1, err)
117
return "", err
118
}
119
120
a.Budget.ChargeLLMTokens(tokens, now)
121
dbg.Debugf("react iteration %d tokens=%d", i+1, tokens)
122
123
action, err := parseReActResponse(raw)
124
if err != nil {
125
out.Errorf("Failed to parse ReAct response: %v", err)
126
dbg.Errorf("parse error at iteration %d: %v\nraw: %s", i+1, err, raw)
127
128
parseRecoveries++
129
if parseRecoveries > maxParseRecoveries {
130
return "", fmt.Errorf("agent failed to produce valid JSON after %d attempts: %w", maxParseRecoveries, err)
131
}
132
133
rawTrim := strings.TrimSpace(raw)
134
rawSnippet := rawTrim
135
if len(rawSnippet) > 200 {
136
rawSnippet = rawSnippet[:200] + "..."
137
}
138
139
a.AddHistory("ACTION_TAKEN: tool=LLM details=INVALID_RESPONSE")
140
a.AddHistory(fmt.Sprintf(
141
"OBSERVATION: ERROR: Your last response violated the ReAct protocol (%s). You MUST reply with EXACTLY ONE JSON object. The first non-whitespace character must be '{' and the last must be '}'. Include \"action_type\". Do not include any prose.",
142
err.Error(),
143
))
144
a.AddHistory(fmt.Sprintf("OBSERVATION: ERROR: Raw response (truncated): %q", rawSnippet))
145
a.AddTranscriptf("[iteration %d][parse-error] %v\nraw=%q\n", i+1, err, rawSnippet)
146
continue
147
}
148
149
parseRecoveries = 0
150
151
if action.ActionType == "tool" {
152
sig := signatureForAction(action)
153
154
immediate := guard.isImmediateRepeat(sig)
155
seen := guard.count(sig)
156
157
if immediate || seen >= 3 {
158
guard.observe(sig)
159
seenNow := guard.count(sig)
160
161
msg := fmt.Sprintf(
162
"OBSERVATION: You are repeating the same tool call (%s %q). Do NOT repeat it. "+
163
"Choose a different next step (e.g., write the file, narrow the read range, or answer).",
164
sig.tool, sig.key,
165
)
166
167
a.AddHistory(msg)
168
a.AddTranscriptf("[iteration %d][repeat-guard] %s\n", i+1, msg)
169
dbg.Debugf("repetition guard injected: %s", msg)
170
171
if seenNow >= 6 {
172
return "", fmt.Errorf("agent appears stuck: repeated tool call too many times: %s %q", sig.tool, sig.key)
173
}
174
continue
175
}
176
177
guard.observe(sig)
178
}
179
180
dbg.Debugf("react iteration %d action_type=%s thought=%q", i+1, action.ActionType, action.Thought)
181
182
if action.Thought != "" {
183
out.Infof("[Iteration %d] Thought: %s", i+1, action.Thought)
184
a.AddTranscriptf("[iteration %d][thought] %s\n", i+1, action.Thought)
185
}
186
187
if action.ActionType == "answer" {
188
result := strings.TrimRightFunc(action.FinalAnswer, unicode.IsSpace)
189
out.Infof("\nResult: %s\n", result)
190
191
if len(a.effects) > 0 {
192
out.Infof("Actions performed: %s", summarizeActionsForUI(a.effects, a.llmCalls))
193
}
194
195
a.AddTranscriptf("[final]\n%s\n", result)
196
return result, nil
197
}
198
199
if action.ActionType != "tool" {
200
err := fmt.Errorf("unknown action_type: %q", action.ActionType)
201
dbg.Errorf("unknown action_type at iteration %d: %v", i+1, err)
202
return "", err
203
}
204
205
step, err := convertReActActionToStep(action)
206
if err != nil {
207
out.Errorf("Failed to convert action to step: %v", err)
208
dbg.Errorf("convert error at iteration %d: %v", i+1, err)
209
210
a.AddHistory(fmt.Sprintf("ACTION_TAKEN: tool=%s details=INVALID_REQUEST", action.Tool))
211
a.AddHistory(fmt.Sprintf("OBSERVATION: ERROR: %s", err.Error()))
212
a.AddTranscriptf("[iteration %d][convert-error] %v\n", i+1, err)
213
continue
214
}
215
216
out.Infof("[Iteration %d] Action: %s %s", i+1, action.Tool, step.Description)
217
a.AddTranscriptf("[iteration %d][action] tool=%s %s\n", i+1, action.Tool, step.Description)
218
219
res, err := a.Runner.RunStep(ctx, a.Config, step)
220
221
if err != nil {
222
if core.IsBudgetStop(err, out) || core.IsPolicyStop(err, out) {
223
dbg.Errorf("stop error at iteration %d: %v", i+1, err)
224
if strings.TrimSpace(res.Transcript) != "" {
225
a.AddTranscript(res.Transcript)
226
}
227
return "", err
228
}
229
230
out.Errorf("Step failed: %s: %v", step.Description, err)
231
dbg.Errorf("step failed at iteration %d: %v transcript=%q", i+1, err, res.Transcript)
232
233
if strings.TrimSpace(res.Transcript) != "" {
234
a.AddTranscript(res.Transcript)
235
}
236
return "", err
237
}
238
239
if strings.TrimSpace(res.Transcript) != "" {
240
a.AddTranscript(res.Transcript)
241
}
242
243
if res.Outcome == types.OutcomeError {
244
mergeEffects(&a.effects, res.Effects)
245
246
out.Errorf("[Iteration %d] Step failed: %s", i+1, step.Description)
247
out.Infof("[Iteration %d] Observation: %s (took %s)", i+1, truncateForDisplay(res.Output, 100), res.Duration)
248
249
a.AddHistory(fmt.Sprintf("ACTION_TAKEN: tool=%s details=%s", action.Tool, step.Description))
250
a.AddHistory(fmt.Sprintf("OBSERVATION: ERROR: %s", res.Output))
251
a.AddHistory(formatEffectsForConversation(res.Effects))
252
253
if types.ToolKind(action.Tool) == types.ToolFiles && (step.Op == "patch" || step.Op == "replace") {
254
a.AddHistory(fmt.Sprintf(
255
"OBSERVATION: FALLBACK REQUIRED: The %s operation failed for %q. "+
256
"Do NOT try op=%q or op=patch/replace again for this file. "+
257
"Your NEXT step MUST be: {\"action_type\":\"tool\",\"tool\":\"file\",\"op\":\"read\",\"path\":%q}. "+
258
"After reading, you MUST construct the FULL updated file contents and use op=\"write\" to overwrite the file.",
259
step.Op, step.Path, step.Op, step.Path,
260
))
261
}
262
263
continue
264
}
265
266
mergeEffects(&a.effects, res.Effects)
267
268
out.Infof("[Iteration %d] Observation: %s (took %s)", i+1, truncateForDisplay(res.Output, 100), res.Duration)
269
dbg.Debugf("observation (iteration %d): %q", i+1, res.Output)
270
271
a.AddHistory(fmt.Sprintf("ACTION_TAKEN: tool=%s details=%s", action.Tool, step.Description))
272
a.AddHistory(fmt.Sprintf("OBSERVATION: %s", res.Output))
273
a.AddHistory(formatEffectsForConversation(res.Effects))
274
}
275
}
276
277
func buildReActPromptFromHistory(history string, stateLine string) string {
278
history = strings.TrimSpace(history)
279
if history == "" {
280
return buildReActPrompt(nil, stateLine)
281
}
282
return buildReActPrompt([]string{history}, stateLine)
283
}
284
285
func buildReActPrompt(conversation []string, stateLine string) string {
286
history := strings.Join(conversation, "\n\n")
287
stateLine = strings.TrimSpace(stateLine)
288
289
return fmt.Sprintf(`You are a ReAct agent. You will iteratively reason and act to answer the user's question.
290
291
You have access to these tools:
292
293
1. shell - Execute shell commands
294
Fields: "command" (string), "args" (array of strings)
295
296
2. llm - Request reasoning or summarization
297
Fields: "prompt" (string)
298
299
3. file - Read or modify files
300
Fields:
301
- "op": "read" | "write" | "patch" | "replace"
302
- "path": string
303
304
For op="read":
305
- returns ENTIRE file contents as text
306
307
For op="write":
308
- "data": string REQUIRED
309
- OVERWRITES the ENTIRE file with exactly "data"
310
311
For op="patch":
312
- "data": string REQUIRED (unified diff)
313
- Applies the unified diff to the file (no full rewrite needed if patch applies cleanly)
314
315
For op="replace":
316
- "old": string REQUIRED (pattern)
317
- "new": string REQUIRED (replacement)
318
- "n": int OPTIONAL
319
- n <= 0 means replace all occurrences
320
- n > 0 means replace first n occurrences
321
322
IMPORTANT FILE SEMANTICS:
323
- file op="read" returns the ENTIRE file contents as text.
324
- file op="write" OVERWRITES the ENTIRE file with exactly "data".
325
It does NOT append. It does NOT merge. It replaces the whole file.
326
- Therefore: if you want to make a small change to an existing file, you MUST:
327
1) read the file,
328
2) construct the full updated contents (including unchanged parts),
329
3) write the full updated contents back.
330
- Prefer op="replace" for small mechanical edits (rename, token swap).
331
- Prefer op="patch" when you have a correct unified diff.
332
- Fall back to read+write only if patch/replace fails or isn't applicable.
333
334
WRITE DEFAULT CONTENT RULE (CRITICAL):
335
- If the user asks to create a new file but does NOT specify what it should contain,
336
you MUST still use file op="write" and you MUST include a non-empty "data" field.
337
- In that case, use EXACTLY one newline as the default content:
338
"data": "\n"
339
(This creates an empty-looking file but satisfies the non-empty data requirement.)
340
- Do NOT ask a follow-up question for content unless the user explicitly requests specific content.
341
342
PATCH FORMAT RULES (VERY IMPORTANT):
343
- For file op="patch", "data" MUST be a valid unified diff.
344
- The diff MUST use ONLY these line prefixes within hunks:
345
- ' ' for context lines
346
- '-' for deletions
347
- '+' for insertions
348
Any other prefix (including no prefix) will FAIL.
349
- Each hunk MUST start with a header like: @@ -oldStart,oldCount +newStart,newCount @@
350
- Include enough context lines (' ' lines) so the patch applies cleanly.
351
- When patching, DO NOT generate prose, explanations, or code fences—only diff text.
352
353
- PREFER-REPLACE RULE: If the change can be expressed as a simple string substitution, use op="replace" instead of op="patch".
354
355
NO-NEWLINE-AT-EOF RULE (CRITICAL FOR PATCHING):
356
- If the file content you read DOES NOT end with a newline, the last line is "no newline at end of file".
357
- If your patch changes or matches that last line, you MUST include the EXACT marker line:
358
\ No newline at end of file
359
immediately AFTER the affected '-' or '+' line in the diff.
360
- If your patch does NOT touch the last line, you do NOT need the marker line.
361
362
FILE TYPE RULE:
363
- Determine file type ONLY from the file extension in "path".
364
- Never infer format from file contents.
365
- Default to plain text if extension is unknown.
366
- Only use markdown syntax if:
367
- path ends in ".md", OR
368
- user explicitly asks for markdown formatting.
369
370
At each step, respond with ONLY valid JSON in this format:
371
372
FOR USING A TOOL:
373
{
374
"thought": "your reasoning about what to do next",
375
"action_type": "tool",
376
"tool": "%s" | "%s" | "%s",
377
378
// shell fields:
379
"command": "...",
380
"args": [...],
381
382
// LLM fields:
383
"prompt": "...",
384
385
// file fields:
386
"op": "read" | "write" | "patch" | "replace",
387
"path": "...",
388
389
// write/patch:
390
"data": "...", // REQUIRED for write and patch; MUST be non-empty for write
391
392
// replace:
393
"old": "...", // REQUIRED for replace
394
"new": "...", // REQUIRED for replace
395
"n": 0 // OPTIONAL for replace
396
}
397
398
FOR FINAL ANSWER:
399
{
400
"thought": "your reasoning about the answer",
401
"action_type": "answer",
402
"final_answer": "your complete answer to the user"
403
}
404
405
CRITICAL RULES:
406
- Return ONLY raw JSON (no markdown, no code fences, no prose)
407
- Return EXACTLY ONE JSON object per response (not an array)
408
- Do NOT output multiple JSON objects back-to-back (no "}{" and no extra text before/after)
409
- One tool call per response. If multiple steps are needed, choose the NEXT single step only.
410
- First non-whitespace character must be '{' and the last non-whitespace character must be '}'
411
- You MUST include "action_type" in every response.
412
- Do NOT invent alternative schemas (e.g., {"text":...}, {"content":...}, {"result":...} are INVALID).
413
- Allowed top-level keys are STRICT:
414
- For action_type="tool": thought, action_type, tool, command, args, prompt, op, path, data, old, new, n
415
- For action_type="answer": thought, action_type, final_answer
416
- No other top-level keys are permitted.
417
- Include only fields relevant to your chosen tool
418
- Keep "thought" concise
419
- When you have enough information to answer, respond with action_type="answer" and include "final_answer"
420
421
WRITE CONTENT RULE (CRITICAL):
422
- file op="write" is INVALID without a non-empty "data" field.
423
- If you cannot produce the full file contents yet, you must NOT call write.
424
Instead, gather what you need first, then call write with complete contents.
425
426
FILE-DELIVERY RULE (CRITICAL):
427
- If the user asks you to write, save, put, or output anything into a file, you MUST do a file tool call with op="write" (or patch/replace for an existing file) BEFORE you respond with action_type="answer".
428
- Do NOT claim you created or wrote a file unless you actually executed a file tool step.
429
- If the user did not specify a filename, choose a reasonable one (e.g., "output.txt") and write to it.
430
431
NEW FILE RULE:
432
- To create a new file, use file op="write".
433
- patch/replace are only for modifying an existing file (after reading it, unless the user gave you exact old/new context).
434
435
ORDERING RULE:
436
- If a file tool call is required, it must happen in a step BEFORE any action_type="answer".
437
- The final answer may only reference files that were actually written or modified.
438
439
PROGRESS RULES:
440
- Never call the exact same tool+args twice in a row.
441
- After reading a file once, do not reread it unless you explain what NEW information you need.
442
- Prefer making the smallest safe change, but remember: writes overwrite the entire file.
443
- If you are stuck, finish with action_type:"answer" explaining what you need next.
444
445
COMPLETION RULE (CRITICAL)
446
- After a tool call succeeds and the user’s goal has been satisfied, your very next response MUST be a final JSON answer in this exact shape:
447
448
{
449
"thought": "brief reasoning about completion",
450
"action_type": "answer",
451
"final_answer": "clear confirmation of what was done and any relevant result"
452
}
453
454
- Do NOT say things like:
455
- "I’m ready to assist further."
456
- "Let me know if you need anything else."
457
- Any plain-text response outside JSON.
458
459
INVALID EXAMPLE (DO NOT DO THIS)
460
461
I’m ready to assist further if you have any new tasks or questions.
462
463
This is invalid because:
464
- It is not JSON.
465
- It does not include action_type.
466
- It breaks the ReAct protocol.
467
468
State:
469
470
%s
471
472
Conversation history:
473
474
%s
475
476
What's your next step?`, types.ToolShell, types.ToolLLM, types.ToolFiles, stateLine, history)
477
}
478
479
func parseReActResponse(raw string) (reActAction, error) {
480
raw = cleanReActOutput(raw)
481
raw = strings.TrimSpace(raw)
482
if raw == "" {
483
return reActAction{}, errors.New("empty response from LLM")
484
}
485
486
one, err := extractFirstJSONObject(raw)
487
if err != nil {
488
return reActAction{}, fmt.Errorf("failed to locate JSON object: %w", err)
489
}
490
491
var action reActAction
492
if err := json.Unmarshal([]byte(one), &action); err != nil {
493
return reActAction{}, fmt.Errorf("failed to parse JSON: %w", err)
494
}
495
496
action.Thought = strings.TrimSpace(action.Thought)
497
action.ActionType = strings.ToLower(strings.TrimSpace(action.ActionType))
498
action.Tool = strings.ToLower(strings.TrimSpace(action.Tool))
499
500
if action.ActionType == "" {
501
return reActAction{}, errors.New("missing action_type")
502
}
503
504
// Compatibility: allow shorthand like {"action_type":"file", ...}
505
// This must work whether "tool" is also set.
506
if action.ActionType != "tool" && action.ActionType != "answer" {
507
switch action.ActionType {
508
case "file", "shell", "llm":
509
// If tool is empty OR matches the shorthand, normalize to canonical form.
510
// (If tool is set to something else, we'll fall through to invalid action_type.)
511
if action.Tool == "" || action.Tool == action.ActionType {
512
action.Tool = action.ActionType
513
action.ActionType = "tool"
514
}
515
}
516
}
517
518
if action.ActionType == "answer" {
519
if strings.TrimSpace(action.FinalAnswer) == "" {
520
return reActAction{}, errors.New("action_type=answer but final_answer is empty")
521
}
522
return action, nil
523
}
524
525
if action.ActionType == "tool" {
526
if action.Tool == "" {
527
return reActAction{}, errors.New("action_type=tool but tool field is empty")
528
}
529
return action, nil
530
}
531
532
return reActAction{}, fmt.Errorf("invalid action_type: %q", action.ActionType)
533
}
534
535
func cleanReActOutput(raw string) string {
536
raw = strings.TrimSpace(raw)
537
538
if strings.HasPrefix(raw, "```") {
539
raw = strings.TrimPrefix(raw, "```")
540
raw = strings.TrimSpace(raw)
541
542
if i := strings.IndexByte(raw, '\n'); i != -1 {
543
firstLine := strings.ToLower(strings.TrimSpace(raw[:i]))
544
if firstLine == "json" || firstLine == "application/json" {
545
raw = raw[i+1:]
546
}
547
}
548
549
raw = strings.TrimSpace(raw)
550
raw = strings.TrimSuffix(raw, "```")
551
raw = strings.TrimSpace(raw)
552
}
553
554
return raw
555
}
556
557
func convertReActActionToStep(action reActAction) (types.Step, error) {
558
switch types.ToolKind(action.Tool) {
559
case types.ToolShell:
560
cmd := strings.TrimSpace(action.Command)
561
if cmd == "" {
562
return types.Step{}, errors.New("shell tool requires command")
563
}
564
return types.Step{
565
Type: types.ToolShell,
566
Description: fmt.Sprintf("Execute: %s %v", cmd, action.Args),
567
Command: cmd,
568
Args: action.Args,
569
}, nil
570
571
case types.ToolLLM:
572
prompt := strings.TrimSpace(action.Prompt)
573
if prompt == "" {
574
return types.Step{}, errors.New("LLM tool requires prompt")
575
}
576
return types.Step{
577
Type: types.ToolLLM,
578
Description: "LLM reasoning",
579
Prompt: prompt,
580
}, nil
581
582
case types.ToolFiles:
583
op := strings.ToLower(strings.TrimSpace(action.Op))
584
path := strings.TrimSpace(action.Path)
585
if op == "" {
586
return types.Step{}, errors.New("file tool requires op")
587
}
588
if path == "" {
589
return types.Step{}, errors.New("file tool requires path")
590
}
591
592
step := types.Step{
593
Type: types.ToolFiles,
594
Description: fmt.Sprintf("File %s: %s", op, path),
595
Op: op,
596
Path: path,
597
Data: action.Data,
598
}
599
600
switch op {
601
case "patch":
602
if strings.TrimSpace(action.Data) == "" {
603
return types.Step{}, errors.New("file patch requires data (unified diff)")
604
}
605
case "replace":
606
if action.Old == "" {
607
return types.Step{}, errors.New("file replace requires old pattern")
608
}
609
// new can be empty string in principle (delete), so don't forbid it.
610
step.Old = action.Old
611
step.New = action.New
612
step.N = action.N
613
case "write":
614
if strings.TrimSpace(action.Data) == "" {
615
return types.Step{}, errors.New("file write requires data")
616
}
617
case "read":
618
// ok
619
default:
620
return types.Step{}, fmt.Errorf("unsupported file op: %q", op)
621
}
622
623
return step, nil
624
625
default:
626
return types.Step{}, fmt.Errorf("unknown tool: %q", action.Tool)
627
}
628
}
629
630
func extractFirstJSONObject(s string) (string, error) {
631
// Find first '{'
632
start := strings.IndexByte(s, '{')
633
if start == -1 {
634
return "", errors.New("no '{' found")
635
}
636
637
inString := false
638
escape := false
639
depth := 0
640
641
for i := start; i < len(s); i++ {
642
ch := s[i]
643
644
if inString {
645
if escape {
646
escape = false
647
continue
648
}
649
if ch == '\\' {
650
escape = true
651
continue
652
}
653
if ch == '"' {
654
inString = false
655
}
656
continue
657
}
658
659
switch ch {
660
case '"':
661
inString = true
662
case '{':
663
depth++
664
case '}':
665
depth--
666
if depth == 0 {
667
// Return the first complete top-level object
668
return strings.TrimSpace(s[start : i+1]), nil
669
}
670
}
671
}
672
673
return "", errors.New("unterminated JSON object")
674
}
675
676
func truncateForDisplay(s string, maxLen int) string {
677
s = strings.TrimSpace(s)
678
if len(s) <= maxLen {
679
return s
680
}
681
return s[:maxLen] + "..."
682
}
683
684
type actionSig struct {
685
tool string
686
key string
687
}
688
689
type repetitionGuard struct {
690
last *actionSig
691
counts map[actionSig]int
692
history []actionSig
693
limit int // max history length to keep counts meaningful
694
}
695
696
func newRepetitionGuard(limit int) *repetitionGuard {
697
if limit <= 0 {
698
limit = 32
699
}
700
return &repetitionGuard{
701
counts: make(map[actionSig]int),
702
limit: limit,
703
}
704
}
705
706
func (g *repetitionGuard) observe(sig actionSig) {
707
// maintain rolling window counts
708
g.history = append(g.history, sig)
709
g.counts[sig]++
710
711
if len(g.history) > g.limit {
712
evicted := g.history[0]
713
g.history = g.history[1:]
714
g.counts[evicted]--
715
if g.counts[evicted] <= 0 {
716
delete(g.counts, evicted)
717
}
718
}
719
720
// last is always updated
721
g.last = &sig
722
}
723
724
func (g *repetitionGuard) isImmediateRepeat(sig actionSig) bool {
725
return g.last != nil && g.last.tool == sig.tool && g.last.key == sig.key
726
}
727
728
func (g *repetitionGuard) count(sig actionSig) int {
729
return g.counts[sig]
730
}
731
732
func signatureForAction(a reActAction) actionSig {
733
tool := strings.ToLower(strings.TrimSpace(a.Tool))
734
735
switch types.ToolKind(tool) {
736
case types.ToolFiles:
737
op := strings.ToLower(strings.TrimSpace(a.Op))
738
path := strings.TrimPrefix(strings.TrimSpace(a.Path), "./")
739
740
if op == "replace" {
741
old := a.Old
742
newv := a.New
743
if len(old) > 40 {
744
old = old[:40]
745
}
746
if len(newv) > 40 {
747
newv = newv[:40]
748
}
749
return actionSig{tool: string(types.ToolFiles), key: fmt.Sprintf("%s:%s old=%q new=%q n=%d", op, path, old, newv, a.N)}
750
}
751
752
if op == "patch" {
753
diff := strings.TrimSpace(a.Data)
754
prefix := diff
755
if len(prefix) > 80 {
756
prefix = prefix[:80]
757
}
758
return actionSig{tool: string(types.ToolFiles), key: fmt.Sprintf("%s:%s len=%d:%q", op, path, len(diff), prefix)}
759
}
760
761
return actionSig{tool: string(types.ToolFiles), key: op + ":" + path}
762
763
case types.ToolShell:
764
cmd := strings.TrimSpace(a.Command)
765
args := normalizeArgs(a.Args)
766
key := strings.TrimSpace(cmd + " " + strings.Join(args, " "))
767
return actionSig{tool: string(types.ToolShell), key: key}
768
769
case types.ToolLLM:
770
p := strings.TrimSpace(a.Prompt)
771
prefix := p
772
if len(prefix) > 80 {
773
prefix = prefix[:80]
774
}
775
return actionSig{tool: string(types.ToolLLM), key: fmt.Sprintf("len=%d:%s", len(p), prefix)}
776
777
default:
778
return actionSig{tool: tool, key: ""}
779
}
780
}
781
782
func normalizeArgs(in []string) []string {
783
if len(in) == 0 {
784
return nil
785
}
786
out := make([]string, 0, len(in))
787
for _, a := range in {
788
s := strings.TrimSpace(a)
789
if s == "" {
790
continue
791
}
792
out = append(out, s)
793
}
794
return out
795
}
796
797
func (a *ReActAgent) promptStateLine() string {
798
if len(a.effects) == 0 {
799
return ""
800
}
801
return "side_effects_total=" + summarizeEffectsForUI(a.effects)
802
}
803
804
func formatEffectsForConversation(effects types.Effects) string {
805
if len(effects) == 0 {
806
return "SIDE_EFFECTS: none"
807
}
808
809
var b strings.Builder
810
b.WriteString("SIDE_EFFECTS:\n")
811
for _, e := range effects {
812
b.WriteString("- kind=")
813
b.WriteString(e.Kind)
814
815
if e.Path != "" {
816
b.WriteString(" path=")
817
b.WriteString(strconv.Quote(e.Path))
818
}
819
820
if e.Bytes != 0 {
821
b.WriteString(" bytes=")
822
b.WriteString(strconv.Itoa(e.Bytes))
823
}
824
825
if len(e.Meta) > 0 {
826
raw, err := json.Marshal(e.Meta)
827
if err == nil {
828
b.WriteString(" meta=")
829
b.WriteString(string(raw))
830
}
831
}
832
833
b.WriteString("\n")
834
}
835
836
return strings.TrimRightFunc(b.String(), unicode.IsSpace)
837
}
838
839
func summarizeEffectsForUI(effects types.Effects) string {
840
if len(effects) == 0 {
841
return "no side effects"
842
}
843
844
counts := map[string]int{}
845
for _, e := range effects {
846
counts[e.Kind]++
847
}
848
849
var parts []string
850
for k, n := range counts {
851
parts = append(parts, fmt.Sprintf("%s x%d", k, n))
852
}
853
sort.Strings(parts)
854
return strings.Join(parts, ", ")
855
}
856
857
func summarizeActionsForUI(effects types.Effects, llmCalls int) string {
858
counts := map[string]int{}
859
for _, e := range effects {
860
counts[e.Kind]++
861
}
862
if llmCalls > 0 {
863
counts["llm.call"] += llmCalls
864
}
865
866
if len(counts) == 0 {
867
return "none"
868
}
869
870
var parts []string
871
for k, n := range counts {
872
parts = append(parts, fmt.Sprintf("%s x%d", k, n))
873
}
874
sort.Strings(parts)
875
return strings.Join(parts, ", ")
876
}
877
878
func mergeEffects(dst *types.Effects, src types.Effects) {
879
if len(src) == 0 {
880
return
881
}
882
*dst = append(*dst, src...)
883
}
884
885