Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions .qwen/e2e-tests/2026-08-10-doctor-memory-tool-result-retention.md
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,
Comment thread
ZijianZhang989 marked this conversation as resolved.
Resolved
"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:
Comment thread
ZijianZhang989 marked this conversation as resolved.
Resolved
Comment thread
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):
Comment thread
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.
153 changes: 153 additions & 0 deletions docs/design/2026-08-10-tool-output-offload-preview.md
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]
Comment thread
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]
Comment thread
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)
Comment thread
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`.
Comment thread
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
Comment thread
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) |
Comment thread
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.
Loading
Loading