@@ -90,12 +90,87 @@ def _is_done_tool(name: str) -> bool:
9090 r'\s*```(?:json)?\s*\{[^}]*(?:"(?:observation_ids|memory_ids|mental_model_ids)"|\})\s*```\s*$' ,
9191 re .DOTALL | re .IGNORECASE ,
9292)
93- _LEAKED_JSON_OBJECT = re .compile (
94- r'\s*\{[^{]*"(?:observation_ids|memory_ids|mental_model_ids|answer)"[^}]*\}\s*$' , re .DOTALL
95- )
9693_TRAILING_IDS_PATTERN = re .compile (
9794 r"\s*(?:observation_ids|memory_ids|mental_model_ids)\s*[=:]\s*\[.*?\]\s*$" , re .DOTALL | re .IGNORECASE
9895)
96+ _JSON_CODE_FENCE_PATTERN = re .compile (r"^\s*```(?:json)?\s*(\{.*\})\s*```\s*$" , re .DOTALL | re .IGNORECASE )
97+
98+ _DONE_ARGUMENT_KEYS = frozenset (
99+ {
100+ "answer" ,
101+ "directive_compliance" ,
102+ "memory_ids" ,
103+ "mental_model_ids" ,
104+ "observation_ids" ,
105+ "model_ids" ,
106+ }
107+ )
108+ _DONE_ARGUMENT_MARKER_KEYS = _DONE_ARGUMENT_KEYS - {"answer" }
109+ _LEAKED_JSON_ID_KEYS = frozenset ({"memory_ids" , "mental_model_ids" , "observation_ids" , "model_ids" })
110+
111+
112+ def _unwrap_leaked_done_arguments (text : str ) -> str | None :
113+ """Return the answer when a done tool call was rendered as JSON text.
114+
115+ Some providers leak the done tool's argument object instead of surfacing it
116+ as a native tool call, e.g. {"answer": "...", "memory_ids": [...]}. Only
117+ unwrap objects that match the done argument shape so normal JSON answers
118+ stay intact.
119+ """
120+ candidate = text .strip ()
121+ if not candidate :
122+ return None
123+
124+ fenced = _JSON_CODE_FENCE_PATTERN .match (candidate )
125+ if fenced :
126+ candidate = fenced .group (1 ).strip ()
127+
128+ try :
129+ payload = json .loads (candidate )
130+ except json .JSONDecodeError :
131+ return None
132+
133+ if not isinstance (payload , dict ):
134+ return None
135+ answer = payload .get ("answer" )
136+ if not isinstance (answer , str ) or not answer .strip ():
137+ return None
138+
139+ keys = set (payload )
140+ if not keys .intersection (_DONE_ARGUMENT_MARKER_KEYS ):
141+ return None
142+ if not keys .issubset (_DONE_ARGUMENT_KEYS ):
143+ return None
144+
145+ for key in ("memory_ids" , "mental_model_ids" , "observation_ids" , "model_ids" ):
146+ value = payload .get (key )
147+ if value is not None and not isinstance (value , list ):
148+ return None
149+
150+ return answer .strip ()
151+
152+
153+ def _strip_trailing_id_json_object (text : str ) -> str :
154+ stripped = text .rstrip ()
155+ if not stripped .endswith ("}" ):
156+ return text .strip ()
157+
158+ start = stripped .rfind ("{" )
159+ if start < 0 :
160+ return text .strip ()
161+
162+ try :
163+ payload = json .loads (stripped [start :])
164+ except json .JSONDecodeError :
165+ return text .strip ()
166+
167+ if not isinstance (payload , dict ) or not payload :
168+ return text .strip ()
169+ keys = set (payload )
170+ if not keys .issubset (_LEAKED_JSON_ID_KEYS ):
171+ return text .strip ()
172+
173+ return stripped [:start ].strip ()
99174
100175
101176def _clean_answer_text (text : str ) -> str :
@@ -104,6 +179,10 @@ def _clean_answer_text(text: str) -> str:
104179 Some LLMs output the done() call as text instead of a proper tool call.
105180 This strips out patterns like: done({"answer": "...", ...})
106181 """
182+ unwrapped = _unwrap_leaked_done_arguments (text )
183+ if unwrapped is not None :
184+ return unwrapped
185+
107186 # Remove done() call pattern from the end of the text
108187 cleaned = _DONE_CALL_PATTERN .sub ("" , text ).strip ()
109188 return cleaned if cleaned else text
@@ -122,13 +201,17 @@ def _clean_done_answer(text: str) -> str:
122201 if not text :
123202 return text
124203
204+ unwrapped = _unwrap_leaked_done_arguments (text )
205+ if unwrapped is not None :
206+ return unwrapped
207+
125208 cleaned = text
126209
127210 # Remove leaked JSON in code blocks at the end
128211 cleaned = _LEAKED_JSON_SUFFIX .sub ("" , cleaned ).strip ()
129212
130213 # Remove leaked raw JSON objects at the end
131- cleaned = _LEAKED_JSON_OBJECT . sub ( "" , cleaned ). strip ( )
214+ cleaned = _strip_trailing_id_json_object ( cleaned )
132215
133216 # Remove trailing ID patterns
134217 cleaned = _TRAILING_IDS_PATTERN .sub ("" , cleaned ).strip ()
0 commit comments