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
-
.coerce(value) ⇒ Object
A leaked XML parameter value is always a string on the wire; keep it a string (the tool schema coerces).
-
.extract_invoke!(text, calls) ⇒ Object
— family B: <invoke name=“fn”><param…></invoke> ——————-.
-
.extract_tool_call_json!(text, calls) ⇒ Object
— family A: <tool_call>json</tool_call> ————————-.
-
.extract_tool_calls_array!(text, calls) ⇒ Object
— family C: [TOOL_CALLS] ———————————-.
-
.normalize_args(args) ⇒ Object
— helpers ———————————————————.
- .parse_invoke_params(body) ⇒ Object
- .recover(content) ⇒ Object
- .safe_json(str) ⇒ Object
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 |