Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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/preserve-truncated-tool-output.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/en/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion docs/zh/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只

## 后台任务

后台任务工具用于管理通过 `Bash`、`Agent` 或 `AskUserQuestion` 启动的后台任务。任务进入终止状态时会自动把状态和末尾输出送回 Agent;如需提前检查进度,使用 `TaskOutput`。
后台任务工具用于管理通过 `Bash`、`Agent` 或 `AskUserQuestion` 启动的后台任务。任务进入终止状态时会自动把状态和已保存的输出路径送回 Agent;如需提前检查进度,使用 `TaskOutput`。

| 工具 | 默认审批 | 说明 |
| --- | --- | --- |
Expand Down
49 changes: 43 additions & 6 deletions packages/agent-core/src/agent/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -157,7 +159,7 @@ type BackgroundTaskNotification = Record<string, unknown> & {
readonly title: string;
readonly severity: 'info' | 'warning';
readonly body: string;
readonly tail_output: string;
readonly children?: readonly string[] | undefined;
};

interface BackgroundTaskNotificationContext {
Expand All @@ -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
Expand Down Expand Up @@ -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<BackgroundTaskInfo | undefined> {
const entry = this.tasks.get(taskId);
Expand Down Expand Up @@ -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,
Expand All @@ -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 = [
{
Expand Down Expand Up @@ -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 [
`<output-file path="${escapeXmlAttr(outputPath)}" bytes="${String(outputSizeBytes)}">`,
`Read the output file to retrieve the result: ${escapeXml(outputPath)}`,
'</output-file>',
].join('\n');
}

function renderOutputPreviewBlock(output: BackgroundTaskOutputSnapshot): string {
return [
`<output-preview bytes="${String(output.previewBytes)}" total_bytes="${String(output.outputSizeBytes)}" truncated="${String(output.truncated)}">`,
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),
'</output-preview>',
].join('\n');
}

function notificationKey(origin: BackgroundTaskOrigin): string {
return `${origin.taskId}\0${origin.status}\0${origin.notificationId}`;
}
Expand Down
42 changes: 11 additions & 31 deletions packages/agent-core/src/agent/context/notification-xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,11 @@
* Title: ...
* Severity: ...
* <body>
* <task-notification> (only when source_kind === 'background_task' and tail_output is non-empty)
* <truncated tail>
* </task-notification>
* <children...>
* </notification>
*
* The opening-tag names (`<notification ` / `<task-notification>`) are
* load-bearing for the projector's `mergeAdjacentUserMessages` detector
* — rename requires updating the detector too.
* The opening tag name (`<notification `) is load-bearing for notification
* consumers that detect chat-history injections.
*
* `agent_id` is emitted only for background_task notifications whose
* source task is an agent subagent — surfacing it structurally lets the
Expand All @@ -36,6 +33,7 @@ export function renderNotificationXml(data: Record<string, unknown>): 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[] = [
Expand All @@ -44,36 +42,12 @@ export function renderNotificationXml(data: Record<string, unknown>): 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('<task-notification>');
lines.push(truncated);
lines.push('</task-notification>');
}
}
lines.push(...children);

lines.push('</notification>');
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);
Expand All @@ -85,3 +59,9 @@ function optionalStringAttr(value: unknown): string | undefined {
if (typeof value !== 'string' || value.length === 0) return undefined;
return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
}

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);
}
8 changes: 7 additions & 1 deletion packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
});
},
},
});
Expand Down
91 changes: 91 additions & 0 deletions packages/agent-core/src/agent/turn/tool-result-budget.ts
Original file line number Diff line number Diff line change
@@ -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<ExecutableToolResult> {
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<ContentPart, { type: 'text' }> => 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<string | undefined> {
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.',
Comment thread
kermanx marked this conversation as resolved.
];
lines.push('', '[preview]', text.slice(0, TOOL_RESULT_PREVIEW_CHARS));

Check warning on line 81 in packages/agent-core/src/agent/turn/tool-result-budget.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-unicorn(no-immediate-mutation)

Do not call `push()` immediately after initializing an array.
return lines.join('\n');
}

function safeToolResultFileStem(toolName: string, toolCallId: string): string {
const label = `${toolName}-${toolCallId}`
.replace(/[^a-zA-Z0-9._-]+/g, '_')

Check warning on line 87 in packages/agent-core/src/agent/turn/tool-result-budget.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-unicorn(prefer-string-replace-all)

Prefer `String#replaceAll()` over `String#replace()` when using a regex with the global flag.
.replace(/^_+|_+$/g, '')

Check warning on line 88 in packages/agent-core/src/agent/turn/tool-result-budget.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-unicorn(prefer-string-replace-all)

Prefer `String#replaceAll()` over `String#replace()` when using a regex with the global flag.
.slice(0, 80);
return label || 'tool-result';
}
7 changes: 6 additions & 1 deletion packages/agent-core/src/loop/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions packages/agent-core/src/loop/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
Loading
Loading