Skip to content

fix(agent-core-v2): spill oversized tool outputs and surface dropped content - #3227

Open
7Sageer wants to merge 8 commits into
mainfrom
feat/tool-result-spill
Open

fix(agent-core-v2): spill oversized tool outputs and surface dropped content#3227
7Sageer wants to merge 8 commits into
mainfrom
feat/tool-result-spill

Conversation

@7Sageer

@7Sageer 7Sageer commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Related Issue

N/A — internal task.

Problem

The engine silently destroyed content the model had no way to recover:

  • Bash / Grep / FetchURL / WebSearch truncated outputs at 50,000 chars and marked them truncated: true; the loop-level spill service skipped already-truncated results, so the cut portion had no disk copy and the model only saw [...truncated] with no retrieval hint.
  • MCP content blocks with unsupported types (resource blobs with non-media mime, resource_links, unknown block types) were silently dropped from tool results — the model never knew content was missing.
  • When an LLM request failed mid-stream (not a user abort), the partial assistant output already shown in the UI was discarded: neither the wire nor the model context kept it, leaving the user's and the model's views inconsistent.

What changed

  • Spill pre-truncated tool results to disk. ToolResultBuilder gains retainFullOutput (with a 10 MB maxRetainedChars cap to stay safe on unbounded streams); on char-cap truncation the result carries the full text via the engine-internal untruncatedOutput contract field. The central ToolResultTruncationService persists it and replaces the output with a pointer: path-first render, true size (with "only the first N characters were preserved" when retention capped out), head 4,096 + tail 1,024 previews, and the exact elided [start, end) range so the model can compute Read/Grep slices. Fail-open on disk errors, and the internal fields are stripped before results continue down the pipeline. Bash, Grep, FetchURL, and WebSearch opt in; per-line maxLineLength cuts stay cosmetic and unchanged.
  • MCP drop notices. The four silent return null paths in convertMCPContentBlock now return [MCP content dropped: ...] text parts carrying mime type, size, and uri — for resource_link the uri lets the model fetch the content itself.
  • Mid-stream error partials. loopService no longer gates appendInterruptedStreamContent on user abort, so partial output is appended to the wire on request errors too, keeping UI, wire, and context consistent.
  • Recovery-loop guard. The truncation service exposes isSpillFilePath; the Read tool marks results for paths inside the agent's spill directory spillExempt, so reading a spill file never triggers another spill.

Tests: new cases for builder retention, the spill pointer format (head/tail/elided offsets, partial preservation, exempt passthrough, isSpillFilePath), MCP drop notices, and a mid-stream error loop test. The test harness gained scripted mid-stream failures: generateBackedResponse now yields already-streamed parts before propagating an error (it previously swallowed them, and an error stream no longer clears the inner traceId). Full agent-core-v2 suite passes except 11 pre-existing test/app/plugin/* failures caused by the missing zip binary in this environment, unrelated to this change.

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue (external PRs: the issue must have a maintainer's /approve).
  • I have added tests that prove my feature works.
  • Ran gen-changesets skill, or this PR needs no changeset.
  • Ran gen-docs skill, or this PR needs no doc update.

@changeset-bot

changeset-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bc1f6f6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

…zation

normalizeToolResult rebuilds every tool result with a field whitelist,
which stripped untruncatedOutput / untruncatedOutputTotalChars /
spillExempt before ToolResultTruncationService could see them: the
spill-on-truncation path never fired for Bash/Grep/FetchURL/WebSearch,
and reads of spill files were not exempted from re-spilling.

Pass the three engine-internal fields through, and add executor-level
integration tests that run a retainFullOutput tool and a spill-exempt
result through the real ToolResultTruncationService.
@7Hanrui

7Hanrui commented Aug 25, 2026

Copy link
Copy Markdown

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

await this.storage.write(this.storageScope, key, encoder.encode(text), { atomic: true });
return { outputPath: join(this.bootstrap.homeDir, this.storageScope, key) };

P1 Badge Persist spill files in the bound runtime

When an agent is switched to a non-local runtime, this writes the retained output through the app-host IFileSystemStorageService and returns an absolute path under the host's bootstrap.homeDir; however, the suggested Read operation acquires the agent's bound runtime filesystem, where that host path generally does not exist. In that context the model receives a recovery pointer it cannot read, so persist the spill in the bound runtime or expose a reader for the host-side store.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

finishReason: meta.finishReason ?? null,
rawFinishReason: meta.rawFinishReason ?? null,
traceId: meta.traceId ?? null,
...(meta.traceId !== undefined ? { traceId: meta.traceId } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pass the optional trace ID directly

This new conditional spread violates the repository's required construction pattern for optional properties; assign traceId: meta.traceId directly so optional fields are handled consistently.

AGENTS.md reference: AGENTS.md:L52-L54

Useful? React with 👍 / 👎.

* Engine-internal: opts the result out of spill-to-disk (e.g. reading a file
* that already lives in the spill directory).
*/
readonly spillExempt?: true | undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the redundant undefined union

The newly added optional spillExempt property explicitly unions undefined, which is the prohibited optional-property shape in this repository; declare it as spillExempt?: true instead, including the duplicate declaration on the error result.

AGENTS.md reference: AGENTS.md:L55-L57

Useful? React with 👍 / 👎.

main banned JSDoc in comment-free packages (#3226); remove the doc
blocks on the new contract fields and on isSpillFilePath. Type the
harness scripted stream error as Error to satisfy only-throw-error
under oxlint --type-aware.
@7Hanrui

7Hanrui commented Aug 25, 2026

Copy link
Copy Markdown

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 28ae3c3252

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

output: renderPersistedToolResult(
input.toolName,
input.toolCallId,
untruncatedOutput,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve additions made after output retention

When a retained builder result is modified after write()—for example, ToolDedupeService.appendReminder adds the repeated-call guard, or BashTool adds an exit/timeout message and its existing full-log reference—untruncatedOutput still contains only the raw writes. This branch saves that stale value and replaces the final output, so those additions disappear; in particular, a noisy repeated tool call never delivers the dedupe reminder and can continue repeating. Preserve final-only suffixes when constructing the spill result instead of replacing them with the retained buffer.

Useful? React with 👍 / 👎.

const tail = text.slice(tailStart);
const lines = [
`Tool output exceeded ${String(TOOL_RESULT_MAX_CHARS)} characters; showing a preview only.`,
`Tool output exceeded ${String(TOOL_RESULT_MAX_CHARS)} characters; the full output was saved to a file.`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not label a capped prefix as the full output

For builder output above DEFAULT_MAX_RETAINED_CHARS (10,000,000 characters), untruncatedOutput contains only the retained prefix, yet this message says the full output was saved and the subsequent next_step again calls it the full output. The size detail later admits that only a prefix survived, but the model is still directed to page through a supposedly complete file and cannot retrieve the omitted tail. Render the file and instructions as a partial preservation whenever totalChars > text.length.

Useful? React with 👍 / 👎.

appendReminder additions lived only in the final output, so they were
dropped when the spill pointer replaced it; mirror the reminder into
untruncatedOutputSuffix for retained results, widening ToolDedupeResult
to ExecutableToolResult plus its message field. Also stop claiming the
full output was saved when retention capped out, narrow spillExempt to
'true' per the optional-property convention, and pass traceId directly
in the harness.
@7Hanrui

7Hanrui commented Aug 25, 2026

Copy link
Copy Markdown

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5ff87b96ed

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

: foregroundTimeoutMs;

const builder = new ToolResultBuilder();
const builder = new ToolResultBuilder({ retainFullOutput: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve Bash metadata when replacing retained output

When Bash emits more than 50,000 characters, the retained fields created here cause the truncation service to replace the final output, but Bash still adds recovery data afterward without updating those fields: addForegroundOutputReference appends the complete task-log path only to output, and backgroundStartedResult reconstructs a detached result without copying untruncatedOutput at all. Consequently, a foreground result over the 10,000,000-character retention cap loses the only pointer to its complete task log, while a noisy command detached after its foreground window is returned pre-truncated and never spilled. Fresh evidence in the current tree is that the completion and dedupe suffixes were mirrored, but these two Bash transformations remain unchanged.

Useful? React with 👍 / 👎.

@7Sageer
7Sageer marked this pull request as ready for review August 25, 2026 10:02
@pkg-pr-new

pkg-pr-new Bot commented Aug 25, 2026

Copy link
Copy Markdown
pnpm dlx https://pkg.pr.new/@moonshot-ai/kimi-code@bc1f6f6
npx https://pkg.pr.new/@moonshot-ai/kimi-code@bc1f6f6

commit: bc1f6f6

…peline

Route every tool result through ToolResultTruncationService.truncateForModel
as the single model-context decision point: spillExempt pass-through, the
50k char budget, per-line shaping, spill persistence with a 10MB retention
cap, and append-or-replace pointer rendering. Tools no longer declare
truncation options; sources keep only memory-safety caps.

- rename ToolResultBuilder to ToolOutputAccumulator and drop its options
- mcp keeps only its media pipeline and shares the unified 50k budget
- bash persists foreground logs at the spill threshold and reuses them as
  spill.outputPath only within the retention budget
- carry completion/error messages in spill.suffix so retention capping
  cannot drop them
- render a bounded preview when spill persistence fails
- suppress suffix lines already present inline in append mode
- call out text-only persistence when media parts stay attached
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants