Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/tidy-media-tool-hints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Stop pointing models without image or video input capability to the unavailable ReadMediaFile tool.
2 changes: 1 addition & 1 deletion packages/agent-core-v2/src/agent/tools/os/read/read.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ When you need several files, prefer to read them in parallel: emit multiple `Rea
- 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.
- 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. `iconv` via Bash). Other encodings (e.g. GBK), binary files, and files containing NUL bytes are refused; use `ReadMediaFile` for images or video, and Bash or an MCP tool for other binary formats.
- 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. `iconv` via Bash). Other encodings (e.g. GBK), binary files, and files containing NUL bytes are refused; ${BINARY_FORMATS_HINT}
- Negative line_offset reads from the end of the file (for example, -100 reads the last 100 lines); the absolute value cannot exceed ${MAX_LINES}.
- Output format: `<line-number>\t<content>` per line.
- A `<system>...</system>` status block is appended after the file content; it summarizes how much was read (line and byte counts, truncation, line-ending notes) and is not part of the file itself.
Expand Down
48 changes: 35 additions & 13 deletions packages/agent-core-v2/src/agent/tools/os/read/readTool.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { IAgentRuntimeService, inspectAgentRuntime } from '#/agent/runtimeBinding/agentRuntime';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView';
import { unwrapErrorCause } from '#/_base/errors/errors';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
Expand Down Expand Up @@ -180,11 +181,17 @@ async function* decodedLines(lines: readonly string[]): AsyncGenerator<string> {
yield* lines;
}

function notReadableFileOutput(path: string): string {
function notReadableFileOutput(path: string, mediaReadable: boolean): string {
if (mediaReadable) {
return (
`"${path}" is not readable as UTF-8 text. ` +
'If it is an image or video, use ReadMediaFile. ' +
'For other binary formats, use Bash or an MCP tool if available.'
);
}
return (
`"${path}" is not readable as UTF-8 text. ` +
'If it is an image or video, use ReadMediaFile. ' +
'For other binary formats, use Bash or an MCP tool if available.'
'For binary formats, use Bash or an MCP tool if available.'
);
}

Expand All @@ -196,23 +203,36 @@ function notUtf8DecodableFileOutput(path: string): string {
);
}

const READ_DESCRIPTION = renderPrompt(readDescriptionTemplate, {
MAX_LINES,
MAX_BYTES_KB: MAX_BYTES / 1024,
MAX_LINE_LENGTH,
});
function binaryFormatsHint(mediaReadable: boolean): string {
return mediaReadable
? 'use `ReadMediaFile` for images or video, and Bash or an MCP tool for other binary formats.'
: 'use Bash or an MCP tool for binary formats.';
}

export class ReadTool implements IReadTool {
declare readonly _serviceBrand: undefined;
readonly name = 'Read' as const;
readonly description = READ_DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(ReadInputSchema);
constructor(
@IAgentRuntimeService private readonly runtime: IAgentRuntimeService,
@ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext,
@ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog,
@IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService,
) {}

get description(): string {
return renderPrompt(readDescriptionTemplate, {
MAX_LINES,
MAX_BYTES_KB: MAX_BYTES / 1024,
MAX_LINE_LENGTH,
BINARY_FORMATS_HINT: binaryFormatsHint(this.mediaReadable()),
});
}

private mediaReadable(): boolean {
return this.toolRegistry.resolve('ReadMediaFile') !== undefined;

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 Honor the effective tool policy before advertising media reads

When a multimodal model registers ReadMediaFile but the workspace, profile, global, or session policy disables it, AgentToolSelectService.activeEntries() removes the tool from the model request and the executor guard rejects calls, while this registry-only check still returns true. Consequently, the new description and rejection messages continue directing the model to an unavailable tool in exactly those denylist scenarios; determine availability through the effective IAgentToolPolicyService as well as registry presence.

AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L7-L7

Useful? React with 👍 / 👎.

}

private workspaceConfig(view: RuntimeWorkspaceView): WorkspaceConfig {
return { workspaceDir: view.workDir, additionalDirs: view.additionalDirs };
}
Expand Down Expand Up @@ -275,7 +295,9 @@ export class ReadTool implements IReadTool {
if (fileType.kind === 'image' || fileType.kind === 'video') {
return {
isError: true,
output: `"${args.path}" is a ${fileType.kind} file. Use ReadMediaFile to read image or video files.`,
output: this.mediaReadable()
? `"${args.path}" is a ${fileType.kind} file. Use ReadMediaFile to read image or video files.`
: `"${args.path}" is a ${fileType.kind} file. The current model cannot read image or video files; use Bash or an MCP tool to inspect or convert it.`,

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 Avoid replacing the media hint with unusable guidance

When ReadMediaFile is denied by policy for a multimodal model, this branch falsely reports that the model lacks media support; additionally, for a text-only plan agent, PLAN_TOOLS contains neither Bash nor MCP and the changed PLAN_ROLE explicitly says there is no shell, yet this error directs the agent to those unavailable tools. This can recreate the same failed-tool retries the change is intended to prevent. Report that media reading is unavailable without attributing the reason to model capability, and only recommend fallbacks that the effective policy/profile actually exposes.

Useful? React with 👍 / 👎.

};
}

Expand All @@ -298,7 +320,7 @@ export class ReadTool implements IReadTool {
} else if (fileType.kind === 'unknown') {
return {
isError: true,
output: notReadableFileOutput(args.path),
output: notReadableFileOutput(args.path, this.mediaReadable()),
};
} else {
lines = fs.readLines(safePath, { errors: 'strict' });
Expand Down Expand Up @@ -353,7 +375,7 @@ export class ReadTool implements IReadTool {

for await (const rawLine of lines) {
if (containsNulByte(rawLine)) {
return { isError: true, output: notReadableFileOutput(displayPath) };
return { isError: true, output: notReadableFileOutput(displayPath, this.mediaReadable()) };
}
currentLineNo += 1;
updateLineEndingFlags(flags, rawLine);
Expand Down Expand Up @@ -411,7 +433,7 @@ export class ReadTool implements IReadTool {

for await (const rawLine of lines) {
if (containsNulByte(rawLine)) {
return { isError: true, output: notReadableFileOutput(displayPath) };
return { isError: true, output: notReadableFileOutput(displayPath, this.mediaReadable()) };
}
currentLineNo += 1;
updateLineEndingFlags(flags, rawLine);
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core-v2/src/features/plan/profile/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const PLAN_ROLE =
'1. What you already know from the information provided\n' +
'2. What questions remain unanswered that would benefit from explore agent investigation\n' +
'3. Your implementation plan (either preliminary if questions remain, or final if sufficient context exists)\n\n' +
'You are a read-only planning agent: you can read and search files (Read, Glob, Grep, ReadMediaFile) ' +
'You are a read-only planning agent: you can read and search files (Read, Glob, Grep) ' +
'and consult the web (WebSearch, FetchURL), but you have no shell and no file-editing tools. ' +
'Where the general instructions tell you to make changes with tools, that does not apply to you — ' +
'do not attempt to run commands or modify files. Your deliverable is the plan itself, returned as ' +
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core-v2/test/agent/loop/loop.test.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ import {
} from '#/agent/tools/os/read/read';
import { ReadTool } from '#/agent/tools/os/read/readTool';
import type { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime';
import type { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { FakeRuntime } from '#/runtime/fakeRuntime';
import { RuntimeRegistry } from '#/runtime/runtimeRegistry';
import type { IHostEnvironment } from '#/os/interface/hostEnvironment';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/tool/toolContract';
import type { ExecutableTool, ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/tool/toolContract';

const signal = new AbortController().signal;
const PERMISSIVE_WORKSPACE = stubWorkspaceContext('/');
Expand Down Expand Up @@ -64,13 +65,22 @@ function createTestEnv(home = '/home'): IHostEnvironment {
};
}

function stubToolRegistry(registered: readonly string[] = ['ReadMediaFile']): IAgentToolRegistryService {
return {
_serviceBrand: undefined,
resolve: (name: string) =>
registered.includes(name) ? ({ name } as unknown as ExecutableTool) : undefined,
} as unknown as IAgentToolRegistryService;
}

function createReadTool(
fs: IHostFileSystem,
env: IHostEnvironment,
workspace: ReturnType<typeof stubWorkspaceContext>,
skillCatalog: ISessionSkillCatalog = {
catalog: { getSkillRoots: () => [] },
} as unknown as ISessionSkillCatalog,
toolRegistry: IAgentToolRegistryService = stubToolRegistry(),
): ReadTool {
const runtime = Object.assign(
new FakeRuntime(
Expand All @@ -86,7 +96,7 @@ function createReadTool(
inspect: () => runtime,
acquire: () => ({ runtime, track: (resource) => resource, dispose: () => {} }),
};
return new ReadTool(resolver, workspace, skillCatalog);
return new ReadTool(resolver, workspace, skillCatalog, toolRegistry);
}

function createSpiedFs(content: string) {
Expand Down Expand Up @@ -528,6 +538,53 @@ describe('ReadTool', () => {
expect(output).not.toContain('Python tools');
});

it('rejects image files without pointing to ReadMediaFile when the media tool is unregistered', async () => {
const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const { fs, readText } = createSpiedMapFs({
'/tmp/sample.png': { bytes: pngHeader },
});
const tool = createReadTool(
fs,
createTestEnv(),
PERMISSIVE_WORKSPACE,
undefined,
stubToolRegistry([]),
);

const result = await execute(tool, { path: '/tmp/sample.png' });
const output = toolContentString(result);

expect(result.isError).toBe(true);
expect(output).toBe(
'"/tmp/sample.png" is a image file. The current model cannot read image or video files; use Bash or an MCP tool to inspect or convert it.',
);
expect(output).not.toContain('ReadMediaFile');
expect(readText).not.toHaveBeenCalled();
});

it('rejects NUL-containing binary files without mentioning ReadMediaFile when unregistered', async () => {
const header = Buffer.concat([Buffer.from('plain prefix'), Buffer.from([0x00, 0x01])]);
const { fs, readText } = createSpiedMapFs({
'/tmp/blob.bin': { bytes: header },
});
const tool = createReadTool(
fs,
createTestEnv(),
PERMISSIVE_WORKSPACE,
undefined,
stubToolRegistry([]),
);

const result = await execute(tool, { path: '/tmp/blob.bin' });
const output = toolContentString(result);

expect(result.isError).toBe(true);
expect(output).toBe(
'"/tmp/blob.bin" is not readable as UTF-8 text. For binary formats, use Bash or an MCP tool if available.',
);
expect(readText).not.toHaveBeenCalled();
});

it('rejects invalid UTF-8 instead of returning replacement characters', async () => {
const replacement = String.fromCodePoint(0xfffd);
const { fs } = createSpiedMapFs({
Expand Down Expand Up @@ -911,6 +968,7 @@ describe('ReadTool', () => {
runtime,
stubWorkspaceContext('/workspace'),
{ catalog: { getSkillRoots: () => [] } } as unknown as ISessionSkillCatalog,
stubToolRegistry(),
);
const execution = tool.resolveExecution({ path: '/workspace/a.txt' });
expect('execute' in execution).toBe(true);
Expand Down Expand Up @@ -970,4 +1028,38 @@ describe('ReadTool description and schema parity', () => {
expect(tool.description).toMatch(/UTF-?8/i);
expect(tool.description).toMatch(/binary/i);
});

it('points binary formats to ReadMediaFile while the media tool is registered', () => {
const tool = toolWithContent('');

expect(tool.description).toContain('use `ReadMediaFile` for images or video');
});

it('drops ReadMediaFile from the description when the media tool is unregistered', () => {
const tool = createReadTool(
createSpiedFs('').fs,
createTestEnv(),
PERMISSIVE_WORKSPACE,
undefined,
stubToolRegistry([]),
);

expect(tool.description).not.toContain('ReadMediaFile');
expect(tool.description).toContain('use Bash or an MCP tool for binary formats.');
});

it('reflects media tool registration changes in the description', () => {
const registered = ['ReadMediaFile'];
const tool = createReadTool(
createSpiedFs('').fs,
createTestEnv(),
PERMISSIVE_WORKSPACE,
undefined,
stubToolRegistry(registered),
);

expect(tool.description).toContain('ReadMediaFile');
registered.length = 0;
expect(tool.description).not.toContain('ReadMediaFile');
});
});
10 changes: 5 additions & 5 deletions packages/agent-core-v2/test/tool/tool.test.ts

Large diffs are not rendered by default.