From ea6a4bfe6ef8914f67f254f24b0c5c543c48a341 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 24 Jun 2026 14:42:11 +0800 Subject: [PATCH] fix: preserve long tool output (#1062) * fix: persist truncated foreground bash output * fix: persist oversized tool results * fix: link background task notifications to saved output * fix: avoid lossy tool result budgeting * fix * fix: include fallback task output previews * fix * fix --- .changeset/preserve-truncated-tool-output.md | 5 + docs/en/reference/tools.md | 2 +- docs/zh/reference/tools.md | 2 +- .../agent-core/src/agent/background/index.ts | 49 +++++++- .../src/agent/context/notification-xml.ts | 42 ++----- packages/agent-core/src/agent/turn/index.ts | 8 +- .../src/agent/turn/tool-result-budget.ts | 91 ++++++++++++++ packages/agent-core/src/loop/tool-call.ts | 7 +- packages/agent-core/src/loop/types.ts | 8 ++ packages/agent-core/src/mcp/output.ts | 21 +++- .../src/tools/builtin/shell/bash.ts | 67 ++++++---- .../src/tools/support/result-builder.ts | 4 + .../test/agent/background/rpc-events.test.ts | 37 +++++- .../agent/bg-idle-notification-repro.test.ts | 14 ++- .../agent-core/test/agent/context.test.ts | 26 ++-- packages/agent-core/test/agent/tool.test.ts | 117 ++++++++++++++++++ packages/agent-core/test/mcp/output.test.ts | 3 + packages/agent-core/test/tools/bash.test.ts | 30 +++++ 18 files changed, 450 insertions(+), 83 deletions(-) create mode 100644 .changeset/preserve-truncated-tool-output.md create mode 100644 packages/agent-core/src/agent/turn/tool-result-budget.ts diff --git a/.changeset/preserve-truncated-tool-output.md b/.changeset/preserve-truncated-tool-output.md new file mode 100644 index 000000000..6e5d51d46 --- /dev/null +++ b/.changeset/preserve-truncated-tool-output.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Preserve full tool output logs when previews are truncated and link background task completion notifications to saved output. diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 91d43f406..37ed1018f 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -99,7 +99,7 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill ## Background Tasks -Background task tools manage tasks started via `Bash`, `Agent`, or `AskUserQuestion`. When a task reaches a terminal state, its status and trailing output are automatically delivered back to the Agent; use `TaskOutput` to check progress early. +Background task tools manage tasks started via `Bash`, `Agent`, or `AskUserQuestion`. When a task reaches a terminal state, its status and saved output path are automatically delivered back to the Agent; use `TaskOutput` to check progress early. | Tool | Default Approval | Description | | --- | --- | --- | diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index bf4527862..9584bcf6e 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -99,7 +99,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 ## 后台任务 -后台任务工具用于管理通过 `Bash`、`Agent` 或 `AskUserQuestion` 启动的后台任务。任务进入终止状态时会自动把状态和末尾输出送回 Agent;如需提前检查进度,使用 `TaskOutput`。 +后台任务工具用于管理通过 `Bash`、`Agent` 或 `AskUserQuestion` 启动的后台任务。任务进入终止状态时会自动把状态和已保存的输出路径送回 Agent;如需提前检查进度,使用 `TaskOutput`。 | 工具 | 默认审批 | 说明 | | --- | --- | --- | diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index 7d04be4eb..a61a4c3b9 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -18,6 +18,7 @@ import type { ContentPart } from '@moonshot-ai/kosong'; import type { Agent } from '../..'; import { errorMessage } from '../../loop/errors'; import { timeoutOutcome } from '../../utils/promise'; +import { escapeXml, escapeXmlAttr } from '../../utils/xml-escape'; import type { BackgroundTaskOrigin } from '../context'; import { renderNotificationXml } from '../context/notification-xml'; import { type BackgroundTaskPersistence } from './persist'; @@ -105,6 +106,7 @@ interface ManagedTask { * reads the persisted log when available. */ const MAX_OUTPUT_BYTES = 1024 * 1024; // 1 MiB +const NOTIFICATION_FALLBACK_PREVIEW_BYTES = 3_000; const SIGTERM_GRACE_MS = 5_000; const USER_INTERRUPT_REASON = 'Interrupted by user'; @@ -157,7 +159,7 @@ type BackgroundTaskNotification = Record & { readonly title: string; readonly severity: 'info' | 'warning'; readonly body: string; - readonly tail_output: string; + readonly children?: readonly string[] | undefined; }; interface BackgroundTaskNotificationContext { @@ -166,8 +168,6 @@ interface BackgroundTaskNotificationContext { readonly notification: BackgroundTaskNotification; } -const NOTIFICATION_TAIL_BYTES = 3_000; - export interface RegisterBackgroundTaskOptions { /** * When false, the task is tracked by the manager but a foreground tool call @@ -427,6 +427,12 @@ export class BackgroundManager { return this.toInfo(entry); } + persistOutput(taskId: string): void { + const entry = this.tasks.get(taskId); + if (entry === undefined) return; + this.startOutputPersist(entry); + } + /** Stop a running task. SIGTERM → 5s grace → SIGKILL. */ async stop(taskId: string, reason?: string): Promise { const entry = this.tasks.get(taskId); @@ -661,8 +667,10 @@ export class BackgroundManager { if (this.deliveredNotificationKeys.has(key)) return; this.scheduledNotificationKeys.add(key); - const tailOutput = (await this.getOutputSnapshot(info.taskId, NOTIFICATION_TAIL_BYTES)) - .preview; + let output = await this.getOutputSnapshot(info.taskId, 0); + if (!output.fullOutputAvailable) { + output = await this.getOutputSnapshot(info.taskId, NOTIFICATION_FALLBACK_PREVIEW_BYTES); + } if (this.isTerminalNotificationSuppressed(info.taskId)) return undefined; const notification: BackgroundTaskNotification = { id: origin.notificationId, @@ -674,7 +682,7 @@ export class BackgroundManager { title: `Background ${info.kind} ${info.status}`, severity: info.status === 'completed' ? 'info' : 'warning', body: buildBackgroundTaskNotificationBody(info), - tail_output: tailOutput, + children: backgroundTaskNotificationChildren(output), }; const content = [ { @@ -853,6 +861,35 @@ export class BackgroundManager { } } +function backgroundTaskNotificationChildren( + output: BackgroundTaskOutputSnapshot, +): readonly string[] | undefined { + if (output.fullOutputAvailable && output.outputPath !== undefined) { + return [renderOutputFileBlock(output.outputPath, output.outputSizeBytes)]; + } + if (output.preview.length === 0) return undefined; + return [renderOutputPreviewBlock(output)]; +} + +function renderOutputFileBlock(outputPath: string, outputSizeBytes: number): string { + return [ + ``, + `Read the output file to retrieve the result: ${escapeXml(outputPath)}`, + '', + ].join('\n'); +} + +function renderOutputPreviewBlock(output: BackgroundTaskOutputSnapshot): string { + return [ + ``, + output.truncated + ? `Showing the last ${String(output.previewBytes)} bytes. No persisted full output is available.` + : 'No persisted full output is available; this preview is the currently buffered task output.', + escapeXml(output.preview), + '', + ].join('\n'); +} + function notificationKey(origin: BackgroundTaskOrigin): string { return `${origin.taskId}\0${origin.status}\0${origin.notificationId}`; } diff --git a/packages/agent-core/src/agent/context/notification-xml.ts b/packages/agent-core/src/agent/context/notification-xml.ts index c1da4f12b..2d12a1d14 100644 --- a/packages/agent-core/src/agent/context/notification-xml.ts +++ b/packages/agent-core/src/agent/context/notification-xml.ts @@ -7,14 +7,11 @@ * Title: ... * Severity: ... * - * (only when source_kind === 'background_task' and tail_output is non-empty) - * - * + * * * - * The opening-tag names (``) are - * load-bearing for the projector's `mergeAdjacentUserMessages` detector - * — rename requires updating the detector too. + * The opening tag name (`): string { const title = typeof data['title'] === 'string' ? data['title'] : ''; const severity = typeof data['severity'] === 'string' ? data['severity'] : ''; const body = typeof data['body'] === 'string' ? data['body'] : ''; + const children = childBlocks(data['children'] ?? data['extraBlocks']); const agentIdAttr = agentId === undefined ? '' : ` agent_id="${agentId}"`; const lines: string[] = [ @@ -44,36 +42,12 @@ export function renderNotificationXml(data: Record): string { if (title.length > 0) lines.push(`Title: ${title}`); if (severity.length > 0) lines.push(`Severity: ${severity}`); if (body.length > 0) lines.push(body); - - if (data['source_kind'] === 'background_task') { - const tailRaw = typeof data['tail_output'] === 'string' ? data['tail_output'] : ''; - if (tailRaw.length > 0) { - const truncated = truncateTailOutput(tailRaw, 20, 3000); - lines.push(''); - lines.push(truncated); - lines.push(''); - } - } + lines.push(...children); lines.push(''); return lines.join('\n'); } -/** - * Truncate tail output to at most `maxLines` lines and `maxChars` - * characters. Takes the *last* N lines, then trims from the front if - * the character budget is exceeded. - */ -function truncateTailOutput(raw: string, maxLines: number, maxChars: number): string { - const allLines = raw.split('\n'); - const tailLines = allLines.length > maxLines ? allLines.slice(-maxLines) : allLines; - let result = tailLines.join('\n'); - if (result.length > maxChars) { - result = result.slice(-maxChars); - } - return result; -} - function stringAttr(value: unknown, fallback: string): string { if (typeof value !== 'string' || value.length === 0) return fallback; return escapeXmlAttr(value); @@ -85,3 +59,9 @@ function optionalStringAttr(value: unknown): string | undefined { if (typeof value !== 'string' || value.length === 0) return undefined; return value.replaceAll('&', '&').replaceAll('"', '"'); } + +function childBlocks(value: unknown): string[] { + if (typeof value === 'string' && value.length > 0) return [value]; + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === 'string' && item.length > 0); +} diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index b7052b396..6f8f30298 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -40,6 +40,7 @@ import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context'; import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../../session/hooks'; import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args'; import { ToolCallDeduplicator } from './tool-dedup'; +import { budgetToolResultForModel } from './tool-result-budget'; interface ActiveTurn { readonly turnId: number; @@ -747,7 +748,12 @@ export class TurnFlow { toolOutput: isError === true ? undefined : toolOutputText(output).slice(0, 2000), }, }); - return finalResult; + return budgetToolResultForModel({ + homedir: this.agent.homedir, + toolName: ctx.toolCall.name, + toolCallId: ctx.toolCall.id, + result: finalResult, + }); }, }, }); diff --git a/packages/agent-core/src/agent/turn/tool-result-budget.ts b/packages/agent-core/src/agent/turn/tool-result-budget.ts new file mode 100644 index 000000000..2808544b7 --- /dev/null +++ b/packages/agent-core/src/agent/turn/tool-result-budget.ts @@ -0,0 +1,91 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, writeFile } from 'node:fs/promises'; + +import type { ContentPart } from '@moonshot-ai/kosong'; +import { join } from 'pathe'; + +import type { ExecutableToolResult } from '../../loop'; + +const TOOL_RESULT_MAX_CHARS = 50_000; +const TOOL_RESULT_PREVIEW_CHARS = 2_000; + +interface BudgetToolResultOptions { + readonly homedir?: string; + readonly toolName: string; + readonly toolCallId: string; + readonly result: ExecutableToolResult; +} + +export async function budgetToolResultForModel( + options: BudgetToolResultOptions, +): Promise { + const text = persistableToolResultText(options.result.output); + if (text === undefined || text.length <= TOOL_RESULT_MAX_CHARS) return options.result; + if (options.result.truncated === true) return options.result; + if (options.homedir === undefined) return options.result; + + const outputPath = await saveToolResult( + { homedir: options.homedir, toolName: options.toolName, toolCallId: options.toolCallId }, + text, + ); + if (outputPath === undefined) return options.result; + const output = renderPersistedToolResult(options.toolName, options.toolCallId, text, outputPath); + return options.result.isError === true + ? { ...options.result, output, isError: true } + : { ...options.result, output }; +} + +function persistableToolResultText(output: ExecutableToolResult['output']): string | undefined { + if (typeof output === 'string') return output; + if ( + !output.every((part): part is Extract => part.type === 'text') + ) { + return undefined; + } + return output.map((part) => part.text).join(''); +} + +async function saveToolResult( + options: { readonly homedir: string; readonly toolName: string; readonly toolCallId: string }, + text: string, +): Promise { + try { + const dir = join(options.homedir, 'tool-results'); + await mkdir(dir, { recursive: true, mode: 0o700 }); + const outputPath = join( + dir, + `${safeToolResultFileStem(options.toolName, options.toolCallId)}-${randomUUID()}.txt`, + ); + await writeFile(outputPath, text, { encoding: 'utf8', flag: 'wx' }); + return outputPath; + } catch { + return undefined; + } +} + +function renderPersistedToolResult( + toolName: string, + toolCallId: string, + text: string, + outputPath: string, +): string { + const lines = [ + `Tool output exceeded ${String(TOOL_RESULT_MAX_CHARS)} characters; showing a preview only.`, + `tool_name: ${toolName}`, + `tool_call_id: ${toolCallId}`, + `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.', + ]; + lines.push('', '[preview]', text.slice(0, TOOL_RESULT_PREVIEW_CHARS)); + return lines.join('\n'); +} + +function safeToolResultFileStem(toolName: string, toolCallId: string): string { + const label = `${toolName}-${toolCallId}` + .replace(/[^a-zA-Z0-9._-]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 80); + return label || 'tool-result'; +} diff --git a/packages/agent-core/src/loop/tool-call.ts b/packages/agent-core/src/loop/tool-call.ts index 1468f9cd2..7379406ce 100644 --- a/packages/agent-core/src/loop/tool-call.ts +++ b/packages/agent-core/src/loop/tool-call.ts @@ -671,7 +671,12 @@ function normalizeToolResult(r: ExecutableToolResult): ExecutableToolResult { output = textJoined.length > 0 ? textJoined : TOOL_OUTPUT_EMPTY; } } - return r.isError === true ? { output, isError: true } : { output }; + if (r.isError === true) { + return r.truncated === true + ? { output, isError: true, truncated: true } + : { output, isError: true }; + } + return r.truncated === true ? { output, truncated: true } : { output }; } function makeToolResult( diff --git a/packages/agent-core/src/loop/types.ts b/packages/agent-core/src/loop/types.ts index 8459450ef..4d954c076 100644 --- a/packages/agent-core/src/loop/types.ts +++ b/packages/agent-core/src/loop/types.ts @@ -78,6 +78,12 @@ export interface ExecutableToolSuccessResult { * this to the user. */ readonly message?: string | undefined; + /** + * True when the tool has already returned a partial result because it + * truncated, paged, or otherwise dropped original output. Later generic + * budgeting must not treat the visible output as complete source text. + */ + readonly truncated?: boolean | undefined; } export interface ExecutableToolErrorResult { @@ -87,6 +93,8 @@ export interface ExecutableToolErrorResult { readonly message?: string | undefined; /** See {@link ExecutableToolSuccessResult.stopTurn}. */ readonly stopTurn?: boolean | undefined; + /** See {@link ExecutableToolSuccessResult.truncated}. */ + readonly truncated?: boolean | undefined; } export type ExecutableToolResult = ExecutableToolSuccessResult | ExecutableToolErrorResult; diff --git a/packages/agent-core/src/mcp/output.ts b/packages/agent-core/src/mcp/output.ts index 9832b04d2..464218cef 100644 --- a/packages/agent-core/src/mcp/output.ts +++ b/packages/agent-core/src/mcp/output.ts @@ -133,7 +133,7 @@ export function convertMCPContentBlock(block: MCPContentBlock): ContentPart | nu export function mcpResultToExecutableOutput( result: MCPToolResult, qualifiedToolName: string, -): { output: string | ContentPart[]; isError: boolean } { +): { output: string | ContentPart[]; isError: boolean; truncated?: true } { const converted: ContentPart[] = []; for (const block of result.content) { const part = convertMCPContentBlock(block); @@ -144,8 +144,10 @@ export function mcpResultToExecutableOutput( const wrapped = wrapMediaOnly(converted, qualifiedToolName); const limited = applyOutputLimits(wrapped); - const output = collapseSingleText(limited); - return { output, isError: result.isError }; + const output = collapseSingleText(limited.parts); + return limited.truncated + ? { output, isError: result.isError, truncated: true } + : { output, isError: result.isError }; } /** @@ -173,20 +175,26 @@ function wrapMediaOnly(parts: readonly ContentPart[], qualifiedToolName: string) * the last surviving text part — this keeps the single-text-part collapse * working when the entire (oversized) input is a single text block. */ -function applyOutputLimits(parts: readonly ContentPart[]): ContentPart[] { +function applyOutputLimits(parts: readonly ContentPart[]): { + readonly parts: ContentPart[]; + readonly truncated: boolean; +} { let remaining = MCP_MAX_OUTPUT_CHARS; + let truncated = false; let textTruncated = false; const out: ContentPart[] = []; for (const part of parts) { if (part.type === 'text') { if (remaining <= 0) { + truncated = true; textTruncated = true; continue; } if (part.text.length > remaining) { out.push({ type: 'text', text: part.text.slice(0, remaining) }); remaining = 0; + truncated = true; textTruncated = true; } else { out.push(part); @@ -198,12 +206,14 @@ function applyOutputLimits(parts: readonly ContentPart[]): ContentPart[] { if (part.type === 'think') { const size = part.think.length + (part.encrypted?.length ?? 0); if (remaining <= 0) { + truncated = true; textTruncated = true; continue; } if (size > remaining) { out.push({ type: 'think', think: part.think.slice(0, remaining) }); remaining = 0; + truncated = true; textTruncated = true; } else { out.push(part); @@ -225,6 +235,7 @@ function applyOutputLimits(parts: readonly ContentPart[]): ContentPart[] { const kind = part.type === 'image_url' ? 'image' : part.type === 'audio_url' ? 'audio' : 'video'; out.push({ type: 'text', text: binaryPartTooLargeNotice(kind, url.length) }); + truncated = true; continue; } out.push(part); @@ -233,7 +244,7 @@ function applyOutputLimits(parts: readonly ContentPart[]): ContentPart[] { if (textTruncated) { appendTruncationNotice(out); } - return out; + return { parts: out, truncated }; } function appendTruncationNotice(out: ContentPart[]): void { diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index 3e124c2b5..df6c4deb8 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -31,7 +31,10 @@ import type { ExecutableToolResult, ToolExecution, ToolUpdate } from '../../../l import { renderPrompt } from '../../../utils/render-prompt'; import { toInputJsonSchema } from '../../support/input-schema'; import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match'; -import { ToolResultBuilder } from '../../support/result-builder'; +import { + type ExecutableToolResultBuilderResult, + ToolResultBuilder, +} from '../../support/result-builder'; import bashDescriptionTemplate from './bash.md?raw'; const MS_PER_SECOND = 1000; @@ -248,12 +251,18 @@ export class BashTool implements BuiltinTool { closeProcessStdin(proc); let collectForegroundOutput = !startsInBackground; + let foregroundOutputPersisted = false; + let foregroundTaskId: string | undefined; const onProcessOutput = startsInBackground ? undefined : (kind: 'stdout' | 'stderr', text: string): void => { if (!collectForegroundOutput) return; onUpdate?.({ kind, text }); builder.write(text); + if (!foregroundOutputPersisted && builder.truncated && foregroundTaskId !== undefined) { + this.backgroundManager.persistOutput(foregroundTaskId); + foregroundOutputPersisted = true; + } }; let taskId: string; @@ -266,6 +275,7 @@ export class BashTool implements BuiltinTool { signal: startsInBackground ? undefined : signal, }, ); + foregroundTaskId = startsInBackground ? undefined : taskId; } catch (error) { collectForegroundOutput = false; await killSpawnedProcess(proc); @@ -299,7 +309,7 @@ export class BashTool implements BuiltinTool { ); } - return this.foregroundCompletionResult(taskId, proc, builder, foregroundTimeoutMs); + return await this.foregroundCompletionResult(taskId, proc, builder, foregroundTimeoutMs); } finally { collectForegroundOutput = false; } @@ -328,41 +338,56 @@ export class BashTool implements BuiltinTool { return undefined; } - private foregroundCompletionResult( + private async foregroundCompletionResult( taskId: string, proc: KaosProcess, builder: ToolResultBuilder, foregroundTimeoutMs: number, - ): ExecutableToolResult { + ): Promise { const current = this.backgroundManager.getTask(taskId); const exitCode = current?.kind === 'process' ? current.exitCode : proc.exitCode; + let result: ExecutableToolResultBuilderResult; if (current?.status === 'timed_out') { const timeoutLabel = formatTimeoutLabel(foregroundTimeoutMs); - return builder.error(`Command killed by timeout (${timeoutLabel})`, { + result = builder.error(`Command killed by timeout (${timeoutLabel})`, { brief: `Killed by timeout (${timeoutLabel})`, }); - } - if (current?.status === 'killed' && current.stopReason === USER_INTERRUPT_REASON) { - return builder.error(USER_INTERRUPT_REASON, { brief: USER_INTERRUPT_REASON }); - } - if ( + } else if (current?.status === 'killed' && current.stopReason === USER_INTERRUPT_REASON) { + result = builder.error(USER_INTERRUPT_REASON, { brief: USER_INTERRUPT_REASON }); + } else if ( (current?.status === 'failed' || current?.status === 'killed') && current.stopReason !== undefined ) { - return builder.error(current.stopReason, { brief: current.stopReason }); + result = builder.error(current.stopReason, { brief: current.stopReason }); + } else if (exitCode === 0) { + result = builder.ok('Command executed successfully.'); + } else { + if (builder.nChars === 0) builder.write(`Process exited with code ${String(exitCode)}`); + result = builder.error(`Command failed with exit code: ${String(exitCode)}.`, { + brief: `Failed with exit code: ${String(exitCode)}`, + }); } + return this.addForegroundOutputReference(taskId, result); + } - const isError = exitCode !== 0; - if (isError && builder.nChars === 0) { - builder.write(`Process exited with code ${String(exitCode)}`); - } + private async addForegroundOutputReference( + taskId: string, + result: ExecutableToolResultBuilderResult, + ): Promise { + if (!result.truncated) return result; + const output = await this.backgroundManager.getOutputSnapshot(taskId, 0); + if (!output.fullOutputAvailable || output.outputPath === undefined) return result; - if (!isError) { - return builder.ok('Command executed successfully.'); - } - return builder.error(`Command failed with exit code: ${String(exitCode)}.`, { - brief: `Failed with exit code: ${String(exitCode)}`, - }); + const taskOutputHint = this.allowBackground + ? `, or TaskOutput(task_id="${taskId}", block=false)` + : ''; + 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}.`; + return { ...result, output: `${result.output}${reference}` }; } private backgroundStartedResult( diff --git a/packages/agent-core/src/tools/support/result-builder.ts b/packages/agent-core/src/tools/support/result-builder.ts index 8618d5671..80254403f 100644 --- a/packages/agent-core/src/tools/support/result-builder.ts +++ b/packages/agent-core/src/tools/support/result-builder.ts @@ -45,6 +45,10 @@ export class ToolResultBuilder { return this.nCharsValue; } + get truncated(): boolean { + return this.truncationHappened; + } + write(text: string): number { if (this.nCharsValue >= this.maxChars) { if (text.length > 0 && !this.truncationHappened) { diff --git a/packages/agent-core/test/agent/background/rpc-events.test.ts b/packages/agent-core/test/agent/background/rpc-events.test.ts index de31e7657..466ddfa88 100644 --- a/packages/agent-core/test/agent/background/rpc-events.test.ts +++ b/packages/agent-core/test/agent/background/rpc-events.test.ts @@ -257,6 +257,8 @@ describe('BackgroundManager — notification delivery', () => { const text = (content as Array<{ text: string }>)[0]!.text; expect(text).toContain('Background agent completed'); expect(text).toContain('final subagent summary'); + expect(text).toContain(' { @@ -280,6 +282,25 @@ describe('BackgroundManager — notification delivery', () => { expect(text).toContain('shell task completed.'); }); + it('uses a bounded output preview when no persisted task output exists', async () => { + const { agent, manager } = createBackgroundManager(); + const output = `early-output-marker\n${'x'.repeat(4_000)}\nfinal subagent line`; + const taskId = manager.registerTask(agentTask(Promise.resolve({ result: output }), 'agent task')); + + await manager.wait(taskId); + + await vi.waitFor(() => { + expect(agent.turn.steer).toHaveBeenCalledTimes(1); + }); + const [content] = agent.turn.steer.mock.calls[0]!; + const text = (content as Array<{ text: string }>)[0]!.text; + expect(text).toContain(' { const { agent, manager } = createBackgroundManager(); const taskId = registerProcess(manager, pendingProcess(), 'sleep 60', 'long shell task'); @@ -325,7 +346,9 @@ describe('BackgroundManager — notification delivery', () => { }); const text = (content as Array<{ text: string }>)[0]!.text; expect(text).toContain('Background agent completed'); - expect(text).toContain('restored subagent summary'); + expect(text).not.toContain('restored subagent summary'); + expect(text).toContain(' { }); const text = (content as Array<{ text: string }>)[0]!.text; expect(text).toContain('Background process completed'); - expect(text).toContain('restored shell output'); + expect(text).not.toContain('restored shell output'); + expect(text).toContain(' { + it('references persisted output without reading a tail for restored process notifications', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-bash-tail-')); try { const taskId = 'bash-large000'; @@ -381,10 +406,12 @@ describe('BackgroundManager — notification delivery', () => { }); expect(readOutputSpy).not.toHaveBeenCalled(); expect(snapshotSpy).toHaveBeenCalledWith(taskId, expect.any(Number)); - expect(snapshotSpy.mock.calls[0]![1]).toBeLessThan(largeOutput.length); + expect(snapshotSpy.mock.calls[0]![1]).toBe(0); const [content] = agent.context.appendUserMessage.mock.calls[0]!; const text = (content as Array<{ text: string }>)[0]!.text; - expect(text).toContain('final output line'); + expect(text).toContain(' { expect(flatHistoryText).toContain(' { @@ -121,7 +123,9 @@ describe('background notification → main agent (real Agent instance)', () => { expect(flatContext).toContain(' { @@ -166,9 +170,13 @@ describe('background notification → main agent (real Agent instance)', () => { for (const id of taskIds) { expect(flatHistoryText).toContain(id); } + expect(flatHistoryText).toContain('group-1 completed'); + expect(flatHistoryText).toContain('group-2 completed'); + expect(flatHistoryText).toContain('group-3 completed'); expect(flatHistoryText).toContain('bg #1 result'); expect(flatHistoryText).toContain('bg #2 result'); expect(flatHistoryText).toContain('bg #3 result'); + expect(flatHistoryText).toContain(' { @@ -225,7 +233,9 @@ describe('background notification → main agent (real Agent instance)', () => { const flatHistoryText = JSON.stringify(lastCall.history); expect(flatHistoryText).toContain(' { @@ -298,7 +308,9 @@ describe('background notification → main agent (real Agent instance)', () => { // Both notifications are in context, waiting for the user. const flatContext = JSON.stringify(ctx.agent.context.data()); - expect(flatContext).toContain('previous bash output'); + expect(flatContext).toContain(' { }); describe('Agent context notification projection', () => { - it('renders task notifications with escaped attributes and a bounded output tail', () => { - const tail = Array.from({ length: 25 }, (_, index) => `line ${String(index + 1)}`).join('\n'); - + it('renders task notifications with escaped attributes and generic children', () => { const text = renderNotificationXml({ id: 'n_"1&2', category: 'task', @@ -816,17 +814,24 @@ describe('Agent context notification projection', () => { title: 'Task finished', severity: 'info', body: 'The task completed.', - tail_output: tail, + children: [ + [ + '', + 'Read the output file to retrieve the result: /tmp/logs/a&b/output.log', + '', + ].join('\n'), + ], }); expect(text).toContain('id="n_"1&2"'); expect(text).toContain('source_id="bg&1"'); expect(text).toContain('Title: Task finished'); expect(text).toContain('Severity: info'); - expect(text).toContain(''); - expect(text).not.toContain('line 5'); - expect(text).toContain('line 6'); - expect(text).toContain('line 25'); + expect(text).toContain(''); + expect(text).toContain( + 'Read the output file to retrieve the result: /tmp/logs/a&b/output.log', + ); + expect(text).not.toContain(''); expect(text.trimEnd()).toMatch(/<\/notification>$/); }); @@ -870,13 +875,14 @@ describe('Agent context notification projection', () => { const text = renderNotificationXml({ id: '', source_kind: 'host', - tail_output: 'should stay out of the XML', + output_path: '/tmp/output.log', }); expect(text).toContain('id="unknown"'); expect(text).toContain('category="unknown"'); expect(text).not.toContain(''); - expect(text).not.toContain('should stay out of the XML'); + expect(text).not.toContain(' { diff --git a/packages/agent-core/test/agent/tool.test.ts b/packages/agent-core/test/agent/tool.test.ts index 56a74a42c..861a2680e 100644 --- a/packages/agent-core/test/agent/tool.test.ts +++ b/packages/agent-core/test/agent/tool.test.ts @@ -1,6 +1,11 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import type { ToolCall } from '@moonshot-ai/kosong'; import { describe, expect, it, vi } from 'vitest'; +import { budgetToolResultForModel } from '../../src/agent/turn/tool-result-budget'; import { HookEngine } from '../../src/session/hooks'; import type { SessionSubagentHost } from '../../src/session/subagent-host'; import { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags'; @@ -372,6 +377,111 @@ describe('Agent tools', () => { `); await ctx.expectResumeMatches(); }); + + it('persists oversized registered tool results before adding them to model context', async () => { + const sessionDir = mkdtempSync(join(tmpdir(), 'tool-result-overflow-')); + try { + const lookupCall: ToolCall = { + type: 'function', + id: 'call_lookup', + name: 'Lookup', + arguments: '{"query":"moon"}', + }; + const largeOutput = `${'x'.repeat(60_000)}tail survives`; + const ctx = testAgent({ homedir: sessionDir }); + ctx.configure(); + await ctx.rpc.setPermission({ mode: 'auto' }); + await ctx.rpc.registerTool({ + name: 'Lookup', + description: 'Look up a short test value.', + parameters: { type: 'object', properties: {} }, + }); + + 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({ output: largeOutput }); + + ctx.mockNextResponse({ type: 'text', text: 'done' }); + await ctx.untilTurnEnd(); + + const toolText = ctx.compactHistory().find((message) => message.role === 'tool')?.text ?? ''; + const outputPath = /^output_path: (.+)$/m.exec(toolText)?.[1]; + expect(toolText).toContain('Tool output exceeded 50000 characters'); + expect(toolText).not.toContain('tail survives'); + expect(outputPath).toBeTruthy(); + expect(readFileSync(outputPath!, 'utf8')).toBe(largeOutput); + } finally { + rmSync(sessionDir, { recursive: true, force: true }); + } + }); + + it('does not overwrite saved oversized tool results with repeated call IDs', async () => { + const sessionDir = mkdtempSync(join(tmpdir(), 'tool-result-overflow-')); + try { + const firstOutput = `${'a'.repeat(60_000)}first tail`; + const secondOutput = `${'b'.repeat(60_000)}second tail`; + + const first = await budgetToolResultForModel({ + homedir: sessionDir, + toolName: 'Lookup', + toolCallId: 'call_lookup', + result: { output: firstOutput }, + }); + const second = await budgetToolResultForModel({ + homedir: sessionDir, + toolName: 'Lookup', + toolCallId: 'call_lookup', + result: { output: secondOutput }, + }); + + const firstPath = savedOutputPath(first.output); + const secondPath = savedOutputPath(second.output); + expect(firstPath).not.toBe(secondPath); + expect(readFileSync(firstPath, 'utf8')).toBe(firstOutput); + expect(readFileSync(secondPath, 'utf8')).toBe(secondOutput); + } finally { + rmSync(sessionDir, { recursive: true, force: true }); + } + }); + + it('keeps oversized tool results intact when no session directory is available', async () => { + const largeOutput = `${'x'.repeat(60_000)}tail survives`; + const result = { output: largeOutput }; + + const budgeted = await budgetToolResultForModel({ + toolName: 'Lookup', + toolCallId: 'call_lookup', + result, + }); + + expect(budgeted).toBe(result); + expect(budgeted.output).toBe(largeOutput); + }); + + it('does not save already-truncated tool result previews as full output', async () => { + const sessionDir = mkdtempSync(join(tmpdir(), 'tool-result-overflow-')); + try { + const largeOutput = `${'x'.repeat(60_000)}[...truncated]`; + const result = { + output: largeOutput, + truncated: true, + }; + + const budgeted = await budgetToolResultForModel({ + homedir: sessionDir, + toolName: 'Lookup', + toolCallId: 'call_lookup', + result, + }); + + expect(budgeted).toBe(result); + expect(budgeted.output).toBe(largeOutput); + expect(budgeted.output).not.toContain('output_path:'); + expect(existsSync(join(sessionDir, 'tool-results'))).toBe(false); + } finally { + rmSync(sessionDir, { recursive: true, force: true }); + } + }); }); function bashCall(): ToolCall { @@ -396,6 +506,13 @@ function agentCall(): ToolCall { }; } +function savedOutputPath(output: unknown): string { + expect(typeof output).toBe('string'); + const outputPath = /^output_path: (.+)$/m.exec(output as string)?.[1]; + expect(outputPath).toBeTruthy(); + return outputPath!; +} + function hookErrorMessageAssertCommand(expected: string): string { const script = [ "let input = '';", diff --git a/packages/agent-core/test/mcp/output.test.ts b/packages/agent-core/test/mcp/output.test.ts index 3fece7048..496ab45dc 100644 --- a/packages/agent-core/test/mcp/output.test.ts +++ b/packages/agent-core/test/mcp/output.test.ts @@ -285,6 +285,7 @@ describe('mcpResultToExecutableOutput', () => { // The notice merges into the single text part so collapseSingleText still // emits a plain string — the very common "single oversized text" case. expect(out.output).toBe('x'.repeat(100_000) + MCP_OUTPUT_TRUNCATED_TEXT); + expect(out.truncated).toBe(true); }); test('drops oversized binary parts in favor of a per-part notice without touching the text budget', () => { @@ -304,6 +305,7 @@ describe('mcpResultToExecutableOutput', () => { // The text-budget marker must NOT appear — only the binary part was dropped. const joined = parts.map((p) => (p.type === 'text' ? p.text : '')).join(''); expect(joined).not.toContain('Output truncated'); + expect(out.truncated).toBe(true); }); test('binary part within the per-part cap survives intact alongside oversized text', () => { @@ -320,5 +322,6 @@ describe('mcpResultToExecutableOutput', () => { { type: 'text', text: 'A'.repeat(100_000) }, { type: 'image_url', imageUrl: { url: 'data:image/png;base64,' + 'B'.repeat(500_000) } }, ]); + expect(out).not.toHaveProperty('truncated'); }); }); diff --git a/packages/agent-core/test/tools/bash.test.ts b/packages/agent-core/test/tools/bash.test.ts index 32e0a306a..65eca4d58 100644 --- a/packages/agent-core/test/tools/bash.test.ts +++ b/packages/agent-core/test/tools/bash.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { PassThrough, Readable, type Writable } from 'node:stream'; import type { Environment, KaosProcess } from '@moonshot-ai/kaos'; @@ -993,6 +996,33 @@ describe('BashTool', () => { expect((result as { message?: string }).message).toContain('Output is truncated'); }); + it('saves full foreground output when the inline result is truncated', async () => { + const sessionDir = mkdtempSync(join(tmpdir(), 'bash-truncated-')); + try { + const fullOutput = `${'short line\n'.repeat(6_000)}tail survives\n`; + const { manager } = createBackgroundManager({ sessionDir }); + const tool = bashTool( + createFakeKaos({ + execWithEnv: vi.fn().mockResolvedValue(processWithOutput({ stdout: fullOutput })), + osEnv: posixEnv, + }), + '/workspace', + manager, + ); + + const result = await executeTool(tool, context({ command: 'flood', timeout: 60 })); + const output = result.output as string; + const outputPath = /^output_path: (.+)$/m.exec(output)?.[1]; + + expect(output).toContain('[...truncated]'); + expect(output).toContain('task_id: bash-'); + expect(outputPath).toBeTruthy(); + expect(readFileSync(outputPath!, 'utf8')).toBe(fullOutput); + } finally { + rmSync(sessionDir, { recursive: true, force: true }); + } + }); + it('marks the truncated output buffer with a "[...truncated]" sentinel at the cut point', async () => { const huge = Buffer.alloc(10 * 1024 * 1024 + 1, 'x'); const tool = bashTool(