diff --git a/packages/agent-core/src/agent/turn/tool-dedup.ts b/packages/agent-core/src/agent/turn/tool-dedup.ts index 31170f8cf..c94a1eeb2 100644 --- a/packages/agent-core/src/agent/turn/tool-dedup.ts +++ b/packages/agent-core/src/agent/turn/tool-dedup.ts @@ -40,6 +40,27 @@ const REPEAT_REMINDER_2_START = 5; const REPEAT_REMINDER_3_START = 8; const REPEAT_FORCE_STOP_STREAK = 12; +// ── Wrong-tool error detection ──────────────────────────────────────── +// +// When a tool error is *categorical* — the model fundamentally used the +// wrong tool for the task (e.g. ReadMediaFile on a text file) — retries +// are never productive. These errors escalate faster than generic +// transient failures. + +const WRONG_TOOL_ERROR_PATTERNS = [ + /is a text file/i, + /is not a supported image or video/i, + /is not a supported image and video/i, + /cannot read text files/i, + /only reads image/i, + /only works with images and videos/i, + /must use the read tool/i, +]; + +/** Faster escalation for wrong-tool errors. */ +const WRONG_TOOL_REMINDER_2_START = 2; +const WRONG_TOOL_FORCE_STOP_STREAK = 4; + interface Deferred { readonly promise: Promise; resolve(value: T): void; @@ -85,6 +106,29 @@ function forceStopResult( return { ...withReminder, stopTurn: true }; } +/** + * Returns the text content of a tool result, collapsing ContentPart[] + * arrays into a single string for pattern matching. + */ +function toolOutputText(output: ExecutableToolResult['output']): string { + if (typeof output === 'string') return output; + return output + .filter( + (p): p is Extract => p.type === 'text', + ) + .map((p) => p.text) + .join(''); +} + +/** + * Detects tool errors that indicate the model used the wrong tool for the + * task — category errors that will never succeed on retry. + */ +function isWrongToolError(output: ExecutableToolResult['output']): boolean { + const text = toolOutputText(output); + return WRONG_TOOL_ERROR_PATTERNS.some((pattern) => pattern.test(text)); +} + /** * Placeholder result returned from `checkSameStep` for a duplicate call. Never * reaches the model — it is replaced in `finalizeResult` by awaiting the @@ -107,10 +151,14 @@ const DEDUP_PLACEHOLDER_RESULT: ExecutableToolResult = { output: '' }; * reminder once the streak hits 3. The reminder escalates as the streak * grows: r1 (gentle nudge) from streak 3, r2 (concrete repeat report) from * streak 5, r3 (dead-end stop instruction) from streak 8. From streak 12 - * onward the turn is force-stopped via `{ stopTurn: true }` so the loop - * cannot keep spinning on the same call. Force-stop does not flip a - * successful tool result into an error — the underlying tool's `isError` - * is preserved. + * onward the turn is force-stopped via `{ stopTurn: true }`. + * + * For *wrong-tool* errors (e.g. ReadMediaFile on a text file), escalation + * is faster: r2 from streak 2 and force-stop from streak 4, because + * retrying the wrong tool never helps. + * + * Force-stop does not flip a successful tool result into an error — the + * underlying tool's `isError` is preserved. * * Telemetry: every finalized original call with streak >= 2 emits a * `tool_call_repeat` event carrying the current streak count as `repeat_count` @@ -231,8 +279,25 @@ export class ToolCallDeduplicator { } let finalResult = result; - let action: 'none' | 'r1' | 'r2' | 'r3' | 'stop' = 'none'; - if (streak >= REPEAT_FORCE_STOP_STREAK) { + let action: 'none' | 'r1' | 'r2' | 'r2_early' | 'r3' | 'stop' = 'none'; + + // Wrong-tool errors (category errors that retries will never fix) + // escalate faster than generic transient failures. + // Only trigger on actual tool errors (isError === true) — successful + // output that coincidentally contains one of these phrases must not be + // fast-tracked. + if (result.isError === true && isWrongToolError(result.output)) { + if (streak >= WRONG_TOOL_FORCE_STOP_STREAK) { + finalResult = forceStopResult(result, REMINDER_TEXT_3); + action = 'stop'; + } else if (streak >= WRONG_TOOL_REMINDER_2_START) { + finalResult = appendReminder( + result, + makeReminderText2(toolName, streak, args), + ); + action = 'r2_early'; + } + } else if (streak >= REPEAT_FORCE_STOP_STREAK) { finalResult = forceStopResult(result, REMINDER_TEXT_3); action = 'stop'; } else if (streak >= REPEAT_REMINDER_3_START) { @@ -267,4 +332,8 @@ export const __testing = { REPEAT_REMINDER_2_START, REPEAT_REMINDER_3_START, REPEAT_FORCE_STOP_STREAK, + WRONG_TOOL_REMINDER_2_START, + WRONG_TOOL_FORCE_STOP_STREAK, + toolOutputText, + isWrongToolError, }; diff --git a/packages/agent-core/src/tools/builtin/file/read-media.md b/packages/agent-core/src/tools/builtin/file/read-media.md index 0ff49fb6c..3495979a8 100644 --- a/packages/agent-core/src/tools/builtin/file/read-media.md +++ b/packages/agent-core/src/tools/builtin/file/read-media.md @@ -1,3 +1,5 @@ +**CRITICAL: This tool ONLY reads image files (PNG, JPG, GIF, WebP, etc.) and video files (MP4, MOV, AVI, etc.). It CANNOT read text files — never pass a .md, .ts, .json, .txt, or any other text file path to this tool. For text files, use the Read tool. To list directories, use `ls` via Bash for a known directory, or Glob for pattern search.** + Read media content from a file. **Tips:** @@ -5,9 +7,8 @@ Read media content from a file. - A `` tag is given before the file content; it summarizes the mime type, byte size and, for images, the original pixel dimensions. When outputting coordinates, give relative coordinates first and compute absolute coordinates from the original image size. After generating or editing media via commands or scripts, read the result back before continuing. - The system will notify you when there is anything wrong when reading the file. - This tool is a tool that you typically want to use in parallel. Always read multiple files in one response when possible. -- This tool can only read image or video files. To read text files, use the Read tool. To list directories, use `ls` via Bash for a known directory, or Glob for pattern search. - If the file doesn't exist or path is invalid, an error will be returned. - The maximum size that can be read is {{ MAX_MEDIA_MEGABYTES }}MB. An error will be returned if the file is larger than this limit. - The media content will be returned in a form that you can directly view and understand. -**Capabilities** \ No newline at end of file +**Capabilities** diff --git a/packages/agent-core/src/tools/builtin/file/read-media.ts b/packages/agent-core/src/tools/builtin/file/read-media.ts index f21886974..06a49a03c 100644 --- a/packages/agent-core/src/tools/builtin/file/read-media.ts +++ b/packages/agent-core/src/tools/builtin/file/read-media.ts @@ -50,9 +50,10 @@ export const ReadMediaFileInputSchema = z.object({ path: z .string() .describe( - 'Path to an image or video file. Relative paths resolve against the working directory; ' + + 'Path to an IMAGE or VIDEO file (e.g., .png, .jpg, .mp4). ' + + 'Relative paths resolve against the working directory; ' + 'a path outside the working directory must be absolute. ' + - 'Directories and text files are not supported.', + 'Text files (e.g., .md, .ts, .txt, .json) are REJECTED — use Read instead.', ), }); @@ -179,15 +180,20 @@ export class ReadMediaFileTool implements BuiltinTool { if (fileType.kind === 'text') { return { isError: true, - output: `"${args.path}" is a text file. Use Read to read text files.`, + output: + `"${args.path}" is a TEXT FILE, not an image or video. ` + + `ReadMediaFile CANNOT read text files. ` + + `You MUST use the Read tool to read this file. ` + + `Do NOT call ReadMediaFile for this file again.`, }; } if (fileType.kind === 'unknown') { return { isError: true, output: - `"${args.path}" is not a supported image or video file. ` + - 'Use Read for text files, or Bash or an MCP tool for other binary formats.', + `"${args.path}" is NOT a supported image or video file. ` + + `ReadMediaFile ONLY works with images and videos. ` + + 'Use the Read tool for text files, or Bash / an MCP tool for other binary formats.', }; } diff --git a/packages/agent-core/test/agent/turn/tool-dedup.test.ts b/packages/agent-core/test/agent/turn/tool-dedup.test.ts index cff03b9bb..1f66775ea 100644 --- a/packages/agent-core/test/agent/turn/tool-dedup.test.ts +++ b/packages/agent-core/test/agent/turn/tool-dedup.test.ts @@ -561,4 +561,373 @@ describe('ToolCallDeduplicator', () => { expect(true).toBe(true); }); }); + + describe('wrong-tool error detection', () => { + const { toolOutputText, isWrongToolError } = __testing; + + it('detects ReadMediaFile-on-text-file as wrong-tool error', () => { + const output = errResult( + '"/x.md" is a TEXT FILE, not an image or video. ' + + 'ReadMediaFile CANNOT read text files. ' + + 'You MUST use the Read tool to read this file.', + ); + expect(isWrongToolError(output.output)).toBe(true); + }); + + it('detects the old text-file error message as wrong-tool error', () => { + const output = '"/x.md" is a text file. Use Read to read text files.'; + expect(isWrongToolError(output)).toBe(true); + }); + + it('detects unsupported file error as wrong-tool error', () => { + const output = errResult( + '"/x.bin" is NOT a supported image or video file. ' + + 'ReadMediaFile ONLY works with images and videos.', + ); + expect(isWrongToolError(output.output)).toBe(true); + }); + + it('does not classify a random error as wrong-tool error', () => { + expect(isWrongToolError('permission denied')).toBe(false); + expect(isWrongToolError('file not found: /x.png')).toBe(false); + expect(isWrongToolError('network timeout')).toBe(false); + }); + + // Regression test for Codex review: isWrongToolError only does text matching, + // it does NOT check `isError`. The guard that prevents fast-tracking successful + // results lives in finalizeResult (result.isError === true && isWrongToolError(...)). + // This test documents that isWrongToolError is a pure text matcher. + it('isWrongToolError is a pure text matcher (isError is not its concern)', () => { + const successOutput = [ + { type: 'text', text: 'Result: the file is a text file, use Read tool.' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,...' } }, + ]; + // isWrongToolError sees only text, so it returns true — the actual safeguard + // is the `result.isError === true` guard in finalizeResult. + expect(isWrongToolError(successOutput)).toBe(true); + expect(isWrongToolError('must use the Read tool for this')).toBe(true); + }); + + it('toolOutputText handles string output', () => { + expect(toolOutputText('hello')).toBe('hello'); + }); + + it('toolOutputText collapses ContentPart[]', () => { + const output: ExecutableToolResult['output'] = [ + { type: 'text', text: 'hello ' }, + { type: 'text', text: 'world' }, + ]; + expect(toolOutputText(output)).toBe('hello world'); + }); + + it('toolOutputText skips non-text parts', () => { + const output: ExecutableToolResult['output'] = [ + { type: 'text', text: 'msg: ' }, + { type: 'image_url', imageUrl: { url: 'data:foo' } }, + { type: 'text', text: 'see above' }, + ]; + expect(toolOutputText(output)).toBe('msg: see above'); + }); + }); + + describe('wrong-tool error escalation', () => { + const { WRONG_TOOL_REMINDER_2_START, WRONG_TOOL_FORCE_STOP_STREAK } = __testing; + + async function runWrongToolStreak( + dedup: ToolCallDeduplicator, + count: number, + ): Promise { + let last: ExecutableToolResult | undefined; + for (let i = 0; i < count; i += 1) { + dedup.beginStep(); + last = await runOriginal( + dedup, + `c${String(i)}`, + 'ReadMediaFile', + { path: '/x.md' }, + errResult( + '"/x.md" is a TEXT FILE, not an image or video. ' + + 'ReadMediaFile CANNOT read text files.', + ), + ); + dedup.endStep(); + } + return last!; + } + + it('triggers r2 at streak 2 for wrong-tool errors instead of waiting for streak 5', async () => { + const dedup = new ToolCallDeduplicator(); + + // streak 1 — no reminder + dedup.beginStep(); + const r1 = await runOriginal( + dedup, + 'c0', + 'ReadMediaFile', + { path: '/x.md' }, + errResult( + '"/x.md" is a TEXT FILE, not an image or video. ' + + 'ReadMediaFile CANNOT read text files.', + ), + ); + dedup.endStep(); + expect(r1.output as string).not.toContain(''); + + // streak 2 — should already get r2 reminder (not wait for streak 3 or 5) + dedup.beginStep(); + const r2 = await runOriginal( + dedup, + 'c1', + 'ReadMediaFile', + { path: '/x.md' }, + errResult( + '"/x.md" is a TEXT FILE, not an image or video. ' + + 'ReadMediaFile CANNOT read text files.', + ), + ); + dedup.endStep(); + expect(r2.output as string).toContain(''); + expect(r2.output as string).toContain('repeated_times: 2'); + expect(r2.output as string).toContain('tool: ReadMediaFile'); + }); + + it('continues r2 at streak 3 and force-stops at streak 4 for wrong-tool errors', async () => { + const dedup = new ToolCallDeduplicator(); + // streak 3 — still gets r2 (wrong-tool path only has r2→stop, no r3 tier) + const streak3 = await runWrongToolStreak(dedup, 3); + expect(streak3.output as string).toContain(''); + expect(streak3.output as string).toContain('repeated_times: 3'); + expect(streak3.stopTurn).toBeUndefined(); + + // streak 4 — force-stop with dead-end text + dedup.beginStep(); + const streak4 = await runOriginal( + dedup, + 'c3', + 'ReadMediaFile', + { path: '/x.md' }, + errResult( + '"/x.md" is a TEXT FILE, not an image or video. ' + + 'ReadMediaFile CANNOT read text files.', + ), + ); + dedup.endStep(); + expect(streak4.output as string).toContain('stuck in a dead end'); + expect(streak4.stopTurn).toBe(true); + }); + + it('does NOT fast-track generic errors (they still follow the original thresholds)', async () => { + const dedup = new ToolCallDeduplicator(); + let last: ExecutableToolResult | undefined; + // A generic error like "permission denied" — not a wrong-tool error + for (let i = 0; i < 3; i += 1) { + dedup.beginStep(); + last = await runOriginal( + dedup, + `c${String(i)}`, + 'Bash', + { cmd: 'rm x' }, + errResult('permission denied'), + ); + dedup.endStep(); + } + // At streak 3, generic errors get r1 (gentle nudge), not skipped + expect(last!.output as string).toContain(''); + expect(last!.output as string).toContain('repeating the exact same tool call'); + expect(last!.output as string).not.toContain('repeated_times'); + expect(last!.stopTurn).toBeUndefined(); + }); + + // Regression test for Codex review: successful output that coincidentally + // contains wrong-tool phrases must follow the generic escalation path, + // NOT the fast wrong-tool path (r2 at streak 2, stop at streak 4). + // The guard `result.isError === true` in finalizeResult handles this. + it('does NOT fast-escalate successful results with wrong-tool-like text', async () => { + const dedup = new ToolCallDeduplicator(); + const { toolOutputText } = __testing; + // A successful result (isError !== true) that contains one of the + // wrong-tool error phrases — this must NOT trigger the wrong-tool fast path. + const successfulButContainingPhrase: ExecutableToolResult = { + output: [ + { type: 'text', text: 'The file is a text file. Use the Read tool.' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,...' } }, + ], + }; + + // Build streak to 3 (generic path: r1 at streak 3). + // Need to use checkSameStep first so finalizeResult can find the call key. + for (let i = 0; i < 3; i += 1) { + dedup.beginStep(); + const cached = dedup.checkSameStep(`c${String(i)}`, 'SomeTool', { path: '/x' }); + expect(cached).toBeNull(); // first occurrence, not a dup + await dedup.finalizeResult( + `c${String(i)}`, + 'SomeTool', + { path: '/x' }, + successfulButContainingPhrase, + ); + dedup.endStep(); + } + + // After 3 successful repeats, should get r1 gentle nudge, NOT r2. + // Wrong-tool path would give r2 at streak 2 (and stop at streak 4). + dedup.beginStep(); + const cached = dedup.checkSameStep('c3', 'SomeTool', { path: '/x' }); + expect(cached).toBeNull(); + const final = await dedup.finalizeResult( + 'c3', + 'SomeTool', + { path: '/x' }, + successfulButContainingPhrase, + ); + const outputText = toolOutputText(final.output); + // Generic path: r1 has "repeating the exact same tool call", r2+ has "repeated_times" + expect(outputText).toContain(''); + expect(outputText).toContain('repeating the exact same tool call'); + expect(outputText).not.toContain('repeated_times'); + expect(final.stopTurn).toBeUndefined(); + }); + }); + + /** + * End-to-end regression test for issue #1218: + * Agent repeatedly calls ReadMediaFile on a text file. + * + * ── A (BEFORE fix) ── + * Without wrong-tool error classification, escalation follows the generic + * path: streak 3 → R1 gentle nudge, streak 5 → R2, streak 8 → R3, + * streak 12 → force-stop. The model wastes up to 11 steps before stopping. + * + * ── B (AFTER fix) ── + * Wrong-tool errors (ReadMediaFile on text) are detected and escalate + * faster: streak 2 → R2 concrete reminder, streak 4 → force-stop. + * + * This test verifies the full escalation chain for the AFTER scenario. + */ + describe('issue #1218 — ReadMediaFile on text file end-to-end', () => { + const READ_MEDIA_TEXT_ERROR = errResult( + '"/workspace/AGENTS.md" is a TEXT FILE, not an image or video. ' + + 'ReadMediaFile CANNOT read text files. ' + + 'You MUST use the Read tool to read this file. ' + + 'Do NOT call ReadMediaFile for this file again.', + ); + const READ_ARGS = { path: '/workspace/AGENTS.md' }; + + async function simulateStep( + dedup: ToolCallDeduplicator, + callId: string, + ): Promise { + dedup.beginStep(); + + // The agent calls ReadMediaFile on a text file — dedup intercepts + const cached = dedup.checkSameStep(callId, 'ReadMediaFile', READ_ARGS); + let result: ExecutableToolResult; + if (cached !== null) { + // Cross-step dedup: replay cached result, no re-execution + // But the model doesn't know — it sees the same error again + result = cached; + } else { + // First call: tool actually executes, returns error + result = READ_MEDIA_TEXT_ERROR; + } + const final = await dedup.finalizeResult( + callId, + 'ReadMediaFile', + READ_ARGS, + result, + ); + dedup.endStep(); + return final; + } + + it('step 1: first call — tool executes, error returned, NO reminder', async () => { + const dedup = new ToolCallDeduplicator(); + const r = await simulateStep(dedup, 'c0'); + + // Tool executed (first time), returned the error + expect(r.output as string).toContain('TEXT FILE'); + expect(r.output as string).toContain('ReadMediaFile CANNOT read text files'); + // No reminder on first call + expect(r.output as string).not.toContain(''); + expect(r.stopTurn).toBeUndefined(); + }); + + it('step 2: agent tries again — R2 reminder fires immediately (was: silent)', async () => { + const dedup = new ToolCallDeduplicator(); + + await simulateStep(dedup, 'c0'); // step 1 + const r = await simulateStep(dedup, 'c1'); // step 2 + + // ✅ FIX: R2 fires at streak 2 (old behavior: nothing until streak 3) + expect(r.output as string).toContain(''); + expect(r.output as string).toContain('repeated_times: 2'); + expect(r.output as string).toContain('tool: ReadMediaFile'); + // Not force-stopped yet + expect(r.stopTurn).toBeUndefined(); + }); + + it('step 3: agent keeps trying — R2 reminder persists', async () => { + const dedup = new ToolCallDeduplicator(); + + await simulateStep(dedup, 'c0'); + await simulateStep(dedup, 'c1'); + const r = await simulateStep(dedup, 'c2'); + + // Still getting R2 + expect(r.output as string).toContain(''); + expect(r.output as string).toContain('repeated_times: 3'); + expect(r.stopTurn).toBeUndefined(); + }); + + it('step 4: force-stop — turn terminates (was: gentle nudge until streak 12)', async () => { + const dedup = new ToolCallDeduplicator(); + + await simulateStep(dedup, 'c0'); + await simulateStep(dedup, 'c1'); + await simulateStep(dedup, 'c2'); + const r = await simulateStep(dedup, 'c3'); + + // ✅ FIX: force-stop at streak 4 (old behavior: streak 12) + expect(r.output as string).toContain('stuck in a dead end'); + expect(r.stopTurn).toBe(true); + // Underlying error flag preserved + expect(r.isError).toBe(true); + }); + + it('CONTRAST: generic errors (not wrong-tool) still use original thresholds', async () => { + const dedup = new ToolCallDeduplicator(); + const genericError = errResult('permission denied'); + + let last: ExecutableToolResult | undefined; + + // Build streak to 3 — generic errors should still be silent + for (let i = 0; i < 3; i++) { + dedup.beginStep(); + const cached = dedup.checkSameStep(`g${i}`, 'Bash', { cmd: 'rm x' }); + const result = cached ?? genericError; + last = await dedup.finalizeResult(`g${i}`, 'Bash', { cmd: 'rm x' }, result); + dedup.endStep(); + } + // Streak 3: generic gets R1 (gentle nudge), not R2 + expect(last!.output as string).toContain('repeating the exact same tool call'); + expect(last!.output as string).not.toContain('repeated_times'); + expect(last!.stopTurn).toBeUndefined(); + + // Continue to streak 5 — generic gets R2 only now + for (let i = 0; i < 2; i++) { + dedup.beginStep(); + const cached = dedup.checkSameStep(`h${i}`, 'Bash', { cmd: 'rm x' }); + const result = cached ?? genericError; + last = await dedup.finalizeResult(`h${i}`, 'Bash', { cmd: 'rm x' }, result); + dedup.endStep(); + } + expect(last!.output as string).toContain('repeated_times'); + const times = parseInt( + (last!.output as string).match(/repeated_times:\s*(\d+)/)?.[1] ?? '0', + 10, + ); + expect(times).toBeGreaterThanOrEqual(5); + expect(last!.stopTurn).toBeUndefined(); + }); + }); }); diff --git a/packages/agent-core/test/loop/tool-call.e2e.test.ts b/packages/agent-core/test/loop/tool-call.e2e.test.ts index 654fabe26..838ef302d 100644 --- a/packages/agent-core/test/loop/tool-call.e2e.test.ts +++ b/packages/agent-core/test/loop/tool-call.e2e.test.ts @@ -826,3 +826,10 @@ class StopSuccessTool implements ExecutableTool> { }; } } + +/** + * Cross-step dedup is exercised through the Agent layer (agent.turn.prompt), + * which wires ToolCallDeduplicator into the loop hooks. The unit tests in + * tool-dedup.test.ts cover the deduplicator directly. These e2e tests + * cover runTurn's pairing/event invariants, not cross-step dedup behaviour. + */ diff --git a/packages/agent-core/test/tools/read-media.test.ts b/packages/agent-core/test/tools/read-media.test.ts index 59c53fd9f..c758bb2f2 100644 --- a/packages/agent-core/test/tools/read-media.test.ts +++ b/packages/agent-core/test/tools/read-media.test.ts @@ -391,9 +391,9 @@ describe('ReadMediaFileTool', () => { }); expect(result.isError).toBe(true); - expect(result.output).toBe( - '"/workspace/sample.txt" is a text file. Use Read to read text files.', - ); + expect(result.output).toContain('is a TEXT FILE, not an image or video'); + expect(result.output).toContain('ReadMediaFile CANNOT read text files'); + expect(result.output).toContain('You MUST use the Read tool'); expect(result.output).not.toContain('ReadFile'); }); @@ -412,9 +412,8 @@ describe('ReadMediaFileTool', () => { }); expect(result.isError).toBe(true); - expect(result.output).toBe( - '"/workspace/blob.bin" is not a supported image or video file. Use Read for text files, or Bash or an MCP tool for other binary formats.', - ); + expect(result.output).toContain('is NOT a supported image or video file'); + expect(result.output).toContain('ReadMediaFile ONLY works with images and videos'); expect(result.output).not.toContain('Python tools'); }); @@ -533,6 +532,20 @@ describe('ReadMediaFileTool', () => { expect(tool.description).toContain('supports image and video files for the current model'); }); + it('surfaces the Read-tool redirect in the first paragraph of the description', () => { + // Issue #1218: agents repeatedly call ReadMediaFile on text files (e.g. + // AGENTS.md) instead of Read. The redirect to the Read tool must lead the + // description so the model sees it before the tips list, not buried as + // a tail bullet. + const tool = new ReadMediaFileTool(createFakeKaos(), PERMISSIVE_WORKSPACE, capabilities()); + const description = tool.description; + const firstParagraphEnd = description.indexOf('\n\n'); + const head = + firstParagraphEnd === -1 ? description : description.slice(0, firstParagraphEnd); + expect(head).toMatch(/use the Read tool/i); + expect(head).toMatch(/text file/i); + }); + it('omits the tool from the toolset when the model has neither image_in nor video_in', () => { // Strict skip semantics: construction returns a sentinel the loader can // use to drop the tool entirely, instead of registering a tool that @@ -647,8 +660,21 @@ describe('ReadMediaFileTool', () => { }); 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.', - ); + expect(result.output).toContain('is NOT a supported image or video file'); + expect(result.output).toContain('ReadMediaFile ONLY works with images and videos'); + }); + + it('surfaces the Read-tool redirect in the first paragraph of the description', () => { + // Issue #1218: agents repeatedly call ReadMediaFile on text files (e.g. + // AGENTS.md) instead of Read. The redirect to the Read tool must lead the + // description so the model sees it before the tips list, not buried as + // a tail bullet. + const tool = new ReadMediaFileTool(createFakeKaos(), PERMISSIVE_WORKSPACE, capabilities()); + const description = tool.description; + const firstParagraphEnd = description.indexOf('\n\n'); + const head = + firstParagraphEnd === -1 ? description : description.slice(0, firstParagraphEnd); + expect(head).toMatch(/use the Read tool/i); + expect(head).toMatch(/text file/i); }); });