Skip to content

feat: XML tool call fallback parser for non-native tool calling models#11288

Draft
roomote[bot] wants to merge 1 commit intomainfrom
feature/xml-tool-call-fallback-11187
Draft

feat: XML tool call fallback parser for non-native tool calling models#11288
roomote[bot] wants to merge 1 commit intomainfrom
feature/xml-tool-call-fallback-11187

Conversation

@roomote
Copy link
Contributor

@roomote roomote bot commented Feb 7, 2026

Related GitHub Issue

Closes: #11187

Description

This PR attempts to address Issue #11187, where models that do not support native function/tool calling (common with some OpenAI-compatible proxies) fail with "Model Response Incomplete" errors because they output tool calls as XML text instead of structured tool_call events.

Implementation approach:

  1. New fallback parser (XmlToolCallFallbackParser.ts): Scans text responses for XML-formatted tool calls (e.g., <read_file><path>...</path></read_file>) and converts them to ToolUse blocks that the existing tool execution infrastructure can process.

  2. Integration in Task.ts: After the API stream completes, if no native tool_use blocks are found but text content exists, the fallback parser is invoked. If it finds valid XML tool calls, they are injected into assistantMessageContent and presented for execution.

Key design decisions:

  • The fallback only activates when no native tool_use blocks are present, so there is zero impact on providers that support native function calling.
  • Parsed tool calls are marked with usedLegacyFormat: true for telemetry tracking.
  • Supports all recognized tool names (including aliases like write_file -> write_to_file).
  • Only recognizes known tool names and parameter names to avoid false positives with other XML-like content (e.g., <environment_details>, <thinking>).

Test Procedure

  • 23 unit tests added for the XmlToolCallFallbackParser covering:
    • All major tool types (read_file, write_to_file, execute_command, search_files, etc.)
    • Multi-line content in parameters
    • Multiple tool calls in a single response
    • Tool aliases
    • Edge cases (empty input, non-tool XML tags, unknown params)
  • All existing tests continue to pass (NativeToolCallParser, presentAssistantMessage, grace-retry-errors).
  • Run: cd src && npx vitest run core/assistant-message/__tests__/XmlToolCallFallbackParser.spec.ts

Pre-Submission Checklist

  • Issue Linked: This PR is linked to an approved GitHub Issue.
  • Scope: Changes are focused on the linked issue.
  • Self-Review: Self-review completed.
  • Testing: 23 new tests added.
  • Documentation Impact: No documentation updates required.
  • Contribution Guidelines: Read and agreed.

Documentation Updates

  • No documentation updates are required.

Additional Notes

Feedback and guidance are welcome. This is an initial attempt at addressing the issue -- the approach favors minimal footprint (a standalone parser with zero impact on native tool calling paths) over re-introducing a full "Tool Call Protocol" setting.


Important

Introduces XmlToolCallFallbackParser to handle XML-formatted tool calls for non-native models, integrated into Task.ts with comprehensive testing.

  • Behavior:
    • Adds XmlToolCallFallbackParser to parse XML-formatted tool calls into ToolUse blocks.
    • Integrated into Task.ts to activate when no native tool_use blocks are found.
    • Supports known tool names and aliases, marking parsed calls with usedLegacyFormat: true.
  • Testing:
    • 23 unit tests added in XmlToolCallFallbackParser.spec.ts for various tool types, multi-line content, aliases, and edge cases.
  • Misc:
    • Logs XML tool call parsing in Task.ts.
    • Ensures zero impact on native tool calling providers.

This description was created by Ellipsis for d12f787. You can customize this summary. It will automatically update as commits are pushed.

…odels

When a model does not support native function/tool calling (common with
some OpenAI-compatible proxies), it outputs tool calls as XML text
instead of structured tool_call events. The extension previously rejected
these with a "Model Response Incomplete" error.

This commit adds a fallback parser that detects XML-formatted tool calls
in text responses (e.g. <read_file><path>...</path></read_file>) and
converts them to ToolUse blocks, allowing the tools to execute normally.

The fallback only activates when no native tool_use blocks are found in
the assistant message, ensuring zero impact on providers that already
support native function calling.

Closes #11187
@roomote
Copy link
Contributor Author

roomote bot commented Feb 7, 2026

Rooviewer Clock   See task

The XML fallback parser itself is well-implemented and tested, but the integration in Task.ts has three issues that prevent the fallback from working at runtime.

  • containsXmlToolMarkup guard fires before fallback: During streaming, presentAssistantMessage processes text chunks and rejects XML tool markup with an error at line 297 of presentAssistantMessage.ts. This happens before the post-stream fallback code ever runs, so the fallback parser never gets a chance to intercept the XML tool calls.
  • currentStreamingContentIndex not reset: After replacing assistantMessageContent with the parsed tool_use blocks, the stale currentStreamingContentIndex causes presentAssistantMessage to early-return without processing any of the new blocks.
  • presentAssistantMessage called in a loop without await: Due to the internal lock mechanism, only the first call attempts to process; subsequent loop iterations are no-ops. A single call is sufficient since presentAssistantMessage chains through blocks internally.

Mention @roomote in a comment to request specific changes to this pull request or fix all unresolved issues.

Comment on lines +3433 to +3466
// XML tool call fallback: When the model doesn't support native function calling
// (common with some OpenAI-compatible proxies), it may output tool calls as XML
// text. If we have text content but no native tool uses, try to parse XML tool
// calls from the text as a fallback. (See: GitHub issue #11187)
if (hasTextContent && !hasToolUses) {
const fallbackResult = parseXmlToolCalls(assistantMessage)
if (fallbackResult.found) {
console.log(
`[Task#${this.taskId}] XML tool call fallback: parsed ${fallbackResult.toolUses.length} tool(s) from text`,
)

// Replace the text block(s) with the parsed tool uses
// Keep only non-text blocks (if any) from the original content
this.assistantMessageContent = this.assistantMessageContent.filter(
(block) => block.type !== "text",
)

// Add the parsed tool uses
for (const toolUse of fallbackResult.toolUses) {
this.assistantMessageContent.push(toolUse)
}

// Present each tool call so they are processed by presentAssistantMessage
this.userMessageContentReady = false
for (const toolUse of fallbackResult.toolUses) {
presentAssistantMessage(this)
}

// Re-check hasToolUses now that we've added fallback-parsed tools
hasToolUses = this.assistantMessageContent.some(
(block) => block.type === "tool_use" || block.type === "mcp_tool_use",
)
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fallback code runs after didCompleteReadingStream = true, but during streaming, presentAssistantMessage is called for each text chunk (line 3050). Inside presentAssistantMessage, the containsXmlToolMarkup check (line 297 of presentAssistantMessage.ts) will detect XML tool tags in the text content and immediately reject them with an error ("XML tool calls are no longer supported"), incrementing consecutiveMistakeCount and setting didAlreadyUseTool = true. This all fires before the stream completes and before this fallback code ever runs, so the fallback parser will never get a chance to intercept the XML tool calls. The containsXmlToolMarkup guard needs to be disabled or bypassed when the fallback path is intended to handle these cases.

Fix it with Roo Code or mention @roomote and request a fix.

Comment on lines +3444 to +3453
// Replace the text block(s) with the parsed tool uses
// Keep only non-text blocks (if any) from the original content
this.assistantMessageContent = this.assistantMessageContent.filter(
(block) => block.type !== "text",
)

// Add the parsed tool uses
for (const toolUse of fallbackResult.toolUses) {
this.assistantMessageContent.push(toolUse)
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After replacing assistantMessageContent (filtering out text blocks and pushing new tool_use blocks), currentStreamingContentIndex is not reset to 0. This index was already incremented past all original blocks during streaming. When presentAssistantMessage is called at line 3458, it checks currentStreamingContentIndex >= assistantMessageContent.length (line 75 of presentAssistantMessage.ts) and early-returns without processing any of the injected tool_use blocks. A this.currentStreamingContentIndex = 0 is needed before the presentAssistantMessage calls.

Fix it with Roo Code or mention @roomote and request a fix.

Comment on lines +3455 to +3459
// Present each tool call so they are processed by presentAssistantMessage
this.userMessageContentReady = false
for (const toolUse of fallbackResult.toolUses) {
presentAssistantMessage(this)
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

presentAssistantMessage is async and uses a lock (presentAssistantMessageLocked). Calling it in a loop without await means the first call acquires the lock, and all subsequent calls in the same synchronous iteration see the lock held, set pendingUpdates = true, and return immediately. Additionally, presentAssistantMessage already chains through all content blocks internally (it increments currentStreamingContentIndex and calls itself recursively at line 1010). A single call would be sufficient here; the loop is both redundant and broken due to the missing await.

Suggested change
// Present each tool call so they are processed by presentAssistantMessage
this.userMessageContentReady = false
for (const toolUse of fallbackResult.toolUses) {
presentAssistantMessage(this)
}
presentAssistantMessage(this)

Fix it with Roo Code or mention @roomote and request a fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] fail to call tools with XML format

1 participant