Skip to content

Recover tool calls emitted as XML text (ROB-558) - #2349

Open
naomi-robusta wants to merge 1 commit into
masterfrom
claude/alert-triage-xml-tags-ck948r
Open

Recover tool calls emitted as XML text (ROB-558)#2349
naomi-robusta wants to merge 1 commit into
masterfrom
claude/alert-triage-xml-tags-ck948r

Conversation

@naomi-robusta

@naomi-robusta naomi-robusta commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes ROB-558 by recovering tool calls that models (particularly Claude) emit as literal XML text instead of structured tool_calls fields. This happens when LiteLLM's prompt-based tool-calling fallback is used for models it doesn't recognize as function-calling-capable. LiteLLM's strict XML parser breaks on unescaped markdown/code in parameter values, causing the raw XML to leak into message content and preventing tool execution.

Key Changes

  • New module holmes/core/tool_call_recovery.py: Implements lenient regex-based parsing to recover tool calls from XML text

    • Handles both modern Anthropic dialect (name="..." attribute) and older <tool_name> tag syntax
    • Tolerates malformed XML (mismatched closing tags, unescaped <, >, & in values)
    • Only recovers calls for tools actually offered to the model (prevents false positives on prose mentioning <invoke>)
    • Strips recovered XML from message content and returns cleaned text
  • Integration in holmes/core/llm.py:

    • Added _offered_tool_names() helper to extract tool names from the tools parameter
    • Added _recover_text_tool_calls() function that mutates the LLM response to recover XML tool calls
    • Integrated recovery into DefaultLLM.completion() after receiving the response from LiteLLM
    • Only attempts recovery when message has no structured tool_calls and content contains tool-call XML
  • Comprehensive test suite tests/core/test_tool_call_recovery.py:

    • Tests modern Anthropic XML format with proper closing tags
    • Tests real ROB-558 payload with mismatched closing tags
    • Tests values containing unescaped XML characters and markdown
    • Tests preservation of leading prose
    • Tests that recovery only happens for offered tools
    • Tests multiple invocations and older <tool_name> dialect
    • Integration tests verifying end-to-end recovery in DefaultLLM.completion()

Implementation Details

  • Uses regex-based parsing instead of strict XML parsing to handle malformed input
  • Recovers tool name from either name="..." attribute or nested <tool_name> tag
  • Extracts parameters by finding opening <parameter name="..."> markers and slicing to the next marker
  • Strips trailing close tags (which may be </parameter>, </key>, or other variants)
  • Attempts JSON parsing of parameter values (matching LiteLLM's behavior) but falls back to raw strings
  • Generates unique IDs for recovered tool calls using UUID
  • Logs a warning when recovery occurs to aid debugging
  • Cleans up leftover XML scaffolding from the message content

https://claude.ai/code/session_019TBGvpjmk6npFcvivReBp1

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of non-streaming responses where tool calls are returned as XML-formatted text.
    • Recognizes supported modern and legacy tool-call formats and converts them into usable tool calls.
    • Preserves normal response text while removing successfully recovered tool-call markup.
    • Avoids altering responses that already contain structured tool calls or unmatched content.

Alert-triage relies on the model calling relay's update_ai_triage_metadata
tool via a structured tool_calls response. On some model routes (Holmes
litellm -> relay litellm -> provider) the model instead narrates the call in
the Anthropic tool-use XML dialect it was trained on:

    <function_calls>
    <invoke name="update_ai_triage_metadata">
    <parameter name="team">...</parameter>
    ...

inside message.content, with no structured tool_calls. litellm's own XML->
tool_calls parser uses strict ET.fromstring and breaks whenever a parameter
value contains markdown / unescaped <, >, & or code fences, so the raw XML
leaks into the content field. Holmes then treats it as the final answer: the
tool never runs (team stays "pending AI investigation") and the raw tags show
up in the user-facing triage result.

Fix: in DefaultLLM.completion() — the single, tool-list-aware choke point that
the agentic loop always calls with stream=False — when a response has no
structured tool_calls but its content holds tool-call XML for a tool that was
actually offered, leniently recover the structured tool call(s) with a
regex-based parser (never ET.fromstring, tolerant of mismatched/absent closing
tags and special chars in values) and strip the XML from the content. Gated on
the recovered tool name matching an offered tool, so ordinary prose that merely
mentions <invoke> is never disturbed. Provider-agnostic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019TBGvpjmk6npFcvivReBp1
Signed-off-by: Claude <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The change adds XML tool-call recovery for non-streaming LLM responses. It parses supported invocation formats, validates tool names against offered tools, creates structured calls, cleans response text, and preserves existing structured calls or ordinary answers.

Changes

XML tool-call recovery

Layer / File(s) Summary
XML invocation parser
holmes/core/tool_call_recovery.py
The parser supports named and legacy <invoke> formats, lenient parameter parsing, offered-tool filtering, JSON-like values, cleanup, and generated tool-call objects.
Non-streaming response integration
holmes/core/llm.py, holmes/core/tool_call_recovery.py
DefaultLLM applies recovery to eligible non-streaming responses when structured tool calls are absent.
Parser and integration validation
tests/core/test_tool_call_recovery.py
Tests cover supported formats, malformed tags, multiple calls, filtering, metadata, structured-call preservation, and ordinary text preservation.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant LiteLLM
  participant DefaultLLM
  participant recover_tool_calls_from_text
  LiteLLM->>DefaultLLM: non-streaming ModelResponse
  DefaultLLM->>recover_tool_calls_from_text: response content and offered tool names
  recover_tool_calls_from_text-->>DefaultLLM: cleaned content and generated tool calls
  DefaultLLM-->>LiteLLM: updated ModelResponse
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: recovering tool calls emitted as XML text.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@netlify

netlify Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploy Preview for holmes-docs ready!

Name Link
🔨 Latest commit 6df1ab1
🔍 Latest deploy log https://app.netlify.com/projects/holmes-docs/deploys/6a706fe6f5a5c40008aa408b
😎 Deploy Preview https://deploy-preview-2349--holmes-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Docker images ready for f3addd68e (built in 5m 36s)

⚠️ Warning: does not support ARM (ARM images are built on release only - not on every PR)

Use these tags to pull the images for testing.

📋 Copy commands

⚠️ Temporary images are deleted after 30 days. Copy to a permanent registry before using them:

gcloud auth configure-docker us-central1-docker.pkg.dev
docker pull us-central1-docker.pkg.dev/robusta-development/temporary-builds/holmes:f3addd68e
docker tag us-central1-docker.pkg.dev/robusta-development/temporary-builds/holmes:f3addd68e me-west1-docker.pkg.dev/robusta-development/development/holmes-dev:f3addd68e
docker push me-west1-docker.pkg.dev/robusta-development/development/holmes-dev:f3addd68e
docker pull us-central1-docker.pkg.dev/robusta-development/temporary-builds/holmes-operator:f3addd68e
docker tag us-central1-docker.pkg.dev/robusta-development/temporary-builds/holmes-operator:f3addd68e me-west1-docker.pkg.dev/robusta-development/development/holmes-operator-dev:f3addd68e
docker push me-west1-docker.pkg.dev/robusta-development/development/holmes-operator-dev:f3addd68e

Patch Helm values in one line (choose the chart you use):

HolmesGPT chart:

helm upgrade --install holmesgpt ./helm/holmes \
  --set registry=me-west1-docker.pkg.dev/robusta-development/development \
  --set image=holmes-dev:f3addd68e \
  --set operator.registry=me-west1-docker.pkg.dev/robusta-development/development \
  --set operator.image=holmes-operator-dev:f3addd68e

Robusta wrapper chart:

helm upgrade --install robusta robusta/robusta \
  --reuse-values \
  --set holmes.registry=me-west1-docker.pkg.dev/robusta-development/development \
  --set holmes.image=holmes-dev:f3addd68e \
  --set holmes.operator.registry=me-west1-docker.pkg.dev/robusta-development/development \
  --set holmes.operator.image=holmes-operator-dev:f3addd68e

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
holmes/core/tool_call_recovery.py (1)

75-84: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Parenthesize the mixed and/or expression.

Ruff flags Line 79 for and/or chaining without parentheses (RUF021). The current precedence happens to produce the correct result, but the intent is not obvious from the code. Add parentheses to make precedence explicit.

🔧 Proposed fix
 def _maybe_json(value: str) -> Any:
     """Mirror LiteLLM's parse_xml_params: decode a value as JSON when it looks
     like a JSON scalar/array/object, otherwise keep the raw (stripped) string."""
     stripped = value.strip()
-    if stripped and stripped[0] in "[{" or stripped in ("true", "false", "null"):
+    if (stripped and stripped[0] in "[{") or stripped in ("true", "false", "null"):
         try:
             return json.loads(stripped)
         except (ValueError, TypeError):
             return value
     return value

As per coding guidelines, "Use Ruff for formatting and linting (configured in pyproject.toml)", and this is flagged by the Ruff RUF021 static analysis hint.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@holmes/core/tool_call_recovery.py` around lines 75 - 84, Update the condition
in _maybe_json to parenthesize the stripped[0] membership check before combining
it with the true/false/null check, preserving the existing evaluation behavior
while making the and/or precedence explicit.

Sources: Coding guidelines, Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@holmes/core/tool_call_recovery.py`:
- Around line 75-84: Update the condition in _maybe_json to parenthesize the
stripped[0] membership check before combining it with the true/false/null check,
preserving the existing evaluation behavior while making the and/or precedence
explicit.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 19160050-0901-407e-839e-726210bfe938

📥 Commits

Reviewing files that changed from the base of the PR and between 8b478c0 and 6df1ab1.

📒 Files selected for processing (3)
  • holmes/core/llm.py
  • holmes/core/tool_call_recovery.py
  • tests/core/test_tool_call_recovery.py

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.

2 participants