Module: Rubino::LLM::ToolCallRecovery

Defined in:
lib/rubino/llm/tool_call_recovery.rb

Overview

Recovers tool calls that a model LEAKED AS TEXT into its assistant content — instead of returning them in the structured tool_calls field —and strips the leaked markup from the visible/saved text.

WHY: some models are trained to emit tool calls as markup (XML/JSON in tags) that a server-side parser is supposed to convert to structured calls. When that conversion fails (e.g. MiniMax’s Anthropic-compatible shim), the raw markup + channel tokens leak into the text: the tool never runs (the model “describes” instead of “does”) and the junk poisons the saved history so the model mimics its own broken format next turn.

This mirrors the vLLM / SGLang per-model tool-call parsers and OpenHands’ fn_call_converter: parse the markup back into arguments and run it. It covers the THREE format-families that account for ~80% of open models:

A) JSON-in-<tool_call>     — Hermes, Qwen2.5/Qwen3
B) XML invoke/parameter    — MiniMax-M2/M3, Qwen3-Coder
C) [TOOL_CALLS] JSON-array — Mistral / Mixtral

Conventions copied from those parsers: peel reasoning <think> FIRST; use a two-branch “closed | unterminated-to-EOF” match so a missing close tag is still recovered.

Defined Under Namespace

Classes: Recovered

Constant Summary collapse

MINIMAX_NS =

MiniMax-M3 prefixes this literal channel/namespace marker on EVERY tag of a leaked tool call (a garbled render of its turn delimiters). Strip it everywhere so the inner <tool_call>/<invoke> structure is parseable, and so it never shows/poisons even when no call is recovered.

"]<]minimax[>["
THINK_BLOCK =

Reasoning blocks some models leak into content. Peeled before extraction (mirrors the upstream reasoning-parser layer) so a tool call mentioned INSIDE reasoning never fires and the scratchpad never shows.

%r{<(think|thinking|reasoning|thought)\b[^>]*>.*?</\1>}im
INVOKE =

Family B — one tool call: <invoke name=“fn”> … </invoke> (closed, or unterminated to EOF). The body holds the parameters.

TOLERANT to MiniMax-M3’s GARBLED leak: M3’s namespace special token ‘]<]minimax[>[` (id 200058) carries the literal chars ] < [ > which collide with XML delimiters, so the gateway routinely mis-segments the tag and drops `name=`, leaving forms like `<invoke“>shell”>` or `invoke name=“shell”>` (documented: llama.cpp #24523, mlx-lm #1145). The canonical vLLM/SGLang parsers hard-require `<invoke name=“` and recover NONE of these. So we eat any garbled punctuation between `invoke` and the first identifier-like token, and capture that token as the tool name —recovering the name from the well-formed AND every garbled variant.

%r{
  <?invoke                       # optional leading < (M3 drops it too)
  [^A-Za-z0-9_]*                 # garbled punctuation: ">, ", stray brackets
  (?:name\s*=\s*)?               # the name= attribute, when it survives
  ["']?\s*([A-Za-z_][\w.-]*)\s*["']?  # the tool name (bareword identifier)
  \s*>                           # close of the opening tag
  (.*?)(?:</invoke>|\z)          # body up to </invoke> or EOF
}imx
PARAM_NAMED =

Family B parameters, two dialects inside an <invoke> body:

<parameter name="key">value</parameter>   (MiniMax-M2)
<key>value</key>                            (bare element = param name)
%r{<parameter\s+name="([^"]+)"\s*>(.*?)(?:</parameter>|\z)}im
PARAM_BARE =
%r{<([a-zA-Z_][\w-]*)\s*>(.*?)</\1>}im
TOOL_CALL_JSON =

Family A — JSON in <tool_call> … </tool_call> (closed | unterminated).

%r{<tool_call>\s*(\{.*?\})\s*(?:</tool_call>|\z)}im
TOOL_CALLS_ARRAY =

Family C — Mistral: [TOOL_CALLS] then a JSON array of calls.

/\[TOOL_CALLS\]\s*(\[.*\])/im
ORPHAN_WRAPPERS =

Bare wrappers left over after the inner calls are extracted, removed so no orphan tags remain in the cleaned text.

%r{</?(?:tool_call|minimax:tool_call|invoke|tool_calls)\b[^>]*>}im

Class Method Summary collapse

Class Method Details

.coerce(value) ⇒ Object

A leaked XML parameter value is always a string on the wire; keep it a string (the tool schema coerces). Only unwrap an obvious JSON scalar.



166
167
168
# File 'lib/rubino/llm/tool_call_recovery.rb', line 166

def coerce(value)
  value
end

.extract_invoke!(text, calls) ⇒ Object

— family B: <invoke name=“fn”><param…></invoke> ——————-



102
103
104
105
106
107
108
109
# File 'lib/rubino/llm/tool_call_recovery.rb', line 102

def extract_invoke!(text, calls)
  text.gsub(INVOKE) do
    name = Regexp.last_match(1)
    body = Regexp.last_match(2).to_s
    calls << { name: name, arguments: parse_invoke_params(body) }
    ""
  end
end

.extract_tool_call_json!(text, calls) ⇒ Object

— family A: <tool_call>json</tool_call> ————————-



125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/rubino/llm/tool_call_recovery.rb', line 125

def extract_tool_call_json!(text, calls)
  text.gsub(TOOL_CALL_JSON) do
    json = Regexp.last_match(1)
    obj  = safe_json(json)
    if obj.is_a?(Hash) && obj["name"]
      calls << { name: obj["name"], arguments: normalize_args(obj["arguments"]) }
      ""
    else
      Regexp.last_match(0) # leave untouched if not a real call
    end
  end
end

.extract_tool_calls_array!(text, calls) ⇒ Object

— family C: [TOOL_CALLS] ———————————-



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/rubino/llm/tool_call_recovery.rb', line 139

def extract_tool_calls_array!(text, calls)
  text.gsub(TOOL_CALLS_ARRAY) do
    arr = safe_json(Regexp.last_match(1))
    if arr.is_a?(Array)
      arr.each do |c|
        next unless c.is_a?(Hash) && c["name"]

        calls << { name: c["name"], arguments: normalize_args(c["arguments"]) }
      end
      ""
    else
      Regexp.last_match(0)
    end
  end
end

.normalize_args(args) ⇒ Object

— helpers ———————————————————



156
157
158
159
160
161
162
# File 'lib/rubino/llm/tool_call_recovery.rb', line 156

def normalize_args(args)
  case args
  when Hash   then args
  when String then safe_json(args).is_a?(Hash) ? safe_json(args) : { "value" => args }
  else {}
  end
end

.parse_invoke_params(body) ⇒ Object



111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/rubino/llm/tool_call_recovery.rb', line 111

def parse_invoke_params(body)
  args = {}
  body.scan(PARAM_NAMED) { |k, v| args[k] = coerce(v.strip) }
  # Bare child elements as params, but only outside the <parameter …> ones
  # already consumed (and never the <parameter> tag itself).
  body.gsub(PARAM_NAMED, "").scan(PARAM_BARE) do |k, v|
    next if k.casecmp("parameter").zero?

    args[k] = coerce(v.strip)
  end
  args
end

.recover(content) ⇒ Object



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/rubino/llm/tool_call_recovery.rb', line 85

def recover(content)
  text = content.to_s
  return Recovered.new(text: text, calls: []) if text.empty?

  text = text.gsub(MINIMAX_NS, "")
  text = text.gsub(THINK_BLOCK, "")

  calls = []
  text = extract_invoke!(text, calls) # B
  text = extract_tool_call_json!(text, calls) # A
  text = extract_tool_calls_array!(text, calls) if calls.empty? # C

  text = text.gsub(ORPHAN_WRAPPERS, "") unless calls.empty?
  Recovered.new(text: text.strip, calls: calls)
end

.safe_json(str) ⇒ Object



170
171
172
173
174
# File 'lib/rubino/llm/tool_call_recovery.rb', line 170

def safe_json(str)
  JSON.parse(str)
rescue JSON::ParserError, TypeError
  nil
end