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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 409 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 433 | ### `/jail` | |
| 432 | 434 | ||
| @@ -457,7 +459,7 @@ Use `/login` to store API keys or subscription credentials. `/model` only shows | |||
| 457 | 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 | 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 | 464 | ### Model fallback (rescue) | |
| 463 | 465 | ||
| @@ -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 { | |||
| 126 | 126 | ||
| 127 | 127 | // AutoCompactThreshold is the percentage of the model context window | |
| 128 | 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 | 131 | AutoCompactThreshold *int `json:"auto_compact_threshold,omitempty"` | |
| 131 | 132 | ||
| 132 | 133 | // 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 window | 127 | // 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 when | 133 | // 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) { | |||
| 21 | 21 | {name: "eighty", value: autoCompactIntPtr(80), want: 80}, | |
| 22 | 22 | {name: "eighty five", value: autoCompactIntPtr(85), want: 85}, | |
| 23 | 23 | {name: "ninety", value: autoCompactIntPtr(90), want: 90}, | |
| 24 | + | {name: "ninety five", value: autoCompactIntPtr(95), want: 95}, | |
| 24 | 25 | {name: "invalid low", value: autoCompactIntPtr(42), want: 85}, | |
| 25 | 26 | {name: "invalid high", value: autoCompactIntPtr(100), want: 85}, | |
| 26 | 27 | } | |
| @@ -344,6 +345,7 @@ func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) { | |||
| 344 | 345 | {name: "seventy percent preset", used: 70, window: 100, threshold: 70, want: true}, | |
| 345 | 346 | {name: "eighty percent preset", used: 80, window: 100, threshold: 80, want: true}, | |
| 346 | 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 | 349 | {name: "off", used: 100, window: 100, threshold: 0, want: false}, | |
| 348 | 350 | {name: "missing usage", used: 0, window: 100, threshold: 85, want: false}, | |
| 349 | 351 | {name: "missing window", used: 85, window: 0, threshold: 85, want: false}, | |
| @@ -374,7 +376,7 @@ func TestSettingsDialogOffersAutoCompactThresholdPresets(t *testing.T) { | |||
| 374 | 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 | 380 | if len(thresholdItem.options) != len(wantValues) { | |
| 379 | 381 | t.Fatalf("auto-compact options = %d, want %d", len(thresholdItem.options), len(wantValues)) | |
| 380 | 382 | } | |
| @@ -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() { | |||
| 4378 | 4378 | {value: "80", label: "80%", desc: "compact with moderate context headroom"}, | |
| 4379 | 4379 | {value: "85", label: "85%", desc: "default balance between history and headroom"}, | |
| 4380 | 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 | 4383 | autoCompactThreshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold) | |
| 4383 | 4384 | autoCompactChoice := 0 | |
| @@ -7829,7 +7830,7 @@ func normalizeAutoCompactThreshold(threshold *int) int { | |||
| 7829 | 7830 | return defaultAutoCompactThreshold | |
| 7830 | 7831 | } | |
| 7831 | 7832 | switch *threshold { | |
| 7832 | - | case 0, 70, 80, 85, 90: | |
| 7833 | + | case 0, 70, 80, 85, 90, 95: | |
| 7833 | 7834 | return *threshold | |
| 7834 | 7835 | default: | |
| 7835 | 7836 | 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 := 0 | 4384 | autoCompactChoice := 0 | ||
| @@ -7829,7 +7830,7 @@ func normalizeAutoCompactThreshold(threshold *int) int { | |||||
| 7829 | return defaultAutoCompactThreshold | 7830 | 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 *threshold | 7834 | return *threshold | ||
| 7834 | default: | 7835 | default: | ||
| 7835 | return defaultAutoCompactThreshold | 7836 | return defaultAutoCompactThreshold | ||
M packages/core/agent.go +5 -1
| @@ -685,6 +685,10 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext | |||
| 685 | 685 | } | |
| 686 | 686 | 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 | 692 | req := provider.Request{ | |
| 689 | 693 | Model: a.Model, | |
| 690 | 694 | System: system, | |
| @@ -696,7 +700,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext | |||
| 696 | 700 | // next in-process request is rejected by providers like Anthropic | |
| 697 | 701 | // with "tool_use ids were found without tool_result blocks". The | |
| 698 | 702 | // repair is pure and a no-op on already-valid transcripts. | |
| 699 | - | Messages: repairToolUseResultPairs(a.Messages()), | |
| 703 | + | Messages: projectToolResultMessages(messages), | |
| 700 | 704 | Tools: tools.Specs(), | |
| 701 | 705 | Reasoning: a.Reasoning, | |
| 702 | 706 | 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" + contextText | 686 | 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 Anthropic | 700 | // next in-process request is rejected by providers like Anthropic | ||
| 697 | // with "tool_use ids were found without tool_result blocks". The | 701 | // 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 { | |||
| 185 | 185 | // text transcript the summarization model can read without trying to | |
| 186 | 186 | // continue the conversation. | |
| 187 | 187 | func serializeTranscript(msgs []provider.Message) string { | |
| 188 | + | msgs = projectToolResultMessages(msgs) | |
| 188 | 189 | var sb strings.Builder | |
| 189 | 190 | for _, m := range msgs { | |
| 190 | 191 | switch m.Role { | |
| @@ -185,6 +185,7 @@ func repairOrphanedToolResults(msgs []provider.Message) []provider.Message { | |||||
| 185 | // text transcript the summarization model can read without trying to | 185 | // 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.Builder | 189 | 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 | + | } | |||