detached 17110ee

feat(context): bound retained tool output

commit
17110eee563542d0e9b75d8cd9863ef3afbbeae8
author
brice · · 2026-08-07 07:23 UTC
committer
brice · · 2026-08-07 08:20 UTC
parent
a28c5f2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +458 -7
M README.md +5 -3
@@ -409,7 +409,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
409409 - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account.
410410 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
411411 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.
412-- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. Structural unfinished tails and truncated output continue as before. After a successful after-turn threshold compaction, zut may make at most two hidden, bounded recovery continuations when the assistant's visible text appears to promise future work; clear completed text settles normally, and a newer queued user request wins. An interrupted interactive session retains an outstanding handoff when resumed, including after portable export and import. Manual `/compact` does not silently start work. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. Truncated-output continuation remains a separate priority rule.
412+- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), `90%`, or `95%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. Structural unfinished tails and truncated output continue as before. After a successful after-turn threshold compaction, zut may make at most two hidden, bounded recovery continuations when the assistant's visible text appears to promise future work; clear completed text settles normally, and a newer queued user request wins. An interrupted interactive session retains an outstanding handoff when resumed, including after portable export and import. Manual `/compact` does not silently start work. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. Truncated-output continuation remains a separate priority rule.
413413 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
414414 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
415415 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
@@ -426,7 +426,9 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
426426
427427 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Manual `/compact` only summarizes; it does not silently start work afterward. Use it when the context meter creeps past ~80%.
428428
429-zut also auto-compacts in the background after an interactive turn reaches the configured context threshold. Structural unfinished tails and output stopped at the model's token limit continue as before. After a successful after-turn threshold compaction, a visible assistant status that appears to promise future work may receive at most two hidden, bounded recovery continuations; clear completed text settles normally, and a newer queued user request supersedes that recovery. An interrupted interactive session resumes an outstanding hidden handoff without displaying it as a chat message. A new branch keeps that handoff only when it copies the complete effective transcript; `/fork` or `/session tree` selection at an earlier turn, and every branch from pre-compaction history, drops it and starts no hidden continuation. Manual `/compact` does not silently start work, and truncated-output continuation remains a separate priority rule. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.
429+zut also auto-compacts in the background after an interactive turn reaches the configured context threshold. Structural unfinished tails and output stopped at the model's token limit continue as before. After a successful after-turn threshold compaction, a visible assistant status that appears to promise future work may receive at most two hidden, bounded recovery continuations; clear completed text settles normally, and a newer queued user request supersedes that recovery. An interrupted interactive session resumes an outstanding hidden handoff without displaying it as a chat message. A new branch keeps that handoff only when it copies the complete effective transcript; `/fork` or `/session tree` selection at an earlier turn, and every branch from pre-compaction history, drops it and starts no hidden continuation. Manual `/compact` does not silently start work, and truncated-output continuation remains a separate priority rule. Choose `off`, `70%`, `80%`, `85%` (default), `90%`, or `95%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.
430+
431+To prevent tool-heavy runs from exhausting context, provider requests and compaction inputs retain up to 32 KiB from each tool result and 128 KiB across all tool-result text, preferring newer results. Omitted output is marked for the model but remains fully visible in the transcript and stored in the session.
430432
431433 ### `/jail`
432434
@@ -457,7 +459,7 @@ Use `/login` to store API keys or subscription credentials. `/model` only shows
457459 - **Live**: IDs discovered from `GET /v1/models` using your stored API key (cached for 6h in `$ZUT_HOME/models-cache.json`, refreshed in the background on startup).
458460 - **Speculative**: IDs that appear in the upstream generator but aren't live on the public API yet. They'll 404 today and start working the moment the provider ships them.
459461
460-The context meter in the status line uses the model's advertised context window to show how much of it your last turn consumed.
462+The context meter in the status line uses the model's advertised context window to show how much of it your last turn consumed. Tool output is still shown in full through the normal transcript rendering and retained in the session transcript. To keep long-running sessions usable, zut bounds the historical tool-result text included in provider-facing context; this projection affects what is sent to the model, not what is displayed or persisted, and it preserves the tool-call/result structure.
461463
462464 ### Model fallback (rescue)
463465
@@ -409,7 +409,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
409 - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account.409 - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account.
410 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.410 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
411 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.411 - **lsp in sub-agents** — allow newly spawned background sub-agents to use the built-in `lsp` tool. Enabled by default; changes persist as `subagent_lsp_enabled` and apply when a child starts.
412-- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. Structural unfinished tails and truncated output continue as before. After a successful after-turn threshold compaction, zut may make at most two hidden, bounded recovery continuations when the assistant's visible text appears to promise future work; clear completed text settles normally, and a newer queued user request wins. An interrupted interactive session retains an outstanding handoff when resumed, including after portable export and import. Manual `/compact` does not silently start work. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. Truncated-output continuation remains a separate priority rule.412+- **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), `90%`, or `95%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. Structural unfinished tails and truncated output continue as before. After a successful after-turn threshold compaction, zut may make at most two hidden, bounded recovery continuations when the assistant's visible text appears to promise future work; clear completed text settles normally, and a newer queued user request wins. An interrupted interactive session retains an outstanding handoff when resumed, including after portable export and import. Manual `/compact` does not silently start work. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. Truncated-output continuation remains a separate priority rule.
413 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.413 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
414 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.414 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
415 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.415 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
@@ -426,7 +426,9 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro
426 426
427 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Manual `/compact` only summarizes; it does not silently start work afterward. Use it when the context meter creeps past ~80%.427 Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Manual `/compact` only summarizes; it does not silently start work afterward. Use it when the context meter creeps past ~80%.
428 428
429-zut also auto-compacts in the background after an interactive turn reaches the configured context threshold. Structural unfinished tails and output stopped at the model's token limit continue as before. After a successful after-turn threshold compaction, a visible assistant status that appears to promise future work may receive at most two hidden, bounded recovery continuations; clear completed text settles normally, and a newer queued user request supersedes that recovery. An interrupted interactive session resumes an outstanding hidden handoff without displaying it as a chat message. A new branch keeps that handoff only when it copies the complete effective transcript; `/fork` or `/session tree` selection at an earlier turn, and every branch from pre-compaction history, drops it and starts no hidden continuation. Manual `/compact` does not silently start work, and truncated-output continuation remains a separate priority rule. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.429+zut also auto-compacts in the background after an interactive turn reaches the configured context threshold. Structural unfinished tails and output stopped at the model's token limit continue as before. After a successful after-turn threshold compaction, a visible assistant status that appears to promise future work may receive at most two hidden, bounded recovery continuations; clear completed text settles normally, and a newer queued user request supersedes that recovery. An interrupted interactive session resumes an outstanding hidden handoff without displaying it as a chat message. A new branch keeps that handoff only when it copies the complete effective transcript; `/fork` or `/session tree` selection at an earlier turn, and every branch from pre-compaction history, drops it and starts no hidden continuation. Manual `/compact` does not silently start work, and truncated-output continuation remains a separate priority rule. Choose `off`, `70%`, `80%`, `85%` (default), `90%`, or `95%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a context-window or payload-too-large response.
430+
431+To prevent tool-heavy runs from exhausting context, provider requests and compaction inputs retain up to 32 KiB from each tool result and 128 KiB across all tool-result text, preferring newer results. Omitted output is marked for the model but remains fully visible in the transcript and stored in the session.
430 432
431 ### `/jail`433 ### `/jail`
432 434
@@ -457,7 +459,7 @@ Use `/login` to store API keys or subscription credentials. `/model` only shows
457 - **Live**: IDs discovered from `GET /v1/models` using your stored API key (cached for 6h in `$ZUT_HOME/models-cache.json`, refreshed in the background on startup).459 - **Live**: IDs discovered from `GET /v1/models` using your stored API key (cached for 6h in `$ZUT_HOME/models-cache.json`, refreshed in the background on startup).
458 - **Speculative**: IDs that appear in the upstream generator but aren't live on the public API yet. They'll 404 today and start working the moment the provider ships them.460 - **Speculative**: IDs that appear in the upstream generator but aren't live on the public API yet. They'll 404 today and start working the moment the provider ships them.
459 461
460-The context meter in the status line uses the model's advertised context window to show how much of it your last turn consumed.462+The context meter in the status line uses the model's advertised context window to show how much of it your last turn consumed. Tool output is still shown in full through the normal transcript rendering and retained in the session transcript. To keep long-running sessions usable, zut bounds the historical tool-result text included in provider-facing context; this projection affects what is sent to the model, not what is displayed or persisted, and it preserves the tool-call/result structure.
461 463
462 ### Model fallback (rescue)464 ### Model fallback (rescue)
463 465
M packages/agent/config.go +2 -1
@@ -126,7 +126,8 @@ type Config struct {
126126
127127 // AutoCompactThreshold is the percentage of the model context window
128128 // that triggers automatic transcript compaction in interactive mode.
129- // nil/missing means 85; zero disables percentage-based triggers.
129+ // nil/missing means 85; valid values are 0, 70, 80, 85, 90, or 95.
130+ // zero disables percentage-based triggers.
130131 AutoCompactThreshold *int `json:"auto_compact_threshold,omitempty"`
131132
132133 // JailByDefault confines tools to the session working directory when
@@ -126,7 +126,8 @@ type Config struct {
126 126
127 // AutoCompactThreshold is the percentage of the model context window127 // AutoCompactThreshold is the percentage of the model context window
128 // that triggers automatic transcript compaction in interactive mode.128 // that triggers automatic transcript compaction in interactive mode.
129- // nil/missing means 85; zero disables percentage-based triggers.129+ // nil/missing means 85; valid values are 0, 70, 80, 85, 90, or 95.
130+ // zero disables percentage-based triggers.
130 AutoCompactThreshold *int `json:"auto_compact_threshold,omitempty"`131 AutoCompactThreshold *int `json:"auto_compact_threshold,omitempty"`
131 132
132 // JailByDefault confines tools to the session working directory when133 // JailByDefault confines tools to the session working directory when
M packages/agent/modes/auto_compact_test.go +3 -1
@@ -21,6 +21,7 @@ func TestNormalizeAutoCompactThreshold(t *testing.T) {
2121 {name: "eighty", value: autoCompactIntPtr(80), want: 80},
2222 {name: "eighty five", value: autoCompactIntPtr(85), want: 85},
2323 {name: "ninety", value: autoCompactIntPtr(90), want: 90},
24+ {name: "ninety five", value: autoCompactIntPtr(95), want: 95},
2425 {name: "invalid low", value: autoCompactIntPtr(42), want: 85},
2526 {name: "invalid high", value: autoCompactIntPtr(100), want: 85},
2627 }
@@ -344,6 +345,7 @@ func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {
344345 {name: "seventy percent preset", used: 70, window: 100, threshold: 70, want: true},
345346 {name: "eighty percent preset", used: 80, window: 100, threshold: 80, want: true},
346347 {name: "ninety percent preset", used: 90, window: 100, threshold: 90, want: true},
348+ {name: "ninety five percent preset", used: 95, window: 100, threshold: 95, want: true},
347349 {name: "off", used: 100, window: 100, threshold: 0, want: false},
348350 {name: "missing usage", used: 0, window: 100, threshold: 85, want: false},
349351 {name: "missing window", used: 85, window: 0, threshold: 85, want: false},
@@ -374,7 +376,7 @@ func TestSettingsDialogOffersAutoCompactThresholdPresets(t *testing.T) {
374376 t.Fatal("settings dialog is missing auto-compact threshold")
375377 }
376378
377- wantValues := []string{"0", "70", "80", "85", "90"}
379+ wantValues := []string{"0", "70", "80", "85", "90", "95"}
378380 if len(thresholdItem.options) != len(wantValues) {
379381 t.Fatalf("auto-compact options = %d, want %d", len(thresholdItem.options), len(wantValues))
380382 }
@@ -21,6 +21,7 @@ func TestNormalizeAutoCompactThreshold(t *testing.T) {
21 {name: "eighty", value: autoCompactIntPtr(80), want: 80},21 {name: "eighty", value: autoCompactIntPtr(80), want: 80},
22 {name: "eighty five", value: autoCompactIntPtr(85), want: 85},22 {name: "eighty five", value: autoCompactIntPtr(85), want: 85},
23 {name: "ninety", value: autoCompactIntPtr(90), want: 90},23 {name: "ninety", value: autoCompactIntPtr(90), want: 90},
24+ {name: "ninety five", value: autoCompactIntPtr(95), want: 95},
24 {name: "invalid low", value: autoCompactIntPtr(42), want: 85},25 {name: "invalid low", value: autoCompactIntPtr(42), want: 85},
25 {name: "invalid high", value: autoCompactIntPtr(100), want: 85},26 {name: "invalid high", value: autoCompactIntPtr(100), want: 85},
26 }27 }
@@ -344,6 +345,7 @@ func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) {
344 {name: "seventy percent preset", used: 70, window: 100, threshold: 70, want: true},345 {name: "seventy percent preset", used: 70, window: 100, threshold: 70, want: true},
345 {name: "eighty percent preset", used: 80, window: 100, threshold: 80, want: true},346 {name: "eighty percent preset", used: 80, window: 100, threshold: 80, want: true},
346 {name: "ninety percent preset", used: 90, window: 100, threshold: 90, want: true},347 {name: "ninety percent preset", used: 90, window: 100, threshold: 90, want: true},
348+ {name: "ninety five percent preset", used: 95, window: 100, threshold: 95, want: true},
347 {name: "off", used: 100, window: 100, threshold: 0, want: false},349 {name: "off", used: 100, window: 100, threshold: 0, want: false},
348 {name: "missing usage", used: 0, window: 100, threshold: 85, want: false},350 {name: "missing usage", used: 0, window: 100, threshold: 85, want: false},
349 {name: "missing window", used: 85, window: 0, threshold: 85, want: false},351 {name: "missing window", used: 85, window: 0, threshold: 85, want: false},
@@ -374,7 +376,7 @@ func TestSettingsDialogOffersAutoCompactThresholdPresets(t *testing.T) {
374 t.Fatal("settings dialog is missing auto-compact threshold")376 t.Fatal("settings dialog is missing auto-compact threshold")
375 }377 }
376 378
377- wantValues := []string{"0", "70", "80", "85", "90"}379+ wantValues := []string{"0", "70", "80", "85", "90", "95"}
378 if len(thresholdItem.options) != len(wantValues) {380 if len(thresholdItem.options) != len(wantValues) {
379 t.Fatalf("auto-compact options = %d, want %d", len(thresholdItem.options), len(wantValues))381 t.Fatalf("auto-compact options = %d, want %d", len(thresholdItem.options), len(wantValues))
380 }382 }
M packages/agent/modes/interactive.go +2 -1
@@ -4378,6 +4378,7 @@ func (i *Interactive) openSettingsDialog() {
43784378 {value: "80", label: "80%", desc: "compact with moderate context headroom"},
43794379 {value: "85", label: "85%", desc: "default balance between history and headroom"},
43804380 {value: "90", label: "90%", desc: "retain more history before compacting"},
4381+ {value: "95", label: "95%", desc: "retain most history before compacting"},
43814382 }
43824383 autoCompactThreshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
43834384 autoCompactChoice := 0
@@ -7829,7 +7830,7 @@ func normalizeAutoCompactThreshold(threshold *int) int {
78297830 return defaultAutoCompactThreshold
78307831 }
78317832 switch *threshold {
7832- case 0, 70, 80, 85, 90:
7833+ case 0, 70, 80, 85, 90, 95:
78337834 return *threshold
78347835 default:
78357836 return defaultAutoCompactThreshold
@@ -4378,6 +4378,7 @@ func (i *Interactive) openSettingsDialog() {
4378 {value: "80", label: "80%", desc: "compact with moderate context headroom"},4378 {value: "80", label: "80%", desc: "compact with moderate context headroom"},
4379 {value: "85", label: "85%", desc: "default balance between history and headroom"},4379 {value: "85", label: "85%", desc: "default balance between history and headroom"},
4380 {value: "90", label: "90%", desc: "retain more history before compacting"},4380 {value: "90", label: "90%", desc: "retain more history before compacting"},
4381+ {value: "95", label: "95%", desc: "retain most history before compacting"},
4381 }4382 }
4382 autoCompactThreshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)4383 autoCompactThreshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
4383 autoCompactChoice := 04384 autoCompactChoice := 0
@@ -7829,7 +7830,7 @@ func normalizeAutoCompactThreshold(threshold *int) int {
7829 return defaultAutoCompactThreshold7830 return defaultAutoCompactThreshold
7830 }7831 }
7831 switch *threshold {7832 switch *threshold {
7832- case 0, 70, 80, 85, 90:7833+ case 0, 70, 80, 85, 90, 95:
7833 return *threshold7834 return *threshold
7834 default:7835 default:
7835 return defaultAutoCompactThreshold7836 return defaultAutoCompactThreshold
M packages/core/agent.go +5 -1
@@ -685,6 +685,10 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
685685 }
686686 system += "[Extension context for this turn]\n" + contextText
687687 }
688+ // Repair pairs before projecting the copied provider-input view. The
689+ // repair can add stub results for aborted calls; those results must also
690+ // remain in the request so every tool call still has a matching result.
691+ messages := repairToolUseResultPairs(a.Messages())
688692 req := provider.Request{
689693 Model: a.Model,
690694 System: system,
@@ -696,7 +700,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
696700 // next in-process request is rejected by providers like Anthropic
697701 // with "tool_use ids were found without tool_result blocks". The
698702 // repair is pure and a no-op on already-valid transcripts.
699- Messages: repairToolUseResultPairs(a.Messages()),
703+ Messages: projectToolResultMessages(messages),
700704 Tools: tools.Specs(),
701705 Reasoning: a.Reasoning,
702706 FastMode: fastMode,
@@ -685,6 +685,10 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
685 }685 }
686 system += "[Extension context for this turn]\n" + contextText686 system += "[Extension context for this turn]\n" + contextText
687 }687 }
688+ // Repair pairs before projecting the copied provider-input view. The
689+ // repair can add stub results for aborted calls; those results must also
690+ // remain in the request so every tool call still has a matching result.
691+ messages := repairToolUseResultPairs(a.Messages())
688 req := provider.Request{692 req := provider.Request{
689 Model: a.Model,693 Model: a.Model,
690 System: system,694 System: system,
@@ -696,7 +700,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
696 // next in-process request is rejected by providers like Anthropic700 // next in-process request is rejected by providers like Anthropic
697 // with "tool_use ids were found without tool_result blocks". The701 // with "tool_use ids were found without tool_result blocks". The
698 // repair is pure and a no-op on already-valid transcripts.702 // repair is pure and a no-op on already-valid transcripts.
699- Messages: repairToolUseResultPairs(a.Messages()),703+ Messages: projectToolResultMessages(messages),
700 Tools: tools.Specs(),704 Tools: tools.Specs(),
701 Reasoning: a.Reasoning,705 Reasoning: a.Reasoning,
702 FastMode: fastMode,706 FastMode: fastMode,
M packages/core/compact.go +1 -0
@@ -185,6 +185,7 @@ func repairOrphanedToolResults(msgs []provider.Message) []provider.Message {
185185 // text transcript the summarization model can read without trying to
186186 // continue the conversation.
187187 func serializeTranscript(msgs []provider.Message) string {
188+ msgs = projectToolResultMessages(msgs)
188189 var sb strings.Builder
189190 for _, m := range msgs {
190191 switch m.Role {
@@ -185,6 +185,7 @@ func repairOrphanedToolResults(msgs []provider.Message) []provider.Message {
185 // text transcript the summarization model can read without trying to185 // text transcript the summarization model can read without trying to
186 // continue the conversation.186 // continue the conversation.
187 func serializeTranscript(msgs []provider.Message) string {187 func serializeTranscript(msgs []provider.Message) string {
188+ msgs = projectToolResultMessages(msgs)
188 var sb strings.Builder189 var sb strings.Builder
189 for _, m := range msgs {190 for _, m := range msgs {
190 switch m.Role {191 switch m.Role {
A packages/core/context_budget.go +145 -0
@@ -0,0 +1,145 @@
1+package core
2+
3+import (
4+ "unicode/utf8"
5+
6+ "github.com/bnema/zut/packages/provider"
7+)
8+
9+const (
10+ maxToolResultTextBytes = 32 * 1024
11+ maxToolResultTotalTextBytes = 128 * 1024
12+ toolResultOmissionMarker = "[tool result content omitted]"
13+)
14+
15+// projectToolResultMessages returns a copied provider-input view of msgs.
16+// Text inside each tool result is bounded independently and then against the
17+// total budget, with newer results receiving the remaining budget first.
18+// Tool-result blocks and all non-text content remain in the projection so
19+// tool-call/result pairing stays valid.
20+func projectToolResultMessages(msgs []provider.Message) []provider.Message {
21+ projected := copyToolResultMessages(msgs)
22+ remaining := maxToolResultTotalTextBytes
23+
24+ for i := len(projected) - 1; i >= 0; i-- {
25+ for j := len(projected[i].Content) - 1; j >= 0; j-- {
26+ result, ok := projected[i].Content[j].(provider.ToolResultBlock)
27+ if !ok {
28+ continue
29+ }
30+
31+ textBytes := toolResultTextBytes(result.Content)
32+ if textBytes == 0 {
33+ continue
34+ }
35+
36+ limit := maxToolResultTextBytes
37+ if remaining < limit {
38+ limit = remaining
39+ }
40+ var retained int
41+ result.Content, retained = projectToolResultContent(result.Content, limit)
42+ projected[i].Content[j] = result
43+ remaining -= retained
44+ }
45+ }
46+
47+ return projected
48+}
49+
50+// copyToolResultMessages copies the slices that the projection can replace.
51+// Content blocks are values, so sharing their immutable fields is safe; the
52+// nested content slice of every ToolResultBlock is copied before projection.
53+func copyToolResultMessages(msgs []provider.Message) []provider.Message {
54+ if msgs == nil {
55+ return nil
56+ }
57+ out := make([]provider.Message, len(msgs))
58+ for i, message := range msgs {
59+ out[i] = message
60+ if message.Content != nil {
61+ out[i].Content = append([]provider.Content(nil), message.Content...)
62+ }
63+ if message.AddedToolNames != nil {
64+ out[i].AddedToolNames = append([]string(nil), message.AddedToolNames...)
65+ }
66+ if message.Meta != nil {
67+ out[i].Meta = make(map[string]string, len(message.Meta))
68+ for key, value := range message.Meta {
69+ out[i].Meta[key] = value
70+ }
71+ }
72+ for j, content := range out[i].Content {
73+ if result, ok := content.(provider.ToolResultBlock); ok {
74+ result.Content = append([]provider.Content(nil), result.Content...)
75+ out[i].Content[j] = result
76+ }
77+ }
78+ }
79+ return out
80+}
81+
82+func toolResultTextBytes(content []provider.Content) int {
83+ total := 0
84+ for _, block := range content {
85+ if text, ok := block.(provider.TextBlock); ok {
86+ total += len(text.Text)
87+ }
88+ }
89+ return total
90+}
91+
92+// projectToolResultContent projects one result's textual content to limit
93+// bytes. Every truncated result receives an omission marker, even after the
94+// aggregate content budget is exhausted. The returned count excludes that
95+// marker so the budget covers retained tool output rather than its notices.
96+// Non-text blocks are retained in their original order.
97+func projectToolResultContent(content []provider.Content, limit int) ([]provider.Content, int) {
98+ textBytes := toolResultTextBytes(content)
99+ if textBytes <= limit {
100+ return append([]provider.Content(nil), content...), textBytes
101+ }
102+
103+ out := make([]provider.Content, 0, len(content)+1)
104+ prefixRemaining := limit
105+ prefixUsed := 0
106+ truncated := false
107+ for _, block := range content {
108+ text, ok := block.(provider.TextBlock)
109+ if !ok {
110+ out = append(out, block)
111+ continue
112+ }
113+ if truncated {
114+ continue
115+ }
116+ if len(text.Text) <= prefixRemaining {
117+ out = append(out, text)
118+ prefixRemaining -= len(text.Text)
119+ prefixUsed += len(text.Text)
120+ continue
121+ }
122+
123+ text.Text = utf8Prefix(text.Text, prefixRemaining)
124+ if text.Text != "" {
125+ out = append(out, text)
126+ }
127+ prefixUsed += len(text.Text)
128+ truncated = true
129+ }
130+ out = append(out, provider.TextBlock{Text: toolResultOmissionMarker})
131+ return out, prefixUsed
132+}
133+
134+func utf8Prefix(text string, limit int) string {
135+ if limit <= 0 {
136+ return ""
137+ }
138+ if limit >= len(text) {
139+ return text
140+ }
141+ for limit > 0 && !utf8.RuneStart(text[limit]) {
142+ limit--
143+ }
144+ return text[:limit]
145+}
@@ -0,0 +1,145 @@
1+package core
2+
3+import (
4+ "unicode/utf8"
5+
6+ "github.com/bnema/zut/packages/provider"
7+)
8+
9+const (
10+ maxToolResultTextBytes = 32 * 1024
11+ maxToolResultTotalTextBytes = 128 * 1024
12+ toolResultOmissionMarker = "[tool result content omitted]"
13+)
14+
15+// projectToolResultMessages returns a copied provider-input view of msgs.
16+// Text inside each tool result is bounded independently and then against the
17+// total budget, with newer results receiving the remaining budget first.
18+// Tool-result blocks and all non-text content remain in the projection so
19+// tool-call/result pairing stays valid.
20+func projectToolResultMessages(msgs []provider.Message) []provider.Message {
21+ projected := copyToolResultMessages(msgs)
22+ remaining := maxToolResultTotalTextBytes
23+
24+ for i := len(projected) - 1; i >= 0; i-- {
25+ for j := len(projected[i].Content) - 1; j >= 0; j-- {
26+ result, ok := projected[i].Content[j].(provider.ToolResultBlock)
27+ if !ok {
28+ continue
29+ }
30+
31+ textBytes := toolResultTextBytes(result.Content)
32+ if textBytes == 0 {
33+ continue
34+ }
35+
36+ limit := maxToolResultTextBytes
37+ if remaining < limit {
38+ limit = remaining
39+ }
40+ var retained int
41+ result.Content, retained = projectToolResultContent(result.Content, limit)
42+ projected[i].Content[j] = result
43+ remaining -= retained
44+ }
45+ }
46+
47+ return projected
48+}
49+
50+// copyToolResultMessages copies the slices that the projection can replace.
51+// Content blocks are values, so sharing their immutable fields is safe; the
52+// nested content slice of every ToolResultBlock is copied before projection.
53+func copyToolResultMessages(msgs []provider.Message) []provider.Message {
54+ if msgs == nil {
55+ return nil
56+ }
57+ out := make([]provider.Message, len(msgs))
58+ for i, message := range msgs {
59+ out[i] = message
60+ if message.Content != nil {
61+ out[i].Content = append([]provider.Content(nil), message.Content...)
62+ }
63+ if message.AddedToolNames != nil {
64+ out[i].AddedToolNames = append([]string(nil), message.AddedToolNames...)
65+ }
66+ if message.Meta != nil {
67+ out[i].Meta = make(map[string]string, len(message.Meta))
68+ for key, value := range message.Meta {
69+ out[i].Meta[key] = value
70+ }
71+ }
72+ for j, content := range out[i].Content {
73+ if result, ok := content.(provider.ToolResultBlock); ok {
74+ result.Content = append([]provider.Content(nil), result.Content...)
75+ out[i].Content[j] = result
76+ }
77+ }
78+ }
79+ return out
80+}
81+
82+func toolResultTextBytes(content []provider.Content) int {
83+ total := 0
84+ for _, block := range content {
85+ if text, ok := block.(provider.TextBlock); ok {
86+ total += len(text.Text)
87+ }
88+ }
89+ return total
90+}
91+
92+// projectToolResultContent projects one result's textual content to limit
93+// bytes. Every truncated result receives an omission marker, even after the
94+// aggregate content budget is exhausted. The returned count excludes that
95+// marker so the budget covers retained tool output rather than its notices.
96+// Non-text blocks are retained in their original order.
97+func projectToolResultContent(content []provider.Content, limit int) ([]provider.Content, int) {
98+ textBytes := toolResultTextBytes(content)
99+ if textBytes <= limit {
100+ return append([]provider.Content(nil), content...), textBytes
101+ }
102+
103+ out := make([]provider.Content, 0, len(content)+1)
104+ prefixRemaining := limit
105+ prefixUsed := 0
106+ truncated := false
107+ for _, block := range content {
108+ text, ok := block.(provider.TextBlock)
109+ if !ok {
110+ out = append(out, block)
111+ continue
112+ }
113+ if truncated {
114+ continue
115+ }
116+ if len(text.Text) <= prefixRemaining {
117+ out = append(out, text)
118+ prefixRemaining -= len(text.Text)
119+ prefixUsed += len(text.Text)
120+ continue
121+ }
122+
123+ text.Text = utf8Prefix(text.Text, prefixRemaining)
124+ if text.Text != "" {
125+ out = append(out, text)
126+ }
127+ prefixUsed += len(text.Text)
128+ truncated = true
129+ }
130+ out = append(out, provider.TextBlock{Text: toolResultOmissionMarker})
131+ return out, prefixUsed
132+}
133+
134+func utf8Prefix(text string, limit int) string {
135+ if limit <= 0 {
136+ return ""
137+ }
138+ if limit >= len(text) {
139+ return text
140+ }
141+ for limit > 0 && !utf8.RuneStart(text[limit]) {
142+ limit--
143+ }
144+ return text[:limit]
145+}
A packages/core/context_budget_test.go +295 -0
@@ -0,0 +1,295 @@
1+package core
2+
3+import (
4+ "context"
5+ "reflect"
6+ "strings"
7+ "testing"
8+ "unicode/utf8"
9+
10+ "github.com/bnema/zut/packages/provider"
11+)
12+
13+func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {
14+ msgs := make([]provider.Message, 0, 10)
15+ for i := 0; i < 5; i++ {
16+ id := "call-" + string(rune('1'+i))
17+ msgs = append(msgs,
18+ provider.Message{
19+ Role: provider.RoleAssistant,
20+ Content: []provider.Content{provider.ToolCallBlock{ID: id, Name: "read"}},
21+ },
22+ provider.Message{
23+ Role: provider.RoleTool,
24+ Content: []provider.Content{provider.ToolResultBlock{
25+ CallID: id,
26+ Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("界", maxToolResultTextBytes+100)}},
27+ }},
28+ },
29+ )
30+ }
31+
32+ projected := projectToolResultMessages(msgs)
33+ total := 0
34+ for _, message := range projected {
35+ for _, content := range message.Content {
36+ result, ok := content.(provider.ToolResultBlock)
37+ if !ok {
38+ continue
39+ }
40+ retainedBytes := retainedToolResultTextBytes(result.Content)
41+ if retainedBytes > maxToolResultTextBytes {
42+ t.Fatalf("result %q retains %d text bytes, want <= %d", result.CallID, retainedBytes, maxToolResultTextBytes)
43+ }
44+ if !hasToolResultOmissionMarker(result.Content) {
45+ t.Fatalf("truncated result %q is missing the omission marker", result.CallID)
46+ }
47+ for _, inner := range result.Content {
48+ if text, ok := inner.(provider.TextBlock); ok && !utf8.ValidString(text.Text) {
49+ t.Fatalf("result %q contains invalid UTF-8", result.CallID)
50+ }
51+ }
52+ total += retainedBytes
53+ }
54+ }
55+ if total > maxToolResultTotalTextBytes {
56+ t.Fatalf("projected tool-result text = %d bytes, want <= %d", total, maxToolResultTotalTextBytes)
57+ }
58+}
59+
60+func TestProjectToolResultMessagesRetainsNewestResultsFirst(t *testing.T) {
61+ msgs := make([]provider.Message, 0, 10)
62+ for i := 1; i <= 5; i++ {
63+ id := "call-" + string(rune('0'+i))
64+ text := strings.Repeat("result-"+id+" ", maxToolResultTextBytes/4)
65+ msgs = append(msgs,
66+ provider.Message{
67+ Role: provider.RoleAssistant,
68+ Content: []provider.Content{provider.ToolCallBlock{ID: id, Name: "read"}},
69+ },
70+ provider.Message{
71+ Role: provider.RoleTool,
72+ Content: []provider.Content{provider.ToolResultBlock{CallID: id, Content: []provider.Content{provider.TextBlock{Text: text}}}},
73+ },
74+ )
75+ }
76+
77+ projected := projectToolResultMessages(msgs)
78+ oldest := toolResultTextForCall(projected, "call-1")
79+ newest := toolResultTextForCall(projected, "call-5")
80+ if oldest != toolResultOmissionMarker {
81+ t.Fatalf("oldest result = %q, want omission marker after newer results filled budget", oldest)
82+ }
83+ if !strings.HasPrefix(newest, "result-call-5 ") || !strings.Contains(newest, toolResultOmissionMarker) {
84+ t.Fatalf("newest result was not retained and truncated first: %q", newest[:min(len(newest), 80)])
85+ }
86+}
87+
88+func TestProjectToolResultMessagesMarksResultsAfterAggregateBudgetIsExhausted(t *testing.T) {
89+ const resultCount = maxToolResultTotalTextBytes/maxToolResultTextBytes + 1
90+ msgs := make([]provider.Message, 0, resultCount)
91+ for i := 0; i < resultCount; i++ {
92+ id := "call-" + string(rune('1'+i))
93+ msgs = append(msgs, provider.Message{
94+ Role: provider.RoleTool,
95+ Content: []provider.Content{provider.ToolResultBlock{
96+ CallID: id,
97+ Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("x", maxToolResultTextBytes+1)}},
98+ }},
99+ })
100+ }
101+
102+ projected := projectToolResultMessages(msgs)
103+ retainedTotal := 0
104+ for _, message := range projected {
105+ for _, content := range message.Content {
106+ result, ok := content.(provider.ToolResultBlock)
107+ if !ok {
108+ continue
109+ }
110+ if !hasToolResultOmissionMarker(result.Content) {
111+ t.Fatalf("result %q is missing the omission marker", result.CallID)
112+ }
113+ retainedTotal += retainedToolResultTextBytes(result.Content)
114+ }
115+ }
116+ if retainedTotal != maxToolResultTotalTextBytes {
117+ t.Fatalf("retained tool-result text = %d bytes, want exactly %d", retainedTotal, maxToolResultTotalTextBytes)
118+ }
119+ if got := toolResultTextForCall(projected, "call-1"); got != toolResultOmissionMarker {
120+ t.Fatalf("aggregate-exhausted result = %q, want omission marker", got)
121+ }
122+}
123+
124+func TestProjectToolResultMessagesPreservesToolResultBlocksAndRawInput(t *testing.T) {
125+ msgs := []provider.Message{
126+ {Role: provider.RoleAssistant, Content: []provider.Content{
127+ provider.ToolCallBlock{ID: "call-1", Name: "read"},
128+ provider.ToolCallBlock{ID: "call-2", Name: "bash"},
129+ }},
130+ {Role: provider.RoleTool, Content: []provider.Content{
131+ provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("one", maxToolResultTextBytes)}}},
132+ provider.ToolResultBlock{CallID: "call-2", Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("two", maxToolResultTextBytes)}}},
133+ }},
134+ }
135+ wantRaw := append([]provider.Message(nil), msgs...)
136+
137+ projected := projectToolResultMessages(msgs)
138+ if !reflect.DeepEqual(msgs, wantRaw) {
139+ t.Fatal("projection mutated the raw input transcript")
140+ }
141+ var resultIDs []string
142+ for _, content := range projected[1].Content {
143+ result, ok := content.(provider.ToolResultBlock)
144+ if !ok {
145+ t.Fatalf("projected content = %T, want ToolResultBlock", content)
146+ }
147+ resultIDs = append(resultIDs, result.CallID)
148+ }
149+ if !reflect.DeepEqual(resultIDs, []string{"call-1", "call-2"}) {
150+ t.Fatalf("projected result IDs = %v, want both tool results in order", resultIDs)
151+ }
152+ if got := toolResultTextForCall(msgs, "call-1"); got != strings.Repeat("one", maxToolResultTextBytes) {
153+ t.Fatal("raw tool result text changed after projection")
154+ }
155+}
156+
157+func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) {
158+ client := &captureClient{}
159+ agent := NewAgent(client, "model", "system", Registry{})
160+ full := strings.Repeat("full-normal-result ", maxToolResultTextBytes)
161+ agent.SetMessages([]provider.Message{
162+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "continue"}}},
163+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "read"}}},
164+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
165+ CallID: "call-1",
166+ Content: []provider.Content{provider.TextBlock{Text: full}},
167+ }}},
168+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-2", Name: "read"}}},
169+ })
170+
171+ if err := agent.Continue(context.Background(), nil); err != nil {
172+ t.Fatalf("Continue: %v", err)
173+ }
174+ projectedText := toolResultTextForCall(client.lastReq.Messages, "call-1")
175+ if retainedToolResultTextBytesForCall(client.lastReq.Messages, "call-1") > maxToolResultTextBytes || !strings.Contains(projectedText, toolResultOmissionMarker) {
176+ t.Fatalf("normal request result was not bounded: %d bytes, %q", len(projectedText), projectedText[:min(len(projectedText), 80)])
177+ }
178+ if !hasToolResultCall(client.lastReq.Messages, "call-2") {
179+ t.Fatal("pair repair result was not preserved in normal request")
180+ }
181+ if got := toolResultTextForCall(agent.Messages(), "call-1"); got != full {
182+ t.Fatalf("raw Agent.Messages tool result changed: got %d bytes, want %d", len(got), len(full))
183+ }
184+ if hasToolResultCall(agent.Messages(), "call-2") {
185+ t.Fatal("pair-repair stub leaked into raw Agent.Messages")
186+ }
187+}
188+
189+func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T) {
190+ client := &compactLifecycleClient{}
191+ agent := NewAgent(client, "model", "system", Registry{})
192+ oldText := strings.Repeat("full-compaction-result ", maxToolResultTextBytes)
193+ tailText := strings.Repeat("full-tail-result ", maxToolResultTextBytes)
194+ agent.SetMessages([]provider.Message{
195+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "summarize this"}}},
196+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "old-call", Name: "read"}}},
197+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
198+ CallID: "old-call",
199+ Content: []provider.Content{provider.TextBlock{Text: oldText}},
200+ }}},
201+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "tail-call", Name: "read"}}},
202+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
203+ CallID: "tail-call",
204+ Content: []provider.Content{provider.TextBlock{Text: tailText}},
205+ }}},
206+ })
207+ var persisted []provider.Message
208+ agent.OnTranscriptCompacted = func(messages []provider.Message) {
209+ persisted = messages
210+ }
211+
212+ if _, err := agent.Compact(context.Background(), 2, nil); err != nil {
213+ t.Fatalf("Compact: %v", err)
214+ }
215+ requestText := client.req.Messages[0].Content[0].(provider.TextBlock).Text
216+ if !strings.Contains(requestText, toolResultOmissionMarker) {
217+ t.Fatal("compaction request is missing the tool-result omission marker")
218+ }
219+ if got := toolResultTextForCall(agent.Messages(), "tail-call"); got != tailText {
220+ t.Fatalf("compacted Agent.Messages tail changed: got %d bytes, want %d", len(got), len(tailText))
221+ }
222+ if got := toolResultTextForCall(persisted, "tail-call"); got != tailText {
223+ t.Fatalf("persisted compacted tail changed: got %d bytes, want %d", len(got), len(tailText))
224+ }
225+ if hasToolResultCall(agent.Messages(), "old-call") {
226+ t.Fatal("summarized tool result remained in the compacted transcript")
227+ }
228+}
229+
230+func retainedToolResultTextBytes(content []provider.Content) int {
231+ total := 0
232+ for _, block := range content {
233+ if text, ok := block.(provider.TextBlock); ok && text.Text != toolResultOmissionMarker {
234+ total += len(text.Text)
235+ }
236+ }
237+ return total
238+}
239+
240+func hasToolResultOmissionMarker(content []provider.Content) bool {
241+ for _, block := range content {
242+ if text, ok := block.(provider.TextBlock); ok && text.Text == toolResultOmissionMarker {
243+ return true
244+ }
245+ }
246+ return false
247+}
248+
249+func retainedToolResultTextBytesForCall(messages []provider.Message, callID string) int {
250+ for _, message := range messages {
251+ for _, content := range message.Content {
252+ result, ok := content.(provider.ToolResultBlock)
253+ if ok && result.CallID == callID {
254+ return retainedToolResultTextBytes(result.Content)
255+ }
256+ }
257+ }
258+ return 0
259+}
260+
261+func toolResultTextForCall(messages []provider.Message, callID string) string {
262+ var text strings.Builder
263+ for _, message := range messages {
264+ for _, content := range message.Content {
265+ result, ok := content.(provider.ToolResultBlock)
266+ if !ok || result.CallID != callID {
267+ continue
268+ }
269+ for _, inner := range result.Content {
270+ if block, ok := inner.(provider.TextBlock); ok {
271+ text.WriteString(block.Text)
272+ }
273+ }
274+ }
275+ }
276+ return text.String()
277+}
278+
279+func hasToolResultCall(messages []provider.Message, callID string) bool {
280+ for _, message := range messages {
281+ for _, content := range message.Content {
282+ if result, ok := content.(provider.ToolResultBlock); ok && result.CallID == callID {
283+ return true
284+ }
285+ }
286+ }
287+ return false
288+}
289+
290+func min(a, b int) int {
291+ if a < b {
292+ return a
293+ }
294+ return b
295+}
@@ -0,0 +1,295 @@
1+package core
2+
3+import (
4+ "context"
5+ "reflect"
6+ "strings"
7+ "testing"
8+ "unicode/utf8"
9+
10+ "github.com/bnema/zut/packages/provider"
11+)
12+
13+func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {
14+ msgs := make([]provider.Message, 0, 10)
15+ for i := 0; i < 5; i++ {
16+ id := "call-" + string(rune('1'+i))
17+ msgs = append(msgs,
18+ provider.Message{
19+ Role: provider.RoleAssistant,
20+ Content: []provider.Content{provider.ToolCallBlock{ID: id, Name: "read"}},
21+ },
22+ provider.Message{
23+ Role: provider.RoleTool,
24+ Content: []provider.Content{provider.ToolResultBlock{
25+ CallID: id,
26+ Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("界", maxToolResultTextBytes+100)}},
27+ }},
28+ },
29+ )
30+ }
31+
32+ projected := projectToolResultMessages(msgs)
33+ total := 0
34+ for _, message := range projected {
35+ for _, content := range message.Content {
36+ result, ok := content.(provider.ToolResultBlock)
37+ if !ok {
38+ continue
39+ }
40+ retainedBytes := retainedToolResultTextBytes(result.Content)
41+ if retainedBytes > maxToolResultTextBytes {
42+ t.Fatalf("result %q retains %d text bytes, want <= %d", result.CallID, retainedBytes, maxToolResultTextBytes)
43+ }
44+ if !hasToolResultOmissionMarker(result.Content) {
45+ t.Fatalf("truncated result %q is missing the omission marker", result.CallID)
46+ }
47+ for _, inner := range result.Content {
48+ if text, ok := inner.(provider.TextBlock); ok && !utf8.ValidString(text.Text) {
49+ t.Fatalf("result %q contains invalid UTF-8", result.CallID)
50+ }
51+ }
52+ total += retainedBytes
53+ }
54+ }
55+ if total > maxToolResultTotalTextBytes {
56+ t.Fatalf("projected tool-result text = %d bytes, want <= %d", total, maxToolResultTotalTextBytes)
57+ }
58+}
59+
60+func TestProjectToolResultMessagesRetainsNewestResultsFirst(t *testing.T) {
61+ msgs := make([]provider.Message, 0, 10)
62+ for i := 1; i <= 5; i++ {
63+ id := "call-" + string(rune('0'+i))
64+ text := strings.Repeat("result-"+id+" ", maxToolResultTextBytes/4)
65+ msgs = append(msgs,
66+ provider.Message{
67+ Role: provider.RoleAssistant,
68+ Content: []provider.Content{provider.ToolCallBlock{ID: id, Name: "read"}},
69+ },
70+ provider.Message{
71+ Role: provider.RoleTool,
72+ Content: []provider.Content{provider.ToolResultBlock{CallID: id, Content: []provider.Content{provider.TextBlock{Text: text}}}},
73+ },
74+ )
75+ }
76+
77+ projected := projectToolResultMessages(msgs)
78+ oldest := toolResultTextForCall(projected, "call-1")
79+ newest := toolResultTextForCall(projected, "call-5")
80+ if oldest != toolResultOmissionMarker {
81+ t.Fatalf("oldest result = %q, want omission marker after newer results filled budget", oldest)
82+ }
83+ if !strings.HasPrefix(newest, "result-call-5 ") || !strings.Contains(newest, toolResultOmissionMarker) {
84+ t.Fatalf("newest result was not retained and truncated first: %q", newest[:min(len(newest), 80)])
85+ }
86+}
87+
88+func TestProjectToolResultMessagesMarksResultsAfterAggregateBudgetIsExhausted(t *testing.T) {
89+ const resultCount = maxToolResultTotalTextBytes/maxToolResultTextBytes + 1
90+ msgs := make([]provider.Message, 0, resultCount)
91+ for i := 0; i < resultCount; i++ {
92+ id := "call-" + string(rune('1'+i))
93+ msgs = append(msgs, provider.Message{
94+ Role: provider.RoleTool,
95+ Content: []provider.Content{provider.ToolResultBlock{
96+ CallID: id,
97+ Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("x", maxToolResultTextBytes+1)}},
98+ }},
99+ })
100+ }
101+
102+ projected := projectToolResultMessages(msgs)
103+ retainedTotal := 0
104+ for _, message := range projected {
105+ for _, content := range message.Content {
106+ result, ok := content.(provider.ToolResultBlock)
107+ if !ok {
108+ continue
109+ }
110+ if !hasToolResultOmissionMarker(result.Content) {
111+ t.Fatalf("result %q is missing the omission marker", result.CallID)
112+ }
113+ retainedTotal += retainedToolResultTextBytes(result.Content)
114+ }
115+ }
116+ if retainedTotal != maxToolResultTotalTextBytes {
117+ t.Fatalf("retained tool-result text = %d bytes, want exactly %d", retainedTotal, maxToolResultTotalTextBytes)
118+ }
119+ if got := toolResultTextForCall(projected, "call-1"); got != toolResultOmissionMarker {
120+ t.Fatalf("aggregate-exhausted result = %q, want omission marker", got)
121+ }
122+}
123+
124+func TestProjectToolResultMessagesPreservesToolResultBlocksAndRawInput(t *testing.T) {
125+ msgs := []provider.Message{
126+ {Role: provider.RoleAssistant, Content: []provider.Content{
127+ provider.ToolCallBlock{ID: "call-1", Name: "read"},
128+ provider.ToolCallBlock{ID: "call-2", Name: "bash"},
129+ }},
130+ {Role: provider.RoleTool, Content: []provider.Content{
131+ provider.ToolResultBlock{CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("one", maxToolResultTextBytes)}}},
132+ provider.ToolResultBlock{CallID: "call-2", Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("two", maxToolResultTextBytes)}}},
133+ }},
134+ }
135+ wantRaw := append([]provider.Message(nil), msgs...)
136+
137+ projected := projectToolResultMessages(msgs)
138+ if !reflect.DeepEqual(msgs, wantRaw) {
139+ t.Fatal("projection mutated the raw input transcript")
140+ }
141+ var resultIDs []string
142+ for _, content := range projected[1].Content {
143+ result, ok := content.(provider.ToolResultBlock)
144+ if !ok {
145+ t.Fatalf("projected content = %T, want ToolResultBlock", content)
146+ }
147+ resultIDs = append(resultIDs, result.CallID)
148+ }
149+ if !reflect.DeepEqual(resultIDs, []string{"call-1", "call-2"}) {
150+ t.Fatalf("projected result IDs = %v, want both tool results in order", resultIDs)
151+ }
152+ if got := toolResultTextForCall(msgs, "call-1"); got != strings.Repeat("one", maxToolResultTextBytes) {
153+ t.Fatal("raw tool result text changed after projection")
154+ }
155+}
156+
157+func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) {
158+ client := &captureClient{}
159+ agent := NewAgent(client, "model", "system", Registry{})
160+ full := strings.Repeat("full-normal-result ", maxToolResultTextBytes)
161+ agent.SetMessages([]provider.Message{
162+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "continue"}}},
163+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "read"}}},
164+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
165+ CallID: "call-1",
166+ Content: []provider.Content{provider.TextBlock{Text: full}},
167+ }}},
168+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-2", Name: "read"}}},
169+ })
170+
171+ if err := agent.Continue(context.Background(), nil); err != nil {
172+ t.Fatalf("Continue: %v", err)
173+ }
174+ projectedText := toolResultTextForCall(client.lastReq.Messages, "call-1")
175+ if retainedToolResultTextBytesForCall(client.lastReq.Messages, "call-1") > maxToolResultTextBytes || !strings.Contains(projectedText, toolResultOmissionMarker) {
176+ t.Fatalf("normal request result was not bounded: %d bytes, %q", len(projectedText), projectedText[:min(len(projectedText), 80)])
177+ }
178+ if !hasToolResultCall(client.lastReq.Messages, "call-2") {
179+ t.Fatal("pair repair result was not preserved in normal request")
180+ }
181+ if got := toolResultTextForCall(agent.Messages(), "call-1"); got != full {
182+ t.Fatalf("raw Agent.Messages tool result changed: got %d bytes, want %d", len(got), len(full))
183+ }
184+ if hasToolResultCall(agent.Messages(), "call-2") {
185+ t.Fatal("pair-repair stub leaked into raw Agent.Messages")
186+ }
187+}
188+
189+func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T) {
190+ client := &compactLifecycleClient{}
191+ agent := NewAgent(client, "model", "system", Registry{})
192+ oldText := strings.Repeat("full-compaction-result ", maxToolResultTextBytes)
193+ tailText := strings.Repeat("full-tail-result ", maxToolResultTextBytes)
194+ agent.SetMessages([]provider.Message{
195+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "summarize this"}}},
196+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "old-call", Name: "read"}}},
197+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
198+ CallID: "old-call",
199+ Content: []provider.Content{provider.TextBlock{Text: oldText}},
200+ }}},
201+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "tail-call", Name: "read"}}},
202+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
203+ CallID: "tail-call",
204+ Content: []provider.Content{provider.TextBlock{Text: tailText}},
205+ }}},
206+ })
207+ var persisted []provider.Message
208+ agent.OnTranscriptCompacted = func(messages []provider.Message) {
209+ persisted = messages
210+ }
211+
212+ if _, err := agent.Compact(context.Background(), 2, nil); err != nil {
213+ t.Fatalf("Compact: %v", err)
214+ }
215+ requestText := client.req.Messages[0].Content[0].(provider.TextBlock).Text
216+ if !strings.Contains(requestText, toolResultOmissionMarker) {
217+ t.Fatal("compaction request is missing the tool-result omission marker")
218+ }
219+ if got := toolResultTextForCall(agent.Messages(), "tail-call"); got != tailText {
220+ t.Fatalf("compacted Agent.Messages tail changed: got %d bytes, want %d", len(got), len(tailText))
221+ }
222+ if got := toolResultTextForCall(persisted, "tail-call"); got != tailText {
223+ t.Fatalf("persisted compacted tail changed: got %d bytes, want %d", len(got), len(tailText))
224+ }
225+ if hasToolResultCall(agent.Messages(), "old-call") {
226+ t.Fatal("summarized tool result remained in the compacted transcript")
227+ }
228+}
229+
230+func retainedToolResultTextBytes(content []provider.Content) int {
231+ total := 0
232+ for _, block := range content {
233+ if text, ok := block.(provider.TextBlock); ok && text.Text != toolResultOmissionMarker {
234+ total += len(text.Text)
235+ }
236+ }
237+ return total
238+}
239+
240+func hasToolResultOmissionMarker(content []provider.Content) bool {
241+ for _, block := range content {
242+ if text, ok := block.(provider.TextBlock); ok && text.Text == toolResultOmissionMarker {
243+ return true
244+ }
245+ }
246+ return false
247+}
248+
249+func retainedToolResultTextBytesForCall(messages []provider.Message, callID string) int {
250+ for _, message := range messages {
251+ for _, content := range message.Content {
252+ result, ok := content.(provider.ToolResultBlock)
253+ if ok && result.CallID == callID {
254+ return retainedToolResultTextBytes(result.Content)
255+ }
256+ }
257+ }
258+ return 0
259+}
260+
261+func toolResultTextForCall(messages []provider.Message, callID string) string {
262+ var text strings.Builder
263+ for _, message := range messages {
264+ for _, content := range message.Content {
265+ result, ok := content.(provider.ToolResultBlock)
266+ if !ok || result.CallID != callID {
267+ continue
268+ }
269+ for _, inner := range result.Content {
270+ if block, ok := inner.(provider.TextBlock); ok {
271+ text.WriteString(block.Text)
272+ }
273+ }
274+ }
275+ }
276+ return text.String()
277+}
278+
279+func hasToolResultCall(messages []provider.Message, callID string) bool {
280+ for _, message := range messages {
281+ for _, content := range message.Content {
282+ if result, ok := content.(provider.ToolResultBlock); ok && result.CallID == callID {
283+ return true
284+ }
285+ }
286+ }
287+ return false
288+}
289+
290+func min(a, b int) int {
291+ if a < b {
292+ return a
293+ }
294+ return b
295+}

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