Repository navigation
feat(cli): report retained tool-result stats in /doctor memory #8875
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 8 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
9d98d8d
feat(cli): report retained tool-result stats in /doctor memory
ba2adf7
docs(design): document tool-output offload/preview state transitions …
2ae0f69
Merge branch 'main' into feat/doctor-tool-result-retention
wenshao ae30398
fix(cli): align retention diagnostics with per-tool budgets and raw-c…
39b5bb4
fix(cli): calibrate oversized detection against truncation layers
9c9f908
fix(core): share tolerance constant and align imageTokenEstimate with…
f98d5a1
fix(doctor): align UI scan to 2x tolerance, fix threshold/error/senti…
6e64cd1
docs: fix design note accuracy (R5-9/R5-15/R5-10)
0881ee5
fix(doctor): add envelope slack for token-aware truncation fallback
0c0e4fc
docs: align §5 sentinel description with diagnostic code
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
137 changes: 137 additions & 0 deletions
137
.qwen/e2e-tests/2026-08-10-doctor-memory-tool-result-retention.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| # E2E: /doctor memory tool-result retention diagnostics | ||
|
|
||
| Date: 2026-08-11 (re-verified after review round 4) · Branch: | ||
| `feat/doctor-tool-result-retention` · Runtime: `npm run dev` in tmux | ||
| (220x52), IdeaLab API key auth, no sandbox, macOS. | ||
|
|
||
| Sizes are measured with the compression pipeline's `estimatePartChars` model | ||
| (raw chars for string outputs; nested media billed at the image estimate). | ||
| Oversized counts compare each result against its own tool's declared budget | ||
| (resolved from the registry by canonicalized name; tools declaring none fall | ||
| back to the configured global truncation threshold), skip results already | ||
| carrying the truncation sentinel, and apply the scheduler's combined-pass 2x | ||
| tolerance. The UI-history scan compares each `tool_group` display against the | ||
| same per-tool budget at the same 2x tolerance. | ||
|
|
||
| ## Scenario 1 — fresh session (baseline) | ||
|
|
||
| Steps: start CLI, run `/doctor memory`. | ||
|
|
||
| Expected/observed: report ends with an all-zero retention section: | ||
|
|
||
| ``` | ||
| Tool result retention | ||
| Tool results in history: 0 | ||
| Total retained: 0 chars | ||
| Largest result: 0 chars | ||
| Oversized results (above tool budget): 0 | ||
| Oversized also rendered in UI history: 0 item(s) | ||
| Oversized also in compression input: no | ||
| ``` | ||
|
|
||
| ## Scenario 2 — large shell output (mitigation active) | ||
|
|
||
| Steps: ask the agent to run `seq 1 9000` (~47k chars), approve the | ||
| confirmation, then `/doctor memory`. | ||
|
|
||
| Observed: | ||
|
|
||
| - Tool output spilled to disk: `Output too long and was saved to: | ||
| ~/.qwen/tmp/<session-hash>/run_shell_command_928ba0eecdb5.output`, UI shows | ||
| `... first 6573 lines hidden ...` plus the tail. | ||
| - Report reflects only the retained preview stub: | ||
|
|
||
| ``` | ||
| Tool result retention | ||
| Tool results in history: 1 | ||
| Total retained: 4543 chars | ||
| Largest result: 4543 chars | ||
| Oversized results (above tool budget): 0 | ||
| Oversized also rendered in UI history: 0 item(s) | ||
| Oversized also in compression input: no | ||
| ``` | ||
|
|
||
| Conclusion: mitigation (shell 30k budget + spill) keeps history retention | ||
| bounded; diagnostics track the live history correctly. The spilled result's | ||
| retained UI display stays well within 2x the shell budget, so the UI | ||
| duplication signal correctly stays at 0 — it only fires when a rendered | ||
| display actually exceeds 2x its tool's budget. | ||
|
|
||
| ## Scenario 3 — multiple tool calls, raised global threshold | ||
|
|
||
| Steps: set `tools.truncateToolOutputThreshold: 100000` in project | ||
| `.qwen/settings.json` (simulation only; removed after testing), run five | ||
| shell calls (`date`, `echo hello`, `ls packages`, `seq 1 6000`, | ||
| `printf 'x%.0s' {1..40000}`), then `/doctor memory`. | ||
|
|
||
| Observed: | ||
|
|
||
| ``` | ||
| Tool result retention | ||
| Tool results in history: 5 | ||
| Total retained: 34572 chars | ||
| Largest result: 29070 chars | ||
| Oversized results (above tool budget): 0 | ||
| Oversized also rendered in UI history: 0 item(s) | ||
| Oversized also in compression input: no | ||
| ``` | ||
|
|
||
| Counts accumulate with the session. The largest result is the `seq 1 6000` | ||
| multi-line output (raw ~28.9k chars, kept under shell's 30k budget); the 40k | ||
| `printf` output is a single line, which the shell tool spills to disk with a | ||
| small ~1.3k preview (hardcoded `previewChars: 4000`), so it contributes only | ||
| the stub. `Oversized results` stays 0 — confirming the oversized counter is | ||
| a regression alarm rather than an everyday signal. | ||
|
|
||
| ## Scenario 4 — `--json` | ||
|
|
||
| Steps: `/doctor memory --json` in the scenario-2 session. | ||
|
|
||
| Observed payload includes: | ||
|
|
||
| ```json | ||
| "toolResultRetention": { | ||
| "toolResultCount": 1, | ||
| "totalChars": 4543, | ||
| "largestResultChars": 4543, | ||
| "oversizedResultCount": 0, | ||
| "oversizedThresholdChars": 25000, | ||
| "largeOutputsInUIHistory": 0, | ||
| "presentInCompressionInput": false | ||
| } | ||
| ``` | ||
|
|
||
| `oversizedThresholdChars` reports the configured global threshold | ||
| (25000 under defaults) — the fallback budget for tools declaring none. | ||
|
|
||
| When no chat history is available, `--json` omits the `toolResultRetention` | ||
| key entirely (no `null`), matching the readable output. | ||
|
|
||
| ## Oversized "yes" branch | ||
|
|
||
| Unreachable in normal operation (per-tool/global layers bound every result at | ||
| or below its declared budget). Covered deterministically by unit tests: | ||
|
ZijianZhang989 marked this conversation as resolved.
Resolved
ZijianZhang989 marked this conversation as resolved.
Resolved
|
||
|
|
||
| - `packages/core/src/utils/tool-result-retention.test.ts` (19 tests): counts, | ||
| max, raw-char measurement of newline-dense outputs, strict `>` boundary at | ||
| 2x budget, sentinel skip (truncation prefix and `<persisted-output>` stubs | ||
| on both `output` and `error` keys), per-tool budget resolver | ||
| (high/low/unknown/`Infinity` budgets), nested media billing, missing | ||
| payload/parts, unserializable payloads, multiple functionResponse parts in | ||
| one Content. | ||
| - `packages/cli/src/ui/commands/doctorCommand.test.ts` (11 retention tests): | ||
|
ZijianZhang989 marked this conversation as resolved.
Resolved
|
||
| readable report with `Oversized results (above tool budget): 1` + | ||
| compression input `yes (shared by reference, no extra copy)` + `/compress` | ||
| hint; UI-history detection scoped to `tool_group` result displays with | ||
| per-tool budgets at 2x tolerance (model text and compliant high-budget | ||
| renders excluded); legacy-alias canonicalization; compliant-session shape | ||
| (zero oversized, no compression advice); `--json` fields; `--json` omits | ||
| the key without history; section omitted (rest of report intact) when | ||
| history reads throw; disabled-truncation guard (no false positives at | ||
| `Infinity` threshold); unresolvable-name fallback; interactive report. | ||
|
|
||
| ## Cleanup | ||
|
|
||
| The temporary `.qwen/settings.json` was gitignored and used for simulation | ||
| only; it is removed before the PR is merged. All tmux sessions killed; no | ||
| source changes from testing. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| # Tool Output Offload/Preview: State Transitions and Privacy Model | ||
|
|
||
| > Design note required by [#4184](https://github.com/QwenLM/qwen-code/issues/4184) | ||
| > (acceptance criterion: "A design note documents the offload/preview state | ||
| > transition and privacy model"). Mitigation implemented in #4880; retention | ||
| > diagnostics added in the accompanying `/doctor memory` change. | ||
|
|
||
| ## 1. Problem | ||
|
|
||
| In long sessions, OOM risk comes from oversized tool outputs being retained in | ||
| conversation history and taxing every later turn, and from duplicate copies of | ||
| history during compression — not just from traditional leaks. The goal is to | ||
| keep structured metadata and a bounded preview in the hot path, persist large | ||
| payloads out of it, and make diagnostics show where memory is retained. | ||
|
|
||
| ## 2. State Transitions | ||
|
|
||
| A tool output moves through the following states before it can enter | ||
| conversation history: | ||
|
|
||
| ```mermaid | ||
| graph TB | ||
| A[Raw tool output] --> S{Starts with truncation sentinel?} | ||
| S -- yes --> J[Metadata appended after truncation, never bisected] | ||
|
ZijianZhang989 marked this conversation as resolved.
Resolved
|
||
| S -- no --> G{Persistence gate: over configured threshold + 3k headroom, and not exempt?} | ||
| G -- yes --> F[Full payload persisted to session temp file, mode 0o600] | ||
| G -- no --> B{Per-tool budget declared?} | ||
| B -- yes --> C[Scheduler per-tool bound, e.g. grep 20k] | ||
| B -- no --> D[Scheduler gate: global threshold 25k chars + 1000 lines] | ||
| C --> E{Still oversized?} | ||
| D --> E | ||
| E -- no --> H[Enters history as-is] | ||
|
ZijianZhang989 marked this conversation as resolved.
Resolved
|
||
| E -- yes --> F | ||
| F --> I2[History retains preview + metadata + read_file pointer] | ||
| I2 --> I[Model recovers full output on demand via read_file] | ||
| H --> J | ||
| I2 --> J | ||
| J -->|non-sentinel body| K{Assembled string over 2x budget?} | ||
| J -->|sentinel body (skip)| M[Per-message batch budget 200k across parallel calls] | ||
| K -- yes --> L[Second pass bounds it once more] | ||
| K -- no --> M | ||
| L --> M | ||
| M --> N[Final tool result recorded in history] | ||
| ``` | ||
|
|
||
| Key properties: | ||
|
|
||
| - **Persistence gate first** (for tools without in-tool truncation). | ||
| `maybePersistLargeToolResult` runs before the scheduler's per-tool/global | ||
| truncation: any non-exempt result over the configured threshold + 3k headroom | ||
| (default 28k) is persisted and stubbed to a preview right away. Exempt: | ||
| `read_file`, `read_mcp_resource`, `enter_plan_mode` (self-managed). | ||
| Shell (30k) and MCP (500k) truncate in-tool during `execute()` before the | ||
| gate sees the result; the sentinel check at entry then routes them past the | ||
| gate. Consequently, per-tool budgets above 28k (agent 32k, web-search 102k) | ||
|
ZijianZhang989 marked this conversation as resolved.
Resolved
|
||
| are second-level bounds — the gate offloads first. | ||
| - **Bounded before history.** Every layer acts before the result is recorded, | ||
| so history never holds an unbounded payload. | ||
| - **Recoverable, never dropped.** Oversized output is persisted to a session | ||
| temp file: the gate writes `tool-results/<callId>.txt`, while in-tool | ||
| truncation (shell, MCP) writes `~/.qwen/tmp/<session-hash>/<tool>_<hex>.output`. | ||
|
ZijianZhang989 marked this conversation as resolved.
Resolved
|
||
| The retained preview carries a pointer; the model can read the full payload | ||
| back with `read_file`. Truncation keeps head and tail (`keep: 'both'`) | ||
| because shell failure summaries appear at the end. | ||
| - **Re-entrancy guard.** A truncated result starts with a sentinel prefix | ||
| (`TOOL_OUTPUT_TRUNCATED_PREFIX`); later passes detect it and skip | ||
| re-truncation, so injected copies of the phrase cannot bypass the budget and | ||
|
ZijianZhang989 marked this conversation as resolved.
Resolved
|
||
| truncation headers never nest. | ||
| - **Metadata integrity.** PostToolUse/skill metadata and system reminders are | ||
| appended only after the raw body is bounded, then the assembled string is | ||
| re-checked against a doubled budget — unless the body already carries the | ||
| truncation sentinel (re-entrancy skip), in which case only the batch budget | ||
| bounds it. | ||
| - **Batch-level bound.** After all parallel calls in one message complete, the | ||
| aggregate is reduced to `toolOutputBatchBudget` (default 200k chars) by | ||
| offloading the largest results — covering the case where many individually | ||
| legal results explode together. | ||
|
|
||
| ## 3. Thresholds | ||
|
|
||
| | Layer | Budget | Configurable | | ||
| | ---------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | ||
| | Persistence gate | configured threshold + 3k headroom (default 28k); exempt: read_file, read_mcp_resource, enter_plan_mode | `settings.tools.truncateToolOutputThreshold` | | ||
| | Per-tool | shell 30k, grep 20k, mcp 500k, agent 32k/tail, read-file self-managed | No (declared by tool) | | ||
|
ZijianZhang989 marked this conversation as resolved.
Resolved
|
||
| | Global | 25k chars + 1000 lines | `settings.tools.truncateToolOutputThreshold` / `truncateToolOutputLines` | | ||
| | Combined pass | 2x of the applicable budget | No | | ||
| | Per-message | 200k chars | `settings.tools.toolOutputBatchBudget` | | ||
| | Disk persistence | 50MB per file, 500MB per session | No | | ||
|
|
||
| Per-tool budgets are char-only: when a tool declares one, the global line cap | ||
| is disabled for it so self-managed paging (read-file) and char budgets (grep) | ||
| are not silently undercut. | ||
|
|
||
| ## 4. Privacy Model | ||
|
|
||
| Maps directly to the non-goals in #4184: | ||
|
|
||
| | Non-goal | Enforcement | | ||
| | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| | Do not upload tool results | Offload target is a local file under the session temp dir only; no network path exists in the truncation code | | ||
| | Do not include private content in diagnostics | `/doctor memory` retention section reports sizes and counts only, never content; safe to paste in bug reports (also in `--json`) | | ||
| | Do not silently drop data without a retrievable pointer | Oversized payloads are persisted with a preview + `read_file` pointer; if persistence is impossible (see below), the bounded preview still explains what happened | | ||
| | Owner-only artifacts | Persisted files are written with mode `0o600`; the shared temp directory itself is not loosened | | ||
|
|
||
| Disk persistence failure modes (all fail toward bounded memory, never toward | ||
| unbounded retention or data exposure): | ||
|
|
||
| - Output larger than 50MB: persistence skipped, in-memory truncation still | ||
| bounds the result. | ||
| - Session budget (500MB) exhausted: persistence skipped, same in-memory bound. | ||
| - Truncation/IO error: the successful tool call is never demoted to an error. | ||
| On a primary persist failure, the code falls back to `truncateAndSaveToFile` | ||
| into the project temp dir — the full payload is retained with a `read_file` | ||
| pointer. Only if the fallback also fails is the result degraded to a | ||
| pointerless bounded preview with a warning logged. | ||
|
|
||
| ## 5. Diagnostics (phase 1 signals) | ||
|
|
||
| `/doctor memory` now reports, live and by reference (no history clone): | ||
|
|
||
| - Tool results in history, total retained chars, largest result. Sizes reuse | ||
| the compression pipeline's `estimatePartChars` model with the same | ||
| `imageTokenEstimate` (resolved via `resolveSlimmingConfig` from env > | ||
| settings > default), so diagnostics and compression agree about the same | ||
| history: string outputs are measured as raw chars (no JSON-escaping | ||
| inflation) and nested media parts are billed at the image token estimate. | ||
| - Oversized results, counted against each result's own tool budget (resolved | ||
| from the tool registry by canonicalized `functionResponse.name`, mirroring | ||
| the scheduler; tools declaring none fall back to the configured global | ||
| threshold). Results already carrying the truncation sentinel are skipped — | ||
| a layer bounded them, and their retained preview can sit slightly above the | ||
| raw budget due to the spill envelope — and the remaining results are only | ||
| flagged beyond the combined-pass 2x tolerance, matching the headroom the | ||
| scheduler itself allows. A retained result past that bound means a | ||
| truncation layer was bypassed — the counter doubles as a regression alarm. | ||
| - Whether oversized outputs are also rendered in UI history (scanned in | ||
| `tool_group` items' `resultDisplay`, compared per display against the same | ||
| per-tool budget — UI history stores display names, not registry keys, so a | ||
| display-name → budget map is built from the tool registry at scan time) and | ||
| in compression input (yes by construction, but compression reads history by | ||
| reference via `getHistoryShallow`, so no extra copy is held). Phase-1 scope: | ||
| only string `resultDisplay` values are measured; structured display objects | ||
| (file diffs, ANSI captures, agent result summaries) carry their own | ||
| rendering contracts and are not char-comparable in the same way — they are | ||
| left for a follow-up PR. | ||
|
|
||
| ## 6. Alternatives Considered | ||
|
|
||
| - **Summarize instead of truncate.** Adds a model round-trip on the hot path | ||
| and complicates the privacy model; the pointer-based recovery achieves the | ||
| same goal deterministically. | ||
| - **Lazy-load history from disk.** Changes the conversation contract and | ||
| provider payload shape; the preview + pointer keeps the contract intact. | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.