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/hide-tool-result-system-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Stop showing tool-produced `<system>` metadata in tool outputs; failed tools now show their own error text.
12 changes: 8 additions & 4 deletions apps/kimi-code/src/tui/components/messages/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2105,10 +2105,14 @@ export class ToolCallComponent extends Container {
return;
}

// Outputs that start with a `<system…>` tag are harness-injected
// reminders piggy-backing on a tool result. They are noise for the
// user, so suppress the body while keeping the header chip intact.
if (result.output.trimStart().startsWith('<system')) {
// Outputs that start with a `<system-reminder>` tag are harness-injected
// reminders piggy-backing on a tool result (e.g. a finalize hook rewrote
// the output). They are noise for the user, so suppress the body while
// keeping the header chip intact. Match the full reminder tag only: tool
// metadata no longer travels inside `output` (it rides the result's
// `note` side channel), so real output starting with a literal `<system>`
// is user data and must stay visible.
if (result.output.trimStart().startsWith('<system-reminder>')) {
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,9 @@ export interface ReadMediaSummary {
mimeType?: string;
bytes?: number;
url?: string;
originalSize?: string;
}

const PATH_TAG_RE = /^<(image|video)\s+path="([^"]+)">$/;
const ORIGINAL_SIZE_RE = /original size\s+(\d+x\d+px)/;
const DATA_URL_RE = /^data:([^;]+);base64,(.*)$/s;

function bytesFromBase64(b64: string): number {
Expand All @@ -55,7 +53,6 @@ export function parseReadMediaOutput(output: string): ReadMediaSummary | null {
let mimeType: string | undefined;
let bytes: number | undefined;
let url: string | undefined;
let originalSize: string | undefined;
let foundMedia = false;

for (const raw of parsed) {
Expand All @@ -64,15 +61,11 @@ export function parseReadMediaOutput(output: string): ReadMediaSummary | null {
const type = part['type'];

if (type === 'text' && typeof part['text'] === 'string') {
const text = part['text'];
const tag = PATH_TAG_RE.exec(text);
const tag = PATH_TAG_RE.exec(part['text']);
if (tag) {
kind = tag[1] as 'image' | 'video';
path = tag[2];
continue;
}
const size = ORIGINAL_SIZE_RE.exec(text);
if (size) originalSize = size[1];
continue;
}

Expand Down Expand Up @@ -103,7 +96,6 @@ export function parseReadMediaOutput(output: string): ReadMediaSummary | null {
if (mimeType !== undefined) summary.mimeType = mimeType;
if (bytes !== undefined) summary.bytes = bytes;
if (url !== undefined) summary.url = url;
if (originalSize !== undefined) summary.originalSize = originalSize;
return summary;
}

Expand All @@ -117,7 +109,6 @@ function metaSegments(summary: ReadMediaSummary): string[] {
const segs: string[] = [];
if (summary.mimeType !== undefined) segs.push(summary.mimeType);
if (summary.bytes !== undefined) segs.push(formatBytes(summary.bytes));
if (summary.originalSize !== undefined) segs.push(summary.originalSize);
return segs;
}

Expand Down
27 changes: 25 additions & 2 deletions apps/kimi-code/test/tui/components/messages/tool-call.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ describe('ToolCallComponent', () => {
});
});

it('hides tool output bodies that start with a <system tag', () => {
it('hides tool output bodies that start with a <system-reminder tag', () => {
const reminderOutput =
'<system-reminder>\nThe task tools have not been used recently.\n</system-reminder>';
const component = new ToolCallComponent(
Expand All @@ -285,7 +285,7 @@ describe('ToolCallComponent', () => {
expect(expanded).not.toContain('task tools');
});

it('hides <system-prefixed output even when the tool result is an error', () => {
it('hides <system-reminder-prefixed output even when the tool result is an error', () => {
const component = new ToolCallComponent(
{
id: 'call_hidden_err',
Expand All @@ -304,6 +304,29 @@ describe('ToolCallComponent', () => {
expect(out).not.toContain('do not show');
});

it('renders output that merely starts with a literal <system> tag', () => {
// Tool metadata no longer travels inside `output` (it rides the result's
// `note` side channel), so real output starting with the literal tag —
// a file that contains it, an MCP tool's text — must stay visible.
const component = new ToolCallComponent(
{
id: 'call_literal',
name: 'Bash',
args: { command: 'cat notes.txt' },
},
{
tool_call_id: 'call_literal',
output: '<system>literal text from a user file</system>\nsecond line',
is_error: false,
},
);

component.setExpanded(true);
const out = strip(component.render(100).join('\n'));
expect(out).toContain('<system>literal text from a user file</system>');
expect(out).toContain('second line');
});

it('renders AgentSwarm results as a one-line summary without raw XML', () => {
const output = [
'<agent_swarm_result>',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ function imageOutput(path: string, b64 = PNG_B64, mime = 'image/png'): string {
{ type: 'text', text: `<image path="${path}">` },
{ type: 'image_url', imageUrl: { url: `data:${mime};base64,${b64}` } },
{ type: 'text', text: '</image>' },
{ type: 'text', text: `Loaded image file "${path}" (${mime}, 70 bytes, original size 1x1px).` },
]);
}

Expand All @@ -58,7 +57,6 @@ describe('parseReadMediaOutput', () => {
expect(m?.path).toBe('/tmp/a.png');
expect(m?.mimeType).toBe('image/png');
expect(m?.bytes).toBeGreaterThan(0);
expect(m?.originalSize).toBe('1x1px');
});

it('extracts video kind and mime', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,17 @@ describe('TruncatedOutputComponent', () => {
expect(visibleWidth(line)).toBeLessThanOrEqual(37);
}
});

it('renders output verbatim, including literal <system> text in file content', () => {
// Tool metadata no longer travels inside `output` (it rides the result's
// `note` side channel), so the renderer must not eat user data that
// merely contains the literal tag.
const component = new TruncatedOutputComponent(
'<system>literal text from a user file</system>\n<image path="/tmp/x.png">',
{ expanded: true, isError: false },
);
const out = strip(component.render(80).join('\n'));
expect(out).toContain('<system>literal text from a user file</system>');
expect(out).toContain('<image path="/tmp/x.png">');
});
});
77 changes: 7 additions & 70 deletions apps/vis/server/src/lib/context-projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
buildCompactionElisionText,
collectCompactableUserMessages,
isRealUserInput,
renderToolResultForModel,
selectCompactionUserMessages,
selectRecentUserMessages,
} from '@moonshot-ai/agent-core';
Expand Down Expand Up @@ -193,14 +194,12 @@ export function projectContext(
}
openSteps.delete(ev.uuid);
} else if (ev.type === 'tool.result') {
// Mirror what the MODEL saw, not the raw output. agent-core's
// ContextMemory.appendLoopEvent (`tool.result` case) stores
// `createToolMessage(toolCallId, toolResultOutputForModel(result))`,
// which normalizes error / empty outputs with sentinel strings. Using
// `ev.result.output` directly would surface content the model never
// received for failed / empty tool calls. See
// `toolResultContentForModel` below.
const content = toolResultContentForModel(ev.result);
// Mirror what the MODEL saw, not the raw output. This calls the
// SAME `renderToolResultForModel` agent-core applies at its LLM
// projection boundary (error status prefix, empty-output
// placeholder, trailing note), so vis's model view is the real
// projection rather than a hand-kept copy.
const content = renderToolResultForModel(ev.result);
const toolMsg: ContextMessage = {
role: 'tool',
content,
Expand Down Expand Up @@ -564,68 +563,6 @@ function addUsage(into: TokenUsage, src: TokenUsage): void {
(into as any).inputCacheCreation += src.inputCacheCreation;
}

// ── Tool-result normalization (mirror of agent-core) ─────────────────────────
// These replicate agent-core's `toolResultOutputForModel` so vis's model-view
// shows the EXACT content the model received for a tool result. The constants
// and branch conditions are copied verbatim from
// `packages/agent-core/src/agent/context/index.ts` (lines 18-22, 350-377). Keep
// them byte-identical with that source — if agent-core changes the sentinels or
// branch logic, update here too.
const TOOL_ERROR_STATUS = '<system>ERROR: Tool execution failed.</system>';
const TOOL_EMPTY_STATUS = '<system>Tool output is empty.</system>';
const TOOL_EMPTY_ERROR_STATUS =
'<system>ERROR: Tool execution failed. Tool output is empty.</system>';
const TOOL_OUTPUT_EMPTY_TEXT = 'Tool output is empty.';

/** Mirrors agent-core `isEmptyOutputText`
* (`packages/agent-core/src/agent/context/index.ts` ~line 375). */
function isEmptyOutputText(output: string): boolean {
return output.length === 0 || output.trim() === TOOL_OUTPUT_EMPTY_TEXT;
}

/** Mirrors agent-core `toolResultOutputForModel`
* (`packages/agent-core/src/agent/context/index.ts` ~line 350), then wraps the
* result into `ContentPart[]` exactly as `createToolMessage` does (a string
* output → a single `{ type: 'text', text }` part). The model saw this
* normalized content in BOTH model and full views (agent-core normalizes at
* append time, before any of the destructive lifecycle events), so the
* tool.result branch uses this output mode-independently. */
function toolResultContentForModel(result: {
output: string | ContentPart[];
isError?: boolean;
}): ContentPart[] {
const output = result.output;
if (typeof output === 'string') {
let normalized: string;
if (result.isError === true) {
if (output.length === 0) {
normalized = TOOL_EMPTY_ERROR_STATUS;
} else if (output.trimStart().startsWith('<system>ERROR:')) {
normalized = output;
} else {
normalized = `${TOOL_ERROR_STATUS}\n${output}`;
}
} else {
normalized = isEmptyOutputText(output) ? TOOL_EMPTY_STATUS : output;
}
// Match createToolMessage: a string output becomes a single text part.
return [{ type: 'text', text: normalized }];
}

if (output.length === 0) {
return [
{
type: 'text',
text: result.isError === true ? TOOL_EMPTY_ERROR_STATUS : TOOL_EMPTY_STATUS,
},
];
}
if (result.isError === true) {
return [{ type: 'text', text: TOOL_ERROR_STATUS }, ...output];
}
return output;
}

const MICRO_TRUNCATED_MARKER = '[Old tool result content cleared]';
const MICRO_MIN_CONTENT_TOKENS = 100;

Expand Down
8 changes: 5 additions & 3 deletions apps/vis/server/test/lib/context-projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,12 @@ describe('context-projector', () => {
]);
});

it('tool.result: error string already starting with <system>ERROR: is passed through (no double prefix)', () => {
const text = '<system>ERROR: already wrapped</system>\ndetails here';
it('tool.result: error string starting with ERROR: still gets the wrapped status', () => {
// The <system>-wrapped status is the harness verdict; the tool's own
// "ERROR:" text is data, so the status is added unconditionally.
const text = 'ERROR: already wrapped\ndetails here';
const msg = projectToolResult({ output: text, isError: true });
expect(msg.content).toEqual([{ type: 'text', text }]);
expect(msg.content).toEqual([{ type: 'text', text: `${TOOL_ERROR_STATUS}\n${text}` }]);
});

it('tool.result: empty string output (non-error) becomes the empty sentinel', () => {
Expand Down
52 changes: 7 additions & 45 deletions packages/agent-core/src/agent/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createToolMessage, type ContentPart, type Message } from '@moonshot-ai/

import type { Agent } from '..';
import { ErrorCodes, KimiError } from '../../errors';
import type { ExecutableToolResult, LoopRecordedEvent } from '../../loop';
import type { LoopRecordedEvent } from '../../loop';
import { extractImageCompressionCaptions } from '../../tools/support/image-compress';
import { estimateTokens, estimateTokensForMessages } from '../../utils/tokens';
import { escapeXml } from '../../utils/xml-escape';
Expand Down Expand Up @@ -32,11 +32,6 @@ import {

export * from './types';

const TOOL_ERROR_STATUS = '<system>ERROR: Tool execution failed.</system>';
const TOOL_EMPTY_STATUS = '<system>Tool output is empty.</system>';
const TOOL_EMPTY_ERROR_STATUS =
'<system>ERROR: Tool execution failed. Tool output is empty.</system>';
const TOOL_OUTPUT_EMPTY_TEXT = 'Tool output is empty.';
const TOOL_INTERRUPTED_ON_RESUME_OUTPUT =
'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.';

Expand Down Expand Up @@ -630,11 +625,16 @@ export class ContextMemory {
// closed in place at a step boundary (a stale duplicate from an older
// tail-only finishResume), or its call is gone.
if (!this.pendingToolResultIds.has(event.toolCallId)) return;
const message = createToolMessage(event.toolCallId, toolResultOutputForModel(event.result));
// History stores the fact verbatim: the tool's own output plus the
// structured isError/note fields. Model-facing status text (error
// prefix, empty placeholder) and the note are rendered only at LLM
// projection time (see tool-result-render.ts).
const message = createToolMessage(event.toolCallId, event.result.output);
this.pushHistory({
...message,
role: 'tool',
isError: event.result.isError,
note: event.result.note,
});
this.pendingToolResultIds.delete(event.toolCallId);
this.flushDeferredMessagesIfToolExchangeClosed();
Expand Down Expand Up @@ -684,40 +684,6 @@ export class ContextMemory {
}
}

function toolResultOutputForModel(result: ExecutableToolResult): string | ContentPart[] {
const output = result.output;
if (typeof output === 'string') {
if (result.isError === true) {
if (output.length === 0) return TOOL_EMPTY_ERROR_STATUS;
if (output.trimStart().startsWith('<system>ERROR:')) return output;
return `${TOOL_ERROR_STATUS}\n${output}`;
}
return isEmptyOutputText(output) ? TOOL_EMPTY_STATUS : output;
}

// Treat an array output with no sendable content (empty, or only empty/
// whitespace-only text blocks) the same as an empty string output: emit the
// placeholder. Otherwise projection would strip the blank text blocks, leave
// the tool message empty, and throw on every send — bricking the session. A
// non-text part (image/etc.) or any non-whitespace text keeps the real output.
if (isEmptyEquivalentContentArray(output)) {
return [
{
type: 'text',
text: result.isError === true ? TOOL_EMPTY_ERROR_STATUS : TOOL_EMPTY_STATUS,
},
];
}
if (result.isError === true) {
return [{ type: 'text', text: TOOL_ERROR_STATUS }, ...output];
}
return output;
}

function isEmptyEquivalentContentArray(output: readonly ContentPart[]): boolean {
return output.every((part) => part.type === 'text' && part.text.trim().length === 0);
}

// Split inline image-compression captions (see buildImageCompressionCaption)
// out of user prompt content. A caption may be a standalone text part (server
// route, ACP) or merged into an adjacent text segment (TUI paste), so each
Expand Down Expand Up @@ -747,10 +713,6 @@ function splitImageCompressionCaptions(content: readonly ContentPart[]): {
return { captions, parts };
}

function isEmptyOutputText(output: string): boolean {
return output.trim().length === 0 || output.trim() === TOOL_OUTPUT_EMPTY_TEXT;
}

function formatUndoUnavailableMessage(
requestedCount: number,
undoableCount: number,
Expand Down
Loading
Loading