detached a28c5f2

Merge pull request #63 from bnema/feat/p2-host-continuation-helper

feat(modes): recover context overflows once
commit
a28c5f27648fb518e96660c5eb4da6053784f0e4
author
bnema · · 2026-08-07 08:15 UTC
committer
GitHub · · 2026-08-07 08:15 UTC
merge of
3110ba7 + 6c6ed6e · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.8.0
8 files · +377 -54
M README.md +2 -0
@@ -247,6 +247,8 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r
247247 - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md).
248248 - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients.
249249
250+When an initial print, stream, or JSON request exceeds the provider context window, zut compacts the existing transcript and continues the already-appended prompt once. Print still writes only the recovered final text, stream keeps assistant text on stdout and tool diagnostics on stderr, and JSON suppresses the recoverable first terminal error so stdout remains JSONL for the successful turn. A compaction failure or a second context-window error remains terminal; this is not a general autonomous-follow-up policy. The same one-shot recovery applies to subagent workers. It does not apply to RPC, SDK, bot, or Telegram requests.
251+
250252 ## zutfile agents
251253
252254 A zutfile packages an agent's instructions, skills, requirements, and enforced tool permissions as a shareable agent. Run one from a local directory, a packed `.zut` artifact, a short name, or directly from a public GitHub repository:
@@ -247,6 +247,8 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r
247 - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md).247 - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md).
248 - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients.248 - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients.
249 249
250+When an initial print, stream, or JSON request exceeds the provider context window, zut compacts the existing transcript and continues the already-appended prompt once. Print still writes only the recovered final text, stream keeps assistant text on stdout and tool diagnostics on stderr, and JSON suppresses the recoverable first terminal error so stdout remains JSONL for the successful turn. A compaction failure or a second context-window error remains terminal; this is not a general autonomous-follow-up policy. The same one-shot recovery applies to subagent workers. It does not apply to RPC, SDK, bot, or Telegram requests.
251+
250 ## zutfile agents252 ## zutfile agents
251 253
252 A zutfile packages an agent's instructions, skills, requirements, and enforced tool permissions as a shareable agent. Run one from a local directory, a packed `.zut` artifact, a short name, or directly from a public GitHub repository:254 A zutfile packages an agent's instructions, skills, requirements, and enforced tool permissions as a shareable agent. Run one from a local directory, a packed `.zut` artifact, a short name, or directly from a public GitHub repository:
M packages/agent/cli.go +30 -6
@@ -931,10 +931,18 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
931931 if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
932932 return err
933933 }
934+ var persistCompaction func([]provider.Message) error
935+ if sess != nil {
936+ persistCompaction = sess.AppendCompaction
937+ }
934938 started := time.Now()
935- usage, err := modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)
939+ usage, recovery, err := modes.RunPrintWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction)
936940 elapsed := time.Since(started)
937- WriteNewTranscript(ag, sess, start)
941+ transcriptStart := start
942+ if recovery.Compacted {
943+ transcriptStart = recovery.OutputStart
944+ }
945+ WriteNewTranscript(ag, sess, transcriptStart)
938946 if err != nil {
939947 return err
940948 }
@@ -999,8 +1007,16 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
9991007 if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
10001008 return err
10011009 }
1002- err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout)
1003- WriteNewTranscript(ag, sess, start)
1010+ var persistCompaction func([]provider.Message) error
1011+ if sess != nil {
1012+ persistCompaction = sess.AppendCompaction
1013+ }
1014+ recovery, err := modes.RunStreamWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, os.Stderr, persistCompaction)
1015+ transcriptStart := start
1016+ if recovery.Compacted {
1017+ transcriptStart = recovery.OutputStart
1018+ }
1019+ WriteNewTranscript(ag, sess, transcriptStart)
10041020 return err
10051021 }
10061022
@@ -1106,8 +1122,16 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
11061122 _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
11071123 return err
11081124 }
1109- err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)
1110- WriteNewTranscript(ag, sess, start)
1125+ var persistCompaction func([]provider.Message) error
1126+ if sess != nil {
1127+ persistCompaction = sess.AppendCompaction
1128+ }
1129+ recovery, err := modes.RunJSONWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction)
1130+ transcriptStart := start
1131+ if recovery.Compacted {
1132+ transcriptStart = recovery.OutputStart
1133+ }
1134+ WriteNewTranscript(ag, sess, transcriptStart)
11111135 return err
11121136 }
11131137
@@ -931,10 +931,18 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
931 if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {931 if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
932 return err932 return err
933 }933 }
934+ var persistCompaction func([]provider.Message) error
935+ if sess != nil {
936+ persistCompaction = sess.AppendCompaction
937+ }
934 started := time.Now()938 started := time.Now()
935- usage, err := modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)939+ usage, recovery, err := modes.RunPrintWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction)
936 elapsed := time.Since(started)940 elapsed := time.Since(started)
937- WriteNewTranscript(ag, sess, start)941+ transcriptStart := start
942+ if recovery.Compacted {
943+ transcriptStart = recovery.OutputStart
944+ }
945+ WriteNewTranscript(ag, sess, transcriptStart)
938 if err != nil {946 if err != nil {
939 return err947 return err
940 }948 }
@@ -999,8 +1007,16 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
999 if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {1007 if err := reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag); err != nil {
1000 return err1008 return err
1001 }1009 }
1002- err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout)1010+ var persistCompaction func([]provider.Message) error
1003- WriteNewTranscript(ag, sess, start)1011+ if sess != nil {
1012+ persistCompaction = sess.AppendCompaction
1013+ }
1014+ recovery, err := modes.RunStreamWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, os.Stderr, persistCompaction)
1015+ transcriptStart := start
1016+ if recovery.Compacted {
1017+ transcriptStart = recovery.OutputStart
1018+ }
1019+ WriteNewTranscript(ag, sess, transcriptStart)
1004 return err1020 return err
1005 }1021 }
1006 1022
@@ -1106,8 +1122,16 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
1106 _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})1122 _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
1107 return err1123 return err
1108 }1124 }
1109- err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)1125+ var persistCompaction func([]provider.Message) error
1110- WriteNewTranscript(ag, sess, start)1126+ if sess != nil {
1127+ persistCompaction = sess.AppendCompaction
1128+ }
1129+ recovery, err := modes.RunJSONWithContextRecovery(ctx, ag, prompt, nil, os.Stdout, persistCompaction)
1130+ transcriptStart := start
1131+ if recovery.Compacted {
1132+ transcriptStart = recovery.OutputStart
1133+ }
1134+ WriteNewTranscript(ag, sess, transcriptStart)
1111 return err1135 return err
1112 }1136 }
1113 1137
A packages/agent/modes/context_recovery.go +84 -0
@@ -0,0 +1,84 @@
1+package modes
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/zut/packages/core"
8+ "github.com/bnema/zut/packages/provider"
9+)
10+
11+// ContextRecoveryOptions configures the host-owned, one-shot recovery for a
12+// provider context-overflow error. It deliberately does not add a terminal
13+// continuation policy to core.Agent.
14+type ContextRecoveryOptions struct {
15+ // PersistCompaction records the compacted transcript before the existing
16+ // prompt is continued. A persistence failure restores the pre-compaction
17+ // transcript and prevents the continuation request.
18+ PersistCompaction func([]provider.Message) error
19+
20+ // SuppressInitialOverflowEvent hides the recoverable first-attempt
21+ // EvTurnEnd overflow from sinks whose protocol cannot retract a terminal
22+ // error frame, such as JSONL. A retry overflow is never suppressed.
23+ SuppressInitialOverflowEvent bool
24+}
25+
26+// ContextRecoveryResult describes a completed host-level context recovery.
27+type ContextRecoveryResult struct {
28+ // OutputStart is the first message to append after a persisted compaction.
29+ // When Compacted is false, it is the message count before Prompt.
30+ OutputStart int
31+ Compacted bool
32+}
33+
34+// PromptWithContextRecovery appends prompt and runs it once. If that provider
35+// request exceeds its context window, it compacts the existing transcript and
36+// continues the already-appended prompt exactly once. Cancellation, a
37+// one-message transcript, a compaction failure, and a second overflow remain
38+// terminal outcomes.
39+func PromptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, sink func(core.AgentEvent), options ContextRecoveryOptions) (result ContextRecoveryResult, err error) {
40+ result.OutputStart = len(ag.Messages())
41+ if sink == nil {
42+ sink = func(core.AgentEvent) {}
43+ }
44+
45+ firstSink := sink
46+ if options.SuppressInitialOverflowEvent {
47+ firstSink = func(event core.AgentEvent) {
48+ if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
49+ return
50+ }
51+ sink(event)
52+ }
53+ }
54+
55+ if err = ag.Prompt(ctx, prompt, images, firstSink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) {
56+ return result, err
57+ }
58+
59+ messages := ag.Messages()
60+ if len(messages) <= 1 {
61+ // The failed prompt is the only message. Summarizing it would replace
62+ // the user's task with an LLM-generated paraphrase.
63+ return result, err
64+ }
65+ keepTail := min(4, len(messages)-1)
66+ if _, compactErr := ag.Compact(ctx, keepTail, nil); compactErr != nil {
67+ return result, fmt.Errorf("compact transcript after initial overflow: %w", compactErr)
68+ }
69+
70+ compacted := ag.Messages()
71+ if options.PersistCompaction != nil {
72+ if persistErr := options.PersistCompaction(compacted); persistErr != nil {
73+ // The session did not receive the compaction checkpoint, so restore
74+ // the original history plus the already-appended prompt rather than
75+ // let a later transcript flush make the session inconsistent.
76+ ag.SetMessages(messages)
77+ return result, fmt.Errorf("persist compacted transcript: %w", persistErr)
78+ }
79+ }
80+
81+ result.OutputStart = len(compacted)
82+ result.Compacted = true
83+ return result, ag.Continue(ctx, sink)
84+}
@@ -0,0 +1,84 @@
1+package modes
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/zut/packages/core"
8+ "github.com/bnema/zut/packages/provider"
9+)
10+
11+// ContextRecoveryOptions configures the host-owned, one-shot recovery for a
12+// provider context-overflow error. It deliberately does not add a terminal
13+// continuation policy to core.Agent.
14+type ContextRecoveryOptions struct {
15+ // PersistCompaction records the compacted transcript before the existing
16+ // prompt is continued. A persistence failure restores the pre-compaction
17+ // transcript and prevents the continuation request.
18+ PersistCompaction func([]provider.Message) error
19+
20+ // SuppressInitialOverflowEvent hides the recoverable first-attempt
21+ // EvTurnEnd overflow from sinks whose protocol cannot retract a terminal
22+ // error frame, such as JSONL. A retry overflow is never suppressed.
23+ SuppressInitialOverflowEvent bool
24+}
25+
26+// ContextRecoveryResult describes a completed host-level context recovery.
27+type ContextRecoveryResult struct {
28+ // OutputStart is the first message to append after a persisted compaction.
29+ // When Compacted is false, it is the message count before Prompt.
30+ OutputStart int
31+ Compacted bool
32+}
33+
34+// PromptWithContextRecovery appends prompt and runs it once. If that provider
35+// request exceeds its context window, it compacts the existing transcript and
36+// continues the already-appended prompt exactly once. Cancellation, a
37+// one-message transcript, a compaction failure, and a second overflow remain
38+// terminal outcomes.
39+func PromptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, sink func(core.AgentEvent), options ContextRecoveryOptions) (result ContextRecoveryResult, err error) {
40+ result.OutputStart = len(ag.Messages())
41+ if sink == nil {
42+ sink = func(core.AgentEvent) {}
43+ }
44+
45+ firstSink := sink
46+ if options.SuppressInitialOverflowEvent {
47+ firstSink = func(event core.AgentEvent) {
48+ if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
49+ return
50+ }
51+ sink(event)
52+ }
53+ }
54+
55+ if err = ag.Prompt(ctx, prompt, images, firstSink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) {
56+ return result, err
57+ }
58+
59+ messages := ag.Messages()
60+ if len(messages) <= 1 {
61+ // The failed prompt is the only message. Summarizing it would replace
62+ // the user's task with an LLM-generated paraphrase.
63+ return result, err
64+ }
65+ keepTail := min(4, len(messages)-1)
66+ if _, compactErr := ag.Compact(ctx, keepTail, nil); compactErr != nil {
67+ return result, fmt.Errorf("compact transcript after initial overflow: %w", compactErr)
68+ }
69+
70+ compacted := ag.Messages()
71+ if options.PersistCompaction != nil {
72+ if persistErr := options.PersistCompaction(compacted); persistErr != nil {
73+ // The session did not receive the compaction checkpoint, so restore
74+ // the original history plus the already-appended prompt rather than
75+ // let a later transcript flush make the session inconsistent.
76+ ag.SetMessages(messages)
77+ return result, fmt.Errorf("persist compacted transcript: %w", persistErr)
78+ }
79+ }
80+
81+ result.OutputStart = len(compacted)
82+ result.Compacted = true
83+ return result, ag.Continue(ctx, sink)
84+}
A packages/agent/modes/context_recovery_test.go +203 -0
@@ -0,0 +1,203 @@
1+package modes
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "sync"
10+ "testing"
11+
12+ "github.com/bnema/zut/packages/core"
13+ "github.com/bnema/zut/packages/provider"
14+)
15+
16+type hostRecoveryClient struct {
17+ mu sync.Mutex
18+ calls int
19+ retried chan provider.Request
20+ retryOverflow bool
21+}
22+
23+func (c *hostRecoveryClient) Name() string { return "host-context-recovery-test" }
24+
25+func (c *hostRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
26+ c.mu.Lock()
27+ c.calls++
28+ call := c.calls
29+ c.mu.Unlock()
30+
31+ events := make(chan provider.Event, 2)
32+ go func() {
33+ defer close(events)
34+ switch call {
35+ case 1:
36+ events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider error: input exceeds the context window")}
37+ case 2:
38+ events <- provider.EventTextDelta{Delta: "summary"}
39+ events <- provider.EventDone{Stop: provider.StopEnd}
40+ default:
41+ c.retried <- req
42+ if c.retryOverflow {
43+ events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("context window exceeded after compaction")}
44+ return
45+ }
46+ events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
47+ Role: provider.RoleAssistant,
48+ Content: []provider.Content{provider.TextBlock{Text: "recovered"}},
49+ }}
50+ }
51+ }()
52+ return events, nil
53+}
54+
55+func TestPromptWithContextRecoveryCompactsOnceAndSuppressesInitialOverflow(t *testing.T) {
56+ client := &hostRecoveryClient{retried: make(chan provider.Request, 1)}
57+ agent := core.NewAgent(client, "test-model", "", nil)
58+ agent.SetMessages([]provider.Message{
59+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
60+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
61+ })
62+
63+ var sawOverflowTurnEnd bool
64+ compacted := false
65+ result, err := PromptWithContextRecovery(context.Background(), agent, "finish the task", nil, func(event core.AgentEvent) {
66+ if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
67+ sawOverflowTurnEnd = true
68+ }
69+ }, ContextRecoveryOptions{
70+ PersistCompaction: func([]provider.Message) error {
71+ compacted = true
72+ return nil
73+ },
74+ SuppressInitialOverflowEvent: true,
75+ })
76+ if err != nil {
77+ t.Fatalf("PromptWithContextRecovery returned %v", err)
78+ }
79+ if !result.Compacted || !compacted {
80+ t.Fatalf("compaction result/persistence = %v/%v, want true/true", result.Compacted, compacted)
81+ }
82+ if sawOverflowTurnEnd {
83+ t.Fatal("recoverable initial overflow turn_end reached the sink")
84+ }
85+ if got := recoveredAssistantText(agent.Messages()[result.OutputStart:]); got != "recovered" {
86+ t.Fatalf("recovery output = %q, want recovered", got)
87+ }
88+ request := <-client.retried
89+ if got := requestUserTextCount(request, "finish the task"); got != 1 {
90+ t.Fatalf("retried request prompt count = %d, want 1: %#v", got, request.Messages)
91+ }
92+}
93+
94+func recoveredAssistantText(messages []provider.Message) string {
95+ for _, message := range messages {
96+ if message.Role != provider.RoleAssistant {
97+ continue
98+ }
99+ for _, content := range message.Content {
100+ if text, ok := content.(provider.TextBlock); ok {
101+ return text.Text
102+ }
103+ }
104+ }
105+ return ""
106+}
107+
108+func TestPromptWithContextRecoveryReportsRetryOverflowToSink(t *testing.T) {
109+ client := &hostRecoveryClient{retried: make(chan provider.Request, 1), retryOverflow: true}
110+ agent := core.NewAgent(client, "test-model", "", nil)
111+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
112+
113+ overflowTurnEnds := 0
114+ _, err := PromptWithContextRecovery(context.Background(), agent, "finish the task", nil, func(event core.AgentEvent) {
115+ if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
116+ overflowTurnEnds++
117+ }
118+ }, ContextRecoveryOptions{SuppressInitialOverflowEvent: true})
119+ if !provider.IsContextOverflowError(err) {
120+ t.Fatalf("PromptWithContextRecovery error = %v, want retry context overflow", err)
121+ }
122+ if overflowTurnEnds != 1 {
123+ t.Fatalf("overflow turn_end count = %d, want retry overflow only", overflowTurnEnds)
124+ }
125+ client.mu.Lock()
126+ calls := client.calls
127+ client.mu.Unlock()
128+ if calls != 3 {
129+ t.Fatalf("provider calls = %d, want prompt, compaction, and one continuation", calls)
130+ }
131+}
132+
133+func newRecoveredModeAgent() *core.Agent {
134+ return core.NewAgent(&hostRecoveryClient{retried: make(chan provider.Request, 1)}, "test-model", "", nil)
135+}
136+
137+func TestRunPrintRecoversContextOverflowWithoutLeakingIntermediateOutput(t *testing.T) {
138+ agent := newRecoveredModeAgent()
139+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
140+ var output bytes.Buffer
141+
142+ if _, err := RunPrint(context.Background(), agent, "finish the task", nil, &output); err != nil {
143+ t.Fatalf("RunPrint returned %v", err)
144+ }
145+ if got := output.String(); got != "recovered\n" {
146+ t.Fatalf("stdout = %q, want recovered only", got)
147+ }
148+}
149+
150+func TestRunStreamRecoversContextOverflowWithoutDiagnosticsLeak(t *testing.T) {
151+ agent := newRecoveredModeAgent()
152+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
153+ var output, diagnostics bytes.Buffer
154+
155+ if err := RunStreamWithDiag(context.Background(), agent, "finish the task", nil, &output, &diagnostics); err != nil {
156+ t.Fatalf("RunStreamWithDiag returned %v", err)
157+ }
158+ if got := output.String(); got != "recovered\n" {
159+ t.Fatalf("stdout = %q, want recovered assistant text once", got)
160+ }
161+ if got := diagnostics.String(); got != "" {
162+ t.Fatalf("stderr diagnostics = %q, want no context-overflow diagnostic", got)
163+ }
164+}
165+
166+func TestRunJSONRecoversContextOverflowWithJSONLOnly(t *testing.T) {
167+ agent := newRecoveredModeAgent()
168+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
169+ var output bytes.Buffer
170+
171+ if err := RunJSON(context.Background(), agent, "finish the task", nil, &output); err != nil {
172+ t.Fatalf("RunJSON returned %v", err)
173+ }
174+
175+ var (
176+ turnEnds int
177+ done int
178+ )
179+ scanner := bufio.NewScanner(&output)
180+ for scanner.Scan() {
181+ var event map[string]any
182+ if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
183+ t.Fatalf("stdout line is not JSONL: %q: %v", scanner.Text(), err)
184+ }
185+ switch event["type"] {
186+ case "error":
187+ t.Fatalf("recoverable overflow wrote error frame: %#v", event)
188+ case "turn_end":
189+ turnEnds++
190+ if _, hasError := event["error"]; hasError {
191+ t.Fatalf("recoverable overflow wrote terminal turn_end: %#v", event)
192+ }
193+ case "done":
194+ done++
195+ }
196+ }
197+ if err := scanner.Err(); err != nil {
198+ t.Fatalf("scan JSONL: %v", err)
199+ }
200+ if turnEnds != 1 || done != 1 {
201+ t.Fatalf("turn_end/done count = %d/%d, want one recovered terminal sequence", turnEnds, done)
202+ }
203+}
@@ -0,0 +1,203 @@
1+package modes
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "sync"
10+ "testing"
11+
12+ "github.com/bnema/zut/packages/core"
13+ "github.com/bnema/zut/packages/provider"
14+)
15+
16+type hostRecoveryClient struct {
17+ mu sync.Mutex
18+ calls int
19+ retried chan provider.Request
20+ retryOverflow bool
21+}
22+
23+func (c *hostRecoveryClient) Name() string { return "host-context-recovery-test" }
24+
25+func (c *hostRecoveryClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
26+ c.mu.Lock()
27+ c.calls++
28+ call := c.calls
29+ c.mu.Unlock()
30+
31+ events := make(chan provider.Event, 2)
32+ go func() {
33+ defer close(events)
34+ switch call {
35+ case 1:
36+ events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("provider error: input exceeds the context window")}
37+ case 2:
38+ events <- provider.EventTextDelta{Delta: "summary"}
39+ events <- provider.EventDone{Stop: provider.StopEnd}
40+ default:
41+ c.retried <- req
42+ if c.retryOverflow {
43+ events <- provider.EventDone{Stop: provider.StopError, Err: errors.New("context window exceeded after compaction")}
44+ return
45+ }
46+ events <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
47+ Role: provider.RoleAssistant,
48+ Content: []provider.Content{provider.TextBlock{Text: "recovered"}},
49+ }}
50+ }
51+ }()
52+ return events, nil
53+}
54+
55+func TestPromptWithContextRecoveryCompactsOnceAndSuppressesInitialOverflow(t *testing.T) {
56+ client := &hostRecoveryClient{retried: make(chan provider.Request, 1)}
57+ agent := core.NewAgent(client, "test-model", "", nil)
58+ agent.SetMessages([]provider.Message{
59+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}},
60+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}},
61+ })
62+
63+ var sawOverflowTurnEnd bool
64+ compacted := false
65+ result, err := PromptWithContextRecovery(context.Background(), agent, "finish the task", nil, func(event core.AgentEvent) {
66+ if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
67+ sawOverflowTurnEnd = true
68+ }
69+ }, ContextRecoveryOptions{
70+ PersistCompaction: func([]provider.Message) error {
71+ compacted = true
72+ return nil
73+ },
74+ SuppressInitialOverflowEvent: true,
75+ })
76+ if err != nil {
77+ t.Fatalf("PromptWithContextRecovery returned %v", err)
78+ }
79+ if !result.Compacted || !compacted {
80+ t.Fatalf("compaction result/persistence = %v/%v, want true/true", result.Compacted, compacted)
81+ }
82+ if sawOverflowTurnEnd {
83+ t.Fatal("recoverable initial overflow turn_end reached the sink")
84+ }
85+ if got := recoveredAssistantText(agent.Messages()[result.OutputStart:]); got != "recovered" {
86+ t.Fatalf("recovery output = %q, want recovered", got)
87+ }
88+ request := <-client.retried
89+ if got := requestUserTextCount(request, "finish the task"); got != 1 {
90+ t.Fatalf("retried request prompt count = %d, want 1: %#v", got, request.Messages)
91+ }
92+}
93+
94+func recoveredAssistantText(messages []provider.Message) string {
95+ for _, message := range messages {
96+ if message.Role != provider.RoleAssistant {
97+ continue
98+ }
99+ for _, content := range message.Content {
100+ if text, ok := content.(provider.TextBlock); ok {
101+ return text.Text
102+ }
103+ }
104+ }
105+ return ""
106+}
107+
108+func TestPromptWithContextRecoveryReportsRetryOverflowToSink(t *testing.T) {
109+ client := &hostRecoveryClient{retried: make(chan provider.Request, 1), retryOverflow: true}
110+ agent := core.NewAgent(client, "test-model", "", nil)
111+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
112+
113+ overflowTurnEnds := 0
114+ _, err := PromptWithContextRecovery(context.Background(), agent, "finish the task", nil, func(event core.AgentEvent) {
115+ if turnEnd, ok := event.(core.EvTurnEnd); ok && provider.IsContextOverflowError(turnEnd.Err) {
116+ overflowTurnEnds++
117+ }
118+ }, ContextRecoveryOptions{SuppressInitialOverflowEvent: true})
119+ if !provider.IsContextOverflowError(err) {
120+ t.Fatalf("PromptWithContextRecovery error = %v, want retry context overflow", err)
121+ }
122+ if overflowTurnEnds != 1 {
123+ t.Fatalf("overflow turn_end count = %d, want retry overflow only", overflowTurnEnds)
124+ }
125+ client.mu.Lock()
126+ calls := client.calls
127+ client.mu.Unlock()
128+ if calls != 3 {
129+ t.Fatalf("provider calls = %d, want prompt, compaction, and one continuation", calls)
130+ }
131+}
132+
133+func newRecoveredModeAgent() *core.Agent {
134+ return core.NewAgent(&hostRecoveryClient{retried: make(chan provider.Request, 1)}, "test-model", "", nil)
135+}
136+
137+func TestRunPrintRecoversContextOverflowWithoutLeakingIntermediateOutput(t *testing.T) {
138+ agent := newRecoveredModeAgent()
139+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
140+ var output bytes.Buffer
141+
142+ if _, err := RunPrint(context.Background(), agent, "finish the task", nil, &output); err != nil {
143+ t.Fatalf("RunPrint returned %v", err)
144+ }
145+ if got := output.String(); got != "recovered\n" {
146+ t.Fatalf("stdout = %q, want recovered only", got)
147+ }
148+}
149+
150+func TestRunStreamRecoversContextOverflowWithoutDiagnosticsLeak(t *testing.T) {
151+ agent := newRecoveredModeAgent()
152+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
153+ var output, diagnostics bytes.Buffer
154+
155+ if err := RunStreamWithDiag(context.Background(), agent, "finish the task", nil, &output, &diagnostics); err != nil {
156+ t.Fatalf("RunStreamWithDiag returned %v", err)
157+ }
158+ if got := output.String(); got != "recovered\n" {
159+ t.Fatalf("stdout = %q, want recovered assistant text once", got)
160+ }
161+ if got := diagnostics.String(); got != "" {
162+ t.Fatalf("stderr diagnostics = %q, want no context-overflow diagnostic", got)
163+ }
164+}
165+
166+func TestRunJSONRecoversContextOverflowWithJSONLOnly(t *testing.T) {
167+ agent := newRecoveredModeAgent()
168+ agent.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prior context"}}}})
169+ var output bytes.Buffer
170+
171+ if err := RunJSON(context.Background(), agent, "finish the task", nil, &output); err != nil {
172+ t.Fatalf("RunJSON returned %v", err)
173+ }
174+
175+ var (
176+ turnEnds int
177+ done int
178+ )
179+ scanner := bufio.NewScanner(&output)
180+ for scanner.Scan() {
181+ var event map[string]any
182+ if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
183+ t.Fatalf("stdout line is not JSONL: %q: %v", scanner.Text(), err)
184+ }
185+ switch event["type"] {
186+ case "error":
187+ t.Fatalf("recoverable overflow wrote error frame: %#v", event)
188+ case "turn_end":
189+ turnEnds++
190+ if _, hasError := event["error"]; hasError {
191+ t.Fatalf("recoverable overflow wrote terminal turn_end: %#v", event)
192+ }
193+ case "done":
194+ done++
195+ }
196+ }
197+ if err := scanner.Err(); err != nil {
198+ t.Fatalf("scan JSONL: %v", err)
199+ }
200+ if turnEnds != 1 || done != 1 {
201+ t.Fatalf("turn_end/done count = %d/%d, want one recovered terminal sequence", turnEnds, done)
202+ }
203+}
M packages/agent/modes/json.go +16 -2
@@ -13,6 +13,16 @@ import (
1313 // RunJSON runs the agent to completion, writing one JSON object per
1414 // AgentEvent as newline-delimited JSON.
1515 func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) error {
16+ _, err := RunJSONWithContextRecovery(ctx, ag, prompt, images, out, nil)
17+ return err
18+}
19+
20+// RunJSONWithContextRecovery is RunJSON with an optional checkpoint writer
21+// for a one-shot context-overflow compaction. It suppresses the recoverable
22+// first turn_end so stdout never contains a terminal error frame before the
23+// continued turn succeeds. OutputStart identifies the suffix to persist after
24+// a successful checkpoint.
25+func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) {
1626 enc := json.NewEncoder(out)
1727 write := func(v any) {
1828 _ = enc.Encode(v)
@@ -23,14 +33,18 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid
2333 write(EventToJSON(ev))
2434 }
2535
26- if err := ag.Prompt(ctx, prompt, images, sink); err != nil {
36+ recovery, err := PromptWithContextRecovery(ctx, ag, prompt, images, sink, ContextRecoveryOptions{
37+ PersistCompaction: persistCompaction,
38+ SuppressInitialOverflowEvent: true,
39+ })
40+ if err != nil {
2741 runErr = err
2842 }
2943
3044 if runErr != nil {
3145 fmt.Fprintln(out, `{"type":"error","message":`+jsonString(runErr.Error())+`}`)
3246 }
33- return runErr
47+ return recovery, runErr
3448 }
3549
3650 // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire
@@ -13,6 +13,16 @@ import (
13 // RunJSON runs the agent to completion, writing one JSON object per13 // RunJSON runs the agent to completion, writing one JSON object per
14 // AgentEvent as newline-delimited JSON.14 // AgentEvent as newline-delimited JSON.
15 func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) error {15 func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) error {
16+ _, err := RunJSONWithContextRecovery(ctx, ag, prompt, images, out, nil)
17+ return err
18+}
19+
20+// RunJSONWithContextRecovery is RunJSON with an optional checkpoint writer
21+// for a one-shot context-overflow compaction. It suppresses the recoverable
22+// first turn_end so stdout never contains a terminal error frame before the
23+// continued turn succeeds. OutputStart identifies the suffix to persist after
24+// a successful checkpoint.
25+func RunJSONWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) {
16 enc := json.NewEncoder(out)26 enc := json.NewEncoder(out)
17 write := func(v any) {27 write := func(v any) {
18 _ = enc.Encode(v)28 _ = enc.Encode(v)
@@ -23,14 +33,18 @@ func RunJSON(ctx context.Context, ag *core.Agent, prompt string, images []provid
23 write(EventToJSON(ev))33 write(EventToJSON(ev))
24 }34 }
25 35
26- if err := ag.Prompt(ctx, prompt, images, sink); err != nil {36+ recovery, err := PromptWithContextRecovery(ctx, ag, prompt, images, sink, ContextRecoveryOptions{
37+ PersistCompaction: persistCompaction,
38+ SuppressInitialOverflowEvent: true,
39+ })
40+ if err != nil {
27 runErr = err41 runErr = err
28 }42 }
29 43
30 if runErr != nil {44 if runErr != nil {
31 fmt.Fprintln(out, `{"type":"error","message":`+jsonString(runErr.Error())+`}`)45 fmt.Fprintln(out, `{"type":"error","message":`+jsonString(runErr.Error())+`}`)
32 }46 }
33- return runErr47+ return recovery, runErr
34 }48 }
35 49
36 // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire50 // EventToJSON converts an AgentEvent to a JSON-friendly map. The on-wire
M packages/agent/modes/print.go +17 -5
@@ -15,6 +15,14 @@ import (
1515 // assistant text block to out. It returns usage for this invocation,
1616 // excluding any cumulative usage restored from a session.
1717 func RunPrint(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) (provider.Usage, error) {
18+ usage, _, err := RunPrintWithContextRecovery(ctx, ag, prompt, images, out, nil)
19+ return usage, err
20+}
21+
22+// RunPrintWithContextRecovery is RunPrint with an optional checkpoint writer
23+// for a one-shot context-overflow compaction. OutputStart identifies the
24+// transcript suffix that callers must persist after a successful checkpoint.
25+func RunPrintWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (provider.Usage, ContextRecoveryResult, error) {
1826 var finalText strings.Builder
1927 var lastAssistant string
2028 var usage provider.Usage
@@ -51,17 +59,21 @@ func RunPrint(ctx context.Context, ag *core.Agent, prompt string, images []provi
5159 }
5260 }
5361
54- if err := ag.Prompt(ctx, prompt, images, sink); err != nil {
55- return usage, err
62+ recovery, err := PromptWithContextRecovery(ctx, ag, prompt, images, sink, ContextRecoveryOptions{
63+ PersistCompaction: persistCompaction,
64+ SuppressInitialOverflowEvent: true,
65+ })
66+ if err != nil {
67+ return usage, recovery, err
5668 }
5769 if runErr != nil {
58- return usage, runErr
70+ return usage, recovery, runErr
5971 }
6072
6173 finalText.WriteString(lastAssistant)
6274 if finalText.Len() > 0 && !strings.HasSuffix(finalText.String(), "\n") {
6375 finalText.WriteString("\n")
6476 }
65- _, err := fmt.Fprint(out, finalText.String())
66- return usage, err
77+ _, err = fmt.Fprint(out, finalText.String())
78+ return usage, recovery, err
6779 }
@@ -15,6 +15,14 @@ import (
15 // assistant text block to out. It returns usage for this invocation,15 // assistant text block to out. It returns usage for this invocation,
16 // excluding any cumulative usage restored from a session.16 // excluding any cumulative usage restored from a session.
17 func RunPrint(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) (provider.Usage, error) {17 func RunPrint(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) (provider.Usage, error) {
18+ usage, _, err := RunPrintWithContextRecovery(ctx, ag, prompt, images, out, nil)
19+ return usage, err
20+}
21+
22+// RunPrintWithContextRecovery is RunPrint with an optional checkpoint writer
23+// for a one-shot context-overflow compaction. OutputStart identifies the
24+// transcript suffix that callers must persist after a successful checkpoint.
25+func RunPrintWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer, persistCompaction func([]provider.Message) error) (provider.Usage, ContextRecoveryResult, error) {
18 var finalText strings.Builder26 var finalText strings.Builder
19 var lastAssistant string27 var lastAssistant string
20 var usage provider.Usage28 var usage provider.Usage
@@ -51,17 +59,21 @@ func RunPrint(ctx context.Context, ag *core.Agent, prompt string, images []provi
51 }59 }
52 }60 }
53 61
54- if err := ag.Prompt(ctx, prompt, images, sink); err != nil {62+ recovery, err := PromptWithContextRecovery(ctx, ag, prompt, images, sink, ContextRecoveryOptions{
55- return usage, err63+ PersistCompaction: persistCompaction,
64+ SuppressInitialOverflowEvent: true,
65+ })
66+ if err != nil {
67+ return usage, recovery, err
56 }68 }
57 if runErr != nil {69 if runErr != nil {
58- return usage, runErr70+ return usage, recovery, runErr
59 }71 }
60 72
61 finalText.WriteString(lastAssistant)73 finalText.WriteString(lastAssistant)
62 if finalText.Len() > 0 && !strings.HasSuffix(finalText.String(), "\n") {74 if finalText.Len() > 0 && !strings.HasSuffix(finalText.String(), "\n") {
63 finalText.WriteString("\n")75 finalText.WriteString("\n")
64 }76 }
65- _, err := fmt.Fprint(out, finalText.String())77+ _, err = fmt.Fprint(out, finalText.String())
66- return usage, err78+ return usage, recovery, err
67 }79 }
M packages/agent/modes/stream.go +17 -4
@@ -22,6 +22,15 @@ func RunStream(ctx context.Context, ag *core.Agent, prompt string, images []prov
2222 // RunStreamWithDiag is RunStream with an explicit diagnostics writer
2323 // for tool activity (tests pass a buffer).
2424 func RunStreamWithDiag(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out, diag io.Writer) error {
25+ _, err := RunStreamWithContextRecovery(ctx, ag, prompt, images, out, diag, nil)
26+ return err
27+}
28+
29+// RunStreamWithContextRecovery is RunStreamWithDiag with an optional
30+// checkpoint writer for a one-shot context-overflow compaction. OutputStart
31+// identifies the transcript suffix that callers must persist after a
32+// successful checkpoint.
33+func RunStreamWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out, diag io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) {
2534 if diag == nil {
2635 diag = io.Discard
2736 }
@@ -101,15 +110,19 @@ func RunStreamWithDiag(ctx context.Context, ag *core.Agent, prompt string, image
101110 }
102111 }
103112
104- if err := ag.Prompt(ctx, prompt, images, sink); err != nil {
105- return err
113+ recovery, err := PromptWithContextRecovery(ctx, ag, prompt, images, sink, ContextRecoveryOptions{
114+ PersistCompaction: persistCompaction,
115+ SuppressInitialOverflowEvent: true,
116+ })
117+ if err != nil {
118+ return recovery, err
106119 }
107120 if runErr != nil {
108- return runErr
121+ return recovery, runErr
109122 }
110123 if wroteText && !lastWasNL {
111124 _, _ = fmt.Fprint(out, "\n")
112125 flush()
113126 }
114- return nil
127+ return recovery, nil
115128 }
@@ -22,6 +22,15 @@ func RunStream(ctx context.Context, ag *core.Agent, prompt string, images []prov
22 // RunStreamWithDiag is RunStream with an explicit diagnostics writer22 // RunStreamWithDiag is RunStream with an explicit diagnostics writer
23 // for tool activity (tests pass a buffer).23 // for tool activity (tests pass a buffer).
24 func RunStreamWithDiag(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out, diag io.Writer) error {24 func RunStreamWithDiag(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out, diag io.Writer) error {
25+ _, err := RunStreamWithContextRecovery(ctx, ag, prompt, images, out, diag, nil)
26+ return err
27+}
28+
29+// RunStreamWithContextRecovery is RunStreamWithDiag with an optional
30+// checkpoint writer for a one-shot context-overflow compaction. OutputStart
31+// identifies the transcript suffix that callers must persist after a
32+// successful checkpoint.
33+func RunStreamWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out, diag io.Writer, persistCompaction func([]provider.Message) error) (ContextRecoveryResult, error) {
25 if diag == nil {34 if diag == nil {
26 diag = io.Discard35 diag = io.Discard
27 }36 }
@@ -101,15 +110,19 @@ func RunStreamWithDiag(ctx context.Context, ag *core.Agent, prompt string, image
101 }110 }
102 }111 }
103 112
104- if err := ag.Prompt(ctx, prompt, images, sink); err != nil {113+ recovery, err := PromptWithContextRecovery(ctx, ag, prompt, images, sink, ContextRecoveryOptions{
105- return err114+ PersistCompaction: persistCompaction,
115+ SuppressInitialOverflowEvent: true,
116+ })
117+ if err != nil {
118+ return recovery, err
106 }119 }
107 if runErr != nil {120 if runErr != nil {
108- return runErr121+ return recovery, runErr
109 }122 }
110 if wroteText && !lastWasNL {123 if wroteText && !lastWasNL {
111 _, _ = fmt.Fprint(out, "\n")124 _, _ = fmt.Fprint(out, "\n")
112 flush()125 flush()
113 }126 }
114- return nil127+ return recovery, nil
115 }128 }
M packages/agent/subagent_worker.go +8 -37
@@ -436,44 +436,15 @@ func canonicalWorkerEvent(typ string) string {
436436
437437 const subagentContextLimitMessage = "request exceeds the model context window; narrow the task or reduce gathered context"
438438
439-// promptWithContextRecovery appends prompt and runs it once. When the provider
440-// rejects that request for exceeding its context limit, it compacts the current
441-// transcript and continues the already-appended prompt exactly once.
442-//
443-// The returned index marks the first message that can contribute to the turn
444-// result or be appended after a compaction checkpoint. Compact replaces the
445-// transcript, so callers must not reuse the index captured before Prompt.
439+// promptWithContextRecovery preserves the worker's existing helper boundary
440+// while delegating its one-shot overflow recovery to modes. The returned index
441+// is the first message to include in the result or append after a persisted
442+// compaction checkpoint.
446443 func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), persistCompaction func([]provider.Message) error) (outputStart int, err error) {
447- outputStart = len(ag.Messages())
448- if err = ag.Prompt(ctx, prompt, nil, sink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) {
449- return outputStart, err
450- }
451-
452- messages := ag.Messages()
453- if len(messages) <= 1 {
454- // The only message is the failed prompt. Summarizing it would replace
455- // the user's task with an LLM-generated paraphrase, so report the
456- // provider's limit and ask the caller to narrow the task instead.
457- return outputStart, err
458- }
459- keepTail := min(4, len(messages)-1)
460- if _, compactErr := ag.Compact(ctx, keepTail, nil); compactErr != nil {
461- return outputStart, fmt.Errorf("compact transcript after initial overflow: %w", compactErr)
462- }
463-
464- compacted := ag.Messages()
465- if persistCompaction != nil {
466- if persistErr := persistCompaction(compacted); persistErr != nil {
467- // Keep the original history plus the user's pending task so the
468- // outer transcript flush can persist that task without committing
469- // an in-memory compaction that the session did not receive.
470- ag.SetMessages(messages)
471- return outputStart, fmt.Errorf("persist compacted transcript: %w", persistErr)
472- }
473- }
474-
475- outputStart = len(compacted)
476- return outputStart, ag.Continue(ctx, sink)
444+ result, err := modes.PromptWithContextRecovery(ctx, ag, prompt, nil, sink, modes.ContextRecoveryOptions{
445+ PersistCompaction: persistCompaction,
446+ })
447+ return result.OutputStart, err
477448 }
478449
479450 func finalAssistantText(messages []provider.Message) string {
@@ -436,44 +436,15 @@ func canonicalWorkerEvent(typ string) string {
436 436
437 const subagentContextLimitMessage = "request exceeds the model context window; narrow the task or reduce gathered context"437 const subagentContextLimitMessage = "request exceeds the model context window; narrow the task or reduce gathered context"
438 438
439-// promptWithContextRecovery appends prompt and runs it once. When the provider439+// promptWithContextRecovery preserves the worker's existing helper boundary
440-// rejects that request for exceeding its context limit, it compacts the current440+// while delegating its one-shot overflow recovery to modes. The returned index
441-// transcript and continues the already-appended prompt exactly once.441+// is the first message to include in the result or append after a persisted
442-//442+// compaction checkpoint.
443-// The returned index marks the first message that can contribute to the turn
444-// result or be appended after a compaction checkpoint. Compact replaces the
445-// transcript, so callers must not reuse the index captured before Prompt.
446 func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), persistCompaction func([]provider.Message) error) (outputStart int, err error) {443 func promptWithContextRecovery(ctx context.Context, ag *core.Agent, prompt string, sink func(core.AgentEvent), persistCompaction func([]provider.Message) error) (outputStart int, err error) {
447- outputStart = len(ag.Messages())444+ result, err := modes.PromptWithContextRecovery(ctx, ag, prompt, nil, sink, modes.ContextRecoveryOptions{
448- if err = ag.Prompt(ctx, prompt, nil, sink); err == nil || ctx.Err() != nil || !provider.IsContextOverflowError(err) {445+ PersistCompaction: persistCompaction,
449- return outputStart, err446+ })
450- }447+ return result.OutputStart, err
451-
452- messages := ag.Messages()
453- if len(messages) <= 1 {
454- // The only message is the failed prompt. Summarizing it would replace
455- // the user's task with an LLM-generated paraphrase, so report the
456- // provider's limit and ask the caller to narrow the task instead.
457- return outputStart, err
458- }
459- keepTail := min(4, len(messages)-1)
460- if _, compactErr := ag.Compact(ctx, keepTail, nil); compactErr != nil {
461- return outputStart, fmt.Errorf("compact transcript after initial overflow: %w", compactErr)
462- }
463-
464- compacted := ag.Messages()
465- if persistCompaction != nil {
466- if persistErr := persistCompaction(compacted); persistErr != nil {
467- // Keep the original history plus the user's pending task so the
468- // outer transcript flush can persist that task without committing
469- // an in-memory compaction that the session did not receive.
470- ag.SetMessages(messages)
471- return outputStart, fmt.Errorf("persist compacted transcript: %w", persistErr)
472- }
473- }
474-
475- outputStart = len(compacted)
476- return outputStart, ag.Continue(ctx, sink)
477 }448 }
478 449
479 func finalAssistantText(messages []provider.Message) string {450 func finalAssistantText(messages []provider.Message) string {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close