1212
1313import json
1414import logging
15+ import re
1516import time
1617import uuid
1718from dataclasses import dataclass , field
@@ -51,40 +52,141 @@ class AgentResult:
5152 total_completion_tokens : int = 0
5253
5354
55+ _TOOL_CALL_OPEN = re .compile (r"<tool_call>\s*" , re .IGNORECASE )
56+ _TOOL_CALL_CLOSE = re .compile (r"\s*</tool_call>" , re .IGNORECASE )
57+
58+
59+ def _strip_tool_call_xml (text : str ) -> str :
60+ """Remove every ``<tool_call>...`` blob from a model response.
61+
62+ FU-040: the chat UI shows ``result.text`` verbatim in the assistant
63+ bubble, so when a model emits a ``<tool_call>`` block AND we
64+ execute the call (either via the engine's structured field or via
65+ ``_parse_tool_calls_from_response``), the user sees the same call
66+ twice — once as raw XML noise and once as a ``ToolCallCard``. We
67+ strip the XML from the text we hand back to the streaming layer.
68+
69+ Uses the same ``JSONDecoder.raw_decode`` walk as the parser so we
70+ only remove the well-formed-JSON region the parser actually
71+ consumed; everything around it (the model's natural-language
72+ framing) stays put. A trailing ``</tool_call>`` close tag, when
73+ present, is also swallowed.
74+ """
75+ if not text or "<tool_call>" not in text .lower ():
76+ return text
77+ decoder = json .JSONDecoder ()
78+ out : list [str ] = []
79+ cursor = 0
80+ while True :
81+ match = _TOOL_CALL_OPEN .search (text , cursor )
82+ if match is None :
83+ out .append (text [cursor :])
84+ break
85+ out .append (text [cursor :match .start ()])
86+ start = match .end ()
87+ while start < len (text ) and text [start ].isspace ():
88+ start += 1
89+ if start >= len (text ):
90+ break
91+ try :
92+ _payload , end = decoder .raw_decode (text , start )
93+ except json .JSONDecodeError :
94+ # Malformed JSON after ``<tool_call>`` — drop the opener
95+ # alone and continue. The garbage payload stays so the
96+ # operator can see what the model emitted.
97+ cursor = match .end ()
98+ continue
99+ cursor = end
100+ close = _TOOL_CALL_CLOSE .match (text , cursor )
101+ if close is not None :
102+ cursor = close .end ()
103+ cleaned = "" .join (out )
104+ # Collapse the double-blank-line that can appear when we strip a
105+ # mid-paragraph tool_call. ``\n\n\n+`` → ``\n\n`` keeps paragraph
106+ # breaks intact while removing the visible gap.
107+ return re .sub (r"\n{3,}" , "\n \n " , cleaned ).strip ()
108+
109+
54110def _parse_tool_calls_from_response (response_text : str ) -> list [dict [str , Any ]] | None :
55111 """Attempt to extract tool calls from a text response.
56112
57113 Models using the OpenAI tool-calling protocol return structured
58114 tool_calls in the response object. For models that embed tool calls
59- in their text output (e.g., Hermes/Functionary format), we try to
60- parse them from common patterns.
115+ in their text output (e.g. Hermes / NousResearch / Qwen3-Coder-Next),
116+ we parse them from the ``<tool_call>...</tool_call>`` XML-ish
117+ convention.
118+
119+ FU-040 (2026-05-10): widened to handle three real-world shapes
120+ Coder-Next emitted in a single chat session:
121+
122+ 1. ``<tool_call>{"name": "x", "arguments": {...}}</tool_call>``
123+ — the canonical Hermes shape. Always worked.
124+ 2. ``<tool_call>{"name": "x", "arguments": {...}}`` — no
125+ closing tag. The previous regex required ``</tool_call>``
126+ and silently dropped these, so the model's tool call
127+ rendered as raw XML text in the assistant bubble with no
128+ execution.
129+ 3. ``<tool_call> [ {url: ...}, {url: ...} ]`` — model
130+ hallucinated a JSON ARRAY of pseudo-results instead of a
131+ call object. Rejected (the array shape has no ``name`` /
132+ ``arguments`` keys to dispatch from), but we keep parsing
133+ so any well-formed call later in the same message still
134+ lands.
135+
136+ The parser walks each ``<tool_call>`` opener and uses the stdlib
137+ ``json.JSONDecoder.raw_decode`` to consume exactly the next valid
138+ JSON value (object OR array) — that handles both shapes (1) and
139+ (2) without requiring a closing tag, and shape (3) decodes to a
140+ list which we discard. ``raw_decode`` also correctly skips nested
141+ braces inside argument string values that a naive regex would
142+ choke on.
61143 """
62- # Try the <tool_call> XML-ish format (Hermes/NousResearch)
63- calls : list [dict [str , Any ]] = []
64- import re
144+ if not response_text or "<tool_call>" not in response_text .lower ():
145+ return None
65146
66- for match in re .finditer (
67- r"<tool_call>\s*(\{.*?\})\s*</tool_call>" ,
68- response_text ,
69- re .DOTALL ,
70- ):
147+ calls : list [dict [str , Any ]] = []
148+ decoder = json .JSONDecoder ()
149+ cursor = 0
150+ while True :
151+ match = _TOOL_CALL_OPEN .search (response_text , cursor )
152+ if match is None :
153+ break
154+ start = match .end ()
155+ # Find the first non-whitespace character; ``raw_decode`` needs
156+ # to start at the JSON token itself, not at preceding spaces.
157+ while start < len (response_text ) and response_text [start ].isspace ():
158+ start += 1
159+ if start >= len (response_text ):
160+ break
71161 try :
72- payload = json .loads (match .group (1 ))
73- name = payload .get ("name" ) or payload .get ("function" )
74- arguments = payload .get ("arguments" ) or payload .get ("parameters" ) or {}
75- if isinstance (arguments , str ):
76- arguments = json .loads (arguments )
77- if name :
78- calls .append ({
79- "id" : f"call_{ uuid .uuid4 ().hex [:8 ]} " ,
80- "type" : "function" ,
81- "function" : {
82- "name" : name ,
83- "arguments" : json .dumps (arguments ) if isinstance (arguments , dict ) else str (arguments ),
84- },
85- })
86- except (json .JSONDecodeError , KeyError ):
162+ payload , end = decoder .raw_decode (response_text , start )
163+ except json .JSONDecodeError :
164+ cursor = start + 1
165+ continue
166+ cursor = end
167+ # Shape (3): the model emitted hallucinated results as a list.
168+ # No ``name`` to dispatch from — skip without aborting the
169+ # outer loop so a later well-formed call in the same message
170+ # still gets picked up.
171+ if not isinstance (payload , dict ):
87172 continue
173+ name = payload .get ("name" ) or payload .get ("function" )
174+ if not name :
175+ continue
176+ arguments = payload .get ("arguments" ) or payload .get ("parameters" ) or {}
177+ if isinstance (arguments , str ):
178+ try :
179+ arguments = json .loads (arguments )
180+ except json .JSONDecodeError :
181+ arguments = {"raw" : arguments }
182+ calls .append ({
183+ "id" : f"call_{ uuid .uuid4 ().hex [:8 ]} " ,
184+ "type" : "function" ,
185+ "function" : {
186+ "name" : name ,
187+ "arguments" : json .dumps (arguments ) if isinstance (arguments , dict ) else str (arguments ),
188+ },
189+ })
88190
89191 return calls if calls else None
90192
@@ -99,8 +201,27 @@ def _execute_tool_call(
99201 tool_name = func .get ("name" , "unknown" )
100202 raw_args = func .get ("arguments" , "{}" )
101203
204+ # FU-039 (2026-05-10): coerce ``arguments`` to a dict at the source.
205+ # Models occasionally emit ``{"arguments": null}`` (Coder-Next does
206+ # this when the tool call has no parameters) or send a non-string,
207+ # non-dict shape we don't recognise. Both routes used to set
208+ # ``arguments = None``, which then landed in ``ToolCallResult``,
209+ # serialised into the persisted session, and crashed the frontend's
210+ # ``ToolCallCard`` at ``Object.entries(null)`` on every subsequent
211+ # render. Result: a single bad tool turn permanently bricked the
212+ # Chat tab. Defaulting to ``{}`` keeps the contract consumers
213+ # already assume — and means the frontend boundary (also added in
214+ # FU-039) only fires for genuinely corrupt records, not the common
215+ # "no args" path.
102216 try :
103- arguments = json .loads (raw_args ) if isinstance (raw_args , str ) else raw_args
217+ if raw_args is None :
218+ arguments = {}
219+ elif isinstance (raw_args , str ):
220+ arguments = json .loads (raw_args ) if raw_args .strip () else {}
221+ elif isinstance (raw_args , dict ):
222+ arguments = raw_args
223+ else :
224+ arguments = {"raw" : raw_args }
104225 except json .JSONDecodeError :
105226 arguments = {"raw" : raw_args }
106227
@@ -244,9 +365,12 @@ def run_agent_loop(
244365 tool_calls = _parse_tool_calls_from_response (result .text )
245366
246367 if not tool_calls :
247- # Model is done — return the final text
368+ # Model is done — return the final text. Strip any
369+ # ``<tool_call>`` XML the parser consumed so the chat
370+ # bubble doesn't show raw call JSON next to a rendered
371+ # ToolCallCard (FU-040).
248372 return AgentResult (
249- text = result .text ,
373+ text = _strip_tool_call_xml ( result .text ) ,
250374 tool_calls = all_tool_results ,
251375 iterations = iteration + 1 ,
252376 total_prompt_tokens = total_prompt ,
@@ -356,8 +480,11 @@ def run_agent_loop_streaming(
356480
357481 if not tool_calls :
358482 # Final response — stream it token by token for the user
359- # Since we already have the full text, emit it in chunks
360- text = result .text
483+ # Since we already have the full text, emit it in chunks.
484+ # Strip any ``<tool_call>`` XML blobs the parser already
485+ # consumed so the assistant bubble doesn't show raw call
486+ # JSON next to the rendered ToolCallCard (FU-040).
487+ text = _strip_tool_call_xml (result .text )
361488 chunk_size = 4
362489 for i in range (0 , len (text ), chunk_size ):
363490 yield {"token" : text [i :i + chunk_size ]}
0 commit comments