From c6bfa43e6b986637b0ba11aa733ee04fc52f8d2e Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 30 Apr 2026 11:16:03 +0000 Subject: [PATCH] fix: strip null values from tool call args to prevent Jinja template errors Local models using Jinja chat templates (e.g. llama.cpp, Ollama) cannot handle null values in tool call arguments, causing "Cannot convert value of type Optional to Jinja Value" errors when selecting follow-up answers. Changes: - Remove strict mode from ask_followup_question tool definition and make mode optional (not required), matching the read_command_output pattern - Update examples to omit mode instead of using null - Strip null mode values when building follow_up JSON in the tool - Strip null values from all tool call arguments during OpenAI message serialization as a general safety net - Add tests for null stripping behavior Addresses #12233 --- .../transform/__tests__/openai-format.spec.ts | 33 +++++++++++++++++++ src/api/transform/openai-format.ts | 9 +++-- .../native-tools/ask_followup_question.ts | 13 +++++--- src/core/tools/AskFollowupQuestionTool.ts | 12 +++++-- .../__tests__/askFollowupQuestionTool.spec.ts | 31 +++++++++++++++++ 5 files changed, 90 insertions(+), 8 deletions(-) diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts index 1a4c7f6518d..c27b4457f76 100644 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ b/src/api/transform/__tests__/openai-format.spec.ts @@ -112,6 +112,39 @@ describe("convertToOpenAiMessages", () => { }) }) + it("should strip null values from tool call arguments to prevent Jinja template errors", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "followup-123", + name: "ask_followup_question", + input: { + question: "Pick one", + follow_up: [ + { text: "Option A", mode: null }, + { text: "Option B", mode: "code" }, + ], + }, + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages) + const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam + const toolCall = assistantMessage.tool_calls![0] as any + const args = JSON.parse(toolCall.function.arguments) + + // null mode should be stripped (becomes undefined, omitted from JSON) + expect(args.follow_up[0]).toEqual({ text: "Option A" }) + expect(args.follow_up[0].mode).toBeUndefined() + // non-null mode should be preserved + expect(args.follow_up[1]).toEqual({ text: "Option B", mode: "code" }) + }) + it("should handle user messages with tool results (no normalization without normalizeToolCallId)", () => { const anthropicMessages: Anthropic.Messages.MessageParam[] = [ { diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index 8974dd599ba..150c57cc8a5 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -467,8 +467,13 @@ export function convertToOpenAiMessages( type: "function", function: { name: toolMessage.name, - // json string - arguments: JSON.stringify(toolMessage.input), + // Serialize as JSON, stripping null values to prevent Jinja template + // errors on local models (e.g. "Cannot convert value of type + // Optional to Jinja Value"). Null in tool args typically means + // "not provided" and should be omitted instead. + arguments: JSON.stringify(toolMessage.input, (_key, value) => + value === null ? undefined : value, + ), }, })) diff --git a/src/core/prompts/tools/native-tools/ask_followup_question.ts b/src/core/prompts/tools/native-tools/ask_followup_question.ts index b0591206ade..4532a97d6aa 100644 --- a/src/core/prompts/tools/native-tools/ask_followup_question.ts +++ b/src/core/prompts/tools/native-tools/ask_followup_question.ts @@ -7,7 +7,7 @@ Parameters: - follow_up: (required) A list of 2-4 suggested answers. Suggestions must be complete, actionable answers without placeholders. Optionally include mode to switch modes (code/architect/etc.) Example: Asking for file path -{ "question": "What is the path to the frontend-config.json file?", "follow_up": [{ "text": "./src/frontend-config.json", "mode": null }, { "text": "./config/frontend-config.json", "mode": null }, { "text": "./frontend-config.json", "mode": null }] } +{ "question": "What is the path to the frontend-config.json file?", "follow_up": [{ "text": "./src/frontend-config.json" }, { "text": "./config/frontend-config.json" }, { "text": "./frontend-config.json" }] } Example: Asking with mode switch { "question": "Would you like me to implement this feature?", "follow_up": [{ "text": "Yes, implement it now", "mode": "code" }, { "text": "No, just plan it out", "mode": "architect" }] }` @@ -25,7 +25,12 @@ export default { function: { name: "ask_followup_question", description: ASK_FOLLOWUP_QUESTION_DESCRIPTION, - strict: true, + // Note: strict mode is intentionally disabled for this tool. + // With strict: true, OpenAI requires ALL properties to be in the 'required' array, + // which forces the LLM to always provide explicit values (even null) for optional params. + // Local models using Jinja chat templates cannot handle null values in tool call arguments, + // causing "Cannot convert value of type Optional to Jinja Value" errors. + // By disabling strict mode, the LLM can omit the optional `mode` parameter entirely. parameters: { type: "object", properties: { @@ -44,11 +49,11 @@ export default { description: FOLLOW_UP_TEXT_DESCRIPTION, }, mode: { - type: ["string", "null"], + type: "string", description: FOLLOW_UP_MODE_DESCRIPTION, }, }, - required: ["text", "mode"], + required: ["text"], additionalProperties: false, }, minItems: 1, diff --git a/src/core/tools/AskFollowupQuestionTool.ts b/src/core/tools/AskFollowupQuestionTool.ts index 22cdbcf5de2..66053d6aa8f 100644 --- a/src/core/tools/AskFollowupQuestionTool.ts +++ b/src/core/tools/AskFollowupQuestionTool.ts @@ -39,10 +39,18 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> { return } - // Transform follow_up suggestions to the format expected by task.ask + // Transform follow_up suggestions to the format expected by task.ask. + // Omit `mode` when it's null/undefined to avoid Jinja template errors + // on local models that can't handle null values in tool call arguments. const follow_up_json = { question, - suggest: follow_up.map((s) => ({ answer: s.text, mode: s.mode })), + suggest: follow_up.map((s) => { + const suggestion: { answer: string; mode?: string } = { answer: s.text } + if (s.mode != null) { + suggestion.mode = s.mode + } + return suggestion + }), } task.consecutiveMistakeCount = 0 diff --git a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts index 63bfad8a3d0..6c6391bbea8 100644 --- a/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts +++ b/src/core/tools/__tests__/askFollowupQuestionTool.spec.ts @@ -82,6 +82,37 @@ describe("askFollowupQuestionTool", () => { ) }) + it("should strip null mode values from suggestions to prevent Jinja template errors", async () => { + const block: ToolUse = { + type: "tool_use", + name: "ask_followup_question", + params: { + question: "What would you like to do?", + }, + nativeArgs: { + question: "What would you like to do?", + follow_up: [ + { text: "Option A", mode: null as any }, + { text: "Option B", mode: "code" }, + ], + }, + partial: false, + } + + await askFollowupQuestionTool.handle(mockCline, block as ToolUse<"ask_followup_question">, { + askApproval: vi.fn(), + handleError: vi.fn(), + pushToolResult: mockPushToolResult, + }) + + // mode: null should be stripped, mode: "code" should be preserved + expect(mockCline.ask).toHaveBeenCalledWith( + "followup", + expect.stringContaining('"suggest":[{"answer":"Option A"},{"answer":"Option B","mode":"code"}]'), + false, + ) + }) + it("should handle mixed suggestions with and without mode attributes", async () => { const block: ToolUse = { type: "tool_use",