2929from __future__ import annotations
3030
3131import json
32+ import re
3233from typing import TYPE_CHECKING , Any
3334
3435import httpx
7677_DEFAULT_MAX_TOKENS = 4096
7778
7879
80+ # MiniMax M2.7 sometimes emits the forced tool call as inline XML markup inside
81+ # a text block rather than a native Anthropic tool_use block, e.g.:
82+ # <minimax:tool_call>
83+ # <invoke name="emit_quiz_deck">
84+ # <parameter name="title">"DT"</parameter>
85+ # <parameter name="questions">[ ... JSON ... ]</parameter>
86+ # </invoke>
87+ # </minimax:tool_call>
88+ # Each <parameter> value is the JSON encoding of that key's value. We reassemble
89+ # them into the tool-input dict the schema expects.
90+ _INVOKE_RE = re .compile (r'<invoke\s+name="(?P<name>[^"]+)"\s*>(?P<body>.*?)</invoke>' , re .DOTALL )
91+ _PARAM_RE = re .compile (
92+ r'<parameter\s+name="(?P<key>[^"]+)"\s*>(?P<val>.*?)</parameter>' , re .DOTALL
93+ )
94+
95+
96+ def _parse_inline_tool_call (text : str , expected_tool_name : str ) -> dict [str , Any ] | None :
97+ """Parse MiniMax's inline ``<invoke>`` markup into a tool-input dict.
98+
99+ Returns the assembled tool input, or ``None`` if the text contains no
100+ matching ``<invoke name="<expected_tool_name>">`` block (so a genuine
101+ text-only refusal still surfaces as an error upstream).
102+ """
103+ for m in _INVOKE_RE .finditer (text ):
104+ if m .group ("name" ) != expected_tool_name :
105+ continue
106+ body = m .group ("body" )
107+ params : dict [str , Any ] = {}
108+ for pm in _PARAM_RE .finditer (body ):
109+ key = pm .group ("key" )
110+ raw = pm .group ("val" ).strip ()
111+ try :
112+ params [key ] = json .loads (raw )
113+ except (ValueError , json .JSONDecodeError ):
114+ # Fall back to the raw string when a value isn't JSON-encoded
115+ # (e.g. a bare title); the schema validator catches real misses.
116+ params [key ] = raw
117+ if params :
118+ return params
119+ return None
120+
121+
79122class AnthropicCompatGenerator :
80123 """Card generator backed by any Anthropic Messages-compatible API.
81124
@@ -196,17 +239,29 @@ def _generate(
196239 tool_choice = {"type" : "tool" , "name" : tool_name }
197240
198241 def call (ctx : CallContext ) -> tuple [Any , list [dict [str , Any ]]]:
199- messages = list (base_messages ) + list (ctx .history_extension )
200- if ctx .last_error is not None :
201- messages .append (
202- {
203- "role" : "user" ,
204- "content" : (
205- f"The previous tool call did not validate against the schema: "
206- f"{ ctx .last_error } . Re-emit a corrected payload that conforms."
207- ),
208- }
209- )
242+ history = list (ctx .history_extension )
243+ # On a retry, fill in the real validation error on the tool_result
244+ # placeholder that the previous attempt appended. The Anthropic
245+ # Messages spec requires the user turn after an assistant tool_use
246+ # to be a tool_result for that tool_use id; MiniMax's /anthropic
247+ # shim enforces this strictly (a plain-text user turn -> error
248+ # 2013). Carrying the correction AS a tool_result keeps the
249+ # alternation valid across all providers.
250+ if ctx .last_error is not None and history :
251+ last = history [- 1 ]
252+ if (
253+ last .get ("role" ) == "user"
254+ and isinstance (last .get ("content" ), list )
255+ and last ["content" ]
256+ and last ["content" ][0 ].get ("type" ) == "tool_result"
257+ ):
258+ last ["content" ][0 ]["content" ] = (
259+ f"The previous tool call did not validate against the "
260+ f"schema: { ctx .last_error } . Re-emit a corrected payload "
261+ f"that conforms exactly."
262+ )
263+
264+ messages = list (base_messages ) + history
210265
211266 payload : dict [str , Any ] = {
212267 "model" : self ._model .id ,
@@ -224,18 +279,27 @@ def call(ctx: CallContext) -> tuple[Any, list[dict[str, Any]]]:
224279 }
225280
226281 resp = self ._post_messages (payload )
227- tool_payload , assistant_turn = self ._extract_tool_payload (resp , tool_name )
228- new_history = list (ctx .history_extension ) + [assistant_turn ]
229- if ctx .last_error is not None :
230- new_history .append (
231- {
232- "role" : "user" ,
233- "content" : (
234- f"The previous tool call did not validate against the schema: "
235- f"{ ctx .last_error } . Re-emit a corrected payload that conforms."
236- ),
237- }
238- )
282+ tool_payload , assistant_turn , tool_use_id = self ._extract_tool_payload (
283+ resp , tool_name
284+ )
285+ # Append the assistant's tool_use turn followed immediately by a
286+ # placeholder tool_result so the history stays protocol-valid. If
287+ # validation fails, the NEXT attempt overwrites the placeholder
288+ # content with the actual error (above).
289+ new_history = [
290+ * history ,
291+ assistant_turn ,
292+ {
293+ "role" : "user" ,
294+ "content" : [
295+ {
296+ "type" : "tool_result" ,
297+ "tool_use_id" : tool_use_id ,
298+ "content" : "Acknowledged." ,
299+ }
300+ ],
301+ },
302+ ]
239303 return tool_payload , new_history
240304
241305 return call_with_correction (
@@ -266,14 +330,18 @@ def _post_messages(self, payload: dict[str, Any]) -> dict[str, Any]:
266330
267331 def _extract_tool_payload (
268332 self , resp : dict [str , Any ], expected_tool_name : str
269- ) -> tuple [dict [str , Any ], dict [str , Any ]]:
333+ ) -> tuple [dict [str , Any ], dict [str , Any ], str ]:
270334 """Pull the ``tool_use`` block out of an Anthropic Messages response.
271335
272336 Anthropic returns ``content`` as a list of typed blocks. With
273337 forced tool_choice, exactly one of those blocks should be a
274338 ``{type:"tool_use", name, input}`` block. We assemble the
275339 assistant turn from the full content list so the correction-turn
276340 history sees what the model actually said.
341+
342+ Returns ``(tool_input, assistant_turn, tool_use_id)``. The
343+ ``tool_use_id`` lets the caller build a protocol-valid
344+ ``tool_result`` correction turn that references this exact call.
277345 """
278346 content = resp .get ("content" )
279347 if not isinstance (content , list ) or not content :
@@ -289,11 +357,20 @@ def _extract_tool_payload(
289357 break
290358
291359 if tool_use_block is None :
360+ # MiniMax M2.7 intermittently narrates the tool call as inline XML
361+ # markup inside a text block instead of emitting a native tool_use
362+ # block (a reasoning-model quirk). Parse that fallback before giving
363+ # up, so generation doesn't fail ~half the time on this provider.
292364 text_blocks = [b for b in content if b .get ("type" ) == "text" ]
293- text = (text_blocks [0 ].get ("text" , "" ) if text_blocks else "" )[:200 ]
365+ full_text = "" .join (b .get ("text" , "" ) for b in text_blocks )
366+ inline = _parse_inline_tool_call (full_text , expected_tool_name )
367+ if inline is not None :
368+ assistant_turn = {"role" : "assistant" , "content" : content }
369+ return inline , assistant_turn , "toolu_inline"
370+ preview = full_text [:200 ]
294371 raise CardGenerationError (
295372 f"{ self ._profile .label } response missing tool_use block "
296- f"(expected { expected_tool_name !r} ). Text content was: { text !r} "
373+ f"(expected { expected_tool_name !r} ). Text content was: { preview !r} "
297374 )
298375
299376 if tool_use_block .get ("name" ) != expected_tool_name :
@@ -314,7 +391,8 @@ def _extract_tool_payload(
314391 )
315392
316393 assistant_turn = {"role" : "assistant" , "content" : content }
317- return args , assistant_turn
394+ tool_use_id = str (tool_use_block .get ("id" ) or "toolu_correction" )
395+ return args , assistant_turn , tool_use_id
318396
319397
320398__all__ = ["AnthropicCompatGenerator" ]
0 commit comments