From 9160feaefcb118adfd250046497e551c29faa160 Mon Sep 17 00:00:00 2001 From: 7Sageer <125936732+7Sageer@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:10:31 +0800 Subject: [PATCH 01/10] fix(agent-core-v2): spill oversized tool outputs and surface dropped content --- .changeset/tool-result-spill.md | 5 ++ .../src/agent/loop/loopService.ts | 4 +- .../agent-core-v2/src/agent/mcp/output.ts | 24 +++--- .../toolResultTruncation.ts | 6 ++ .../toolResultTruncationService.ts | 62 +++++++++++--- .../src/agent/tools/fetch-url/fetchUrlTool.ts | 2 +- .../src/agent/tools/os/bash/bashTool.ts | 4 +- .../src/agent/tools/os/grep/grepTool.ts | 2 +- .../src/agent/tools/os/read/readTool.ts | 7 +- .../agent/tools/web-search/webSearchTool.ts | 2 +- .../agent-core-v2/src/tool/result-builder.ts | 53 +++++++++++- .../agent-core-v2/src/tool/toolContract.ts | 30 +++++++ .../test/agent/loop/loop.test.ts | 30 +++++++ .../test/agent/mcp/output.test.ts | 69 ++++++++++++---- .../agent/toolExecutor/toolExecutor.test.ts | 1 + .../test/agent/toolResultTruncation/stubs.ts | 1 + .../toolResultTruncation.test.ts | 80 ++++++++++++++++++- packages/agent-core-v2/test/harness/agent.ts | 64 +++++++++------ .../test/harness/scripted-generate.ts | 6 ++ .../os/backends/node-local/tools/read.test.ts | 4 +- .../test/tool/result-builder.test.ts | 72 +++++++++++++++++ packages/agent-core-v2/test/tool/tool.test.ts | 4 +- 22 files changed, 455 insertions(+), 77 deletions(-) create mode 100644 .changeset/tool-result-spill.md diff --git a/.changeset/tool-result-spill.md b/.changeset/tool-result-spill.md new file mode 100644 index 00000000000..bade79329aa --- /dev/null +++ b/.changeset/tool-result-spill.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Oversized tool outputs, dropped MCP content blocks, and interrupted assistant output are no longer silently lost. diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 87e8c7f6854..c7a8729b089 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -846,7 +846,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { try { response = await request.result; } catch (error) { - this.appendInterruptedStreamContent(turnId, currentStep, stepUuid, streamParts, turnSignal); + this.appendInterruptedStreamContent(turnId, currentStep, stepUuid, streamParts); throw error; } this.lastRequestTraceId = request.trace.traceId; @@ -938,9 +938,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { currentStep: number, stepUuid: string, streamParts: StreamPartCollector, - turnSignal: AbortSignal, ): void { - if (!turnSignal.aborted) return; for (const part of streamParts.drainInterruptedContent()) { this.context.appendLoopEvent({ type: 'content.part', diff --git a/packages/agent-core-v2/src/agent/mcp/output.ts b/packages/agent-core-v2/src/agent/mcp/output.ts index 996ca4ae26e..b3194b487f4 100644 --- a/packages/agent-core-v2/src/agent/mcp/output.ts +++ b/packages/agent-core-v2/src/agent/mcp/output.ts @@ -28,7 +28,11 @@ function binaryPartTooLargeNotice(kind: 'image' | 'audio' | 'video', urlLength: return `[${kind}_url dropped: ~${approxMb} MB exceeds ${capMb} MB per-part limit. Try a smaller resource.]`; } -export function convertMCPContentBlock(block: MCPContentBlock): ContentPart | null { +function droppedBlockNotice(reason: string): ContentPart { + return { type: 'text', text: `[MCP content dropped: ${reason}]` }; +} + +export function convertMCPContentBlock(block: MCPContentBlock): ContentPart { if (block.type === 'text' && typeof block.text === 'string') { return { type: 'text', text: block.text }; } @@ -74,9 +78,12 @@ export function convertMCPContentBlock(block: MCPContentBlock): ContentPart | nu videoUrl: { url: `data:${mimeType};base64,${res.blob}` }, }; } - return null; + const approxMb = ((res.blob.length * 3) / 4 / (1024 * 1024)).toFixed(1); + return droppedBlockNotice( + `resource blob with unsupported mimeType "${mimeType}" (~${approxMb} MB, uri: ${res.uri}) was not delivered.`, + ); } - return null; + return droppedBlockNotice(`resource (uri: ${res.uri}) carried no text or blob payload.`); } if (block.type === 'resource_link' && typeof block.uri === 'string') { @@ -93,10 +100,12 @@ export function convertMCPContentBlock(block: MCPContentBlock): ContentPart | nu if (mimeType.startsWith('video/')) { return { type: 'video_url', videoUrl: { url: block.uri } }; } - return null; + return droppedBlockNotice( + `resource_link with unsupported mimeType "${mimeType}" was not delivered. Fetch it directly if needed: ${block.uri}`, + ); } - return null; + return droppedBlockNotice(`content block of unsupported type "${block.type}" was not delivered.`); } export async function mcpResultToExecutableOutput( @@ -111,10 +120,7 @@ export async function mcpResultToExecutableOutput( }> { const converted: ContentPart[] = []; for (const block of result.content) { - const part = convertMCPContentBlock(block); - if (part !== null) { - converted.push(part); - } + converted.push(convertMCPContentBlock(block)); } const wrapped = wrapMediaOnly(converted, qualifiedToolName); diff --git a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncation.ts b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncation.ts index 4fac7ffe0b4..b2371614506 100644 --- a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncation.ts +++ b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncation.ts @@ -15,6 +15,12 @@ export interface IAgentToolResultTruncationService { truncateForModel( input: ToolResultTruncationInput, ): Promise; + + /** + * Whether an absolute path lives inside this agent's spill directory + * (files previously persisted by this service). + */ + isSpillFilePath(path: string): boolean; } export const IAgentToolResultTruncationService: ServiceIdentifier< diff --git a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts index d0631b533a4..adc28f48359 100644 --- a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts +++ b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts @@ -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( input: ToolResultTruncationInput, ): Promise { + 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.`, `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'); } diff --git a/packages/agent-core-v2/src/agent/tools/fetch-url/fetchUrlTool.ts b/packages/agent-core-v2/src/agent/tools/fetch-url/fetchUrlTool.ts index 937e0afd282..d2bd6f8bce9 100644 --- a/packages/agent-core-v2/src/agent/tools/fetch-url/fetchUrlTool.ts +++ b/packages/agent-core-v2/src/agent/tools/fetch-url/fetchUrlTool.ts @@ -50,7 +50,7 @@ export class FetchURLTool implements IFetchURLTool { }; } - const builder = new ToolResultBuilder({ maxLineLength: null }); + const builder = new ToolResultBuilder({ maxLineLength: null, retainFullOutput: true }); const note = kind === 'passthrough' ? 'The returned content is the full response body, returned verbatim.' diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts index ed0fe99083d..bb822ab71ab 100644 --- a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts @@ -186,7 +186,7 @@ export class BashTool implements IBashTool { : normalizeTimeoutMs(args.timeout, true) : foregroundTimeoutMs; - const builder = new ToolResultBuilder(); + const builder = new ToolResultBuilder({ retainFullOutput: true }); 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'; diff --git a/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts b/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts index 243825b5f56..c96dbf2c8b7 100644 --- a/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts @@ -287,7 +287,7 @@ export class GrepTool implements IGrepTool { : visibleBody; const combined = [...headerLines, body, ...messages].filter((part) => part !== '').join('\n'); - const builder = new ToolResultBuilder(); + const builder = new ToolResultBuilder({ retainFullOutput: true }); builder.write(combined); return builder.ok(); } diff --git a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts index 6b849fa1570..6f69bff84c4 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts @@ -29,6 +29,7 @@ import { TRANSCODE_MAX_BYTES, type ReadInput, } from './read'; +import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation'; import readDescriptionTemplate from './read.md?raw'; interface LineEndingFlags { @@ -207,6 +208,7 @@ export class ReadTool implements IReadTool { @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, + @IAgentToolResultTruncationService private readonly resultTruncation: IAgentToolResultTruncationService, ) {} private workspaceConfig(view: RuntimeWorkspaceView): WorkspaceConfig { @@ -243,7 +245,10 @@ export class ReadTool implements IReadTool { if (lease.runtime.identity.generation !== inspected.identity.generation) { return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' }; } - return await this.execution(lease.runtime.fs!, args, path); + const result = await this.execution(lease.runtime.fs!, args, path); + return this.resultTruncation.isSpillFilePath(path) + ? { ...result, spillExempt: true as const } + : result; } finally { lease.dispose(); } diff --git a/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts b/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts index d0a4f51a786..3aa19e6f9c6 100644 --- a/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts +++ b/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts @@ -52,7 +52,7 @@ export class WebSearchTool implements IWebSearchTool { } try { const results = await provider.search(args.query, { toolCallId, signal }); - const builder = new ToolResultBuilder({ maxLineLength: null }); + const builder = new ToolResultBuilder({ maxLineLength: null, retainFullOutput: true }); if (results.length === 0) { builder.write('No search results found.'); diff --git a/packages/agent-core-v2/src/tool/result-builder.ts b/packages/agent-core-v2/src/tool/result-builder.ts index cd8bb710f38..56f0a49e831 100644 --- a/packages/agent-core-v2/src/tool/result-builder.ts +++ b/packages/agent-core-v2/src/tool/result-builder.ts @@ -4,12 +4,15 @@ import type { ExecutableToolErrorResult, ExecutableToolSuccessResult } from './t const DEFAULT_MAX_CHARS = 50_000; const DEFAULT_MAX_LINE_LENGTH = 2000; +const DEFAULT_MAX_RETAINED_CHARS = 10_000_000; const TRUNCATION_MARKER = '[...truncated]'; const TRUNCATION_MESSAGE = 'Output is truncated to fit in the message.'; export interface ToolResultBuilderOptions { readonly maxChars?: number; readonly maxLineLength?: number | null; + readonly retainFullOutput?: boolean; + readonly maxRetainedChars?: number; } export type ExecutableToolResultBuilderResult = ( @@ -19,20 +22,31 @@ export type ExecutableToolResultBuilderResult = ( readonly output: string; readonly truncated: boolean; readonly brief?: string; + readonly untruncatedOutput?: string; + readonly untruncatedOutputTotalChars?: number; }; export class ToolResultBuilder { private readonly maxChars: number; private readonly maxLineLength: number | null; + private readonly maxRetainedChars: number; private readonly buffer: string[] = []; + private readonly retained: string[] | null = null; + private retainedChars = 0; private nCharsValue = 0; + private totalCharsValue = 0; private truncationHappened = false; + private charCapTruncationHappened = false; constructor(options: ToolResultBuilderOptions = {}) { this.maxChars = options.maxChars ?? DEFAULT_MAX_CHARS; this.maxLineLength = options.maxLineLength === undefined ? DEFAULT_MAX_LINE_LENGTH : options.maxLineLength; + this.maxRetainedChars = options.maxRetainedChars ?? DEFAULT_MAX_RETAINED_CHARS; + if (options.retainFullOutput === true) { + this.retained = []; + } if (this.maxLineLength !== null && this.maxLineLength <= TRUNCATION_MARKER.length) { throw new BugIndicatingError('maxLineLength must be greater than the truncation marker length.'); @@ -43,16 +57,30 @@ export class ToolResultBuilder { return this.nCharsValue; } + get totalChars(): number { + return this.totalCharsValue; + } + get truncated(): boolean { return this.truncationHappened; } write(text: string): number { + this.totalCharsValue += text.length; + if (this.retained !== null && this.retainedChars < this.maxRetainedChars) { + const remainingRetention = this.maxRetainedChars - this.retainedChars; + const kept = text.length <= remainingRetention ? text : text.slice(0, remainingRetention); + this.retained.push(kept); + this.retainedChars += kept.length; + } if (this.nCharsValue >= this.maxChars) { - if (text.length > 0 && !this.truncationHappened) { - this.buffer.push(TRUNCATION_MARKER); - this.nCharsValue += TRUNCATION_MARKER.length; - this.truncationHappened = true; + if (text.length > 0) { + this.charCapTruncationHappened = true; + if (!this.truncationHappened) { + this.buffer.push(TRUNCATION_MARKER); + this.nCharsValue += TRUNCATION_MARKER.length; + this.truncationHappened = true; + } } return 0; } @@ -63,6 +91,7 @@ export class ToolResultBuilder { let charsWritten = 0; for (const originalLine of lines) { if (this.nCharsValue >= this.maxChars) { + this.charCapTruncationHappened = true; if (!this.truncationHappened) { this.buffer.push(TRUNCATION_MARKER); this.nCharsValue += TRUNCATION_MARKER.length; @@ -78,6 +107,9 @@ export class ToolResultBuilder { : Math.min(remainingChars, this.maxLineLength); let line = originalLine; if (line.length > limit) { + if (line.length > remainingChars) { + this.charCapTruncationHappened = true; + } const lineBreak = /[\r\n]+$/.exec(line)?.[0] ?? ''; const suffix = TRUNCATION_MARKER + lineBreak; const effectiveMaxLength = Math.max(limit, suffix.length); @@ -119,6 +151,7 @@ export class ToolResultBuilder { : output, truncated: this.truncationHappened, brief: options.brief, + ...this.fullOutputFields(), }; } @@ -144,6 +177,18 @@ export class ToolResultBuilder { : `${output}\n${finalMessage}`, truncated: this.truncationHappened, brief: options.brief, + ...this.fullOutputFields(), + }; + } + + private fullOutputFields(): { + readonly untruncatedOutput?: string; + readonly untruncatedOutputTotalChars?: number; + } { + if (this.retained === null || !this.charCapTruncationHappened) return {}; + return { + untruncatedOutput: this.retained.join(''), + untruncatedOutputTotalChars: this.totalCharsValue, }; } } diff --git a/packages/agent-core-v2/src/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts index 552c708c264..5c08e53f7c1 100644 --- a/packages/agent-core-v2/src/tool/toolContract.ts +++ b/packages/agent-core-v2/src/tool/toolContract.ts @@ -26,6 +26,21 @@ export interface ExecutableToolSuccessResult { readonly truncated?: boolean | undefined; readonly note?: string; readonly delivery?: ToolDelivery | undefined; + /** + * Engine-internal: full output before the tool truncated it, handed to the + * truncation service for spill-to-disk. Never sent to the model or the wire. + */ + readonly untruncatedOutput?: string; + /** + * Engine-internal: true total size of `untruncatedOutput` when retention was + * capped; greater than `untruncatedOutput.length` means only a prefix was preserved. + */ + readonly untruncatedOutputTotalChars?: number; + /** + * 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; } export interface ExecutableToolErrorResult { @@ -35,6 +50,21 @@ export interface ExecutableToolErrorResult { readonly truncated?: boolean | undefined; readonly note?: string; readonly delivery?: ToolDelivery | undefined; + /** + * Engine-internal: full output before the tool truncated it, handed to the + * truncation service for spill-to-disk. Never sent to the model or the wire. + */ + readonly untruncatedOutput?: string; + /** + * Engine-internal: true total size of `untruncatedOutput` when retention was + * capped; greater than `untruncatedOutput.length` means only a prefix was preserved. + */ + readonly untruncatedOutputTotalChars?: number; + /** + * 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; } export type ExecutableToolResult = ExecutableToolSuccessResult | ExecutableToolErrorResult; diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 5fd1356b53c..d1b28621fd8 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -276,6 +276,36 @@ describe('Agent loop', () => { ); }); + it('appends streamed partial content to the wire when the request fails mid-stream', async () => { + profile.update({ activeToolNames: [] }); + ctx.mockNextProviderResponse({ + parts: [{ type: 'text', text: 'partial before error' }], + error: new Error('stream broke mid-flight'), + }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] }); + await ctx.untilTurnEnd(); + + expect(ctx.allEvents).toContainEqual( + expect.objectContaining({ + type: '[wire]', + event: 'context.append_loop_event', + args: expect.objectContaining({ + event: expect.objectContaining({ + type: 'content.part', + part: { type: 'text', text: 'partial before error' }, + }), + }), + }), + ); + expect(ctx.allEvents).toContainEqual( + expect.objectContaining({ + event: 'turn.step.interrupted', + args: expect.objectContaining({ reason: 'error', message: 'stream broke mid-flight' }), + }), + ); + }); + it('does not run loop error handlers for aborted turns', async () => { let called = false; loop.registerLoopErrorHandler({ diff --git a/packages/agent-core-v2/test/agent/mcp/output.test.ts b/packages/agent-core-v2/test/agent/mcp/output.test.ts index c1425f77eba..5d8ad8a6cf9 100644 --- a/packages/agent-core-v2/test/agent/mcp/output.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/output.test.ts @@ -144,25 +144,39 @@ describe('convertMCPContentBlock', () => { }); }); - test('returns null for blob EmbeddedResource with unsupported mimeType', () => { + test('replaces a blob EmbeddedResource with unsupported mimeType with a drop notice', () => { const block = assertValidMcpBlock({ type: 'resource', resource: { uri: 'file:///doc.pdf', mimeType: 'application/pdf', blob: 'XXX' }, }); - expect(convertMCPContentBlock(block)).toBeNull(); + const part = convertMCPContentBlock(block); + expect(part?.type).toBe('text'); + const text = (part as { text: string }).text; + expect(text).toContain('MCP content dropped'); + expect(text).toContain('application/pdf'); + expect(text).toContain('file:///doc.pdf'); }); - test('blob EmbeddedResource defaults to application/octet-stream and returns null', () => { + test('blob EmbeddedResource defaults to application/octet-stream in the drop notice', () => { const block = assertValidMcpBlock({ type: 'resource', resource: { uri: 'file:///unknown', blob: 'XXX' }, }); - expect(convertMCPContentBlock(block)).toBeNull(); + const part = convertMCPContentBlock(block); + expect(part?.type).toBe('text'); + const text = (part as { text: string }).text; + expect(text).toContain('MCP content dropped'); + expect(text).toContain('application/octet-stream'); + expect(text).toContain('file:///unknown'); }); - test('returns null for resource block missing resource field', () => { + test('replaces a resource block missing the resource field with a drop notice', () => { const block = { type: 'resource' } as MCPContentBlock; - expect(convertMCPContentBlock(block)).toBeNull(); + const part = convertMCPContentBlock(block); + expect(part?.type).toBe('text'); + const text = (part as { text: string }).text; + expect(text).toContain('MCP content dropped'); + expect(text).toContain('"resource"'); }); test('converts resource_link with image/* mimeType to ImageURLPart with URL', () => { @@ -218,29 +232,46 @@ describe('convertMCPContentBlock', () => { }); }); - test('returns null for resource_link with unsupported mimeType', () => { + test('replaces a resource_link with unsupported mimeType with a drop notice carrying the uri', () => { const block = assertValidMcpBlock({ type: 'resource_link', name: 'file.bin', uri: 'https://example.com/file.bin', mimeType: 'application/octet-stream', }); - expect(convertMCPContentBlock(block)).toBeNull(); + const part = convertMCPContentBlock(block); + expect(part?.type).toBe('text'); + const text = (part as { text: string }).text; + expect(text).toContain('MCP content dropped'); + expect(text).toContain('application/octet-stream'); + expect(text).toContain('https://example.com/file.bin'); }); - test('returns null for unknown block type', () => { + test('replaces an unknown block type with a drop notice', () => { const block: MCPContentBlock = { type: 'fancy_new_type', text: 'whatever' }; - expect(convertMCPContentBlock(block)).toBeNull(); + const part = convertMCPContentBlock(block); + expect(part?.type).toBe('text'); + const text = (part as { text: string }).text; + expect(text).toContain('MCP content dropped'); + expect(text).toContain('"fancy_new_type"'); }); - test('returns null for text block missing text field', () => { + test('replaces a text block missing the text field with a drop notice', () => { const block: MCPContentBlock = { type: 'text' }; - expect(convertMCPContentBlock(block)).toBeNull(); + const part = convertMCPContentBlock(block); + expect(part?.type).toBe('text'); + const text = (part as { text: string }).text; + expect(text).toContain('MCP content dropped'); + expect(text).toContain('"text"'); }); - test('returns null for image block missing data field', () => { + test('replaces an image block missing the data field with a drop notice', () => { const block: MCPContentBlock = { type: 'image', mimeType: 'image/png' }; - expect(convertMCPContentBlock(block)).toBeNull(); + const part = convertMCPContentBlock(block); + expect(part?.type).toBe('text'); + const text = (part as { text: string }).text; + expect(text).toContain('MCP content dropped'); + expect(text).toContain('"image"'); }); }); @@ -355,7 +386,7 @@ describe('mcpResultToExecutableOutput', () => { expect(out).toEqual({ output: [], isError: false }); }); - test('drops unconvertible blocks and keeps the rest', async () => { + test('keeps unconvertible blocks as drop notices alongside the rest', async () => { const out = await mcpResultToExecutableOutput( result([ { type: 'text', text: 'kept' }, @@ -363,7 +394,13 @@ describe('mcpResultToExecutableOutput', () => { ]), 'mcp__s__t', ); - expect(out).toEqual({ output: 'kept', isError: false }); + const parts = out.output as ContentPart[]; + expect(parts[0]).toEqual({ type: 'text', text: 'kept' }); + const notice = parts[1]; + expect(notice?.type).toBe('text'); + const text = (notice as { text: string }).text; + expect(text).toContain('MCP content dropped'); + expect(text).toContain('"fancy_new_type"'); }); test('wraps media-only output in mcp_tool_result tags using the qualified name', async () => { diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index e1a448b18a4..3d25f0cc045 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -69,6 +69,7 @@ beforeEach(() => { reg.defineInstance(IAgentToolResultTruncationService, { _serviceBrand: undefined, truncateForModel: (input) => truncateForModel(input), + isSpillFilePath: () => false, }); reg.defineInstance(IEventBus, { publish: (event: ProtocolEvent) => { diff --git a/packages/agent-core-v2/test/agent/toolResultTruncation/stubs.ts b/packages/agent-core-v2/test/agent/toolResultTruncation/stubs.ts index 2aaa687c87f..60b972033fc 100644 --- a/packages/agent-core-v2/test/agent/toolResultTruncation/stubs.ts +++ b/packages/agent-core-v2/test/agent/toolResultTruncation/stubs.ts @@ -8,6 +8,7 @@ export function stubToolResultTruncationService(): ToolResultTruncationServiceSt return { _serviceBrand: undefined, truncateForModel: async ({ result }) => result, + isSpillFilePath: () => false, }; } diff --git a/packages/agent-core-v2/test/agent/toolResultTruncation/toolResultTruncation.test.ts b/packages/agent-core-v2/test/agent/toolResultTruncation/toolResultTruncation.test.ts index a17aaeadfa2..8a4d53bfe3e 100644 --- a/packages/agent-core-v2/test/agent/toolResultTruncation/toolResultTruncation.test.ts +++ b/packages/agent-core-v2/test/agent/toolResultTruncation/toolResultTruncation.test.ts @@ -43,7 +43,7 @@ describe('ToolResultTruncationService', () => { }); it('persists oversized string output and renders a bounded model preview', async () => { - const fullOutput = `${'x'.repeat(50_001)}tail survives on disk`; + const fullOutput = `HEAD_MARKER${'x'.repeat(50_000)}MIDDLE_MARKER${'y'.repeat(2_000)}TAIL_MARKER`; const result = await truncation.truncateForModel({ toolName: 'Lookup Tool', @@ -59,7 +59,11 @@ describe('ToolResultTruncationService', () => { expect(rendered).toContain('Tool output exceeded 50000 characters'); expect(rendered).toContain('tool_name: Lookup Tool'); expect(rendered).toContain('tool_call_id: call:lookup'); - expect(rendered).not.toContain('tail survives on disk'); + expect(rendered).toContain(`output_size_chars: ${String(fullOutput.length)}`); + expect(rendered).toContain('HEAD_MARKER'); + expect(rendered).toContain('TAIL_MARKER'); + expect(rendered).not.toContain('MIDDLE_MARKER'); + expect(rendered).toMatch(/\[elided: chars \[4096, \d+\)\]/); const outputPath = renderedOutputPath(rendered); expect(outputPath).toContain( @@ -71,6 +75,78 @@ describe('ToolResultTruncationService', () => { await expect(readFile(outputPath, 'utf8')).resolves.toBe(fullOutput); }); + it('spills retained full output carried by a pre-truncated tool result', async () => { + const full = `HEAD${'x'.repeat(60_000)}TAIL`; + + const result = await truncation.truncateForModel({ + toolName: 'Bash', + toolCallId: 'call_bash', + result: { + output: `${'x'.repeat(50_000)}[...truncated]`, + truncated: true, + untruncatedOutput: full, + untruncatedOutputTotalChars: full.length, + }, + }); + + expect(result.truncated).toBe(true); + expect('untruncatedOutput' in result).toBe(false); + expect('untruncatedOutputTotalChars' in result).toBe(false); + const rendered = result.output; + expect(typeof rendered).toBe('string'); + if (typeof rendered !== 'string') throw new Error('expected string output'); + expect(rendered).toContain(`output_size_chars: ${String(full.length)}`); + expect(rendered).not.toContain('[...truncated]'); + await expect(readFile(renderedOutputPath(rendered), 'utf8')).resolves.toBe(full); + }); + + it('reports when retention preserved only a prefix of the full output', async () => { + const preserved = 'x'.repeat(60_000); + + const result = await truncation.truncateForModel({ + toolName: 'Bash', + toolCallId: 'call_partial', + result: { + output: 'truncated view', + truncated: true, + untruncatedOutput: preserved, + untruncatedOutputTotalChars: 25_000_000, + }, + }); + + const rendered = result.output; + expect(typeof rendered).toBe('string'); + if (typeof rendered !== 'string') throw new Error('expected string output'); + expect(rendered).toContain( + 'output_size_chars: 25000000 (only the first 60000 characters were preserved)', + ); + await expect(readFile(renderedOutputPath(rendered), 'utf8')).resolves.toBe(preserved); + }); + + it('passes spill-exempt results through untouched', async () => { + const exempt = { output: 'z'.repeat(60_000), spillExempt: true as const }; + + await expect( + truncation.truncateForModel({ + toolName: 'Read', + toolCallId: 'call_read', + result: exempt, + }), + ).resolves.toBe(exempt); + }); + + it('identifies paths inside the agent spill directory', () => { + const dir = join(homeDir, 'sessions/workspace/session/agents/main/tool-results'); + expect(truncation.isSpillFilePath(join(dir, 'Bash-call-1.txt'))).toBe(true); + expect(truncation.isSpillFilePath(dir)).toBe(true); + expect( + truncation.isSpillFilePath( + join(homeDir, 'sessions/workspace/session/agents/main/other/file.txt'), + ), + ).toBe(false); + expect(truncation.isSpillFilePath(join(homeDir, 'tool-results-evil/file.txt'))).toBe(false); + }); + it('persists oversized text content parts as one complete text file', async () => { const output: ContentPart[] = [ { type: 'text', text: 'first\n' }, diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 36af93d4659..3c950d0ecc0 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -2876,29 +2876,41 @@ async function generateBackedResponse( options?: GenerateOptions, ): Promise { const parts: StreamedMessagePart[] = []; - const result = await generateFn( - provider, - systemPrompt, - tools, - history, - { - onMessagePart: (part) => { - parts.push(structuredClone(part)); + let result: Awaited>; + try { + result = await generateFn( + provider, + systemPrompt, + tools, + history, + { + onMessagePart: (part) => { + parts.push(structuredClone(part)); + }, }, - }, - { - signal: options?.signal, - auth: options?.auth, - cacheKey: options?.cacheKey, - sampling: options?.sampling, - thinking: options?.thinking, - maxCompletionTokens: options?.maxCompletionTokens, - usedContextTokens: options?.usedContextTokens, - maxContextTokens: options?.maxContextTokens, - responseFormat: options?.responseFormat, - onTraceId: options?.onTraceId, - }, - ); + { + signal: options?.signal, + auth: options?.auth, + cacheKey: options?.cacheKey, + sampling: options?.sampling, + thinking: options?.thinking, + maxCompletionTokens: options?.maxCompletionTokens, + usedContextTokens: options?.usedContextTokens, + maxContextTokens: options?.maxContextTokens, + responseFormat: options?.responseFormat, + onTraceId: options?.onTraceId, + }, + ); + } catch (error) { + if (parts.length === 0) throw error; + return createStreamedMessage(normalizeProviderStreamParts(parts), { + id: null, + usage: null, + finishReason: null, + rawFinishReason: null, + error, + }); + } return createStreamedMessage( parts.length > 0 ? normalizeProviderStreamParts(parts) @@ -2961,19 +2973,21 @@ function createStreamedMessage( parts: readonly StreamedMessagePart[], meta: Pick< Awaited>, - 'id' | 'usage' | 'finishReason' | 'rawFinishReason' | 'traceId' - >, + 'id' | 'usage' | 'finishReason' | 'rawFinishReason' + > & + Partial>, 'traceId'>> & { error?: unknown }, ): StreamedMessage { return { id: meta.id, usage: meta.usage, finishReason: meta.finishReason ?? null, rawFinishReason: meta.rawFinishReason ?? null, - traceId: meta.traceId ?? null, + ...(meta.traceId !== undefined ? { traceId: meta.traceId } : {}), async *[Symbol.asyncIterator]() { for (const part of parts) { yield structuredClone(part); } + if (meta.error !== undefined) throw meta.error; }, }; } diff --git a/packages/agent-core-v2/test/harness/scripted-generate.ts b/packages/agent-core-v2/test/harness/scripted-generate.ts index 9f1eb71b816..8f8d4958b56 100644 --- a/packages/agent-core-v2/test/harness/scripted-generate.ts +++ b/packages/agent-core-v2/test/harness/scripted-generate.ts @@ -17,6 +17,7 @@ interface ScriptedResponse { readonly finishReason?: FinishReason | null | undefined; readonly rawFinishReason?: string | null | undefined; readonly traceId?: string | null | undefined; + readonly error?: Error | undefined; } export function createScriptedGenerate() { @@ -33,12 +34,14 @@ export function createScriptedGenerate() { readonly finishReason?: FinishReason | null | undefined; readonly rawFinishReason?: string | null | undefined; readonly traceId?: string | null | undefined; + readonly error?: Error | undefined; }) { responses.push({ parts: structuredClone(input.parts ?? []), ...(input.finishReason !== undefined ? { finishReason: input.finishReason } : {}), ...(input.rawFinishReason !== undefined ? { rawFinishReason: input.rawFinishReason } : {}), ...(input.traceId !== undefined ? { traceId: input.traceId } : {}), + ...(input.error !== undefined ? { error: input.error } : {}), }); } @@ -75,6 +78,9 @@ export function createScriptedGenerate() { await callbacks?.onMessagePart?.(structuredClone(part)); options?.signal?.throwIfAborted(); } + if (response.error !== undefined) { + throw response.error; + } options?.onStreamEnd?.(); const inferredFinishReason: FinishReason = toolCalls.length > 0 ? 'tool_calls' : 'completed'; diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts index 45b77fdcd8e..a01ad33b149 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts @@ -14,6 +14,7 @@ import { TRANSCODE_MAX_BYTES, } from '#/agent/tools/os/read/read'; import { ReadTool } from '#/agent/tools/os/read/readTool'; +import { stubToolResultTruncationService } from '../../../../agent/toolResultTruncation/stubs'; import type { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; import { FakeRuntime } from '#/runtime/fakeRuntime'; import { RuntimeRegistry } from '#/runtime/runtimeRegistry'; @@ -86,7 +87,7 @@ function createReadTool( inspect: () => runtime, acquire: () => ({ runtime, track: (resource) => resource, dispose: () => {} }), }; - return new ReadTool(resolver, workspace, skillCatalog); + return new ReadTool(resolver, workspace, skillCatalog, stubToolResultTruncationService()); } function createSpiedFs(content: string) { @@ -895,6 +896,7 @@ describe('ReadTool', () => { runtime, stubWorkspaceContext('/workspace'), { catalog: { getSkillRoots: () => [] } } as unknown as ISessionSkillCatalog, + stubToolResultTruncationService(), ); const execution = tool.resolveExecution({ path: '/workspace/a.txt' }); expect('execute' in execution).toBe(true); diff --git a/packages/agent-core-v2/test/tool/result-builder.test.ts b/packages/agent-core-v2/test/tool/result-builder.test.ts index b5f76331589..f91f9f592af 100644 --- a/packages/agent-core-v2/test/tool/result-builder.test.ts +++ b/packages/agent-core-v2/test/tool/result-builder.test.ts @@ -145,4 +145,76 @@ describe('ToolResultBuilder', () => { expect(result.output).toBe('ok\n'); }); + + it('retains the full output when retention is on and the char cap is hit', () => { + const builder = new ToolResultBuilder({ maxChars: 10, maxLineLength: null, retainFullOutput: true }); + + builder.write('Hello'); + builder.write(' world!'); + const result = builder.ok(); + + expect(result.truncated).toBe(true); + expect(result.untruncatedOutput).toBe('Hello world!'); + expect(result.untruncatedOutputTotalChars).toBe(12); + expect(builder.totalChars).toBe(12); + }); + + it('attaches the full output on error results as well', () => { + const builder = new ToolResultBuilder({ maxChars: 10, maxLineLength: null, retainFullOutput: true }); + + builder.write('Very long output that exceeds limit'); + const result = builder.error('Command failed'); + + expect(result.untruncatedOutput).toBe('Very long output that exceeds limit'); + expect(result.untruncatedOutputTotalChars).toBe(35); + }); + + it('does not attach full output when retention is off', () => { + const builder = new ToolResultBuilder({ maxChars: 10, maxLineLength: null }); + + builder.write('Hello world!'); + const result = builder.ok(); + + expect(result.truncated).toBe(true); + expect(result.untruncatedOutput).toBeUndefined(); + expect(result.untruncatedOutputTotalChars).toBeUndefined(); + }); + + it('does not attach full output for line-length-only truncation', () => { + const builder = new ToolResultBuilder({ maxChars: 100, maxLineLength: 20, retainFullOutput: true }); + + builder.write('This is a very long line that should be truncated\n'); + const result = builder.ok(); + + expect(result.truncated).toBe(true); + expect(result.untruncatedOutput).toBeUndefined(); + expect(result.untruncatedOutputTotalChars).toBeUndefined(); + }); + + it('does not attach full output when nothing was truncated', () => { + const builder = new ToolResultBuilder({ maxChars: 100, retainFullOutput: true }); + + builder.write('short'); + const result = builder.ok(); + + expect(result.truncated).toBe(false); + expect(result.untruncatedOutput).toBeUndefined(); + }); + + it('caps retention and still reports the true total size', () => { + const builder = new ToolResultBuilder({ + maxChars: 10, + maxLineLength: null, + retainFullOutput: true, + maxRetainedChars: 12, + }); + + builder.write('Hello '); + builder.write('world! and beyond'); + const result = builder.ok(); + + expect(result.untruncatedOutput).toBe('Hello world!'); + expect(result.untruncatedOutputTotalChars).toBe(23); + expect(builder.totalChars).toBe(23); + }); }); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 56bc76c5d1c..4192dc6bb50 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -4002,7 +4002,7 @@ describe('Agent tools', () => { }, }); - const fullOutput = `${'x'.repeat(50_001)}tail survives on disk`; + const fullOutput = `${'x'.repeat(25_000)}middle elided from preview${'x'.repeat(25_000)}`; ctx.mockNextResponse({ type: 'text', text: 'I will look it up.' }, lookupCall); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Look up moon' }] }); await ctx.untilToolCall({ @@ -4016,7 +4016,7 @@ describe('Agent tools', () => { expect(toolMessage).toContain('Tool output exceeded 50000 characters'); expect(toolMessage).toContain('tool_name: Lookup'); expect(toolMessage).toContain('tool_call_id: call_lookup'); - expect(toolMessage).not.toContain('tail survives on disk'); + expect(toolMessage).not.toContain('middle elided from preview'); const outputPath = renderedOutputPath(toolMessage); expect(outputPath).toContain( From ba22cbc392907212f088dfac66c9c2bd8d80b324 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 25 Aug 2026 15:38:52 +0800 Subject: [PATCH 02/10] fix(agent-core-v2): preserve spill fields through tool result normalization 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. --- .changeset/tool-result-spill.md | 2 +- .../agent/toolExecutor/toolExecutorService.ts | 11 ++- .../agent/toolExecutor/toolExecutor.test.ts | 94 ++++++++++++++++++- 3 files changed, 104 insertions(+), 3 deletions(-) diff --git a/.changeset/tool-result-spill.md b/.changeset/tool-result-spill.md index bade79329aa..c1aea5c5116 100644 --- a/.changeset/tool-result-spill.md +++ b/.changeset/tool-result-spill.md @@ -1,5 +1,5 @@ --- -"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code": minor --- Oversized tool outputs, dropped MCP content blocks, and interrupted assistant output are no longer silently lost. diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index cd6534193ce..7d66e00d8f4 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -884,7 +884,16 @@ function normalizeToolResult(result: ExecutableToolResult): ToolResult { stopTurn?: boolean; truncated?: true; note?: string; - } = { output, stopTurn: result.stopTurn }; + untruncatedOutput?: string; + untruncatedOutputTotalChars?: number; + spillExempt?: true; + } = { + output, + stopTurn: result.stopTurn, + untruncatedOutput: result.untruncatedOutput, + untruncatedOutputTotalChars: result.untruncatedOutputTotalChars, + spillExempt: result.spillExempt, + }; if (result.truncated === true) base.truncated = true; if (typeof result.note === 'string' && result.note.length > 0) base.note = result.note; if (result.isError === true) { diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index 3d25f0cc045..dbdd2281037 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -1,10 +1,15 @@ +import { readFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import type { ToolCall } from '#/kosong/contract/message'; import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; -import { createServices, type TestInstantiationService } from '#/_base/di/test'; +import { createServices, TestInstantiationService } from '#/_base/di/test'; import { ToolAccesses, type ExecutableTool, @@ -14,6 +19,7 @@ import { type ToolResult, type ToolUpdate, } from '#/tool/toolContract'; +import { ToolResultBuilder } from '#/tool/result-builder'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { BeforeToolExecuteEvent, @@ -27,13 +33,18 @@ import { import { AgentToolExecutorService } from '#/agent/toolExecutor/toolExecutorService'; import { parseToolCallArguments } from '#/tool/tool-args-parse'; import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation'; +import { ToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncationService'; import { makeAgentScopeContext, IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService'; import { IEventBus } from '#/app/event/eventBus'; import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { registerLogServices } from '../../_base/log/stubs'; +import { stubBootstrap } from '../../app/bootstrap/stubs'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; import { registerStateServices } from '../../state/stubs'; import { registerTestAgentWireServices } from '../../wire/stubs'; @@ -1014,6 +1025,87 @@ describe('parseToolCallArguments', () => { }); }); +describe('truncation pipeline', () => { + let homeDir: string; + + beforeEach(async () => { + homeDir = await mkdtemp(join(tmpdir(), 'tool-executor-truncation-')); + const truncationContainer = disposables.add(new TestInstantiationService()); + truncationContainer.stub(IBootstrapService, stubBootstrap(homeDir)); + truncationContainer.stub( + IAgentScopeContext, + makeAgentScopeContext({ + agentId: 'main', + agentScope: 'sessions/workspace/session/agents/main', + }), + ); + truncationContainer.stub(IFileSystemStorageService, new FileStorageService(homeDir)); + truncationContainer.set( + IAgentToolResultTruncationService, + new SyncDescriptor(ToolResultTruncationService), + ); + const truncation = truncationContainer.get(IAgentToolResultTruncationService); + truncateForModel = (input) => truncation.truncateForModel(input); + }); + + afterEach(async () => { + await rm(homeDir, { recursive: true, force: true }); + }); + + it('spills builder-truncated output to disk and renders a pointer for the model', async () => { + const line = `${'x'.repeat(100)}\n`; + const fullOutput = `HEAD_MARKER\n${line.repeat(300)}MIDDLE_MARKER\n${line.repeat( + 300, + )}TAIL_MARKER\n`; + const tool = new TestTool('noisy', { + execute: async () => { + const builder = new ToolResultBuilder({ retainFullOutput: true }); + builder.write(fullOutput); + return builder.ok(); + }, + }); + registry.register(tool); + + const [result] = await execute([toolCall('call_noisy', 'noisy', {})]); + + expect(result?.truncated).toBe(true); + expect(result).not.toHaveProperty('untruncatedOutput'); + expect(result).not.toHaveProperty('untruncatedOutputTotalChars'); + const rendered = result?.output; + expect(typeof rendered).toBe('string'); + if (typeof rendered !== 'string') throw new Error('expected string output'); + expect(rendered).toContain('Tool output exceeded 50000 characters'); + expect(rendered).toContain('tool_name: noisy'); + expect(rendered).toContain('tool_call_id: call_noisy'); + expect(rendered).toContain('HEAD_MARKER'); + expect(rendered).toContain('TAIL_MARKER'); + expect(rendered).not.toContain('MIDDLE_MARKER'); + expect(rendered).toMatch(/\[elided: chars \[4096, \d+\)\]/); + + const outputPath = renderedOutputPath(rendered); + expect(outputPath).toContain( + join(homeDir, 'sessions/workspace/session/agents/main/tool-results/noisy-call_noisy-'), + ); + expect(readFileSync(outputPath, 'utf8')).toBe(fullOutput); + }); + + it('passes spill-exempt results through the truncation pipeline unchanged', async () => { + const output = `SPILL_CHUNK\n${`${'y'.repeat(100)}\n`.repeat(600)}`; + registry.register(new TestTool('reader', { result: { output, spillExempt: true } })); + + const [result] = await execute([toolCall('call_reader', 'reader', {})]); + + expect(result?.output).toBe(output); + expect(result?.truncated).toBeUndefined(); + }); +}); + +function renderedOutputPath(output: string): string { + const match = /^output_path: (.+)$/m.exec(output); + if (match === null) throw new Error('expected tool output to include output_path'); + return match[1]!; +} + async function execute( calls: ToolCall[], signal?: AbortSignal, From 28ae3c325287e0e01d32a1c77ab44f3a61bcb78d Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 25 Aug 2026 16:10:40 +0800 Subject: [PATCH 03/10] refactor(agent-core-v2): drop banned JSDoc and fix harness error typing 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. --- .../toolResultTruncation.ts | 4 ---- .../agent-core-v2/src/tool/toolContract.ts | 24 ------------------- packages/agent-core-v2/test/harness/agent.ts | 4 ++-- 3 files changed, 2 insertions(+), 30 deletions(-) diff --git a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncation.ts b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncation.ts index b2371614506..2bf2743a01f 100644 --- a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncation.ts +++ b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncation.ts @@ -16,10 +16,6 @@ export interface IAgentToolResultTruncationService { input: ToolResultTruncationInput, ): Promise; - /** - * Whether an absolute path lives inside this agent's spill directory - * (files previously persisted by this service). - */ isSpillFilePath(path: string): boolean; } diff --git a/packages/agent-core-v2/src/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts index 5c08e53f7c1..ea8165d3d07 100644 --- a/packages/agent-core-v2/src/tool/toolContract.ts +++ b/packages/agent-core-v2/src/tool/toolContract.ts @@ -26,20 +26,8 @@ export interface ExecutableToolSuccessResult { readonly truncated?: boolean | undefined; readonly note?: string; readonly delivery?: ToolDelivery | undefined; - /** - * Engine-internal: full output before the tool truncated it, handed to the - * truncation service for spill-to-disk. Never sent to the model or the wire. - */ readonly untruncatedOutput?: string; - /** - * Engine-internal: true total size of `untruncatedOutput` when retention was - * capped; greater than `untruncatedOutput.length` means only a prefix was preserved. - */ readonly untruncatedOutputTotalChars?: number; - /** - * 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; } @@ -50,20 +38,8 @@ export interface ExecutableToolErrorResult { readonly truncated?: boolean | undefined; readonly note?: string; readonly delivery?: ToolDelivery | undefined; - /** - * Engine-internal: full output before the tool truncated it, handed to the - * truncation service for spill-to-disk. Never sent to the model or the wire. - */ readonly untruncatedOutput?: string; - /** - * Engine-internal: true total size of `untruncatedOutput` when retention was - * capped; greater than `untruncatedOutput.length` means only a prefix was preserved. - */ readonly untruncatedOutputTotalChars?: number; - /** - * 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; } diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 295510f64e8..6b09134d996 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -2897,7 +2897,7 @@ async function generateBackedResponse( usage: null, finishReason: null, rawFinishReason: null, - error, + error: error instanceof Error ? error : new Error('generateBackedResponse failed'), }); } return createStreamedMessage( @@ -2964,7 +2964,7 @@ function createStreamedMessage( Awaited>, 'id' | 'usage' | 'finishReason' | 'rawFinishReason' > & - Partial>, 'traceId'>> & { error?: unknown }, + Partial>, 'traceId'>> & { error?: Error }, ): StreamedMessage { return { id: meta.id, From 5a08a784da6195c7417efef70164b2e5d3ae1342 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 25 Aug 2026 16:37:47 +0800 Subject: [PATCH 04/10] fix(agent-core-v2): preserve completion status when spilling output --- .../agent/toolExecutor/toolExecutorService.ts | 2 ++ .../toolResultTruncationService.ts | 10 ++++++++- .../agent-core-v2/src/tool/result-builder.ts | 9 +++++--- .../agent-core-v2/src/tool/toolContract.ts | 2 ++ .../agent/toolExecutor/toolExecutor.test.ts | 21 +++++++++++++++++++ .../toolResultTruncation.test.ts | 5 +++++ .../os/backends/node-local/tools/bash.test.ts | 1 + .../test/tool/result-builder.test.ts | 3 +++ 8 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index 7d66e00d8f4..704378c88b9 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -886,12 +886,14 @@ function normalizeToolResult(result: ExecutableToolResult): ToolResult { note?: string; untruncatedOutput?: string; untruncatedOutputTotalChars?: number; + untruncatedOutputSuffix?: string; spillExempt?: true; } = { output, stopTurn: result.stopTurn, untruncatedOutput: result.untruncatedOutput, untruncatedOutputTotalChars: result.untruncatedOutputTotalChars, + untruncatedOutputSuffix: result.untruncatedOutputSuffix, spillExempt: result.spillExempt, }; if (result.truncated === true) base.truncated = true; diff --git a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts index adc28f48359..6d71d0e86c6 100644 --- a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts +++ b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts @@ -36,7 +36,12 @@ export class ToolResultTruncationService implements IAgentToolResultTruncationSe ): Promise { if (input.result.spillExempt === true) return input.result; - const { untruncatedOutput, untruncatedOutputTotalChars, ...rest } = input.result; + const { + untruncatedOutput, + untruncatedOutputTotalChars, + untruncatedOutputSuffix, + ...rest + } = input.result; if (untruncatedOutput !== undefined) { const saved = await this.saveToolResult(input.toolName, input.toolCallId, untruncatedOutput); if (saved === undefined) return rest as T; @@ -48,6 +53,7 @@ export class ToolResultTruncationService implements IAgentToolResultTruncationSe untruncatedOutput, saved.outputPath, untruncatedOutputTotalChars ?? untruncatedOutput.length, + untruncatedOutputSuffix, ), truncated: true, } as T; @@ -110,6 +116,7 @@ function renderPersistedToolResult( text: string, outputPath: string, totalChars: number, + outputSuffix?: string, ): string { const head = text.slice(0, TOOL_RESULT_PREVIEW_HEAD_CHARS); const tailStart = Math.max(head.length, text.length - TOOL_RESULT_PREVIEW_TAIL_CHARS); @@ -134,6 +141,7 @@ function renderPersistedToolResult( } lines.push('', `[preview: chars [${String(tailStart)}, ${String(text.length)})]`, tail); } + if (outputSuffix !== undefined) lines.push('', outputSuffix); return lines.join('\n'); } diff --git a/packages/agent-core-v2/src/tool/result-builder.ts b/packages/agent-core-v2/src/tool/result-builder.ts index 56f0a49e831..6399f67d339 100644 --- a/packages/agent-core-v2/src/tool/result-builder.ts +++ b/packages/agent-core-v2/src/tool/result-builder.ts @@ -24,6 +24,7 @@ export type ExecutableToolResultBuilderResult = ( readonly brief?: string; readonly untruncatedOutput?: string; readonly untruncatedOutputTotalChars?: number; + readonly untruncatedOutputSuffix?: string; }; export class ToolResultBuilder { @@ -151,7 +152,7 @@ export class ToolResultBuilder { : output, truncated: this.truncationHappened, brief: options.brief, - ...this.fullOutputFields(), + ...this.fullOutputFields(shouldAppendMessage ? finalMessage : undefined), }; } @@ -177,18 +178,20 @@ export class ToolResultBuilder { : `${output}\n${finalMessage}`, truncated: this.truncationHappened, brief: options.brief, - ...this.fullOutputFields(), + ...this.fullOutputFields(finalMessage.length > 0 ? finalMessage : undefined), }; } - private fullOutputFields(): { + private fullOutputFields(finalMessage: string | undefined): { readonly untruncatedOutput?: string; readonly untruncatedOutputTotalChars?: number; + readonly untruncatedOutputSuffix?: string; } { if (this.retained === null || !this.charCapTruncationHappened) return {}; return { untruncatedOutput: this.retained.join(''), untruncatedOutputTotalChars: this.totalCharsValue, + untruncatedOutputSuffix: finalMessage, }; } } diff --git a/packages/agent-core-v2/src/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts index ea8165d3d07..793dd26a716 100644 --- a/packages/agent-core-v2/src/tool/toolContract.ts +++ b/packages/agent-core-v2/src/tool/toolContract.ts @@ -28,6 +28,7 @@ export interface ExecutableToolSuccessResult { readonly delivery?: ToolDelivery | undefined; readonly untruncatedOutput?: string; readonly untruncatedOutputTotalChars?: number; + readonly untruncatedOutputSuffix?: string; readonly spillExempt?: true | undefined; } @@ -40,6 +41,7 @@ export interface ExecutableToolErrorResult { readonly delivery?: ToolDelivery | undefined; readonly untruncatedOutput?: string; readonly untruncatedOutputTotalChars?: number; + readonly untruncatedOutputSuffix?: string; readonly spillExempt?: true | undefined; } diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index dbdd2281037..600a51ea5c6 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -1089,6 +1089,27 @@ describe('truncation pipeline', () => { expect(readFileSync(outputPath, 'utf8')).toBe(fullOutput); }); + it('keeps the builder completion message after spilling an error result', async () => { + const fullOutput = `${'x'.repeat(50_001)}tail`; + const tool = new TestTool('failing-noisy', { + execute: async () => { + const builder = new ToolResultBuilder({ retainFullOutput: true }); + builder.write(fullOutput); + return builder.error('Command failed with exit code: 1.'); + }, + }); + registry.register(tool); + + const [result] = await execute([toolCall('call_failing_noisy', 'failing-noisy', {})]); + + expect(result?.isError).toBe(true); + const rendered = result?.output; + expect(typeof rendered).toBe('string'); + if (typeof rendered !== 'string') throw new Error('expected string output'); + expect(rendered).toContain('Command failed with exit code: 1.'); + expect(readFileSync(renderedOutputPath(rendered), 'utf8')).toBe(fullOutput); + }); + it('passes spill-exempt results through the truncation pipeline unchanged', async () => { const output = `SPILL_CHUNK\n${`${'y'.repeat(100)}\n`.repeat(600)}`; registry.register(new TestTool('reader', { result: { output, spillExempt: true } })); diff --git a/packages/agent-core-v2/test/agent/toolResultTruncation/toolResultTruncation.test.ts b/packages/agent-core-v2/test/agent/toolResultTruncation/toolResultTruncation.test.ts index 8a4d53bfe3e..2beaf4bdfd4 100644 --- a/packages/agent-core-v2/test/agent/toolResultTruncation/toolResultTruncation.test.ts +++ b/packages/agent-core-v2/test/agent/toolResultTruncation/toolResultTruncation.test.ts @@ -86,6 +86,8 @@ describe('ToolResultTruncationService', () => { truncated: true, untruncatedOutput: full, untruncatedOutputTotalChars: full.length, + untruncatedOutputSuffix: + 'Command failed with exit code: 1. Output is truncated to fit in the message.', }, }); @@ -96,6 +98,9 @@ describe('ToolResultTruncationService', () => { expect(typeof rendered).toBe('string'); if (typeof rendered !== 'string') throw new Error('expected string output'); expect(rendered).toContain(`output_size_chars: ${String(full.length)}`); + expect(rendered).toContain( + 'Command failed with exit code: 1. Output is truncated to fit in the message.', + ); expect(rendered).not.toContain('[...truncated]'); await expect(readFile(renderedOutputPath(rendered), 'utf8')).resolves.toBe(full); }); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts index 858a69064f8..4e3853bd9e6 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts @@ -1158,6 +1158,7 @@ describe('BashTool', () => { const output = result.output as string; expect(output).toContain('[...truncated]'); expect(output).toContain('Output is truncated'); + expect(output).toContain('Command failed with exit code: 1.'); }); it('saves full foreground output when the inline result is truncated', async () => { diff --git a/packages/agent-core-v2/test/tool/result-builder.test.ts b/packages/agent-core-v2/test/tool/result-builder.test.ts index f91f9f592af..ef16551cf99 100644 --- a/packages/agent-core-v2/test/tool/result-builder.test.ts +++ b/packages/agent-core-v2/test/tool/result-builder.test.ts @@ -167,6 +167,9 @@ describe('ToolResultBuilder', () => { expect(result.untruncatedOutput).toBe('Very long output that exceeds limit'); expect(result.untruncatedOutputTotalChars).toBe(35); + expect(result.untruncatedOutputSuffix).toBe( + 'Command failed Output is truncated to fit in the message.', + ); }); it('does not attach full output when retention is off', () => { From 5ff87b96ed82ca5e4b9891a07bbb1d20b4913b1e Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 25 Aug 2026 16:49:28 +0800 Subject: [PATCH 05/10] fix(agent-core-v2): mirror dedupe reminders into the spill suffix 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. --- .../src/agent/toolDedupe/toolDedupe.ts | 13 ++------ .../src/agent/toolDedupe/toolDedupeService.ts | 8 +++-- .../toolResultTruncationService.ts | 6 ++-- .../agent-core-v2/src/tool/toolContract.ts | 4 +-- .../test/agent/toolDedupe/toolDedupe.test.ts | 33 +++++++++++++++++++ .../toolResultTruncation.test.ts | 4 +++ packages/agent-core-v2/test/harness/agent.ts | 2 +- 7 files changed, 53 insertions(+), 17 deletions(-) diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts index 95dccc8a0df..5763f4e21d1 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts @@ -1,23 +1,16 @@ import type { ContentPart } from '#/kosong/contract/message'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { ExecutableToolErrorResult, ExecutableToolSuccessResult } from '#/tool/toolContract'; export type ToolDedupeOutput = string | ContentPart[]; -export interface ToolDedupeSuccessResult { - readonly output: ToolDedupeOutput; - readonly isError?: false | undefined; - readonly stopTurn?: boolean | undefined; +export interface ToolDedupeSuccessResult extends ExecutableToolSuccessResult { readonly message?: string | undefined; - readonly truncated?: boolean | undefined; } -export interface ToolDedupeErrorResult { - readonly output: ToolDedupeOutput; - readonly isError: true; - readonly stopTurn?: boolean | undefined; +export interface ToolDedupeErrorResult extends ExecutableToolErrorResult { readonly message?: string | undefined; - readonly truncated?: boolean | undefined; } export type ToolDedupeResult = ToolDedupeSuccessResult | ToolDedupeErrorResult; diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts index 756bdc04412..e20ee96beea 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -105,9 +105,13 @@ function appendReminder(result: ToolDedupeResult, reminderText: string): ToolDed } newOutput = arr; } + const untruncatedOutputSuffix = + result.untruncatedOutput !== undefined + ? (result.untruncatedOutputSuffix ?? '') + reminderText + : undefined; return result.isError === true - ? { ...result, output: newOutput, isError: true } - : { ...result, output: newOutput }; + ? { ...result, output: newOutput, isError: true, untruncatedOutputSuffix } + : { ...result, output: newOutput, untruncatedOutputSuffix }; } function forceStopResult(result: ToolDedupeResult, reminderText: string): ToolDedupeResult { diff --git a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts index 6d71d0e86c6..7d64b65f8fd 100644 --- a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts +++ b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts @@ -122,7 +122,9 @@ function renderPersistedToolResult( 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; the full output was saved to a file.`, + totalChars > text.length + ? `Tool output exceeded ${String(TOOL_RESULT_MAX_CHARS)} characters; the first ${String(text.length)} characters (of ${String(totalChars)}) were saved to a file.` + : `Tool output exceeded ${String(TOOL_RESULT_MAX_CHARS)} characters; the full output was saved to a file.`, `tool_name: ${toolName}`, `tool_call_id: ${toolCallId}`, totalChars > text.length @@ -130,7 +132,7 @@ function renderPersistedToolResult( : `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, or Grep to search it.', + 'next_step: Use Read with output_path to page through the saved output, or Grep to search it.', '', `[preview: chars [0, ${String(head.length)})]`, head, diff --git a/packages/agent-core-v2/src/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts index 793dd26a716..11cbe49ea5e 100644 --- a/packages/agent-core-v2/src/tool/toolContract.ts +++ b/packages/agent-core-v2/src/tool/toolContract.ts @@ -29,7 +29,7 @@ export interface ExecutableToolSuccessResult { readonly untruncatedOutput?: string; readonly untruncatedOutputTotalChars?: number; readonly untruncatedOutputSuffix?: string; - readonly spillExempt?: true | undefined; + readonly spillExempt?: true; } export interface ExecutableToolErrorResult { @@ -42,7 +42,7 @@ export interface ExecutableToolErrorResult { readonly untruncatedOutput?: string; readonly untruncatedOutputTotalChars?: number; readonly untruncatedOutputSuffix?: string; - readonly spillExempt?: true | undefined; + readonly spillExempt?: true; } export type ExecutableToolResult = ExecutableToolSuccessResult | ExecutableToolErrorResult; diff --git a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts index df49c89cae4..6e0d8e29023 100644 --- a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts +++ b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts @@ -501,6 +501,39 @@ describe('AgentToolDedupeService', () => { expect(final!.result.isError).toBe(true); expect(final!.result.output as string).toContain(''); }); + + it('mirrors the reminder into untruncatedOutputSuffix for retained results', async () => { + const h = createHarness(); + const tool = new EchoTool('X', () => ({ + output: 'truncated view', + truncated: true, + untruncatedOutput: 'full output', + })); + h.registry.register(tool); + for (let i = 0; i < 2; i += 1) { + await runStep(h, 1, i + 1, [toolCall(`p${String(i)}`, 'X', {})]); + } + const [final] = await runStep(h, 1, 3, [toolCall('final', 'X', {})]); + expect(final!.result.untruncatedOutputSuffix).toBe(REMINDER_TEXT_1); + }); + + it('appends the reminder after an existing untruncatedOutputSuffix', async () => { + const h = createHarness(); + const tool = new EchoTool('X', () => ({ + output: 'truncated view', + truncated: true, + untruncatedOutput: 'full output', + untruncatedOutputSuffix: 'Command failed with exit code: 1.', + })); + h.registry.register(tool); + for (let i = 0; i < 2; i += 1) { + await runStep(h, 1, i + 1, [toolCall(`p${String(i)}`, 'X', {})]); + } + const [final] = await runStep(h, 1, 3, [toolCall('final', 'X', {})]); + expect(final!.result.untruncatedOutputSuffix).toBe( + 'Command failed with exit code: 1.' + REMINDER_TEXT_1, + ); + }); }); describe('key canonicalization', () => { diff --git a/packages/agent-core-v2/test/agent/toolResultTruncation/toolResultTruncation.test.ts b/packages/agent-core-v2/test/agent/toolResultTruncation/toolResultTruncation.test.ts index 2beaf4bdfd4..1f7b70580a9 100644 --- a/packages/agent-core-v2/test/agent/toolResultTruncation/toolResultTruncation.test.ts +++ b/packages/agent-core-v2/test/agent/toolResultTruncation/toolResultTruncation.test.ts @@ -122,6 +122,10 @@ describe('ToolResultTruncationService', () => { const rendered = result.output; expect(typeof rendered).toBe('string'); if (typeof rendered !== 'string') throw new Error('expected string output'); + expect(rendered).toContain( + 'the first 60000 characters (of 25000000) were saved to a file.', + ); + expect(rendered).not.toContain('the full output was saved'); expect(rendered).toContain( 'output_size_chars: 25000000 (only the first 60000 characters were preserved)', ); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 6b09134d996..27079579646 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -2971,7 +2971,7 @@ function createStreamedMessage( usage: meta.usage, finishReason: meta.finishReason ?? null, rawFinishReason: meta.rawFinishReason ?? null, - ...(meta.traceId !== undefined ? { traceId: meta.traceId } : {}), + traceId: meta.traceId, async *[Symbol.asyncIterator]() { for (const part of parts) { yield structuredClone(part); From 09a78368bdef1e3c108e9177feb60aac0947b25b Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 25 Aug 2026 17:27:17 +0800 Subject: [PATCH 06/10] fix(agent-core-v2): prefer persisted Bash task logs --- .../src/agent/tools/os/bash/bashTool.ts | 8 +++++- .../os/backends/node-local/tools/bash.test.ts | 25 ++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts index bb822ab71ab..25e81e0dd42 100644 --- a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts @@ -351,7 +351,13 @@ export class BashTool implements IBashTool { `output_path: ${output.outputPath}\n` + `output_size_bytes: ${String(output.outputSizeBytes)}\n` + `next_step: Use Read with output_path to page through the full log${taskOutputHint}.`; - return { ...result, output: `${result.output}${reference}` }; + return { + ...result, + output: `${result.output}${reference}`, + untruncatedOutput: undefined, + untruncatedOutputTotalChars: undefined, + untruncatedOutputSuffix: undefined, + }; } private backgroundStartedResult( diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts index 4e3853bd9e6..de49c49262b 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts @@ -346,7 +346,9 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function createFakeTaskService(options: { maxRunningTasks?: number } = {}): { +function createFakeTaskService( + options: { maxRunningTasks?: number; outputPersistenceAvailable?: boolean } = {}, +): { readonly service: IAgentTaskService; readonly tasks: Map; readonly persisted: Set; @@ -564,7 +566,7 @@ function createFakeTaskService(options: { maxRunningTasks?: number } = {}): { }, persistOutput(taskId: string): void { - persisted.add(taskId); + if (options.outputPersistenceAvailable !== false) persisted.add(taskId); }, async getOutputSnapshot(taskId: string): Promise { @@ -1161,7 +1163,7 @@ describe('BashTool', () => { expect(output).toContain('Command failed with exit code: 1.'); }); - it('saves full foreground output when the inline result is truncated', async () => { + it('uses the persisted task log as the only recovery source when foreground output is truncated', async () => { const fullOutput = `${'short line\n'.repeat(6_000)}tail survives\n`; const { runner } = createTestRunner(processWithOutput({ stdout: fullOutput })); const { service, persisted } = createFakeTaskService(); @@ -1178,6 +1180,23 @@ describe('BashTool', () => { expect(output).toContain(`output_path: /fake/tasks/${taskId}/output.log`); expect(output).toContain('Use Read with output_path'); expect(output).toContain(`TaskOutput(task_id="${taskId}")`); + expect(result.untruncatedOutput).toBeUndefined(); + expect(result.untruncatedOutputTotalChars).toBeUndefined(); + expect(result.untruncatedOutputSuffix).toBeUndefined(); + }); + + it('retains full foreground output for generic spill when task-log persistence is unavailable', async () => { + const fullOutput = `${'short line\n'.repeat(6_000)}tail survives\n`; + const { runner } = createTestRunner(processWithOutput({ stdout: fullOutput })); + const { service, persisted } = createFakeTaskService({ outputPersistenceAvailable: false }); + const tool = bashTool(runner, createTestEnv(), createTestCtx(), service); + + const result = await executeTool(tool, context({ command: 'flood', timeout: 60 })); + + expect(result.output).not.toContain('[Full output saved]'); + expect(persisted.size).toBe(0); + expect(result.untruncatedOutput).toBe(fullOutput); + expect(result.untruncatedOutputTotalChars).toBe(fullOutput.length); }); it('omits the TaskOutput hint from the saved-output reference when background tools are disabled', async () => { From bc1f6f6efec7670f31d272906c11045ea5fd12ab Mon Sep 17 00:00:00 2001 From: 7Sageer <125936732+7Sageer@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:38:27 +0800 Subject: [PATCH 07/10] refactor(agent-core-v2): unify tool-result truncation in the spill pipeline 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 --- .../agent-core-v2/src/agent/mcp/output.ts | 94 +----- .../agent-core-v2/src/agent/mcp/tools/mcp.ts | 28 +- .../src/agent/toolDedupe/toolDedupeService.ts | 10 +- .../agent/toolExecutor/toolExecutorService.ts | 9 +- .../toolResultTruncationService.ts | 295 +++++++++++++---- .../src/agent/tools/fetch-url/fetchUrlTool.ts | 4 +- .../src/agent/tools/os/bash/bashTool.ts | 64 ++-- .../src/agent/tools/os/grep/grepTool.ts | 13 +- .../src/agent/tools/os/read/read.md | 2 +- .../src/agent/tools/os/read/readTool.ts | 4 +- .../agent/tools/web-search/webSearchTool.ts | 4 +- .../src/tool/output-accumulator.ts | 79 +++++ .../agent-core-v2/src/tool/result-builder.ts | 197 ----------- .../agent-core-v2/src/tool/toolContract.ts | 18 +- .../fullCompaction/fullCompaction.test.ts | 6 +- .../test/agent/loop/loop.test.ts | 4 +- .../agent-core-v2/test/agent/mcp/mcp.test.ts | 8 +- .../test/agent/mcp/output.test.ts | 55 ++- .../test/agent/toolDedupe/toolDedupe.test.ts | 13 +- .../agent/toolExecutor/toolExecutor.test.ts | 40 ++- .../toolResultTruncation.test.ts | 312 +++++++++++++++--- .../os/backends/node-local/tools/bash.test.ts | 99 ++++-- .../os/backends/node-local/tools/grep.test.ts | 43 ++- .../os/backends/node-local/tools/read.test.ts | 4 +- .../test/tool/output-accumulator.test.ts | 124 +++++++ .../test/tool/result-builder.test.ts | 223 ------------- packages/agent-core-v2/test/tool/tool.test.ts | 15 +- 27 files changed, 986 insertions(+), 781 deletions(-) create mode 100644 packages/agent-core-v2/src/tool/output-accumulator.ts delete mode 100644 packages/agent-core-v2/src/tool/result-builder.ts create mode 100644 packages/agent-core-v2/test/tool/output-accumulator.test.ts delete mode 100644 packages/agent-core-v2/test/tool/result-builder.test.ts diff --git a/packages/agent-core-v2/src/agent/mcp/output.ts b/packages/agent-core-v2/src/agent/mcp/output.ts index b3194b487f4..6a93158a29b 100644 --- a/packages/agent-core-v2/src/agent/mcp/output.ts +++ b/packages/agent-core-v2/src/agent/mcp/output.ts @@ -1,5 +1,6 @@ import type { ContentPart } from '#/kosong/contract/message'; import type { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { ExecutableToolResult } from '#/tool/toolContract'; import { compressImageContentParts } from '#/agent/media/image-compress'; import { @@ -14,11 +15,6 @@ export interface McpOutputOptions { readonly telemetry?: ITelemetryService; } -export const MCP_MAX_OUTPUT_CHARS = 100_000; -const MCP_OUTPUT_TRUNCATED_TEXT = `\n\n[Output truncated: exceeded ${String( - MCP_MAX_OUTPUT_CHARS, -)} character limit. Use pagination or more specific queries to get remaining content.]`; - export const MCP_MAX_BINARY_PART_BYTES = 10 * 1024 * 1024; const MCP_MAX_BINARY_PART_CHARS = Math.ceil((MCP_MAX_BINARY_PART_BYTES * 4) / 3); @@ -112,12 +108,7 @@ export async function mcpResultToExecutableOutput( result: MCPToolResult, qualifiedToolName: string, options: McpOutputOptions = {}, -): Promise<{ - output: string | ContentPart[]; - isError: boolean; - note?: string; - truncated?: true; -}> { +): Promise { const converted: ContentPart[] = []; for (const block of result.content) { converted.push(convertMCPContentBlock(block)); @@ -144,8 +135,7 @@ export async function mcpResultToExecutableOutput( } } - const budgeted = applyTextBudget(wrapped); - const compressed = await compressImageContentParts(budgeted.parts, { + const compressed = await compressImageContentParts(wrapped, { telemetry: options.telemetry === undefined ? undefined @@ -160,15 +150,15 @@ export async function mcpResultToExecutableOutput( }, }); const capped = applyBinaryPartCap(compressed.parts); - const truncated = budgeted.truncated || capped.truncated; const output = collapseSingleText(capped.parts); const note = compressed.captions.length > 0 ? compressed.captions.join('\n') : undefined; - return { + const base = { output, - isError: result.isError, note, - truncated: truncated ? true : undefined, + truncated: capped.truncated ? true : undefined, + spill: capped.notices.length > 0 ? { suffix: capped.notices.join('\n') } : undefined, }; + return result.isError ? { ...base, isError: true } : base; } function serializeStructuredExtras(extras: Record): string | undefined { @@ -214,63 +204,14 @@ function wrapMediaOnly(parts: readonly ContentPart[], qualifiedToolName: string) ]; } -function applyTextBudget(parts: readonly ContentPart[]): { - readonly parts: ContentPart[]; - readonly truncated: boolean; -} { - let remaining = MCP_MAX_OUTPUT_CHARS; - let truncated = false; - const out: ContentPart[] = []; - - for (const part of parts) { - if (part.type === 'text') { - if (remaining <= 0) { - truncated = true; - continue; - } - if (part.text.length > remaining) { - out.push({ type: 'text', text: part.text.slice(0, remaining) }); - remaining = 0; - truncated = true; - } else { - out.push(part); - remaining -= part.text.length; - } - continue; - } - - if (part.type === 'think') { - const size = part.think.length + (part.encrypted?.length ?? 0); - if (remaining <= 0) { - truncated = true; - continue; - } - if (size > remaining) { - out.push({ type: 'think', think: part.think.slice(0, remaining) }); - remaining = 0; - truncated = true; - } else { - out.push(part); - remaining -= size; - } - continue; - } - - out.push(part); - } - - if (truncated) { - appendTruncationNotice(out); - } - return { parts: out, truncated }; -} - function applyBinaryPartCap(parts: readonly ContentPart[]): { readonly parts: ContentPart[]; readonly truncated: boolean; + readonly notices: string[]; } { let truncated = false; const out: ContentPart[] = []; + const notices: string[] = []; for (const part of parts) { if (part.type === 'text' || part.type === 'think') { @@ -287,25 +228,16 @@ function applyBinaryPartCap(parts: readonly ContentPart[]): { if (url.length > MCP_MAX_BINARY_PART_CHARS) { const kind = part.type === 'image_url' ? 'image' : part.type === 'audio_url' ? 'audio' : 'video'; - out.push({ type: 'text', text: binaryPartTooLargeNotice(kind, url.length) }); + const notice = binaryPartTooLargeNotice(kind, url.length); + out.push({ type: 'text', text: notice }); + notices.push(notice); truncated = true; continue; } out.push(part); } - return { parts: out, truncated }; -} - -function appendTruncationNotice(out: ContentPart[]): void { - for (let i = out.length - 1; i >= 0; i--) { - const candidate = out[i]; - if (candidate?.type === 'text') { - out[i] = { type: 'text', text: candidate.text + MCP_OUTPUT_TRUNCATED_TEXT }; - return; - } - } - out.push({ type: 'text', text: MCP_OUTPUT_TRUNCATED_TEXT }); + return { parts: out, truncated, notices }; } function collapseSingleText(parts: readonly ContentPart[]): string | ContentPart[] { diff --git a/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts b/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts index 1067f577469..a5e22046950 100644 --- a/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts +++ b/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts @@ -3,7 +3,7 @@ import type { ITelemetryService } from '#/app/telemetry/telemetry'; import { Error2, ErrorCodes, toErrorMessage } from '#/errors'; import { isAbortError } from '#/_base/utils/abort'; -import type { ExecutableTool, ExecutableToolContext, ExecutableToolResult } from '#/tool/toolContract'; +import type { ExecutableTool, ExecutableToolContext } from '#/tool/toolContract'; import { mcpResultToExecutableOutput } from '#/agent/mcp/output'; import type { MCPClient, MCPToolResult } from '#/mcpCore/types'; import { @@ -49,12 +49,10 @@ export function createMcpTool( } catch (error) { result = await retryAfterReconnect(error, client, args, context, options, callTool); } - return normalizeMcpToolResult( - await mcpResultToExecutableOutput(result, qualifiedName, { - originalsDir: options.originalsDir, - telemetry: options.telemetry, - }), - ); + return mcpResultToExecutableOutput(result, qualifiedName, { + originalsDir: options.originalsDir, + telemetry: options.telemetry, + }); }, }), }; @@ -113,19 +111,3 @@ async function retryAfterReconnect( } return callTool(freshClient, args, context.signal); } - -function normalizeMcpToolResult(result: { - readonly output: ExecutableToolResult['output']; - readonly isError: boolean; - readonly note?: string; - readonly truncated?: true; -}): ExecutableToolResult { - if (result.isError) { - return result.truncated === true - ? { output: result.output, isError: true, note: result.note, truncated: true } - : { output: result.output, isError: true, note: result.note }; - } - return result.truncated === true - ? { output: result.output, note: result.note, truncated: true } - : { output: result.output, note: result.note }; -} diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts index e20ee96beea..85ce0885885 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -105,13 +105,13 @@ function appendReminder(result: ToolDedupeResult, reminderText: string): ToolDed } newOutput = arr; } - const untruncatedOutputSuffix = - result.untruncatedOutput !== undefined - ? (result.untruncatedOutputSuffix ?? '') + reminderText + const spill = + result.spill !== undefined + ? { ...result.spill, suffix: (result.spill.suffix ?? '') + reminderText } : undefined; return result.isError === true - ? { ...result, output: newOutput, isError: true, untruncatedOutputSuffix } - : { ...result, output: newOutput, untruncatedOutputSuffix }; + ? { ...result, output: newOutput, isError: true, spill } + : { ...result, output: newOutput, spill }; } function forceStopResult(result: ToolDedupeResult, reminderText: string): ToolDedupeResult { diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index 704378c88b9..47fc56a48cf 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -23,6 +23,7 @@ import { type RunnableToolExecution, type ToolExecution, type ToolResult, + type ToolResultSpill, type ToolUpdate, } from '#/tool/toolContract'; import type { @@ -884,16 +885,12 @@ function normalizeToolResult(result: ExecutableToolResult): ToolResult { stopTurn?: boolean; truncated?: true; note?: string; - untruncatedOutput?: string; - untruncatedOutputTotalChars?: number; - untruncatedOutputSuffix?: string; + spill?: ToolResultSpill; spillExempt?: true; } = { output, stopTurn: result.stopTurn, - untruncatedOutput: result.untruncatedOutput, - untruncatedOutputTotalChars: result.untruncatedOutputTotalChars, - untruncatedOutputSuffix: result.untruncatedOutputSuffix, + spill: result.spill, spillExempt: result.spillExempt, }; if (result.truncated === true) base.truncated = true; diff --git a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts index 7d64b65f8fd..a9b666a664e 100644 --- a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts +++ b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts @@ -2,7 +2,11 @@ import { randomUUID } from 'node:crypto'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import type { ExecutableToolResult } from '#/tool/toolContract'; +import { + DEFAULT_TOOL_RESULT_MAX_CHARS, + DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS, + type ExecutableToolResult, +} from '#/tool/toolContract'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import type { ContentPart } from '#/kosong/contract/message'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -12,12 +16,19 @@ import { type ToolResultTruncationInput, } from './toolResultTruncation'; -const TOOL_RESULT_MAX_CHARS = 50_000; const TOOL_RESULT_PREVIEW_HEAD_CHARS = 4_096; const TOOL_RESULT_PREVIEW_TAIL_CHARS = 1_024; +const TOOL_RESULT_MAX_LINE_CHARS = 2_000; +const TRUNCATION_MARKER = '[...truncated]'; const encoder = new TextEncoder(); +interface ShapedOutput { + readonly output: ExecutableToolResult['output']; + readonly textChars: number; + readonly hasMedia: boolean; +} + export class ToolResultTruncationService implements IAgentToolResultTruncationService { declare readonly _serviceBrand: undefined; @@ -34,47 +45,68 @@ export class ToolResultTruncationService implements IAgentToolResultTruncationSe async truncateForModel( input: ToolResultTruncationInput, ): Promise { - if (input.result.spillExempt === true) return input.result; - - const { - untruncatedOutput, - untruncatedOutputTotalChars, - untruncatedOutputSuffix, - ...rest - } = input.result; - if (untruncatedOutput !== undefined) { - const saved = await this.saveToolResult(input.toolName, input.toolCallId, untruncatedOutput); - if (saved === undefined) return rest as T; + const { result } = input; + if (result.spillExempt === true) return result; + + const rawText = persistableToolResultText(result.output); + if (rawText.length <= DEFAULT_TOOL_RESULT_MAX_CHARS) return result; + + const { spill, ...rest } = result; + const retainedText = + rawText.length <= DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS + ? rawText + : rawText.slice(0, DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS); + const shaped = shapeOutput(result.output, TOOL_RESULT_MAX_LINE_CHARS); + const totalChars = spill?.totalChars ?? rawText.length; + const suffix = spill?.suffix ?? ''; + + const saved = + spill?.outputPath !== undefined + ? { outputPath: spill.outputPath, preservedChars: totalChars } + : await this.saveToolResult(input.toolName, input.toolCallId, retainedText); + if (saved === undefined) { + const fallback = renderUnpersistedToolResult( + input.toolName, + input.toolCallId, + retainedText, + totalChars, + DEFAULT_TOOL_RESULT_MAX_CHARS, + suffix, + ); + return { ...rest, output: mergeSpillPointer(shaped.output, fallback), truncated: true } as T; + } + + if (shaped.textChars <= DEFAULT_TOOL_RESULT_MAX_CHARS) { + const inlineSuffix = dropInlineSuffixLines(suffix, persistableToolResultText(shaped.output)); return { ...rest, - output: renderPersistedToolResult( - input.toolName, - input.toolCallId, - untruncatedOutput, - saved.outputPath, - untruncatedOutputTotalChars ?? untruncatedOutput.length, - untruncatedOutputSuffix, + output: appendToToolResultOutput( + shaped.output, + renderAppendedSpillPointer( + saved.outputPath, + saved.preservedChars, + totalChars, + inlineSuffix, + shaped.hasMedia, + ), ), 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; - - const saved = await this.saveToolResult(input.toolName, input.toolCallId, text); - if (saved === undefined) return input.result; - + const pointer = renderPersistedToolResult( + input.toolName, + input.toolCallId, + retainedText, + saved.outputPath, + saved.preservedChars, + totalChars, + DEFAULT_TOOL_RESULT_MAX_CHARS, + suffix, + shaped.hasMedia, + ); return { ...rest, - output: renderPersistedToolResult( - input.toolName, - input.toolCallId, - text, - saved.outputPath, - text.length, - ), + output: mergeSpillPointer(shaped.output, pointer), truncated: true, } as T; } @@ -89,62 +121,203 @@ export class ToolResultTruncationService implements IAgentToolResultTruncationSe toolName: string, toolCallId: string, text: string, - ): Promise<{ readonly outputPath: string } | undefined> { + ): Promise<{ readonly outputPath: string; readonly preservedChars: number } | undefined> { try { const key = `${safeToolResultFileStem(toolName, toolCallId)}-${randomUUID()}.txt`; await this.storage.write(this.storageScope, key, encoder.encode(text), { atomic: true }); - return { outputPath: join(this.bootstrap.homeDir, this.storageScope, key) }; + return { + outputPath: join(this.bootstrap.homeDir, this.storageScope, key), + preservedChars: text.length, + }; } catch { return undefined; } } } -function persistableToolResultText(output: ExecutableToolResult['output']): string | undefined { +function shapeOutput( + output: ExecutableToolResult['output'], + maxLineChars: number, +): ShapedOutput { + if (typeof output === 'string') { + const shaped = shapeStringPerLine(output, maxLineChars); + return { output: shaped.text, textChars: shaped.text.length, hasMedia: false }; + } + const out: ContentPart[] = []; + let textChars = 0; + let hasMedia = false; + for (const part of output) { + if (part.type === 'text') { + const shaped = shapeStringPerLine(part.text, maxLineChars); + out.push({ type: 'text', text: shaped.text }); + textChars += shaped.text.length; + continue; + } + if (part.type === 'think') { + textChars += part.think.length + (part.encrypted?.length ?? 0); + } else { + hasMedia = true; + } + out.push(part); + } + return { output: out, textChars, hasMedia }; +} + +function shapeStringPerLine( + text: string, + maxLineChars: number, +): { readonly text: string; readonly truncated: boolean } { + let truncated = false; + const lines = text.match(/[^\r\n]*(?:\r\n|[\n\r])|[^\r\n]+/g) ?? []; + const out: string[] = []; + for (const originalLine of lines) { + let line = originalLine; + if (line.length > maxLineChars) { + const lineBreak = /[\r\n]+$/.exec(line)?.[0] ?? ''; + const suffix = TRUNCATION_MARKER + lineBreak; + const effectiveMaxLength = Math.max(maxLineChars, suffix.length); + line = line.slice(0, effectiveMaxLength - suffix.length) + suffix; + truncated = true; + } + out.push(line); + } + return { text: out.join(''), truncated }; +} + +function persistableToolResultText(output: ExecutableToolResult['output']): string { if (typeof output === 'string') return output; - if ( - !output.every((part): part is Extract => part.type === 'text') - ) { - return undefined; + let text = ''; + for (const part of output) { + if (part.type === 'text') text += part.text; + else if (part.type === 'think') text += part.think; + } + return text; +} + +function mergeSpillPointer( + output: ExecutableToolResult['output'], + pointer: string, +): ExecutableToolResult['output'] { + if (typeof output === 'string') return pointer; + const mediaParts = output.filter((part) => part.type !== 'text' && part.type !== 'think'); + if (mediaParts.length === 0) return pointer; + return [{ type: 'text', text: pointer }, ...mediaParts]; +} + +function renderAppendedSpillPointer( + outputPath: string, + preservedChars: number, + totalChars: number, + suffix: string, + hasMedia: boolean, +): string { + const firstLine = + totalChars > preservedChars + ? `[Per-line truncation occurred; only the first ${String(preservedChars)} characters (of ${String(totalChars)}) were saved to a file.` + : hasMedia + ? '[Per-line truncation occurred; the complete text output was saved to a file (media parts stay attached to this result).' + : '[Per-line truncation occurred; the complete output was saved to a file.'; + const lines = [ + firstLine, + `output_path: ${outputPath}`, + 'next_step: Use Read with output_path to page through the saved output, or Grep to search it.]', + ]; + if (suffix.length > 0) lines.push('', suffix); + return lines.join('\n'); +} + +function appendToToolResultOutput( + output: ExecutableToolResult['output'], + note: string, +): ExecutableToolResult['output'] { + if (typeof output === 'string') { + return output.endsWith('\n') || output.length === 0 ? `${output}${note}` : `${output}\n${note}`; + } + const parts = [...output]; + const last = parts.at(-1); + if (last !== undefined && last.type === 'text') { + parts[parts.length - 1] = { type: 'text', text: `${last.text}\n${note}` }; + } else { + parts.push({ type: 'text', text: note }); } - return output.map((part) => part.text).join(''); + return parts; } function renderPersistedToolResult( toolName: string, toolCallId: string, - text: string, + previewText: string, outputPath: string, + preservedChars: number, totalChars: number, - outputSuffix?: string, + maxChars: number, + suffix: string, + hasMedia: boolean, ): 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 partial = preservedChars < totalChars; const lines = [ - totalChars > text.length - ? `Tool output exceeded ${String(TOOL_RESULT_MAX_CHARS)} characters; the first ${String(text.length)} characters (of ${String(totalChars)}) were saved to a file.` - : `Tool output exceeded ${String(TOOL_RESULT_MAX_CHARS)} characters; the full output was saved to a file.`, + partial + ? `Tool output exceeded ${String(maxChars)} characters; the first ${String(preservedChars)} characters (of ${String(totalChars)}) were saved to a file.` + : hasMedia + ? `Tool output exceeded ${String(maxChars)} characters; the full text output was saved to a file (media parts stay attached to this result).` + : `Tool output exceeded ${String(maxChars)} characters; the full output was saved to a file.`, `tool_name: ${toolName}`, `tool_call_id: ${toolCallId}`, - 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'))}`, + partial + ? `output_size_chars: ${String(totalChars)} (only the first ${String(preservedChars)} characters were preserved)` + : `output_size_chars: ${String(totalChars)}`, + ]; + if (preservedChars === previewText.length) { + lines.push(`output_size_bytes: ${String(Buffer.byteLength(previewText, 'utf8'))}`); + } + lines.push( `output_path: ${outputPath}`, 'next_step: Use Read with output_path to page through the saved output, or Grep to search it.', - '', - `[preview: chars [0, ${String(head.length)})]`, - head, + ); + appendPreviewLines(lines, previewText); + if (suffix.length > 0) lines.push('', suffix); + return lines.join('\n'); +} + +function renderUnpersistedToolResult( + toolName: string, + toolCallId: string, + previewText: string, + totalChars: number, + maxChars: number, + suffix: string, +): string { + const lines = [ + `Tool output exceeded ${String(maxChars)} characters and could not be saved to a file; only this preview is available.`, + `tool_name: ${toolName}`, + `tool_call_id: ${toolCallId}`, + `output_size_chars: ${String(totalChars)}`, ]; + appendPreviewLines(lines, previewText); + if (suffix.length > 0) lines.push('', suffix); + return lines.join('\n'); +} + +function appendPreviewLines(lines: string[], previewText: string): void { + const head = previewText.slice(0, TOOL_RESULT_PREVIEW_HEAD_CHARS); + const tailStart = Math.max(head.length, previewText.length - TOOL_RESULT_PREVIEW_TAIL_CHARS); + const tail = previewText.slice(tailStart); + lines.push('', `[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); + lines.push('', `[preview: chars [${String(tailStart)}, ${String(previewText.length)})]`, tail); } - if (outputSuffix !== undefined) lines.push('', outputSuffix); - return lines.join('\n'); +} + +function dropInlineSuffixLines(suffix: string, shapedText: string): string { + if (suffix.length === 0) return ''; + const lines = suffix.split('\n'); + if (!lines.some((line) => line.length > 0 && shapedText.includes(line))) return suffix; + return lines + .filter((line) => line.length > 0 && !shapedText.includes(line)) + .join('\n'); } function safeToolResultFileStem(toolName: string, toolCallId: string): string { diff --git a/packages/agent-core-v2/src/agent/tools/fetch-url/fetchUrlTool.ts b/packages/agent-core-v2/src/agent/tools/fetch-url/fetchUrlTool.ts index d2bd6f8bce9..9954ee42cbe 100644 --- a/packages/agent-core-v2/src/agent/tools/fetch-url/fetchUrlTool.ts +++ b/packages/agent-core-v2/src/agent/tools/fetch-url/fetchUrlTool.ts @@ -6,7 +6,7 @@ import { type ExecutableToolResult, type ToolExecution, } from '#/tool/toolContract'; -import { ToolResultBuilder } from '#/tool/result-builder'; +import { ToolOutputAccumulator } from '#/tool/output-accumulator'; import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { IWebFetchService } from '#/app/web/web'; @@ -50,7 +50,7 @@ export class FetchURLTool implements IFetchURLTool { }; } - const builder = new ToolResultBuilder({ maxLineLength: null, retainFullOutput: true }); + const builder = new ToolOutputAccumulator(); const note = kind === 'passthrough' ? 'The returned content is the full response body, returned verbatim.' diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts index 25e81e0dd42..e03a70b8b23 100644 --- a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts @@ -9,11 +9,17 @@ import { IAgentRuntimeService, inspectAgentRuntime } from '#/agent/runtimeBindin import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { getShellPathBridge } from '#/_base/execEnv/shellPathBridge'; -import type { ExecutableToolResult, ToolExecution, ToolUpdate } from '#/tool/toolContract'; import { - type ExecutableToolResultBuilderResult, - ToolResultBuilder, -} from '#/tool/result-builder'; + DEFAULT_TOOL_RESULT_MAX_CHARS, + DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS, + type ExecutableToolResult, + type ToolExecution, + type ToolUpdate, +} from '#/tool/toolContract'; +import { + type ToolOutputAccumulatorResult, + ToolOutputAccumulator, +} from '#/tool/output-accumulator'; import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { toInputJsonSchema } from '#/tool/input-schema'; import { literalRulePattern, matchesGlobRuleSubject } from '#/tool/rule-match'; @@ -186,7 +192,7 @@ export class BashTool implements IBashTool { : normalizeTimeoutMs(args.timeout, true) : foregroundTimeoutMs; - const builder = new ToolResultBuilder({ retainFullOutput: true }); + const builder = new ToolOutputAccumulator(); let proc: IHostProcess; try { proc = lease.track(await this.spawn(lease.runtime.process!, env, effectiveCwd, command)); @@ -208,7 +214,11 @@ export class BashTool implements IBashTool { if (!collectForegroundOutput) return; onUpdate?.({ kind, text }); builder.write(text); - if (!foregroundOutputPersisted && builder.truncated && foregroundTaskId !== undefined) { + if ( + !foregroundOutputPersisted && + builder.totalChars > DEFAULT_TOOL_RESULT_MAX_CHARS && + foregroundTaskId !== undefined + ) { this.tasks.persistOutput(foregroundTaskId); foregroundOutputPersisted = true; } @@ -302,12 +312,12 @@ export class BashTool implements IBashTool { private async foregroundCompletionResult( taskId: string, proc: IHostProcess, - builder: ToolResultBuilder, + builder: ToolOutputAccumulator, foregroundTimeoutMs: number, ): Promise { const current = this.tasks.getTask(taskId); const exitCode = current?.kind === 'process' ? current.exitCode : proc.exitCode; - let result: ExecutableToolResultBuilderResult; + let result: ToolOutputAccumulatorResult; if (current?.status === 'timed_out') { const timeoutLabel = formatTimeoutLabel(foregroundTimeoutMs); result = builder.error(`Command killed by timeout (${timeoutLabel})`, { @@ -331,32 +341,36 @@ export class BashTool implements IBashTool { brief: `Failed with exit code: ${String(exitCode)}`, }); } - return this.addForegroundOutputReference(taskId, result); + return this.addForegroundOutputReference(taskId, result, builder.totalChars); } private async addForegroundOutputReference( taskId: string, - result: ExecutableToolResultBuilderResult, + result: ToolOutputAccumulatorResult, + totalChars: number, ): Promise { - if (!result.truncated) return result; + if (totalChars <= DEFAULT_TOOL_RESULT_MAX_CHARS) return result; const output = await this.tasks.getOutputSnapshot(taskId, 0); - if (!output.fullOutputAvailable || output.outputPath === undefined) return result; + if ( + !output.fullOutputAvailable || + output.outputPath === undefined || + output.outputSizeBytes > DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS + ) { + return result; + } const taskOutputHint = this.allowBackground() - ? `, or TaskOutput(task_id="${taskId}")` + ? `\nnext_step: Use TaskOutput(task_id="${taskId}") to query the task output.` : ''; - const reference = - `\n\n[Full output saved]\n` + - `task_id: ${taskId}\n` + - `output_path: ${output.outputPath}\n` + - `output_size_bytes: ${String(output.outputSizeBytes)}\n` + - `next_step: Use Read with output_path to page through the full log${taskOutputHint}.`; + const taskInfo = `task_id: ${taskId}\noutput_size_bytes: ${String(output.outputSizeBytes)}${taskOutputHint}`; + const existingSuffix = result.spill?.suffix; return { ...result, - output: `${result.output}${reference}`, - untruncatedOutput: undefined, - untruncatedOutputTotalChars: undefined, - untruncatedOutputSuffix: undefined, + spill: { + outputPath: output.outputPath, + totalChars, + suffix: existingSuffix !== undefined ? `${existingSuffix}\n${taskInfo}` : taskInfo, + }, }; } @@ -365,7 +379,7 @@ export class BashTool implements IBashTool { proc: IHostProcess, description: string, labels: { title: string; brief: string }, - builder = new ToolResultBuilder({ retainFullOutput: true }), + builder = new ToolOutputAccumulator(), scenario: 'background_started' | 'foreground_detached' = 'background_started', ): ExecutableToolResult { const status = this.tasks.getTask(taskId)?.status ?? 'running'; @@ -382,7 +396,6 @@ export class BashTool implements IBashTool { const foregroundOutput = foregroundResult.output.length > 0 ? foregroundResult.output : ''; const result: ExecutableToolResult & { readonly brief: string; - readonly truncated: boolean; } = { isError: false, output: @@ -390,7 +403,6 @@ export class BashTool implements IBashTool { ? metadata : `${metadata}\n\nforeground_output:\n${foregroundOutput}`, brief: labels.brief, - truncated: foregroundResult.truncated, }; return result; } diff --git a/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts b/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts index c96dbf2c8b7..174817c6e27 100644 --- a/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts @@ -1,6 +1,6 @@ import { normalize } from 'pathe'; -import { ToolResultBuilder } from '#/tool/result-builder'; +import { ToolOutputAccumulator } from '#/tool/output-accumulator'; import { ToolAccesses, type ExecutableToolResult, @@ -246,7 +246,10 @@ export class GrepTool implements IGrepTool { if (paginationTruncated) { const total = afterOffset.length + offset; const nextOffset = offset + headLimit; - const paginationNotice = `Results truncated to ${String(headLimit)} lines (total: ${String(total)}). Use offset=${String(nextOffset)} to see more.`; + const paginationNotice = + bufferTruncated || timedOut + ? `Results truncated to ${String(headLimit)} lines (total: ${String(total)} of a partial result set). Use offset=${String(nextOffset)} to see more.` + : `Results truncated to ${String(headLimit)} lines (total: ${String(total)}). Use offset=${String(nextOffset)} to see more.`; if (mode === 'count_matches') { headerLines.push(paginationNotice); } else { @@ -255,12 +258,12 @@ export class GrepTool implements IGrepTool { } if (bufferTruncated) { messages.push( - `[stdout truncated at ${String(MAX_OUTPUT_BYTES)} bytes; incomplete trailing line omitted]`, + `[Output truncated at ${String(MAX_OUTPUT_BYTES)} bytes of rg output — the result set is incomplete. Narrow the pattern, path, or glob filters and re-run to recover complete results.]`, ); } if (timedOut) { messages.push( - `Grep timed out after ${String(DEFAULT_TIMEOUT_MS / 1000)}s; partial results returned`, + `Grep timed out after ${String(DEFAULT_TIMEOUT_MS / 1000)}s; partial results returned. Narrow the path, glob, or pattern and retry for complete results.`, ); } @@ -287,7 +290,7 @@ export class GrepTool implements IGrepTool { : visibleBody; const combined = [...headerLines, body, ...messages].filter((part) => part !== '').join('\n'); - const builder = new ToolResultBuilder({ retainFullOutput: true }); + const builder = new ToolOutputAccumulator(); builder.write(combined); return builder.ok(); } diff --git a/packages/agent-core-v2/src/agent/tools/os/read/read.md b/packages/agent-core-v2/src/agent/tools/os/read/read.md index 8597a664793..504bec75762 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/read.md +++ b/packages/agent-core-v2/src/agent/tools/os/read/read.md @@ -5,7 +5,7 @@ If the user provides a concrete file path to a text file, call Read directly. Do When you need several files, prefer to read them in parallel: emit multiple `Read` calls in a single response instead of reading one file per turn. - Relative paths resolve against the working directory; a path outside the working directory must be absolute. -- Returns up to ${MAX_LINES} lines or ${MAX_BYTES_KB} KB per call, whichever comes first; lines longer than ${MAX_LINE_LENGTH} chars are truncated mid-line. +- Returns up to ${MAX_LINES} lines or ${MAX_BYTES_KB} KB per call, whichever comes first; lines longer than ${MAX_LINE_LENGTH} chars are truncated mid-line (recover the elided content with Bash, e.g. `cut` or `sed`). - Page larger files with `line_offset` (1-based start line) and `n_lines`. Omit `n_lines` to read up to the ${MAX_LINES}-line cap. - Sensitive files (`.env` files, credential stores, SSH private keys, and similar secrets) are refused to protect secrets; do not attempt to read them. Templates and public keys are exempt: `.env.example` / `.env.sample` / `.env.template` and public SSH keys such as `id_rsa.pub` read normally. - UTF-8 text files are read directly. UTF-16 LE/BE text files (with or without a BOM) are detected automatically and transcoded to UTF-8 for display; the status block notes the detected encoding, and Edit/Write on such a file still expect UTF-8 — convert its encoding first (e.g. with `iconv`). Other encodings (e.g. GBK), binary files, and files containing NUL bytes are refused. diff --git a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts index 6f69bff84c4..85fb3f32a4f 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts @@ -517,7 +517,9 @@ export class ReadTool implements IReadTool { parts.push('End of file reached.'); } if (input.truncatedLineNumbers.length > 0) { - parts.push(`Lines [${input.truncatedLineNumbers.join(', ')}] were truncated.`); + parts.push( + `Lines [${input.truncatedLineNumbers.join(', ')}] were truncated to ${String(MAX_LINE_LENGTH)} characters; use Bash (e.g. cut or sed) to read the elided content of those lines.`, + ); } if (input.lineEndingStyle === 'mixed') { parts.push( diff --git a/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts b/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts index 3aa19e6f9c6..d16d1b990bd 100644 --- a/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts +++ b/packages/agent-core-v2/src/agent/tools/web-search/webSearchTool.ts @@ -6,7 +6,7 @@ import { type ExecutableToolResult, type ToolExecution, } from '#/tool/toolContract'; -import { ToolResultBuilder } from '#/tool/result-builder'; +import { ToolOutputAccumulator } from '#/tool/output-accumulator'; import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { IWebSearchProviderService } from '#/app/auth/webSearch/webSearch'; @@ -52,7 +52,7 @@ export class WebSearchTool implements IWebSearchTool { } try { const results = await provider.search(args.query, { toolCallId, signal }); - const builder = new ToolResultBuilder({ maxLineLength: null, retainFullOutput: true }); + const builder = new ToolOutputAccumulator(); if (results.length === 0) { builder.write('No search results found.'); diff --git a/packages/agent-core-v2/src/tool/output-accumulator.ts b/packages/agent-core-v2/src/tool/output-accumulator.ts new file mode 100644 index 00000000000..dfbbbb2edad --- /dev/null +++ b/packages/agent-core-v2/src/tool/output-accumulator.ts @@ -0,0 +1,79 @@ +import { + DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS, + type ExecutableToolErrorResult, + type ExecutableToolSuccessResult, + type ToolResultSpill, +} from './toolContract'; + +export type ToolOutputAccumulatorResult = ( + | ExecutableToolErrorResult + | ExecutableToolSuccessResult +) & { + readonly output: string; + readonly brief?: string; +}; + +export class ToolOutputAccumulator { + private readonly buffer: string[] = []; + private retainedChars = 0; + private totalCharsValue = 0; + + get nChars(): number { + return this.retainedChars; + } + + get totalChars(): number { + return this.totalCharsValue; + } + + write(text: string): void { + this.totalCharsValue += text.length; + if (this.retainedChars >= DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS) return; + const remainingRetention = DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS - this.retainedChars; + const kept = text.length <= remainingRetention ? text : text.slice(0, remainingRetention); + this.buffer.push(kept); + this.retainedChars += kept.length; + } + + ok(message = '', options: { readonly brief?: string } = {}): ToolOutputAccumulatorResult { + let finalMessage = message; + if (finalMessage.length > 0 && !finalMessage.endsWith('.')) { + finalMessage += '.'; + } + const output = this.buffer.join(''); + return { + isError: false, + output: output.length === 0 ? finalMessage : output, + brief: options.brief, + spill: this.retentionSpill(), + }; + } + + error( + message: string, + options: { readonly brief?: string } = {}, + ): ToolOutputAccumulatorResult { + const output = this.buffer.join(''); + return { + isError: true, + output: + message.length === 0 + ? output + : output.length === 0 + ? message + : output.endsWith('\n') + ? `${output}${message}` + : `${output}\n${message}`, + brief: options.brief, + spill: this.retentionSpill(message), + }; + } + + private retentionSpill(suffix?: string): ToolResultSpill | undefined { + if (this.totalCharsValue <= this.retainedChars) return undefined; + return { + totalChars: this.totalCharsValue, + suffix: suffix !== undefined && suffix.length > 0 ? suffix : undefined, + }; + } +} diff --git a/packages/agent-core-v2/src/tool/result-builder.ts b/packages/agent-core-v2/src/tool/result-builder.ts deleted file mode 100644 index 6399f67d339..00000000000 --- a/packages/agent-core-v2/src/tool/result-builder.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { BugIndicatingError } from '#/errors'; - -import type { ExecutableToolErrorResult, ExecutableToolSuccessResult } from './toolContract'; - -const DEFAULT_MAX_CHARS = 50_000; -const DEFAULT_MAX_LINE_LENGTH = 2000; -const DEFAULT_MAX_RETAINED_CHARS = 10_000_000; -const TRUNCATION_MARKER = '[...truncated]'; -const TRUNCATION_MESSAGE = 'Output is truncated to fit in the message.'; - -export interface ToolResultBuilderOptions { - readonly maxChars?: number; - readonly maxLineLength?: number | null; - readonly retainFullOutput?: boolean; - readonly maxRetainedChars?: number; -} - -export type ExecutableToolResultBuilderResult = ( - | ExecutableToolErrorResult - | ExecutableToolSuccessResult -) & { - readonly output: string; - readonly truncated: boolean; - readonly brief?: string; - readonly untruncatedOutput?: string; - readonly untruncatedOutputTotalChars?: number; - readonly untruncatedOutputSuffix?: string; -}; - -export class ToolResultBuilder { - private readonly maxChars: number; - private readonly maxLineLength: number | null; - private readonly maxRetainedChars: number; - - private readonly buffer: string[] = []; - private readonly retained: string[] | null = null; - private retainedChars = 0; - private nCharsValue = 0; - private totalCharsValue = 0; - private truncationHappened = false; - private charCapTruncationHappened = false; - - constructor(options: ToolResultBuilderOptions = {}) { - this.maxChars = options.maxChars ?? DEFAULT_MAX_CHARS; - this.maxLineLength = - options.maxLineLength === undefined ? DEFAULT_MAX_LINE_LENGTH : options.maxLineLength; - this.maxRetainedChars = options.maxRetainedChars ?? DEFAULT_MAX_RETAINED_CHARS; - if (options.retainFullOutput === true) { - this.retained = []; - } - - if (this.maxLineLength !== null && this.maxLineLength <= TRUNCATION_MARKER.length) { - throw new BugIndicatingError('maxLineLength must be greater than the truncation marker length.'); - } - } - - get nChars(): number { - return this.nCharsValue; - } - - get totalChars(): number { - return this.totalCharsValue; - } - - get truncated(): boolean { - return this.truncationHappened; - } - - write(text: string): number { - this.totalCharsValue += text.length; - if (this.retained !== null && this.retainedChars < this.maxRetainedChars) { - const remainingRetention = this.maxRetainedChars - this.retainedChars; - const kept = text.length <= remainingRetention ? text : text.slice(0, remainingRetention); - this.retained.push(kept); - this.retainedChars += kept.length; - } - if (this.nCharsValue >= this.maxChars) { - if (text.length > 0) { - this.charCapTruncationHappened = true; - if (!this.truncationHappened) { - this.buffer.push(TRUNCATION_MARKER); - this.nCharsValue += TRUNCATION_MARKER.length; - this.truncationHappened = true; - } - } - return 0; - } - - const lines = text.match(/[^\r\n]*(?:\r\n|[\n\r])|[^\r\n]+/g) ?? []; - if (lines.length === 0) return 0; - - let charsWritten = 0; - for (const originalLine of lines) { - if (this.nCharsValue >= this.maxChars) { - this.charCapTruncationHappened = true; - if (!this.truncationHappened) { - this.buffer.push(TRUNCATION_MARKER); - this.nCharsValue += TRUNCATION_MARKER.length; - this.truncationHappened = true; - } - break; - } - - const remainingChars = this.maxChars - this.nCharsValue; - const limit = - this.maxLineLength === null - ? remainingChars - : Math.min(remainingChars, this.maxLineLength); - let line = originalLine; - if (line.length > limit) { - if (line.length > remainingChars) { - this.charCapTruncationHappened = true; - } - const lineBreak = /[\r\n]+$/.exec(line)?.[0] ?? ''; - const suffix = TRUNCATION_MARKER + lineBreak; - const effectiveMaxLength = Math.max(limit, suffix.length); - line = line.slice(0, effectiveMaxLength - suffix.length) + suffix; - } - if (line !== originalLine) { - this.truncationHappened = true; - } - - this.buffer.push(line); - charsWritten += line.length; - this.nCharsValue += line.length; - } - - return charsWritten; - } - - ok(message = '', options: { readonly brief?: string } = {}): ExecutableToolResultBuilderResult { - let finalMessage = message; - if (finalMessage.length > 0 && !finalMessage.endsWith('.')) { - finalMessage += '.'; - } - if (this.truncationHappened) { - finalMessage = - finalMessage.length === 0 ? TRUNCATION_MESSAGE : `${finalMessage} ${TRUNCATION_MESSAGE}`; - } - - const output = this.buffer.join(''); - const shouldAppendMessage = - finalMessage.length > 0 && (this.truncationHappened || output.length === 0); - return { - isError: false, - output: shouldAppendMessage - ? output.length === 0 - ? finalMessage - : output.endsWith('\n') - ? `${output}${finalMessage}` - : `${output}\n${finalMessage}` - : output, - truncated: this.truncationHappened, - brief: options.brief, - ...this.fullOutputFields(shouldAppendMessage ? finalMessage : undefined), - }; - } - - error( - message: string, - options: { readonly brief?: string } = {}, - ): ExecutableToolResultBuilderResult { - const finalMessage = this.truncationHappened - ? message.length === 0 - ? TRUNCATION_MESSAGE - : `${message} ${TRUNCATION_MESSAGE}` - : message; - const output = this.buffer.join(''); - return { - isError: true, - output: - finalMessage.length === 0 - ? output - : output.length === 0 - ? finalMessage - : output.endsWith('\n') - ? `${output}${finalMessage}` - : `${output}\n${finalMessage}`, - truncated: this.truncationHappened, - brief: options.brief, - ...this.fullOutputFields(finalMessage.length > 0 ? finalMessage : undefined), - }; - } - - private fullOutputFields(finalMessage: string | undefined): { - readonly untruncatedOutput?: string; - readonly untruncatedOutputTotalChars?: number; - readonly untruncatedOutputSuffix?: string; - } { - if (this.retained === null || !this.charCapTruncationHappened) return {}; - return { - untruncatedOutput: this.retained.join(''), - untruncatedOutputTotalChars: this.totalCharsValue, - untruncatedOutputSuffix: finalMessage, - }; - } -} diff --git a/packages/agent-core-v2/src/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts index 11cbe49ea5e..b368b2aa3b4 100644 --- a/packages/agent-core-v2/src/tool/toolContract.ts +++ b/packages/agent-core-v2/src/tool/toolContract.ts @@ -5,6 +5,16 @@ import type { ToolInputDisplay } from '@moonshot-ai/protocol'; export type ExecutableToolOutput = string | ContentPart[]; +export const DEFAULT_TOOL_RESULT_MAX_CHARS = 50_000; + +export const DEFAULT_TOOL_RESULT_MAX_RETAINED_CHARS = 10_000_000; + +export interface ToolResultSpill { + readonly outputPath?: string; + readonly totalChars?: number; + readonly suffix?: string; +} + export type ToolDeliveryKind = 'steer'; export interface ToolDeliveryMessage { @@ -26,9 +36,7 @@ export interface ExecutableToolSuccessResult { readonly truncated?: boolean | undefined; readonly note?: string; readonly delivery?: ToolDelivery | undefined; - readonly untruncatedOutput?: string; - readonly untruncatedOutputTotalChars?: number; - readonly untruncatedOutputSuffix?: string; + readonly spill?: ToolResultSpill; readonly spillExempt?: true; } @@ -39,9 +47,7 @@ export interface ExecutableToolErrorResult { readonly truncated?: boolean | undefined; readonly note?: string; readonly delivery?: ToolDelivery | undefined; - readonly untruncatedOutput?: string; - readonly untruncatedOutputTotalChars?: number; - readonly untruncatedOutputSuffix?: string; + readonly spill?: ToolResultSpill; readonly spillExempt?: true; } diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index a6f6df82138..51ea6c3f08c 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -658,7 +658,7 @@ describe('FullCompaction', () => { event: 'compaction_finished', properties: expect.objectContaining({ source: 'manual', - tokens_before: 17_840, + tokens_before: 17_855, retry_count: 1, trace_id: 'trace-compact-1', }), @@ -1072,7 +1072,7 @@ describe('FullCompaction', () => { properties: expect.objectContaining({ agent_id: 'main', source: 'manual', - tokens_before: 17_840, + tokens_before: 17_855, duration_ms: expect.any(Number), round: 1, retry_count: 0, @@ -1297,7 +1297,7 @@ describe('FullCompaction', () => { event: 'compaction_failed', properties: expect.objectContaining({ source: 'manual', - tokens_before: 17_840, + tokens_before: 17_855, duration_ms: expect.any(Number), retry_count: 4, error_type: 'APIConnectionError', diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index d1b28621fd8..82c74274317 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -136,8 +136,8 @@ describe('Agent loop', () => { [emit] turn.step.started { "time": "