Erinaldorodrigues commited on
Commit
b4d233d
·
verified ·
1 Parent(s): 9a879dc

Upload 2 files

Browse files
Files changed (2) hide show
  1. openai_compat_loopfix.py +1155 -0
  2. tool_calls_loopfix.py +462 -0
openai_compat_loopfix.py ADDED
@@ -0,0 +1,1155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure OpenAI compatibility helpers used by the Space endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from tool_calls import normalize_openai_tool_arguments
12
+
13
+
14
+ EMPTY_PARAMETERS = {"type": "object", "properties": {}}
15
+ # OpenClaude includes human-facing operational manuals in tool descriptions.
16
+ # They are useful to its native client but can consume most of the Qwen context
17
+ # once the same catalog is rendered again in the model prompt. Keep enough
18
+ # context to select and call a tool while preserving the full JSON-schema shape.
19
+ MAX_TOOL_DESCRIPTION_CHARS = 800
20
+ MAX_SCHEMA_DESCRIPTION_CHARS = 240
21
+
22
+ FAILED_RESULT_RE = re.compile(
23
+ r"(?im)(?:"
24
+ r"<tool_use_error>|"
25
+ r"\bexit\s*(?:code)?\s*[:=]?\s*[1-9]\d*\b|"
26
+ r"\bstatus\s*(?:code)?\s*[:=]?\s*[345]\d\d\b|"
27
+ r"^\s*(?:FAILED|ERROR)(?:\s|:)|"
28
+ r"\b[1-9]\d*\s+(?:failed|errors?)\b|"
29
+ r"\b(?:command not found|no such file|permission denied|timed out)\b|"
30
+ r"\b(?:invalid api key|invalid token|unauthorized|forbidden)\b|"
31
+ r"\b(?:invalid tool parameters|inputvalidationerror)\b|"
32
+ r"\b(?:required parameter|schema)[^\n]*(?:missing|not sent)\b|"
33
+ r'"status"\s*:\s*"(?:error|401|403)"|'
34
+ r'"status"\s*:\s*(?:401|403)\b|'
35
+ r"\bHTTP/\S+\s+(?:3\d\d|4\d\d|5\d\d)\b"
36
+ r")"
37
+ )
38
+ VERIFICATION_COMMAND_RE = re.compile(
39
+ r"(?i)(?:"
40
+ r"\bpytest\b|"
41
+ r"\bpython(?:3)?\s+-m\s+(?:unittest|pytest)\b|"
42
+ r"\bpython(?:3)?\s+[^\n;&|]*test[^\n;&|]*\.py\b|"
43
+ r"\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b|"
44
+ r"\b(?:cargo|go)\s+test\b|"
45
+ r"\b(?:cargo)\s+check\b|"
46
+ r"\b(?:mvn|gradle)\s+(?:test|check|build)\b|"
47
+ r"\bmake\s+(?:check|test)\b|"
48
+ r"\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:build|check|lint)\b|"
49
+ r"(?:^|[\s/])(?:bash\s+)?[^\s;&|]*test[^\s;&|]*\.sh\b|"
50
+ r"\bpython(?:3)?\s+-m\s+py_compile\b|"
51
+ r"\b(?:ruff|mypy|eslint|tsc)\b"
52
+ r")"
53
+ )
54
+ POSITIVE_VERIFICATION_RE = re.compile(
55
+ r"(?im)(?:"
56
+ r"^\s*OK\s*$|"
57
+ r"\bRan\s+\d+\s+tests?\b|"
58
+ r"\b\d+\s+passed\b|"
59
+ r"\bBUILD\s+SUCCESS(?:FUL)?\b|"
60
+ r"\b(?:tests?|checks?)\s+(?:passed|successful)\b|"
61
+ r"\b[A-Z][A-Z0-9_]+_OK\b|"
62
+ r"\(?(?:Bash )?completed (?:successfully )?"
63
+ r"(?:with no|without)(?: textual)? output\)?"
64
+ r")"
65
+ )
66
+ INSPECTION_COMMAND_RE = re.compile(
67
+ r"(?i)^\s*(?:sudo\s+)?(?:"
68
+ r"cd\b[^;&|]*(?:&&|;)\s*)?"
69
+ r"(?:"
70
+ r"ls|pwd|find|rg|grep|cat|sed|head|tail|wc|stat|tree|git|cd|"
71
+ r"apt(?:-get)?\s+(?:list|show|policy)|"
72
+ r"apt-cache\s+(?:policy|show|search)|"
73
+ r"dpkg\s+(?:-l|--list|--status)|"
74
+ r"command\s+-v|which|type"
75
+ r")\b"
76
+ )
77
+ SYSTEM_MAINTENANCE_REQUEST_RE = re.compile(
78
+ r"(?i)\b(?:"
79
+ r"atualize|atualizar|atualiza[cç][aã]o|upgrade|update|"
80
+ r"instale|instalar|instala[cç][aã]o|install|"
81
+ r"remova|remover|remo[cç][aã]o|remove|"
82
+ r"desinstale|desinstalar|desinstala[cç][aã]o|uninstall"
83
+ r")\b"
84
+ )
85
+ SYSTEM_MUTATION_COMMAND_RE = re.compile(
86
+ r"(?i)^\s*(?:sudo\s+)?(?:"
87
+ r"apt(?:-get)?\s+(?:update|upgrade|full-upgrade|dist-upgrade|install|remove|purge)|"
88
+ r"dnf\s+(?:upgrade|update|install|remove)|"
89
+ r"yum\s+(?:update|upgrade|install|remove)|"
90
+ r"pacman\s+-S|zypper\s+(?:update|install|remove)"
91
+ r")\b"
92
+ )
93
+ WEB_REQUEST_RE = re.compile(
94
+ r"(?i)\b(?:"
95
+ r"pesquis(?:e|ar|a)|busque|procure|not[ií]cias?|[uú]ltimas?|"
96
+ r"hoje|agora|atual(?:izado|izada|mente)?|search|latest|news|browser|web"
97
+ r")\b"
98
+ )
99
+ WEB_SUBJECT_RE = re.compile(
100
+ r"(?i)\b(?:"
101
+ r"web|internet|pesquis\w*|busc\w*|procur\w*|not[ií]cias?|"
102
+ r"search|latest|news|info|site|p[aá]gina"
103
+ r")\b"
104
+ )
105
+ LOCAL_INSPECTION_RE = re.compile(
106
+ r"(?i)\b(?:"
107
+ r"mem[oó]ria|ram|cpu|processador|disco|armazenamento|hardware|"
108
+ r"sistema|kernel|processos?|servi[cç]os?|rede|endere[cç]o\s+ip|"
109
+ r"gpu|temperatura|bateria|swap|arquivos?|diret[oó]rios?|pastas?|"
110
+ r"pacotes?|packages?|depend[eê]ncias?|dependencies"
111
+ r")\b"
112
+ )
113
+ INSPECTION_INTENT_RE = re.compile(
114
+ r"(?i)\b(?:"
115
+ r"verifi(?:que|car|ca[cç][aã]o)|confira|cheque|inspecione|"
116
+ r"mostre|liste|diagnostique|analise|check|inspect|show|list|explore"
117
+ r")\b"
118
+ )
119
+ READ_REQUEST_RE = re.compile(
120
+ r"(?i)\b(?:leia|ler|read|veja|ver|open|abra)\b"
121
+ )
122
+ EXPLICIT_TOOL_REQUEST_RE = re.compile(
123
+ r"(?i)\b(?:use|usar|utilize|utilizar|chame|chamar|call|invoke|"
124
+ r"execute|executar)\s+"
125
+ r"(?:(?:obrigatoriamente|necessariamente|somente|only|just|"
126
+ r"a|o|as|os|the|ferramenta|tool)\s+)*"
127
+ r"(?P<tool>bash|read|write|edit|glob|grep|websearch|webfetch|"
128
+ r"task|agent|notebookedit|lsp)\b"
129
+ )
130
+ IMPLEMENTATION_REQUEST_RE = re.compile(
131
+ r"(?i)\b(?:"
132
+ r"implemente|implement|corrija|corrigir|fix|edite|editar|modify|"
133
+ r"altere|alterar|crie|criar|create|write|escreva|instale|install|"
134
+ r"baixe|download|execute|rode|run|teste|testar|automatiz\w*"
135
+ r")\b"
136
+ )
137
+ PROGRAMMING_CONTEXT_RE = re.compile(
138
+ r"(?i)\b(?:"
139
+ r"arquivo|file|c[oó]digo|code|projeto|project|reposit[oó]rio|repo|"
140
+ r"script|programa|aplica[cç][aã]o|app|fun[cç][aã]o|function|classe|"
141
+ r"m[oó]dulo|module|teste|test|bug|erro|error|build|site|endpoint|"
142
+ r"proxy|api|depend[eê]ncia|package|solu[cç][aã]o|funcionalidade|feature"
143
+ r")\b"
144
+ )
145
+ ACTION_NOW_RE = re.compile(
146
+ r"(?i)\b(?:fa[cç]a|execute|rode|run|do)\s+(?:isso\s+)?agora\b|"
147
+ r"\bdo\s+it\s+now\b"
148
+ )
149
+ NO_TOOLS_RE = re.compile(
150
+ r"(?i)\b(?:"
151
+ r"n[aã]o\s+(?:use|usar|chame|chamar)|"
152
+ r"sem|"
153
+ r"do\s+not\s+(?:use|call)|"
154
+ r"never\s+(?:use|call)|"
155
+ r"without"
156
+ r")\s+(?:as?\s+)?(?:ferramentas?|tools?)\b"
157
+ )
158
+ SIMPLE_GREETING_RE = re.compile(
159
+ r"(?i)^\s*(?:oi|ol[aá]|hello|hi|hey|bom\s+dia|boa\s+tarde|boa\s+noite)"
160
+ r"[\s!,.?]*$"
161
+ )
162
+ OPENCLAUDE_METADATA_BLOCK_RE = re.compile(
163
+ r"<(?P<tag>available-deferred-tools|system-reminder)\b[^>]*>.*?</(?P=tag)>",
164
+ re.DOTALL | re.IGNORECASE,
165
+ )
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class ToolFlowState:
170
+ """Request-local progress state; no conversation state is stored globally."""
171
+
172
+ active: bool = False
173
+ requires_tool: bool = False
174
+ can_finalize: bool = False
175
+ reason: str = ""
176
+ instruction: str | None = None
177
+ forced_tool: str | None = None
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class _ToolResultEvent:
182
+ name: str
183
+ arguments: dict[str, Any]
184
+ content: str
185
+ is_error: bool
186
+ batch: int
187
+
188
+
189
+ def _bounded_description(value: Any, limit: int) -> str:
190
+ """Return a compact single-line description suitable for a model prompt."""
191
+ text = re.sub(r"\s+", " ", str(value or "")).strip()
192
+ if len(text) <= limit:
193
+ return text
194
+ shortened = text[: max(1, limit - 1)].rsplit(" ", 1)[0].rstrip()
195
+ return (shortened or text[: limit - 1]).rstrip() + "…"
196
+
197
+
198
+ def _compact_schema_descriptions(value: Any) -> Any:
199
+ """Bound schema prose without removing structural validation information."""
200
+ if isinstance(value, Mapping):
201
+ return {
202
+ key: (
203
+ _bounded_description(raw_value, MAX_SCHEMA_DESCRIPTION_CHARS)
204
+ if key == "description"
205
+ else _compact_schema_descriptions(raw_value)
206
+ )
207
+ for key, raw_value in value.items()
208
+ }
209
+ if isinstance(value, list):
210
+ return [_compact_schema_descriptions(item) for item in value]
211
+ return value
212
+
213
+
214
+ def _content_text(content: Any) -> str:
215
+ if isinstance(content, str):
216
+ return content
217
+ if isinstance(content, list):
218
+ parts: list[str] = []
219
+ for block in content:
220
+ if isinstance(block, Mapping):
221
+ text = block.get("text", block.get("content", ""))
222
+ if text:
223
+ parts.append(str(text))
224
+ elif block is not None:
225
+ parts.append(str(block))
226
+ return "\n".join(parts)
227
+ return "" if content is None else str(content)
228
+
229
+
230
+ def _user_request_text(content: Any) -> str:
231
+ """Remove OpenClaude's injected metadata before classifying user intent.
232
+
233
+ OpenClaude places deferred-tool lists, skill descriptions, and snip markers
234
+ inside a user-role message. Those blocks can contain words such as
235
+ ``create``, ``code``, or ``test``; treating them as the user's request can
236
+ incorrectly force ``tool_choice=required`` for a plain greeting.
237
+ """
238
+ text = _content_text(content)
239
+ previous = None
240
+ while text != previous:
241
+ previous = text
242
+ text = OPENCLAUDE_METADATA_BLOCK_RE.sub("", text)
243
+ return text.strip()
244
+
245
+
246
+ def _call_arguments(value: Any) -> dict[str, Any]:
247
+ if isinstance(value, Mapping):
248
+ return dict(value)
249
+ if isinstance(value, str):
250
+ try:
251
+ parsed = json.loads(value)
252
+ except json.JSONDecodeError:
253
+ return {}
254
+ return dict(parsed) if isinstance(parsed, Mapping) else {}
255
+ return {}
256
+
257
+
258
+ def _is_synthetic_continuation(message: Mapping[str, Any]) -> bool:
259
+ content = message.get("content")
260
+ if isinstance(content, list) and any(
261
+ isinstance(block, Mapping) and block.get("type") == "tool_result"
262
+ for block in content
263
+ ):
264
+ return True
265
+ text = _content_text(content).casefold()
266
+ return (
267
+ not text.strip()
268
+ or "[tool results received]" in text
269
+ or (
270
+ "continue with the task" in text
271
+ and "resume your thought" in text
272
+ )
273
+ or (
274
+ "<system-reminder" in text
275
+ and not re.sub(
276
+ r"<system-reminder\b[^>]*>.*?</system-reminder>",
277
+ "",
278
+ text,
279
+ flags=re.DOTALL | re.IGNORECASE,
280
+ ).strip()
281
+ )
282
+ )
283
+
284
+
285
+ def _current_turn_messages(messages: object) -> list[object]:
286
+ if not isinstance(messages, list):
287
+ return []
288
+ start = 0
289
+ for index, message in enumerate(messages):
290
+ if (
291
+ isinstance(message, Mapping)
292
+ and str(message.get("role", "")).casefold() == "user"
293
+ and not _is_synthetic_continuation(message)
294
+ ):
295
+ start = index
296
+ return messages[start:]
297
+
298
+
299
+ def _tool_result_events(messages: object) -> list[_ToolResultEvent]:
300
+ current_messages = _current_turn_messages(messages)
301
+ calls_by_id: dict[str, tuple[str, dict[str, Any], int]] = {}
302
+ pending_order: list[str] = []
303
+ events: list[_ToolResultEvent] = []
304
+ batch = 0
305
+
306
+ for message in current_messages:
307
+ if not isinstance(message, Mapping):
308
+ continue
309
+ role = str(message.get("role", "")).casefold()
310
+ if role == "assistant":
311
+ raw_calls = message.get("tool_calls") or []
312
+ if raw_calls:
313
+ batch += 1
314
+ for index, raw_call in enumerate(raw_calls):
315
+ if not isinstance(raw_call, Mapping):
316
+ continue
317
+ function = raw_call.get("function")
318
+ if not isinstance(function, Mapping):
319
+ continue
320
+ name = function.get("name")
321
+ if not isinstance(name, str) or not name:
322
+ continue
323
+ call_id = raw_call.get("id")
324
+ if not isinstance(call_id, str) or not call_id:
325
+ call_id = f"__ordered_{len(calls_by_id)}_{index}"
326
+ calls_by_id[call_id] = (
327
+ name,
328
+ _call_arguments(function.get("arguments", {})),
329
+ batch,
330
+ )
331
+ pending_order.append(call_id)
332
+ continue
333
+ if role != "tool":
334
+ continue
335
+
336
+ call_id = message.get("tool_call_id")
337
+ call: tuple[str, dict[str, Any], int] | None = None
338
+ if isinstance(call_id, str) and call_id:
339
+ call = calls_by_id.pop(call_id, None)
340
+ if call_id in pending_order:
341
+ pending_order.remove(call_id)
342
+ elif pending_order:
343
+ fallback_id = pending_order.pop(0)
344
+ call = calls_by_id.pop(fallback_id, None)
345
+
346
+ if call is None:
347
+ explicit_name = message.get("name")
348
+ if not isinstance(explicit_name, str) or not explicit_name:
349
+ continue
350
+ call = (explicit_name, {}, batch)
351
+
352
+ content = _content_text(message.get("content"))
353
+ structured_error = message.get("is_error") is True
354
+ if isinstance(message.get("content"), list):
355
+ structured_error = structured_error or any(
356
+ isinstance(block, Mapping) and block.get("is_error") is True
357
+ for block in message["content"]
358
+ )
359
+ events.append(
360
+ _ToolResultEvent(
361
+ name=call[0],
362
+ arguments=call[1],
363
+ content=content,
364
+ is_error=structured_error or bool(FAILED_RESULT_RE.search(content)),
365
+ batch=call[2],
366
+ )
367
+ )
368
+ return events
369
+
370
+
371
+ def _bash_command(event: _ToolResultEvent) -> str:
372
+ command = event.arguments.get("command", event.arguments.get("cmd", ""))
373
+ return command if isinstance(command, str) else str(command)
374
+
375
+
376
+ def _bash_proves_completion(event: _ToolResultEvent) -> bool:
377
+ if event.is_error:
378
+ return False
379
+ command = _bash_command(event)
380
+ if not VERIFICATION_COMMAND_RE.search(command):
381
+ return False
382
+ return bool(POSITIVE_VERIFICATION_RE.search(event.content))
383
+
384
+
385
+ def _latest_user_request(messages: object) -> str:
386
+ requests: list[str] = []
387
+ if not isinstance(messages, list):
388
+ return ""
389
+ for message in messages:
390
+ if (
391
+ isinstance(message, Mapping)
392
+ and str(message.get("role", "")).casefold() == "user"
393
+ and not _is_synthetic_continuation(message)
394
+ ):
395
+ text = _user_request_text(message.get("content"))
396
+ if text:
397
+ requests.append(text)
398
+ if not requests:
399
+ return ""
400
+ latest = requests[-1]
401
+ if len(requests) > 1 and ACTION_NOW_RE.search(latest):
402
+ return requests[-2] + "\n" + latest
403
+ return latest
404
+
405
+
406
+ def is_simple_greeting(messages: object) -> bool:
407
+ """Identify a greeting that does not need a model or tool prompt.
408
+
409
+ OpenClaude sends its complete tool catalog even for ``ola``. Calling a
410
+ model on ZeroGPU for that turn adds unnecessary queue time, so the API can
411
+ answer it deterministically before inference.
412
+ """
413
+ return bool(SIMPLE_GREETING_RE.fullmatch(_latest_user_request(messages)))
414
+
415
+
416
+ def _explicitly_disables_tools(messages: object) -> bool:
417
+ if not isinstance(messages, list):
418
+ return False
419
+ return any(
420
+ isinstance(message, Mapping)
421
+ and str(message.get("role", "")).casefold()
422
+ in {"system", "developer", "user"}
423
+ and bool(NO_TOOLS_RE.search(_content_text(message.get("content"))))
424
+ for message in messages
425
+ )
426
+
427
+
428
+ def _initial_tool_flow(
429
+ messages: object,
430
+ available_by_fold: Mapping[str, str],
431
+ ) -> ToolFlowState:
432
+ """Force action for concrete first-turn requests instead of accepting plans."""
433
+ request = _latest_user_request(messages)
434
+ if not request or not available_by_fold:
435
+ return ToolFlowState()
436
+
437
+ explicit_tool = EXPLICIT_TOOL_REQUEST_RE.search(request)
438
+ if explicit_tool:
439
+ requested_name = explicit_tool.group("tool").casefold()
440
+ forced_tool = available_by_fold.get(requested_name)
441
+ if forced_tool is None:
442
+ forced_tool = available_by_fold.get(
443
+ {"agent": "task", "task": "agent"}.get(requested_name, "")
444
+ )
445
+ if forced_tool is not None:
446
+ return ToolFlowState(
447
+ active=True,
448
+ requires_tool=True,
449
+ reason=f"the user explicitly requested the {forced_tool} tool",
450
+ instruction=(
451
+ f"OPENCLAUDE FLOW STATE: call {forced_tool} now because the "
452
+ "user explicitly requested it. Do not print a sample call "
453
+ "as prose and do not answer with a plan."
454
+ ),
455
+ forced_tool=forced_tool,
456
+ )
457
+
458
+ if (
459
+ "websearch" in available_by_fold
460
+ and WEB_REQUEST_RE.search(request)
461
+ and WEB_SUBJECT_RE.search(request)
462
+ ):
463
+ return ToolFlowState(
464
+ active=True,
465
+ requires_tool=True,
466
+ reason="the user requested current web research",
467
+ instruction=(
468
+ "OPENCLAUDE FLOW STATE: perform the requested research now. "
469
+ "Call WebSearch with a concise query; do not merely describe how "
470
+ "you would search and do not substitute curl or invented APIs."
471
+ ),
472
+ forced_tool=available_by_fold["websearch"],
473
+ )
474
+
475
+ if (
476
+ "bash" in available_by_fold
477
+ and LOCAL_INSPECTION_RE.search(request)
478
+ and INSPECTION_INTENT_RE.search(request)
479
+ ):
480
+ return ToolFlowState(
481
+ active=True,
482
+ requires_tool=True,
483
+ reason="the user requested inspection of the local system",
484
+ instruction=(
485
+ "OPENCLAUDE FLOW STATE: inspect the local system now. Call Bash "
486
+ "with a safe read-only command that directly answers the request; "
487
+ "do not print a command as prose and do not ask for confirmation."
488
+ ),
489
+ forced_tool=available_by_fold["bash"],
490
+ )
491
+
492
+ if "read" in available_by_fold and READ_REQUEST_RE.search(request):
493
+ return ToolFlowState(
494
+ active=True,
495
+ requires_tool=True,
496
+ reason="the user explicitly requested reading a file",
497
+ instruction=(
498
+ "OPENCLAUDE FLOW STATE: call Read now for the relevant file. "
499
+ "Do not describe a future read operation."
500
+ ),
501
+ forced_tool=available_by_fold["read"],
502
+ )
503
+
504
+ concrete_implementation = bool(
505
+ IMPLEMENTATION_REQUEST_RE.search(request)
506
+ and (
507
+ PROGRAMMING_CONTEXT_RE.search(request)
508
+ or re.search(r"(?i)\bautomatiz\w*\b", request)
509
+ )
510
+ )
511
+ if ACTION_NOW_RE.search(request) or concrete_implementation:
512
+ return ToolFlowState(
513
+ active=True,
514
+ requires_tool=True,
515
+ reason="the user requested immediate tool-backed action",
516
+ instruction=(
517
+ "OPENCLAUDE FLOW STATE: act on the request now by calling one "
518
+ "appropriate available tool. Do not answer with a plan, example "
519
+ "commands, or a request for the user to repeat the task."
520
+ ),
521
+ )
522
+
523
+ # OpenClaude may send ``tool_choice=required`` even for greetings and
524
+ # other conversational turns. Those turns must be allowed to finalize;
525
+ # requiring a synthetic tool call makes a harmless "oi" become a 502.
526
+ return ToolFlowState(reason="no concrete tool action was requested")
527
+
528
+
529
+ def analyze_tool_flow(
530
+ messages: object,
531
+ raw_tools: object,
532
+ ) -> ToolFlowState:
533
+ """Derive whether an agent must continue or may emit its final response."""
534
+ if _explicitly_disables_tools(messages):
535
+ return ToolFlowState(
536
+ can_finalize=True,
537
+ reason="the request explicitly disables all tools",
538
+ )
539
+ available_by_fold = {
540
+ tool["function"]["name"].casefold(): tool["function"]["name"]
541
+ for tool in normalize_tools(raw_tools)
542
+ }
543
+ available = set(available_by_fold)
544
+ events = _tool_result_events(messages)
545
+ if not events:
546
+ return _initial_tool_flow(messages, available_by_fold)
547
+
548
+ # A successful search/fetch is terminal evidence for a research request.
549
+ # This intentionally prevents WebSearch -> WebFetch -> repeated curl loops.
550
+ web_evidence = any(
551
+ event.name.casefold() in {"websearch", "webfetch"}
552
+ and not event.is_error
553
+ and bool(event.content.strip())
554
+ for event in events
555
+ )
556
+
557
+ request = _latest_user_request(messages)
558
+ agentic_intent = not request or bool(
559
+ IMPLEMENTATION_REQUEST_RE.search(request)
560
+ and (
561
+ PROGRAMMING_CONTEXT_RE.search(request)
562
+ or re.search(r"(?i)\bautomatiz\w*\b", request)
563
+ )
564
+ )
565
+ agentic = (
566
+ agentic_intent
567
+ and "bash" in available
568
+ and bool({"edit", "write"} & available)
569
+ )
570
+ dirty = False
571
+ dirty_batch = -1
572
+ agentic_started = False
573
+ last_reason = ""
574
+
575
+ if agentic:
576
+ for event in events:
577
+ name = event.name.casefold()
578
+ if name == "read":
579
+ agentic_started = True
580
+ dirty = True
581
+ dirty_batch = max(dirty_batch, event.batch)
582
+ last_reason = "files were inspected but implementation is still pending"
583
+ elif name in {"edit", "write"}:
584
+ agentic_started = True
585
+ dirty = True
586
+ dirty_batch = max(dirty_batch, event.batch)
587
+ last_reason = "files changed and must be verified with Bash"
588
+ elif event.is_error and agentic_started:
589
+ dirty = True
590
+ dirty_batch = max(dirty_batch, event.batch)
591
+ last_reason = f"{event.name} returned an error that must be recovered"
592
+ elif name == "bash":
593
+ command = _bash_command(event)
594
+ if event.is_error:
595
+ agentic_started = True
596
+ dirty = True
597
+ dirty_batch = max(dirty_batch, event.batch)
598
+ last_reason = "the Bash command or test failed"
599
+ elif INSPECTION_COMMAND_RE.search(command):
600
+ agentic_started = True
601
+ dirty = True
602
+ dirty_batch = max(dirty_batch, event.batch)
603
+ last_reason = "inspection output is not completion evidence"
604
+ elif (
605
+ agentic_started
606
+ and dirty
607
+ and event.batch > dirty_batch
608
+ and _bash_proves_completion(event)
609
+ ):
610
+ dirty = False
611
+ last_reason = "a Bash verification passed after the latest change"
612
+ elif agentic_started and dirty:
613
+ last_reason = "Bash did not provide positive test evidence"
614
+
615
+ if agentic_started and dirty:
616
+ return ToolFlowState(
617
+ active=True,
618
+ requires_tool=True,
619
+ reason=last_reason,
620
+ instruction=(
621
+ "OPENCLAUDE FLOW STATE: the task is not complete. "
622
+ f"Reason: {last_reason}. Call exactly one appropriate tool now; "
623
+ "do not describe a future plan. After reading, edit or write the "
624
+ "implementation. After changes, use Bash to run the requested "
625
+ "tests and continue fixing failures until the output proves success."
626
+ ),
627
+ )
628
+
629
+ if agentic_started and not dirty:
630
+ return ToolFlowState(
631
+ active=True,
632
+ can_finalize=True,
633
+ reason=last_reason,
634
+ instruction=(
635
+ "OPENCLAUDE FLOW STATE: verification passed after the latest "
636
+ "change. Do not call another tool. Report the completed work and "
637
+ "the test evidence directly in Brazilian Portuguese."
638
+ ),
639
+ )
640
+
641
+ if web_evidence:
642
+ return ToolFlowState(
643
+ active=True,
644
+ can_finalize=True,
645
+ reason="usable web evidence is available",
646
+ instruction=(
647
+ "OPENCLAUDE FLOW STATE: usable WebSearch/WebFetch results are "
648
+ "already available. Do not call WebFetch, Bash, curl, or another "
649
+ "tool. Synthesize a concrete answer now from the supplied results, "
650
+ "include useful source links, and never invent API keys or facts."
651
+ ),
652
+ )
653
+
654
+ last_webfetch_error = max(
655
+ (
656
+ index
657
+ for index, event in enumerate(events)
658
+ if event.name.casefold() == "webfetch" and event.is_error
659
+ ),
660
+ default=-1,
661
+ )
662
+ last_websearch_error = max(
663
+ (
664
+ index
665
+ for index, event in enumerate(events)
666
+ if event.name.casefold() == "websearch" and event.is_error
667
+ ),
668
+ default=-1,
669
+ )
670
+ toolsearch_recovered = (
671
+ last_webfetch_error >= 0
672
+ and any(
673
+ index > last_webfetch_error
674
+ and event.name.casefold() == "toolsearch"
675
+ and not event.is_error
676
+ for index, event in enumerate(events)
677
+ )
678
+ )
679
+
680
+ forced_tool: str | None = None
681
+ recovery = ""
682
+ web_error_name = ""
683
+ if last_webfetch_error >= 0:
684
+ web_error_name = "WebFetch"
685
+ if toolsearch_recovered and "webfetch" in available:
686
+ forced_tool = available_by_fold["webfetch"]
687
+ recovery = (
688
+ "Retry WebFetch now with both required fields: url and prompt."
689
+ )
690
+ elif "webfetch" not in available and "toolsearch" in available:
691
+ forced_tool = available_by_fold["toolsearch"]
692
+ recovery = (
693
+ "Load WebFetch by calling ToolSearch with query select:WebFetch."
694
+ )
695
+ elif "webfetch" in available:
696
+ forced_tool = available_by_fold["webfetch"]
697
+ recovery = (
698
+ "Retry WebFetch with both required fields: url and prompt."
699
+ )
700
+ elif "websearch" in available:
701
+ forced_tool = available_by_fold["websearch"]
702
+ recovery = "Recover with WebSearch using a concise, relevant query."
703
+ elif last_websearch_error >= 0 and "websearch" in available:
704
+ web_error_name = "WebSearch"
705
+ forced_tool = available_by_fold["websearch"]
706
+ recovery = "Retry WebSearch using a concise, relevant query."
707
+
708
+ if forced_tool:
709
+ return ToolFlowState(
710
+ active=True,
711
+ requires_tool=True,
712
+ reason=f"{web_error_name} returned an error",
713
+ instruction=(
714
+ f"OPENCLAUDE FLOW STATE: {web_error_name} failed. "
715
+ f"{recovery} Do not answer with a plan and do not invent "
716
+ "credentials, endpoints, or placeholder tokens."
717
+ ),
718
+ forced_tool=forced_tool,
719
+ )
720
+
721
+ # A successful evidence-producing tool must be able to terminate a turn.
722
+ # OpenClaude repeats ``tool_choice=required`` across tool-result requests;
723
+ # leaving the state neutral therefore forces another call even when the
724
+ # previous action already answered the user's request.
725
+ last_event = events[-1]
726
+ if (
727
+ last_event.name.casefold() == "read"
728
+ and not last_event.is_error
729
+ and bool(last_event.content.strip())
730
+ ):
731
+ return ToolFlowState(
732
+ active=True,
733
+ can_finalize=True,
734
+ reason="a successful Read result is available",
735
+ instruction=(
736
+ "OPENCLAUDE FLOW STATE: the requested Read tool returned usable "
737
+ "evidence. Do not call another tool; synthesize the answer "
738
+ "directly from the result in Brazilian Portuguese."
739
+ ),
740
+ )
741
+
742
+ if last_event.name.casefold() == "bash" and not last_event.is_error:
743
+ command = _bash_command(last_event)
744
+ maintenance_requested = bool(SYSTEM_MAINTENANCE_REQUEST_RE.search(request))
745
+ inspection_completed = bool(INSPECTION_COMMAND_RE.search(command))
746
+ maintenance_completed = bool(SYSTEM_MUTATION_COMMAND_RE.search(command))
747
+
748
+ # Read-only inspection is terminal unless the same user request also
749
+ # asks for a package/system mutation. A successful mutation command is
750
+ # terminal by itself. This prevents required -> Bash -> required loops
751
+ # such as apt-list / apt-upgrade / apt-list repeating forever.
752
+ if maintenance_completed or (inspection_completed and not maintenance_requested):
753
+ return ToolFlowState(
754
+ active=True,
755
+ can_finalize=True,
756
+ reason=(
757
+ "the requested system maintenance command completed"
758
+ if maintenance_completed
759
+ else "the requested Bash inspection returned usable evidence"
760
+ ),
761
+ instruction=(
762
+ "OPENCLAUDE FLOW STATE: the latest Bash result is sufficient "
763
+ "for this request. Do not repeat Bash or print another tool "
764
+ "call as prose. Summarize the actual tool result now in "
765
+ "Brazilian Portuguese."
766
+ ),
767
+ )
768
+
769
+ # Hard loop guard: if the same successful tool call has already executed
770
+ # more than once in the current turn, never ask the model to repeat it.
771
+ if not last_event.is_error:
772
+ last_signature = (
773
+ last_event.name.casefold(),
774
+ json.dumps(last_event.arguments, ensure_ascii=False, sort_keys=True),
775
+ )
776
+ repeated = sum(
777
+ 1
778
+ for event in events
779
+ if not event.is_error
780
+ and (
781
+ event.name.casefold(),
782
+ json.dumps(event.arguments, ensure_ascii=False, sort_keys=True),
783
+ )
784
+ == last_signature
785
+ )
786
+ if repeated > 1:
787
+ return ToolFlowState(
788
+ active=True,
789
+ can_finalize=True,
790
+ reason="the same successful tool call was already repeated",
791
+ instruction=(
792
+ "OPENCLAUDE FLOW STATE: stop the tool loop now. The same "
793
+ "successful call has already run more than once. Do not "
794
+ "repeat it; summarize the available results."
795
+ ),
796
+ )
797
+
798
+ return ToolFlowState()
799
+
800
+
801
+ def resolve_tool_choice(
802
+ requested_choice: object,
803
+ state: ToolFlowState,
804
+ ) -> object:
805
+ """Resolve client tool choice against the reconstructed conversation state.
806
+
807
+ A concrete function choice remains authoritative. ``required`` is slightly
808
+ different for OpenClaude: the client repeats it across turns, so the Space
809
+ must narrow it to a detected function, or downgrade it to ``none`` once a
810
+ usable tool result is already available / no concrete tool action exists.
811
+ """
812
+ if isinstance(requested_choice, Mapping):
813
+ return requested_choice
814
+
815
+ requested_mode = (
816
+ requested_choice.casefold()
817
+ if isinstance(requested_choice, str)
818
+ else None
819
+ )
820
+
821
+ if requested_mode == "required":
822
+ if state.requires_tool:
823
+ if state.forced_tool:
824
+ return {
825
+ "type": "function",
826
+ "function": {"name": state.forced_tool},
827
+ }
828
+ return "required"
829
+ if state.can_finalize or state.reason == "no concrete tool action was requested":
830
+ return "none"
831
+ return "required"
832
+
833
+ is_auto = requested_choice is None or requested_mode == "auto"
834
+ if not is_auto:
835
+ return requested_choice
836
+
837
+ if state.can_finalize or state.reason == "no concrete tool action was requested":
838
+ return "none"
839
+ if state.requires_tool:
840
+ if state.forced_tool:
841
+ return {
842
+ "type": "function",
843
+ "function": {"name": state.forced_tool},
844
+ }
845
+ return "required"
846
+ return requested_choice
847
+
848
+
849
+ def normalize_tools(raw_tools: object) -> list[dict[str, Any]]:
850
+ """Return valid function definitions for Qwen's native tool template."""
851
+ if not isinstance(raw_tools, list):
852
+ return []
853
+
854
+ normalized: list[dict[str, Any]] = []
855
+ for raw_tool in raw_tools:
856
+ if not isinstance(raw_tool, Mapping):
857
+ continue
858
+ function = raw_tool.get("function")
859
+ candidate = function if isinstance(function, Mapping) else raw_tool
860
+ name = candidate.get("name")
861
+ if not isinstance(name, str) or not name:
862
+ continue
863
+ parameters = candidate.get(
864
+ "parameters", candidate.get("input_schema", EMPTY_PARAMETERS)
865
+ )
866
+ if not isinstance(parameters, Mapping):
867
+ parameters = EMPTY_PARAMETERS
868
+ normalized.append(
869
+ {
870
+ "type": "function",
871
+ "function": {
872
+ "name": name,
873
+ "description": _bounded_description(
874
+ candidate.get("description"), MAX_TOOL_DESCRIPTION_CHARS
875
+ ),
876
+ "parameters": _compact_schema_descriptions(parameters),
877
+ },
878
+ }
879
+ )
880
+ return normalized
881
+
882
+
883
+ def select_tools(
884
+ raw_tools: object,
885
+ tool_choice: object,
886
+ ) -> tuple[list[dict[str, Any]], str]:
887
+ """Apply OpenAI ``tool_choice`` semantics before prompting the model.
888
+
889
+ The returned mode is one of ``auto``, ``none``, ``required``, or
890
+ ``forced``. A forced choice only exposes the selected function to Qwen,
891
+ which is the most reliable way to enforce it with a native tool template.
892
+ """
893
+ tools = normalize_tools(raw_tools)
894
+ if tool_choice is None:
895
+ return tools, "auto"
896
+
897
+ if isinstance(tool_choice, str):
898
+ mode = tool_choice.casefold()
899
+ if mode == "none":
900
+ return [], "none"
901
+ if mode in {"auto", "required"}:
902
+ if mode == "required" and not tools:
903
+ raise ValueError("tool_choice='required' needs at least one tool")
904
+ return tools, mode
905
+ raise ValueError(f"Unsupported tool_choice: {tool_choice}")
906
+
907
+ if not isinstance(tool_choice, Mapping):
908
+ raise ValueError("tool_choice must be 'auto', 'none', 'required', or a function")
909
+ function = tool_choice.get("function")
910
+ name = function.get("name") if isinstance(function, Mapping) else None
911
+ if tool_choice.get("type") != "function" or not isinstance(name, str) or not name:
912
+ raise ValueError("Forced tool_choice must contain function.name")
913
+
914
+ selected = [
915
+ tool
916
+ for tool in tools
917
+ if tool["function"]["name"].casefold() == name.casefold()
918
+ ]
919
+ if not selected:
920
+ raise ValueError(f"Forced tool is not defined in tools: {name}")
921
+ return selected[:1], "forced"
922
+
923
+
924
+ def tool_names(tools: list[dict[str, Any]]) -> set[str]:
925
+ return {tool["function"]["name"] for tool in tools}
926
+
927
+
928
+ def indexed_tool_calls(calls: list[dict[str, Any]]) -> list[dict[str, Any]]:
929
+ """Add the per-call index required in streamed OpenAI deltas."""
930
+ return [{**call, "index": index} for index, call in enumerate(calls)]
931
+
932
+
933
+ def tool_choice_instruction(mode: str, tools: list[dict[str, Any]]) -> str | None:
934
+ """Supply the constraint that Qwen's template cannot express directly."""
935
+ if mode == "required":
936
+ return "You must call one or more of the available tools in this response."
937
+ if mode == "forced":
938
+ return (
939
+ f"You must call the {tools[0]['function']['name']} tool in this response. "
940
+ "Do not answer with plain text."
941
+ )
942
+ return None
943
+
944
+
945
+ def _schema_example(parameters: object) -> dict[str, Any]:
946
+ if not isinstance(parameters, Mapping):
947
+ return {}
948
+ properties = parameters.get("properties")
949
+ if not isinstance(properties, Mapping):
950
+ return {}
951
+ required = parameters.get("required")
952
+ keys = required if isinstance(required, list) and required else list(properties)[:1]
953
+ example: dict[str, Any] = {}
954
+ for key in keys:
955
+ if not isinstance(key, str):
956
+ continue
957
+ raw_schema = properties.get(key)
958
+ schema = raw_schema if isinstance(raw_schema, Mapping) else {}
959
+ value_type = schema.get("type")
960
+ if value_type in {"integer", "number"}:
961
+ value: Any = 1
962
+ elif value_type == "boolean":
963
+ value = True
964
+ elif value_type == "array":
965
+ value = []
966
+ elif value_type == "object":
967
+ value = {}
968
+ elif "path" in key.casefold():
969
+ value = "/absolute/path"
970
+ elif "query" in key.casefold():
971
+ value = "search terms"
972
+ elif key.casefold() == "url":
973
+ value = "https://example.com"
974
+ else:
975
+ value = "value"
976
+ example[key] = value
977
+ return example
978
+
979
+
980
+ def tool_protocol_instruction(
981
+ tools: list[dict[str, Any]],
982
+ parallel_tool_calls: bool = False,
983
+ ) -> str | None:
984
+ """Return the complete notebook-agent contract enforced by the Space."""
985
+ if not tools:
986
+ return None
987
+ lines = [
988
+ "OPENAI TOOL CALL FORMAT — MANDATORY",
989
+ "You are operating on the user's real notebook, not a simulation.",
990
+ "Always communicate with the user in Brazilian Portuguese (pt-BR).",
991
+ "Perform requested implementation, diagnosis, download, execution, "
992
+ "testing, local inspection, or current web research with the available "
993
+ "tools instead of describing commands or a future plan.",
994
+ "Never claim that a file changed, a command ran, or a test passed unless "
995
+ "a tool result in this conversation proves it.",
996
+ "Never repeat a tool call whose successful result is already present in "
997
+ "the conversation; use that result and continue or finalize instead.",
998
+ "After WebSearch or WebFetch returns usable evidence, synthesize the "
999
+ "answer from it. Do not fall back to repeated curl calls.",
1000
+ "Never invent API keys, tokens, endpoints, or placeholder credentials.",
1001
+ "For greetings, small talk, or a self-contained factual answer, respond "
1002
+ "directly without a tool unless the flow state below requires one.",
1003
+ (
1004
+ "When calling tools, emit one or more complete tool calls and no prose, "
1005
+ "Markdown, or code fence. Multiple calls are allowed only when they are "
1006
+ "independent and can run in parallel."
1007
+ if parallel_tool_calls
1008
+ else "When calling a tool, emit exactly one call and no prose, Markdown, "
1009
+ "or code fence."
1010
+ ),
1011
+ 'Exact syntax: <tool_call>{"name":"TOOL_NAME","arguments":{"key":"value"}}</tool_call>',
1012
+ "Arguments must be valid JSON matching the selected schema.",
1013
+ "Available tools:",
1014
+ ]
1015
+ available_names = {
1016
+ str(tool.get("function", {}).get("name", "")).casefold()
1017
+ for tool in tools
1018
+ if isinstance(tool.get("function"), Mapping)
1019
+ }
1020
+ if "webfetch" in available_names:
1021
+ lines.insert(
1022
+ 5,
1023
+ "Call only a tool listed below. Follow every tool schema exactly. "
1024
+ "WebFetch requires both url and prompt; never omit required fields.",
1025
+ )
1026
+ else:
1027
+ lines.insert(
1028
+ 5,
1029
+ "Call only a tool listed below. Deferred tools are unavailable in "
1030
+ "this backend; explain when a needed capability is not listed "
1031
+ "instead of invoking an unlisted tool.",
1032
+ )
1033
+ first_example: tuple[str, dict[str, Any]] | None = None
1034
+ for tool in tools:
1035
+ function = tool.get("function")
1036
+ if not isinstance(function, Mapping):
1037
+ continue
1038
+ name = function.get("name")
1039
+ if not isinstance(name, str) or not name:
1040
+ continue
1041
+ parameters = function.get("parameters")
1042
+ lines.append(
1043
+ json.dumps(
1044
+ {
1045
+ "name": name,
1046
+ "description": str(function.get("description") or ""),
1047
+ "parameters": (
1048
+ dict(parameters)
1049
+ if isinstance(parameters, Mapping)
1050
+ else EMPTY_PARAMETERS
1051
+ ),
1052
+ },
1053
+ ensure_ascii=False,
1054
+ separators=(",", ":"),
1055
+ )
1056
+ )
1057
+ if first_example is None:
1058
+ first_example = (name, _schema_example(parameters))
1059
+ if first_example:
1060
+ lines.append(
1061
+ "Example syntax: <tool_call>"
1062
+ + json.dumps(
1063
+ {
1064
+ "name": first_example[0],
1065
+ "arguments": first_example[1],
1066
+ },
1067
+ ensure_ascii=False,
1068
+ separators=(",", ":"),
1069
+ )
1070
+ + "</tool_call>"
1071
+ )
1072
+ return "\n".join(lines)
1073
+
1074
+
1075
+ def text_content(content: Any) -> str:
1076
+ """Convert text-only OpenAI message blocks into chat-template text."""
1077
+ if isinstance(content, str):
1078
+ return content
1079
+ if isinstance(content, list):
1080
+ return "\n".join(
1081
+ block.get("text", "")
1082
+ for block in content
1083
+ if isinstance(block, Mapping)
1084
+ and block.get("type") in {"text", "input_text"}
1085
+ )
1086
+ return "" if content is None else str(content)
1087
+
1088
+
1089
+ def normalized_tool_calls(raw_calls: object) -> list[dict[str, Any]]:
1090
+ """Keep valid OpenAI calls in the shape Qwen's template understands."""
1091
+ if not isinstance(raw_calls, list):
1092
+ return []
1093
+
1094
+ calls: list[dict[str, Any]] = []
1095
+ for raw_call in raw_calls:
1096
+ if not isinstance(raw_call, Mapping):
1097
+ continue
1098
+ function = raw_call.get("function")
1099
+ if not isinstance(function, Mapping):
1100
+ continue
1101
+ name = function.get("name")
1102
+ if not isinstance(name, str) or not name:
1103
+ continue
1104
+ call: dict[str, Any] = {
1105
+ "type": "function",
1106
+ "function": {
1107
+ "name": name,
1108
+ "arguments": normalize_openai_tool_arguments(
1109
+ function.get("arguments", {})
1110
+ ),
1111
+ },
1112
+ }
1113
+ if isinstance(raw_call.get("id"), str) and raw_call["id"]:
1114
+ call["id"] = raw_call["id"]
1115
+ calls.append(call)
1116
+ return calls
1117
+
1118
+
1119
+ def normalize_messages(
1120
+ messages: list[dict[str, Any]],
1121
+ extra_system_instruction: str | None = None,
1122
+ ) -> list[dict[str, Any]]:
1123
+ """Normalize multimodal content while preserving native tool history."""
1124
+ normalized: list[dict[str, Any]] = []
1125
+ for message in messages:
1126
+ raw_role = str(message.get("role", "user")).lower()
1127
+ if raw_role in {"system", "developer"}:
1128
+ role = "system"
1129
+ elif raw_role in {"assistant", "tool"}:
1130
+ role = raw_role
1131
+ else:
1132
+ role = "user"
1133
+
1134
+ entry: dict[str, Any] = {
1135
+ "role": role,
1136
+ "content": text_content(message.get("content")),
1137
+ }
1138
+ if role == "assistant":
1139
+ calls = normalized_tool_calls(message.get("tool_calls"))
1140
+ if calls:
1141
+ entry["tool_calls"] = calls
1142
+ if role == "tool" and isinstance(message.get("tool_call_id"), str):
1143
+ entry["tool_call_id"] = message["tool_call_id"]
1144
+ normalized.append(entry)
1145
+
1146
+ if extra_system_instruction:
1147
+ if normalized and normalized[0]["role"] == "system":
1148
+ normalized[0]["content"] = (
1149
+ f"{normalized[0]['content']}\n\n{extra_system_instruction}"
1150
+ ).strip()
1151
+ else:
1152
+ normalized.insert(
1153
+ 0, {"role": "system", "content": extra_system_instruction}
1154
+ )
1155
+ return normalized
tool_calls_loopfix.py ADDED
@@ -0,0 +1,462 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Translate common Qwen/OpenClaude textual tool calls to OpenAI payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import html
7
+ import json
8
+ import re
9
+ import shlex
10
+ import uuid
11
+ from collections.abc import Mapping
12
+ from typing import Any
13
+
14
+
15
+ JSON_TOOL_CALL_RE = re.compile(
16
+ r"<tool_call>\s*(?P<payload>\{.*?\})\s*</tool_call>",
17
+ re.DOTALL | re.IGNORECASE,
18
+ )
19
+ XML_JSON_TOOL_CALL_RE = re.compile(
20
+ r"<xml>\s*(?P<payload>\{.*?\})\s*</xml>",
21
+ re.DOTALL | re.IGNORECASE,
22
+ )
23
+ STANDARD_XML_TOOL_CALL_RE = re.compile(
24
+ r"<tool_call>\s*(?P<body>.*?)\s*</tool_call>",
25
+ re.DOTALL | re.IGNORECASE,
26
+ )
27
+ FUNCTION_NAME_RE = re.compile(
28
+ r"<function\s*=\s*(?P<name>[A-Za-z_][\w.-]*)\s*>",
29
+ re.IGNORECASE,
30
+ )
31
+ PARAMETER_RE = re.compile(
32
+ r"<parameter\s*=\s*(?P<key>[A-Za-z_][\w.-]*)\s*>"
33
+ r"(?P<value>.*?)</parameter\s*>",
34
+ re.DOTALL | re.IGNORECASE,
35
+ )
36
+ DASHED_XML_TOOL_CALL_RE = re.compile(
37
+ r"<tool-call>\s*<name>\s*(?P<name>[A-Za-z_][\w.-]*)\s*</name>\s*"
38
+ r"<arguments>\s*(?P<arguments>.*?)\s*</arguments>\s*</tool-call>",
39
+ re.DOTALL | re.IGNORECASE,
40
+ )
41
+ NAMED_ARGUMENT_RE = re.compile(
42
+ r"<argument\s+name\s*=\s*(?P<quote>[\"'])"
43
+ r"(?P<key>[A-Za-z_][\w.-]*)(?P=quote)\s*>"
44
+ r"(?P<value>.*?)</argument\s*>",
45
+ re.DOTALL | re.IGNORECASE,
46
+ )
47
+ ELEMENT_ARGUMENT_RE = re.compile(
48
+ r"<(?P<key>[A-Za-z_][\w.-]*)\s*>(?P<value>.*?)</(?P=key)\s*>",
49
+ re.DOTALL | re.IGNORECASE,
50
+ )
51
+ SELF_CLOSING_TOOL_RE = re.compile(
52
+ r"<(?P<name>[A-Za-z_][\w.-]*)\b(?P<attributes>[^<>]*?)/\s*>",
53
+ re.DOTALL,
54
+ )
55
+ ATTRIBUTE_RE = re.compile(
56
+ r'''(?P<key>[A-Za-z_][\w.-]*)\s*=\s*(?:
57
+ "(?P<double>(?:\\.|[^"\\])*)"
58
+ |'(?P<single>(?:\\.|[^'\\])*)'
59
+ )''',
60
+ re.DOTALL | re.VERBOSE,
61
+ )
62
+ HTML_ENTITY_RE = re.compile(
63
+ r"&(?:#[0-9]+|#[xX][0-9A-Fa-f]+|[A-Za-z][A-Za-z0-9]+);"
64
+ )
65
+ ASSISTANT_CALLED_TOOL_RE = re.compile(
66
+ r"^\s*\[Assistant called tool (?P<name>[A-Za-z_][\w.-]*) "
67
+ r"with arguments (?P<arguments>\{.*\})\]\s*$",
68
+ re.DOTALL,
69
+ )
70
+ TEXTUAL_TOOL_CALL_RE = re.compile(
71
+ r"^[ \t]*(?P<name>[A-Za-z_][\w.-]*)[ \t]+"
72
+ r"(?:with|using)[ \t]+(?P<arguments>.+?)[ \t]*$",
73
+ re.MULTILINE | re.IGNORECASE,
74
+ )
75
+ FENCED_JSON_RE = re.compile(
76
+ r"^\s*```(?:json)?\s*(?P<payload>\{.*\})\s*```\s*$",
77
+ re.DOTALL | re.IGNORECASE,
78
+ )
79
+ TOOL_CALL_CLOSE_RE = re.compile(r"</tool_call>\s*$", re.IGNORECASE)
80
+ XML_JSON_TOOL_CALL_CLOSE_RE = re.compile(
81
+ r"<xml>\s*\{.*?\}\s*</xml>\s*$",
82
+ re.DOTALL | re.IGNORECASE,
83
+ )
84
+ SELF_CLOSING_TOOL_AT_END_RE = re.compile(
85
+ r"<(?:tool\b|[A-Z][A-Za-z0-9_.-]*)\b[^<>]*/\s*>\s*(?:```)?\s*$",
86
+ re.DOTALL,
87
+ )
88
+ KNOWN_TEXTUAL_TOOL_NAMES = frozenset(
89
+ {
90
+ "agent",
91
+ "askuserquestion",
92
+ "bash",
93
+ "edit",
94
+ "enterplanmode",
95
+ "glob",
96
+ "grep",
97
+ "lsp",
98
+ "notebookedit",
99
+ "read",
100
+ "skill",
101
+ "task",
102
+ "todowrite",
103
+ "webfetch",
104
+ "websearch",
105
+ "write",
106
+ }
107
+ )
108
+
109
+
110
+ def _looks_like_tool_payload(payload: object) -> bool:
111
+ """Return whether a mapping has the OpenAI/Qwen tool-call shape."""
112
+ if not isinstance(payload, Mapping):
113
+ return False
114
+ function = payload.get("function")
115
+ if isinstance(function, Mapping):
116
+ return isinstance(function.get("name"), str) and bool(function.get("name"))
117
+ return isinstance(payload.get("name"), str) and bool(payload.get("name"))
118
+
119
+
120
+ def _terminal_json_tool_payload(text: str) -> tuple[int, int, Mapping[str, Any]] | None:
121
+ """Recover a complete bare JSON tool call at the end of model output.
122
+
123
+ Small coder models sometimes obey the JSON schema but omit the surrounding
124
+ ``<tool_call>`` tags, occasionally after a short explanatory prefix. The
125
+ normal extractor can parse a *pure* JSON response, but the generation
126
+ stopping criterion previously failed to stop there, allowing the model to
127
+ continue with prose and additional simulated calls. Scan JSON-object starts
128
+ and accept only a terminal mapping with a tool-call shape.
129
+ """
130
+ candidate_text = text.rstrip()
131
+ decoder = json.JSONDecoder()
132
+ for start, char in enumerate(candidate_text):
133
+ if char != "{":
134
+ continue
135
+ try:
136
+ payload, consumed = decoder.raw_decode(candidate_text[start:])
137
+ except json.JSONDecodeError:
138
+ continue
139
+ end = start + consumed
140
+ if candidate_text[end:].strip():
141
+ continue
142
+ if _looks_like_tool_payload(payload):
143
+ return start, len(candidate_text), payload
144
+ return None
145
+
146
+
147
+ def has_complete_tool_call(text: str) -> bool:
148
+ """Return true once generation has ended a supported tool-call form."""
149
+ fenced_json = FENCED_JSON_RE.fullmatch(text)
150
+ return bool(
151
+ TOOL_CALL_CLOSE_RE.search(text)
152
+ or XML_JSON_TOOL_CALL_CLOSE_RE.search(text)
153
+ or SELF_CLOSING_TOOL_AT_END_RE.search(text)
154
+ or (
155
+ fenced_json
156
+ and _looks_like_tool_payload(_mapping_literal(fenced_json.group("payload")))
157
+ )
158
+ or _terminal_json_tool_payload(text)
159
+ or any(
160
+ match.group("name").casefold() in KNOWN_TEXTUAL_TOOL_NAMES
161
+ for match in TEXTUAL_TOOL_CALL_RE.finditer(text)
162
+ )
163
+ )
164
+
165
+
166
+ def _canonical_name(name: Any, allowed_names: set[str] | None) -> str | None:
167
+ if not isinstance(name, str) or not name:
168
+ return None
169
+ if not allowed_names:
170
+ return name
171
+ by_casefold = {candidate.casefold(): candidate for candidate in allowed_names}
172
+ normalized = name.casefold()
173
+ canonical = by_casefold.get(normalized)
174
+ if canonical is not None:
175
+ return canonical
176
+ # OpenClaude exposes the legacy Agent executor as Task. Accept both names
177
+ # in textual generations while returning the advertised catalog name.
178
+ alias = {"agent": "task", "task": "agent"}.get(normalized)
179
+ return by_casefold.get(alias) if alias else None
180
+
181
+
182
+ def _coerce_value(value: str) -> Any:
183
+ value = _unescape_entities(value.strip())
184
+ try:
185
+ return json.loads(value)
186
+ except json.JSONDecodeError:
187
+ return value
188
+
189
+
190
+ def _decode_attribute(value: str) -> str:
191
+ try:
192
+ value = json.loads(f'"{value}"')
193
+ except json.JSONDecodeError:
194
+ pass
195
+ return _unescape_entities(value)
196
+
197
+
198
+ def _unescape_entities(value: str) -> str:
199
+ """Decode explicit entities without treating a URL's bare ``&`` as HTML.
200
+
201
+ ``html.unescape`` accepts legacy semicolon-less names such as ``&curren``.
202
+ That turns a query key like ``&current_weather`` into ``¤t_weather``.
203
+ XML entities are terminated with a semicolon, so only decode that form.
204
+ """
205
+ return HTML_ENTITY_RE.sub(lambda match: html.unescape(match.group(0)), value)
206
+
207
+
208
+ def _attributes(raw: str) -> dict[str, str]:
209
+ values: dict[str, str] = {}
210
+ for match in ATTRIBUTE_RE.finditer(raw):
211
+ value = match.group("double")
212
+ if value is None:
213
+ value = match.group("single")
214
+ if value is not None:
215
+ values[match.group("key")] = _decode_attribute(value)
216
+ return values
217
+
218
+
219
+ def _arguments(value: Any) -> dict[str, Any] | None:
220
+ if isinstance(value, Mapping):
221
+ return dict(value)
222
+ if not isinstance(value, str):
223
+ return None
224
+ parsed = _mapping_literal(_unescape_entities(value))
225
+ return dict(parsed) if isinstance(parsed, Mapping) else None
226
+
227
+
228
+ def _mapping_literal(value: str) -> Mapping[str, Any] | None:
229
+ """Parse JSON or a Python-style mapping without evaluating expressions."""
230
+ try:
231
+ parsed = json.loads(value)
232
+ except json.JSONDecodeError:
233
+ try:
234
+ parsed = ast.literal_eval(value)
235
+ except (SyntaxError, ValueError):
236
+ return None
237
+ return parsed if isinstance(parsed, Mapping) else None
238
+
239
+
240
+ def normalize_openai_tool_arguments(value: Any) -> dict[str, Any]:
241
+ """Return the mapping required by Qwen3's chat-template ``items`` filter.
242
+
243
+ OpenAI serializes function arguments as a JSON string, while Qwen3's
244
+ official template iterates them as a mapping when replaying tool history.
245
+ Accept both representations so a completed tool call can be followed by a
246
+ tool result without raising a template ``TypeError``.
247
+ """
248
+ parsed = _arguments(value)
249
+ return parsed if parsed is not None else {}
250
+
251
+
252
+ def _openai_call(
253
+ name: Any,
254
+ arguments: Any,
255
+ allowed_names: set[str] | None,
256
+ ) -> dict[str, Any] | None:
257
+ canonical_name = _canonical_name(name, allowed_names)
258
+ if canonical_name is None:
259
+ return None
260
+ if isinstance(arguments, str):
261
+ parsed = _arguments(arguments)
262
+ arguments = parsed if parsed is not None else {}
263
+ if not isinstance(arguments, Mapping):
264
+ arguments = {}
265
+ return {
266
+ "id": f"call_{uuid.uuid4().hex[:24]}",
267
+ "type": "function",
268
+ "function": {
269
+ "name": canonical_name,
270
+ "arguments": json.dumps(
271
+ dict(arguments), ensure_ascii=False, separators=(",", ":")
272
+ ),
273
+ },
274
+ }
275
+
276
+
277
+ def _payload_call(
278
+ payload: Any,
279
+ allowed_names: set[str] | None,
280
+ ) -> dict[str, Any] | None:
281
+ if not isinstance(payload, Mapping):
282
+ return None
283
+ function = payload.get("function")
284
+ if isinstance(function, Mapping):
285
+ return _openai_call(
286
+ function.get("name"),
287
+ function.get("arguments", {}),
288
+ allowed_names,
289
+ )
290
+ return _openai_call(
291
+ payload.get("name"), payload.get("arguments", {}), allowed_names
292
+ )
293
+
294
+
295
+ def _xml_arguments(arguments: str) -> dict[str, Any]:
296
+ named = {
297
+ match.group("key"): _coerce_value(match.group("value"))
298
+ for match in NAMED_ARGUMENT_RE.finditer(arguments)
299
+ }
300
+ if named:
301
+ return named
302
+ return {
303
+ match.group("key"): _coerce_value(match.group("value"))
304
+ for match in ELEMENT_ARGUMENT_RE.finditer(arguments)
305
+ }
306
+
307
+
308
+ def _textual_arguments(value: str) -> dict[str, Any] | None:
309
+ """Parse Qwen's compact ``tool with key=value`` representation."""
310
+ raw = value.strip().rstrip(";").strip()
311
+ mapping = _mapping_literal(raw)
312
+ if isinstance(mapping, Mapping):
313
+ return dict(mapping)
314
+
315
+ # Normalize optional whitespace around '=' before shlex handles quoted
316
+ # values containing spaces. No expressions are evaluated here.
317
+ raw = re.sub(
318
+ r"(?P<key>[A-Za-z_][\w.-]*)\s*=\s*",
319
+ r"\g<key>=",
320
+ raw,
321
+ )
322
+ try:
323
+ tokens = shlex.split(raw, posix=True)
324
+ except ValueError:
325
+ return None
326
+
327
+ arguments: dict[str, Any] = {}
328
+ for token in tokens:
329
+ if "=" not in token:
330
+ continue
331
+ key, item = token.split("=", 1)
332
+ if not re.fullmatch(r"[A-Za-z_][\w.-]*", key):
333
+ continue
334
+ arguments[key] = _coerce_value(item)
335
+ return arguments or None
336
+
337
+
338
+ def extract_tool_call(
339
+ text: str,
340
+ allowed_names: set[str] | None = None,
341
+ ) -> tuple[dict[str, Any] | None, str]:
342
+ """Extract the first supported tool call for backward compatibility."""
343
+ calls, visible = extract_tool_calls(text, allowed_names)
344
+ return (calls[0] if calls else None), visible
345
+
346
+
347
+ def extract_tool_calls(
348
+ text: str,
349
+ allowed_names: set[str] | None = None,
350
+ ) -> tuple[list[dict[str, Any]], str]:
351
+ """Extract all tool calls while accepting Qwen's common XML variations.
352
+
353
+ Matching calls deliberately clear visible content. Agent clients should
354
+ receive structured OpenAI calls rather than Markdown/XML renditions of the
355
+ same calls before they execute the tools.
356
+ """
357
+ candidates: list[tuple[int, int, dict[str, Any]]] = []
358
+
359
+ for match in JSON_TOOL_CALL_RE.finditer(text):
360
+ call = _payload_call(
361
+ _mapping_literal(match.group("payload")),
362
+ allowed_names,
363
+ )
364
+ if call:
365
+ candidates.append((match.start(), match.end(), call))
366
+
367
+ for match in XML_JSON_TOOL_CALL_RE.finditer(text):
368
+ call = _payload_call(
369
+ _mapping_literal(match.group("payload")),
370
+ allowed_names,
371
+ )
372
+ if call:
373
+ candidates.append((match.start(), match.end(), call))
374
+
375
+ for match in STANDARD_XML_TOOL_CALL_RE.finditer(text):
376
+ body = match.group("body")
377
+ function = FUNCTION_NAME_RE.search(body)
378
+ if function:
379
+ call = _openai_call(
380
+ function.group("name"),
381
+ {
382
+ parameter.group("key"): _coerce_value(parameter.group("value"))
383
+ for parameter in PARAMETER_RE.finditer(body)
384
+ },
385
+ allowed_names,
386
+ )
387
+ if call:
388
+ candidates.append((match.start(), match.end(), call))
389
+
390
+ for match in DASHED_XML_TOOL_CALL_RE.finditer(text):
391
+ call = _openai_call(
392
+ match.group("name"),
393
+ _xml_arguments(match.group("arguments")),
394
+ allowed_names,
395
+ )
396
+ if call:
397
+ candidates.append((match.start(), match.end(), call))
398
+
399
+ for match in SELF_CLOSING_TOOL_RE.finditer(text):
400
+ tag_name = match.group("name")
401
+ attributes = _attributes(match.group("attributes"))
402
+ if tag_name.casefold() == "tool":
403
+ tool_name = attributes.pop("name", None)
404
+ arguments = _arguments(
405
+ attributes.pop("arguments", attributes.pop("args", ""))
406
+ )
407
+ if arguments is None:
408
+ arguments = attributes
409
+ else:
410
+ tool_name = tag_name
411
+ arguments = attributes
412
+ call = _openai_call(tool_name, arguments, allowed_names)
413
+ if call:
414
+ candidates.append((match.start(), match.end(), call))
415
+
416
+ for match in TEXTUAL_TOOL_CALL_RE.finditer(text):
417
+ arguments = _textual_arguments(match.group("arguments"))
418
+ if arguments is None:
419
+ continue
420
+ call = _openai_call(match.group("name"), arguments, allowed_names)
421
+ if call:
422
+ candidates.append((match.start(), match.end(), call))
423
+
424
+ assistant_called = ASSISTANT_CALLED_TOOL_RE.fullmatch(text)
425
+ if assistant_called:
426
+ call = _openai_call(
427
+ assistant_called.group("name"),
428
+ _arguments(assistant_called.group("arguments")),
429
+ allowed_names,
430
+ )
431
+ if call:
432
+ candidates.append((assistant_called.start(), assistant_called.end(), call))
433
+
434
+ if not candidates:
435
+ fenced_json = FENCED_JSON_RE.fullmatch(text)
436
+ raw_json = fenced_json.group("payload") if fenced_json else text.strip()
437
+ call = _payload_call(_mapping_literal(raw_json), allowed_names)
438
+ if call:
439
+ candidates.append((0, len(text), call))
440
+
441
+ if not candidates:
442
+ terminal_json = _terminal_json_tool_payload(text)
443
+ if terminal_json is not None:
444
+ start, end, payload = terminal_json
445
+ call = _payload_call(payload, allowed_names)
446
+ if call:
447
+ candidates.append((start, end, call))
448
+
449
+ if not candidates:
450
+ return [], text
451
+
452
+ # Different parsers can recognize the same outer wrapper. Keep one result
453
+ # per source span while preserving the order produced by the model.
454
+ unique: list[dict[str, Any]] = []
455
+ seen_spans: set[tuple[int, int]] = set()
456
+ for start, end, call in sorted(candidates, key=lambda item: (item[0], item[1])):
457
+ span = (start, end)
458
+ if span in seen_spans:
459
+ continue
460
+ seen_spans.add(span)
461
+ unique.append(call)
462
+ return unique, ""