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
6 changes: 6 additions & 0 deletions .changeset/fix-read-media-image-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---

Detect the real image format from file contents when reading media, so a mismatched filename extension no longer produces a data URL the model API rejects.
28 changes: 21 additions & 7 deletions packages/agent-core/src/tools/support/file-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,17 +374,31 @@ export function detectFileType(
}
return sniffed;
}
if (
type === 'media' &&
mediaHint !== null &&
mediaHint.kind !== 'text'
) {
return mediaHint;
// Sniff failed. In media mode, only video falls back to the extension:
// some video containers (e.g. MPEG-PS `.mpg`) have no magic we recognise,
// so the extension is the only signal. Every image format the model
// accepts (PNG/JPEG/GIF/WebP) has a reliable magic signature, so a failed
// sniff on an image means it is not a supported image — returning the
// extension MIME would only produce a mismatched data URL the model API
// rejects. (Runs before the NUL check so a video extension wins even when
// the header happens to contain a 0x00 byte.)
if (type === 'media') {
if (mediaHint?.kind === 'video') {
return mediaHint;
}
if (mediaHint?.kind === 'image') {
return { kind: 'unknown', mimeType: '' };
}
}
if (buf.includes(0x00)) {
return { kind: 'unknown', mimeType: '' };
}
// No sniff and no NUL: fall through to hint / text / unknown logic.
// No sniff and no NUL (text mode): still do not trust an image extension
// hint, for the same reason as above. Anything else falls through to the
// hint / text / unknown logic.
if (mediaHint?.kind === 'image') {
return { kind: 'unknown', mimeType: '' };
}
}

if (mediaHint) return mediaHint;
Expand Down
9 changes: 9 additions & 0 deletions packages/agent-core/test/tools/file-type.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,15 @@ describe('detectFileType', () => {
expect(detectFileType('clip.mpg', mpegProgramStreamHeader).kind).toBe('unknown');
});

it('returns unknown for an image extension whose bytes fail to sniff', () => {
// A `.png` file with no recognisable image magic and no NUL byte must not
// be reported as `image/png` — otherwise a media reader would ship a
// mismatched data URL that the model API rejects as
// `application/octet-stream`.
const garbage = Buffer.from('plain ascii, definitely not a png');
expect(detectFileType('fake.png', garbage).kind).toBe('unknown');
});

it('extension in NON_TEXT_SUFFIXES → unknown', () => {
// A `.zip` file with no header and no image/video hint must not
// be treated as text.
Expand Down
69 changes: 69 additions & 0 deletions packages/agent-core/test/tools/read-media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,4 +582,73 @@ describe('ReadMediaFileTool', () => {
expect(relative.isError).toBe(true);
expect(readBytes).not.toHaveBeenCalled();
});

it('uses the sniffed MIME over a mismatched image extension', async () => {
// `.png` path but JPEG bytes — the data URL must advertise `image/jpeg`
// (the real bytes), not `image/png` (the extension), otherwise the model
// API rejects it as `application/octet-stream`.
const data = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.from('jpegdata')]);
const tool = makeReadMediaTool({
stat: vi.fn<Kaos['stat']>().mockResolvedValue({ ...DEFAULT_STAT, stSize: data.length }),
readBytes: vi.fn<Kaos['readBytes']>().mockResolvedValue(data),
});

const result = await executeTool(tool, {
turnId: 't1',
toolCallId: 'c_mismatch',
args: { path: '/workspace/actually-jpeg.png' },
signal,
});

const parts = outputParts(result);
expect((parts[2] as { imageUrl: { url: string } }).imageUrl.url).toBe(
`data:image/jpeg;base64,${data.toString('base64')}`,
);
});

it('ships sniffed image formats to the provider without gating', async () => {
// A `.png` file that is actually a BMP is reported as `image/bmp`. The
// tool does not gate on image format — it ships the real bytes with the
// sniffed MIME, and the provider decides which formats it accepts.
const data = Buffer.concat([Buffer.from('BM'), Buffer.from('bmpdata')]);
const tool = makeReadMediaTool({
stat: vi.fn<Kaos['stat']>().mockResolvedValue({ ...DEFAULT_STAT, stSize: data.length }),
readBytes: vi.fn<Kaos['readBytes']>().mockResolvedValue(data),
});

const result = await executeTool(tool, {
turnId: 't1',
toolCallId: 'c_bmp',
args: { path: '/workspace/photo.png' },
signal,
});

const parts = outputParts(result);
expect((parts[2] as { imageUrl: { url: string } }).imageUrl.url).toBe(
`data:image/bmp;base64,${data.toString('base64')}`,
);
});

it('rejects a media-extension file whose bytes are not a supported image', async () => {
// `.png` path with garbage bytes (no NUL) fails to sniff; the tool must
// report "not a supported image or video file" instead of building a
// mismatched data URL.
const data = Buffer.from('this is not an image, just plain ascii text');
const tool = makeReadMediaTool({
stat: vi.fn<Kaos['stat']>().mockResolvedValue({ ...DEFAULT_STAT, stSize: data.length }),
readBytes: vi.fn<Kaos['readBytes']>().mockResolvedValue(data),
});

const result = await executeTool(tool, {
turnId: 't1',
toolCallId: 'c_garbage',
args: { path: '/workspace/fake.png' },
signal,
});

expect(result.isError).toBe(true);
expect(result.output).toBe(
'"/workspace/fake.png" is not a supported image or video file. Use Read for text files, or Bash or an MCP tool for other binary formats.',
);
});
});
Loading