Skip to content

Harden tool-output budgeting, observability, and artifact lifecycle #7306

Description

@doudouOUC

Status

Phase 1 correctness and its focused contract hardening are complete. The shared finalization implementation merged in #7323 on 2026-07-21, and the Shell no-artifact regression coverage plus persistedOutputFiles tri-state documentation merged in #7470 on 2026-07-22. #7323 also appeared in the automatically generated v0.20.1 changelog, so no manual edit to generated release files is needed.

Phase 2 is complete. The textual ACP and Headless transport bounds merged in #8450 and #9012, and #8447 is closed. Privacy-safe boundary diagnostics merged in #9039 on 2026-08-17, and #8448 is closed.

Phases 1 and 2 are complete. Phase 3 artifact lifecycle and Phase 4 budget evolution are deferred and should proceed only from focused, evidence-backed issues. Dynamic context-budget and masking proposals remain tracked in #2566 and #2567; generic frame limits, backpressure, replay aggregate limits, and daemon-wide resource protection remain tracked separately in #8091.

Final Phase 1 evidence:

  • The focused finalizer reproduction went from 14,096 model-facing characters to exactly the configured 10,000-character budget while preserving both producer artifact references and writing no duplicate aggregate artifact.
  • A live headless main-vs-PR comparison with a 5,000-character budget went from 60,980 characters on the wire on main to exactly 5,000 on the PR; the complete 80,276 characters remained recoverable in two producer artifacts.
  • The final review covered 16 touched test suites with 1,692 passing, 1 skipped, and 0 failed tests; an independent real-disk finalizer harness passed 22/22 cases; the final PR CI was green.
  • Interactive, headless, ACP, agent, and speculative paths now finalize at their model-submission boundary, and the recorded response matches the submitted response.
  • The no-artifact producer-truncation path now has a direct Shell regression test: shortened content remains model-facing, the original oversized body is absent, and persistedOutputFiles is recorded as [] so the finalizer does not retry producer persistence.
  • The code-side contract now documents all three persistedOutputFiles states: undefined means no producer persistence decision, [] means the producer decided but produced no reusable artifact, and a non-empty array contains reusable producer artifact paths.

Display-side baseline measurement

The 2026-07-23 synthetic measurement used character counts, UTF-8 byte counts, and SHA-256 hashes without logging output content. It found that MCP producer truncation changes llmContent but normally preserves the complete returnDisplay. ACP live maps the former into structured content and the latter into rawOutput before final model-response finalization, while headless JSON and stream-json also consume the complete display value.

  • A single 499,999-byte ASCII result remained below the MCP per-part threshold. The finalized model-facing payload was 200,000 bytes, but the ACP SessionUpdate JSON was 1,000,281 bytes because the full result appeared in both content and rawOutput.
  • Two 300,000-byte text parts each remained below the per-part threshold and aggregated to 600,001 bytes before finalization. The finalized model-facing payload was 200,000 bytes, but the ACP SessionUpdate JSON was 1,200,293 bytes.
  • A 500,001-code-unit CJK result occupied 1,500,003 UTF-8 bytes. MCP reduced the producer model preview to 1,679 bytes, but the ACP SessionUpdate JSON remained 1,502,108 bytes because rawOutput retained the full display.
  • Interactive retained history and recorded display metadata are already compacted to 32,000 JavaScript code units. They are not the primary amplification path, although character limits are not equivalent to UTF-8 byte limits.
  • Headless JSON and stream-json currently emit the complete display value before finalization. Text mode does not print the tool body directly but may retain it in adapter memory.
  • qwen serve already diagnoses pipe frames at or above 256 KiB using actual UTF-8 sizes, but it does not truncate, hash, or cover direct ACP and headless output.

The hashes confirmed that the large rawOutput/display payloads contained the original synthetic data rather than accounting overhead. The observed gap is therefore a real display/transport amplification issue, not a failure of the Phase 1 model-input budget.

Problem

Before Phase 1, tool outputs passed through several independent truncation and aggregation paths. Shell could truncate and persist an individual result before scheduling, other tools could be shortened by the scheduler, and a later batch-offload path applied a separate aggregate threshold. The batch path inferred state from rendered marker text and could skip results that were already producer-truncated. When several individually truncated Shell results were returned in one turn, their combined model-facing payload could therefore exceed the configured final budget.

The same conceptual batch could also be assembled in interactive, headless, ACP, agent, and speculative runtimes. Without a shared finalization boundary, the model request and the recorded transcript could diverge, persistence could be duplicated, and diagnostics could not reliably identify which layer changed a payload.

Root cause

There was no single authoritative final tool-response budget immediately before model submission. Persistence metadata was encoded in user-facing marker strings instead of structured state, and producer-level truncation was incorrectly treated as proof that the aggregate batch needed no further budgeting. Phase 1 addressed this root cause with a shared finalizer, structured persistence metadata, and a no-I/O send-boundary guard.

Display transport remains a separate concern: ACP and headless projections can serialize a complete display value after model-facing finalization, sometimes in two representations. Existing limits are measured in different units and applied at different boundaries, so they do not provide a stable wire-size contract.

Desired outcome

Every model request should receive a deterministic, bounded representation of tool responses. Full output should be persisted at most once when required, artifact references should remain available in the compact response, all runtimes should use the same finalization semantics, and the transcript should record exactly the parts submitted to the model.

Eligible textual ACP live/replay and headless tool-result fields should additionally have explicit UTF-8 JSON-byte budgets. The first Phase 2 implementation will keep both ACP content and rawOutput for compatibility and bound each independently; omitting or deduplicating equivalent wire fields is deferred until a compatible protocol decision is justified. Structured, rich, terminal, diff, media, and A2UI data must retain their current semantics unless a field is independently eligible for textual projection.

Delivery stages

Phase 2 final design

Fixed contract and scope

  • Use a fixed 65,536-byte budget for each eligible textual projection. The limit is measured as the UTF-8 byte length of the field's JSON serialization, so JSON quotes, escapes, array wrappers, and content-block wrappers count toward the budget.
  • Use an internal CLI-only projector. Do not add a public API, core export, user-facing configuration, capability negotiation, sidecar file, or new disk write.
  • Keep ACP content and string rawOutput present in the first version and bound them independently. An identical content/raw string may reuse the same computed in-memory preview, but both fields remain serialized. Raw-field omission or wire deduplication is deferred.
  • Keep structured _meta.artifacts unchanged in ACP PR 1. Artifact metadata budgeting and full ownership, retention, cleanup, and reuse semantics remain separate follow-up work and Phase 3 concerns.
  • Generic NDJSON hard frame caps, backpressure, replay aggregate/container limits, and daemon-wide resource protection remain tracked by tracking(serve): Split daemon resource protection into reviewable PRs #8091. Phase 2 must not claim to solve those broader limits.

PR 1 — ACP textual tool-result projection

  • Apply projection at both ACP delivery boundaries: live updates in Session.sendUpdate() and the history replay collector. Covering only Session.sendUpdate() would miss bulk replay/load paths.
  • Do not project in the canonical transcript update constructor or replay state machine. Offline transcript export must remain lossless, and projection there would incorrectly make transport loss look canonical.
  • Bound eligible pure-text content so JSON.stringify(content) is at most 65,536 UTF-8 bytes, including multi-block wrappers. Bound string rawOutput independently under the same rule.
  • Use an O(n) scanner that accounts for native JSON escape expansion for ASCII, quotes, backslashes, controls, CJK, emoji, paired surrogates, and lone surrogates. Do not stringify the entire oversized value, repeatedly binary-search it, or join all blocks into another unbounded string.
  • Produce a deterministic preview with approximately 20% head and 80% tail, including the truncation marker inside the budget. Force a bounded copy so an oversized backing string is not retained through a small slice.
  • Share the content budget across text blocks with a deterministic water-fill allocation while preserving block count and order. If the empty-text structure alone exceeds the budget, collapse it to one bounded omission marker.
  • Exempt the whole A2UI update using the existing A2UI metadata predicate because the bridge parses command JSON after the child wire boundary.
  • Exempt structured rawOutput and mixed, diff, terminal, media, or otherwise non-pure-text content from that field's textual projection. An exemption for one field must not prevent projection of another independently eligible field.
  • Preserve existing Shell/MCP artifact footers in the tail on a best-effort basis. Do not parse rendered truncation markers as state and do not claim that full output is recoverable when no producer artifact exists.
  • Keep ordinary textual ACP fixture frames below 256 KiB after projection, but do not introduce or document a universal frame cap in this PR.
  • Update the Phase 1 design statement that immediate ACP display events remain unchanged, because this PR intentionally changes their bounded transport projection.

PR 2 — Headless textual tool-result projection

  • Apply projection after the adapter has selected the semantic tool-result value, immediately before BaseJsonOutputAdapter.emitToolResult() emits or retains it.
  • Bound JSON.stringify(tool_result.content) to at most 65,536 UTF-8 bytes for eligible textual results.
  • Cover JSON, stream-json, internal Text retention, SDK sessions, subagents, and DualOutput without gating on outputFormat.
  • Do not inspect or inject persistedOutputFiles paths into Headless wire text. Existing producer footers may survive in the retained tail on a best-effort basis, while undefined, [], and non-empty metadata remain internal.
  • Increment DUAL_OUTPUT_PROTOCOL_VERSION from 1 to 2 because downstream consumers observe a new bounded-content contract; keep the supported event set unchanged and update headless/DualOutput documentation. Do not change SDK schemas.
  • Keep custom adapters, callers that emit prebuilt tool_result messages directly, overall JSON-array/session limits, stream backpressure, and upstream retention of a complete returnDisplay outside this PR.

PR 3 — Privacy-safe boundary length/hash diagnostics

  • Enable diagnostics only when QWEN_DEBUG_LOG_FILE is configured and only for oversized or mutated representations. Do not publish content hashes as production metrics.
  • Record internal stages for producer, finalizer input/output, recorder, ACP or headless projection input/output, and actual wire emission. Distinguish model text, display, ACP content/raw, headless content, and frame representations.
  • Record JavaScript code-unit count, raw UTF-8 bytes, JSON UTF-8 bytes, whether the value changed, and artifact tri-state/kind.
  • Use HMAC-SHA-256 with a process-random key. Hash each path or slot with length-delimited input instead of joining values. HMAC session, prompt, and tool-call identifiers as well; never log raw output, artifact paths, or identifiers. Values are intentionally comparable only within one process.
  • Measure ACP bytes from a post-serialization write observation in the NDJSON stream and headless bytes at its writer boundary, so the reported wire size is the actual emitted size.
  • Rate-limit diagnostic events to 50 per minute and report a suppressed-event count. Diagnostic failure must never change the tool result or transport path.
  • Keep the existing large-pipe-frame observer as the qwen serve attribution mechanism for frames at or above 256 KiB; do not duplicate that responsibility.

Phase 2 acceptance criteria

Phase 1 acceptance criteria

  • The combined model-facing tool-response payload never exceeds the configured final character budget, including batches containing multiple producer-truncated results.
  • Producer persistence and aggregate fallback persistence do not write the same full output twice.
  • Persisted artifact references survive final compaction.
  • Small responses and non-tool user/model content remain unchanged.
  • A persistence failure still yields a bounded model-facing response without fabricating an artifact reference.
  • Interactive, headless, ACP, agent, and speculative paths share the same behavior.
  • The response parts recorded in history are the same parts sent in the next model request.

Deferred follow-ups from Phase 1 review

These items were reviewed as non-blocking. Completed items are recorded here; the remaining items should be handled only as focused follow-ups when their impact justifies the work.

Next recommended step

Phase 2 is complete through #8450, #9012, and #9039. No further implementation is planned under this umbrella. Phase 3 and Phase 4 are deferred until concrete evidence justifies focused work; use #2566 and #2567 for context-budget evolution and #8091 for daemon-wide resource protection.

Out of scope for Phase 1

Wire-level byte/hash telemetry, exact tokenizer-based accounting, media budgeting, display transport bounds, artifact cleanup policy, and a broader temporary-file redesign remain intentionally deferred to the later stages above.

Metadata

Metadata

Assignees

Labels

category/coreCore engine and logicpriority/P2Medium - Moderately impactful, noticeable problemscope/coretype/enhancementNon-bug improvement or optimization

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions