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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tool-result-spill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Oversized tool outputs, dropped MCP content blocks, and interrupted assistant output are no longer silently lost.
4 changes: 1 addition & 3 deletions packages/agent-core-v2/src/agent/loop/loopService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
24 changes: 15 additions & 9 deletions packages/agent-core-v2/src/agent/mcp/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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') {
Expand All @@ -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(
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ export interface IAgentToolResultTruncationService {
truncateForModel<T extends ExecutableToolResult>(
input: ToolResultTruncationInput<T>,
): Promise<T>;

/**
* 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<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -33,6 +34,25 @@ export class ToolResultTruncationService implements IAgentToolResultTruncationSe
async truncateForModel<T extends ExecutableToolResult>(
input: ToolResultTruncationInput<T>,
): Promise<T> {
if (input.result.spillExempt === true) return input.result;

const { untruncatedOutput, untruncatedOutputTotalChars, ...rest } = input.result;
if (untruncatedOutput !== undefined) {
const saved = await this.saveToolResult(input.toolName, input.toolCallId, untruncatedOutput);
if (saved === undefined) return rest as T;
return {
...rest,
output: renderPersistedToolResult(
input.toolName,
input.toolCallId,
untruncatedOutput,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve additions made after output retention

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

Useful? React with 👍 / 👎.

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;
Expand All @@ -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,
Expand Down Expand Up @@ -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.`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

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

Useful? React with 👍 / 👎.

`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');
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export class BashTool implements IBashTool {
: normalizeTimeoutMs(args.timeout, true)
: foregroundTimeoutMs;

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve Bash metadata when replacing retained output

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

Useful? React with 👍 / 👎.

let proc: IHostProcess;
try {
proc = lease.track(await this.spawn(lease.runtime.process!, env, effectiveCwd, command));
Expand Down Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
7 changes: 6 additions & 1 deletion packages/agent-core-v2/src/agent/tools/os/read/readTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
Loading
Loading