From ad99b22254d79a0ce978d09ab23379c5bdce346d Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 10 Jul 2026 17:49:45 +0800 Subject: [PATCH] fix(core): use consistent error response for plan mode blocked tools When a non-read-only tool is called in plan mode, the LLM receives { output: "" } instead of { error: "..." }. The output key looks like success to the LLM, so it does not recognize the tool was denied and may try alternative approaches to bypass the restriction. Use createErrorResponse() so the LLM sees a clear error signal with error key, Error object, and errorType: EXECUTION_DENIED. Preserve differentiated guidance per caller type: - Plan-required teammates: "Call exit_plan_mode to exit plan mode..." - SDK / ordinary subagents: "Present your plan directly to the caller..." Signed-off-by: Alex --- .../core/src/core/coreToolScheduler.test.ts | 30 +++++++++++++---- packages/core/src/core/coreToolScheduler.ts | 32 +++++++++++-------- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 36df456f9ba..0705d878e4d 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -6233,9 +6233,18 @@ describe('CoreToolScheduler plan mode with ask_user_question', () => { expect(completedCalls[0].response.resultDisplay).toBe( 'Plan mode blocked a non-read-only tool call.', ); - expect( - JSON.stringify(completedCalls[0].response.responseParts), - ).toContain('exit_plan_mode tool'); + // Response must use error key (not output) so LLM recognizes it as a failure + const responseParts = completedCalls[0].response.responseParts; + const responseJson = JSON.stringify(responseParts); + expect(responseJson).toContain('"error"'); + expect(responseJson).toContain('Tool blocked by plan mode'); + expect(responseJson).toContain('write_file'); + // Plan-required teammates get exit_plan_mode hint + expect(responseJson).toContain('exit_plan_mode'); + expect(completedCalls[0].response.error).toBeInstanceOf(Error); + expect(completedCalls[0].response.errorType).toBe( + ToolErrorType.EXECUTION_DENIED, + ); } }); @@ -6302,11 +6311,18 @@ describe('CoreToolScheduler plan mode with ask_user_question', () => { .calls[0][0] as ToolCall[]; expect(completedCalls[0].status).toBe('error'); if (completedCalls[0].status === 'error') { - const responseText = JSON.stringify( - completedCalls[0].response.responseParts, + // SDK, subagent, and teammate paths all get the same error format + // but different guidance: SDK/subagents get "Present your plan directly" + const responseParts = completedCalls[0].response.responseParts; + const responseJson = JSON.stringify(responseParts); + expect(responseJson).toContain('"error"'); + expect(responseJson).toContain('Tool blocked by plan mode'); + expect(responseJson).toContain('Present your plan directly'); + expect(responseJson).not.toContain('exit_plan_mode'); + expect(completedCalls[0].response.error).toBeInstanceOf(Error); + expect(completedCalls[0].response.errorType).toBe( + ToolErrorType.EXECUTION_DENIED, ); - expect(responseText).toContain('Present your plan directly'); - expect(responseText).not.toContain('exit_plan_mode tool'); } }, ); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index b4718dc46cc..cfbea8d5fff 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -112,7 +112,6 @@ import { } from '../tools/modifiable-tool.js'; import * as Diff from 'diff'; import levenshtein from 'fast-levenshtein'; -import { getPlanModeSystemReminder } from './prompts.js'; import { ShellToolInvocation } from '../tools/shell.js'; import { IdeClient } from '../ide/ide-client.js'; import { @@ -2458,22 +2457,27 @@ export class CoreToolScheduler { isEnterPlanModeTool, ) ) { + // SDK and ordinary subagent-like callers should return plans + // directly; they do not have exit_plan_mode available. Plan-required + // teammates have a dedicated exit_plan_mode approval path. + const isPlanRequiredTeammate = + !shouldUsePlanOnlyReminderInSubagentContext() && + !this.config.getSdkMode(); + const planModeError = new Error( + `Tool blocked by plan mode: "${reqInfo.name}" is not a read-only tool. ` + + `Only read-only tools (read_file, grep_search, glob, list_directory, ` + + `web_fetch, etc.) are allowed in plan mode.` + + (isPlanRequiredTeammate + ? ` Call exit_plan_mode to exit plan mode and execute this tool.` + : ` Present your plan directly to the caller instead of executing this tool.`), + ); this.setStatusInternal(reqInfo.callId, 'error', { - callId: reqInfo.callId, - responseParts: convertToFunctionResponse( - reqInfo.name, - reqInfo.callId, - // SDK callers and ordinary subagent-like callers should - // return plans directly. Plan-required teammates have a - // dedicated exit_plan_mode approval path instead. - getPlanModeSystemReminder( - shouldUsePlanOnlyReminderInSubagentContext() || - this.config.getSdkMode(), - ), + ...createErrorResponse( + reqInfo, + planModeError, + ToolErrorType.EXECUTION_DENIED, ), resultDisplay: 'Plan mode blocked a non-read-only tool call.', - error: undefined, - errorType: undefined, }); setToolSpanFailure( toolSpan,