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<Any> 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
This commit is contained in:
Roo Code 2026-04-30 11:16:03 +00:00
parent ad25634905
commit c6bfa43e6b
5 changed files with 90 additions and 8 deletions

View file

@ -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[] = [
{

View file

@ -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<Any> 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,
),
},
}))

View file

@ -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<Any> 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,

View file

@ -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

View file

@ -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",