Repository navigation
fix(agent-core-v2): spill oversized tool outputs and surface dropped content #3227
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
Changes from 2 commits
9160fea
ba22cbc
e1f84dd
28ae3c3
5a08a78
5ff87b9
09a7836
bc1f6f6
7d64bdc
d6dcda5
ea57ad7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@moonshot-ai/kimi-code": minor | ||
| --- | ||
|
|
||
| Oversized tool outputs, dropped MCP content blocks, and interrupted assistant output are no longer silently lost. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,14 +6,15 @@ import type { ExecutableToolResult } from '#/tool/toolContract'; | |
| import { IBootstrapService } from '#/app/bootstrap/bootstrap'; | ||
| import type { ContentPart } from '#/kosong/contract/message'; | ||
| import { IFileSystemStorageService } from '#/persistence/interface/storage'; | ||
| import { join } from 'pathe'; | ||
| import { join, normalize } from 'pathe'; | ||
| import { | ||
| IAgentToolResultTruncationService, | ||
| type ToolResultTruncationInput, | ||
| } from './toolResultTruncation'; | ||
|
|
||
| const TOOL_RESULT_MAX_CHARS = 50_000; | ||
| const TOOL_RESULT_PREVIEW_CHARS = 2_000; | ||
| const TOOL_RESULT_PREVIEW_HEAD_CHARS = 4_096; | ||
| const TOOL_RESULT_PREVIEW_TAIL_CHARS = 1_024; | ||
|
|
||
| const encoder = new TextEncoder(); | ||
|
|
||
|
|
@@ -33,6 +34,25 @@ export class ToolResultTruncationService implements IAgentToolResultTruncationSe | |
| async truncateForModel<T extends ExecutableToolResult>( | ||
| input: ToolResultTruncationInput<T>, | ||
| ): Promise<T> { | ||
| if (input.result.spillExempt === true) return input.result; | ||
|
|
||
| const { untruncatedOutput, untruncatedOutputTotalChars, ...rest } = input.result; | ||
| if (untruncatedOutput !== undefined) { | ||
| const saved = await this.saveToolResult(input.toolName, input.toolCallId, untruncatedOutput); | ||
| if (saved === undefined) return rest as T; | ||
| return { | ||
| ...rest, | ||
| output: renderPersistedToolResult( | ||
| input.toolName, | ||
| input.toolCallId, | ||
| untruncatedOutput, | ||
| saved.outputPath, | ||
| untruncatedOutputTotalChars ?? untruncatedOutput.length, | ||
| ), | ||
| truncated: true, | ||
| } as T; | ||
| } | ||
|
|
||
| const text = persistableToolResultText(input.result.output); | ||
| if (text === undefined || text.length <= TOOL_RESULT_MAX_CHARS) return input.result; | ||
| if (input.result.truncated === true) return input.result; | ||
|
|
@@ -41,12 +61,24 @@ export class ToolResultTruncationService implements IAgentToolResultTruncationSe | |
| if (saved === undefined) return input.result; | ||
|
|
||
| return { | ||
| ...input.result, | ||
| output: renderPersistedToolResult(input.toolName, input.toolCallId, text, saved.outputPath), | ||
| ...rest, | ||
| output: renderPersistedToolResult( | ||
| input.toolName, | ||
| input.toolCallId, | ||
| text, | ||
| saved.outputPath, | ||
| text.length, | ||
| ), | ||
| truncated: true, | ||
| } as T; | ||
| } | ||
|
|
||
| isSpillFilePath(path: string): boolean { | ||
| const dir = normalize(join(this.bootstrap.homeDir, this.storageScope)); | ||
| const normalized = normalize(path); | ||
| return normalized === dir || normalized.startsWith(`${dir}/`); | ||
| } | ||
|
|
||
| private async saveToolResult( | ||
| toolName: string, | ||
| toolCallId: string, | ||
|
|
@@ -77,19 +109,31 @@ function renderPersistedToolResult( | |
| toolCallId: string, | ||
| text: string, | ||
| outputPath: string, | ||
| totalChars: number, | ||
| ): string { | ||
| const head = text.slice(0, TOOL_RESULT_PREVIEW_HEAD_CHARS); | ||
| const tailStart = Math.max(head.length, text.length - TOOL_RESULT_PREVIEW_TAIL_CHARS); | ||
| 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.`, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For builder output above Useful? React with 👍 / 👎. |
||
| `tool_name: ${toolName}`, | ||
| `tool_call_id: ${toolCallId}`, | ||
| `output_size_chars: ${String(text.length)}`, | ||
| totalChars > text.length | ||
| ? `output_size_chars: ${String(totalChars)} (only the first ${String(text.length)} characters were preserved)` | ||
| : `output_size_chars: ${String(text.length)}`, | ||
| `output_size_bytes: ${String(Buffer.byteLength(text, 'utf8'))}`, | ||
| `output_path: ${outputPath}`, | ||
| 'next_step: Use Read with output_path to page through the full output.', | ||
| 'next_step: Use Read with output_path to page through the full output, or Grep to search it.', | ||
| '', | ||
| '[preview]', | ||
| text.slice(0, TOOL_RESULT_PREVIEW_CHARS), | ||
| `[preview: chars [0, ${String(head.length)})]`, | ||
| head, | ||
| ]; | ||
| if (tail !== '') { | ||
| if (tailStart > head.length) { | ||
| lines.push('', `[elided: chars [${String(head.length)}, ${String(tailStart)})]`); | ||
| } | ||
| lines.push('', `[preview: chars [${String(tailStart)}, ${String(text.length)})]`, tail); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -186,7 +186,7 @@ export class BashTool implements IBashTool { | |
| : normalizeTimeoutMs(args.timeout, true) | ||
| : foregroundTimeoutMs; | ||
|
|
||
| const builder = new ToolResultBuilder(); | ||
| const builder = new ToolResultBuilder({ retainFullOutput: true }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Bash emits more than 50,000 characters, the retained fields created here cause the truncation service to replace the final Useful? React with 👍 / 👎. |
||
| let proc: IHostProcess; | ||
| try { | ||
| proc = lease.track(await this.spawn(lease.runtime.process!, env, effectiveCwd, command)); | ||
|
|
@@ -359,7 +359,7 @@ export class BashTool implements IBashTool { | |
| proc: IHostProcess, | ||
| description: string, | ||
| labels: { title: string; brief: string }, | ||
| builder = new ToolResultBuilder(), | ||
| builder = new ToolResultBuilder({ retainFullOutput: true }), | ||
| scenario: 'background_started' | 'foreground_detached' = 'background_started', | ||
| ): ExecutableToolResult { | ||
| const status = this.tasks.getTask(taskId)?.status ?? 'running'; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a retained builder result is modified after
write()—for example,ToolDedupeService.appendReminderadds the repeated-call guard, orBashTooladds an exit/timeout message and its existing full-log reference—untruncatedOutputstill contains only the raw writes. This branch saves that stale value and replaces the finaloutput, 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 👍 / 👎.