localailb/assistant
0
1"""
2agent_streaming.py — Shared live step-streaming for every smolagents
3CodeAgent-driven chat handler in this app (General Chat / RAG Chat /
4Deep Research / Data Analysis — see chat.py / data_analysis.py).
5
6smolagents' own streamed UI (see smolagents' `gradio_ui.py` /
7`stream_to_gradio()`) streams each ActionStep/PlanningStep live by
8calling `agent.run(task, stream=True, ...)` and iterating the returned
9generator, rather than blocking silently until the whole run finishes —
10the LAST item that generator yields is the plain final answer itself
11(not wrapped in a step object). This module adapts that exact pattern to
12this app's own chat history shape (plain {"role", "content"} dicts
13served over the web UI), so every agentic tab here
14(chat.py's chat_general_agentic / chat_rag / chat_deep_research, and
15data_analysis.py's run_data_analysis()) can show live progress instead
16of a single frozen "thinking…" bubble for however long a multi-step run
17takes.
18
19Public contract
20----------------
21stream_agent_steps(agent, task, reset=True) is a generator yielding
22(step_msg, is_final, final_output, diagnostics) 4-tuples:
23
24 - While the run is still in progress: `step_msg` is a plain
25 {"role": "assistant", "content": "..."} dict describing the step
26 that just happened (or None if that particular step produced
27 nothing worth showing), `is_final` is False, `final_output` is
28 None, and `diagnostics` is None. Callers append step_msg to their
29 chat history and yield.
30
31 - On the LAST item: `step_msg` is None, `is_final` is True,
32 `final_output` holds the agent's actual final answer (whatever a
33 plain, non-streaming `agent.run()` call would have returned), and
34 `diagnostics` is a SNAPSHOT dict {total_steps,
35 max_consecutive_errors, aborted, last_error, loop_detected,
36 loop_call} of that run's failure diagnostics (see the fail-fast
37 section below) — callers use this as the "real"
38 result to post-process (citations, formatting, etc.) instead of
39 trying to parse it back out of the step stream, and read their
40 failure hint straight from `diagnostics` (no get_last_run() call
41 needed after the loop).
42
43Every call site in this app follows the same loop shape:
44
45 result = None
46 for step_msg, is_final, final_output, diag in agent_streaming.stream_agent_steps(agent, task, reset=not use_memory):
47 if not is_final:
48 if step_msg is not None:
49 history.append(step_msg)
50 yield history, ...
51 continue
52 result = final_output
53 # diag now holds this run's diagnostics snapshot
54
55Version compatibility
56----------------------
57Different smolagents releases expose live streaming slightly
58differently. This module tries, in order:
59
60 1. `agent.run(task, reset=reset, stream=True)` — the standard way to
61 get a live generator of memory steps as they happen. Each yielded
62 item is either a memory-step object (ActionStep / PlanningStep /
63 ...) or, on the very last item, the final answer itself.
64 2. If `stream=True` isn't accepted at all (raises TypeError — an
65 older smolagents without streaming support), falls back to a
66 single non-streaming `agent.run(task, reset=reset)` call and
67 yields just ONE (None, True, result, diagnostics) tuple — no live
68 steps, but the app still works exactly as it did before streaming
69 existed.
70
71Because the exact memory-step shape/attributes can also vary by
72version, step formatting below is defensive: every attribute is read
73with getattr(..., default) inside a try/except, so a field this
74installed version doesn't have (or a totally unrecognized step type)
75degrades to a shorter message — or is silently skipped — instead of
76crashing the whole run.
77"""
78
79import ast
80import html
81import json
82import re
83import threading
84import traceback
85from typing import Optional
86
87from ui import i18n
88
89# Best-effort import of smolagents' own memory-step classes, for a
90# precise isinstance() check. Falls back to matching on the class NAME
91# string (see _is_memory_step()) if this smolagents version has moved
92# or renamed these — the same defensive fallback pattern general_agent.py
93# / rag_agent.py already use for other smolagents version differences
94# (e.g. the `code_block_tags` / `instructions` kwarg probing).
95try:
96 from smolagents.memory import ActionStep, PlanningStep, TaskStep, SystemPromptStep
97 _MEMORY_STEP_TYPES = (ActionStep, PlanningStep, TaskStep, SystemPromptStep)
98except ImportError:
99 _MEMORY_STEP_TYPES = ()
100
101_MEMORY_STEP_CLASS_NAMES = (
102 "ActionStep", "PlanningStep", "TaskStep", "SystemPromptStep", "FinalAnswerStep",
103)
104
105# DeepSeek's native code fence: '<|DSML|python>…</|DSML|python>'. The
106# '|' is the full-width vertical bar U+FF5C (DeepSeek chat/reasoner models
107# emit this instead of the ASCII ```python fence), but ASCII '|' variants
108# appear in the wild too, so both are tolerated — and the ASCII closing tag
109# typically carries a LEADING pipe too ('|</DSML|python|>'), so an optional
110# pipe before the closing '</' is accepted. `python`/`py` (or no language
111# tag) is allowed inside the markers.
112_DSML_FENCE_RE = re.compile(
113 r"<\s*[\uFF5C|]\s*DSML\s*[\uFF5C|]\s*(?:python|py)?\s*[\uFF5C|]?\s*>"
114 r"(.*?)"
115 r"\s*[\uFF5C|]?\s*</\s*[\uFF5C|]?\s*DSML\s*[\uFF5C|]\s*(?:python|py)?\s*[\uFF5C|]?\s*>",
116 re.DOTALL | re.IGNORECASE,
117)
118
119# ── ReAct envelope recovery ────────────────────────────────────────────
120# llama.cpp's Gemma chat template renders smolagents tool calls as the
121# classic ReAct envelope:
122#
123# Action:
124# {
125# "name": "final_answer",
126# "arguments": { "answer": "..." }
127# }
128#
129# The primary smolagents JSON parser handles CLEAN envelopes fine, but
130# small local GGUFs frequently write UNESCAPED quotes inside the answer
131# text (or unquoted JSON keys), which makes json.loads throw — and the
132# smart fallback previously wrapped the WHOLE raw envelope into the final
133# answer as plain text (the "Action: {…} JSON leak"). These helpers
134# recover the real tool call from a malformed envelope instead.
135
136
137def _tool_call_dict(name: str, arguments) -> dict:
138 """Normalize a recovered tool call into the shape smolagents expects."""
139 return {
140 "name": name,
141 "arguments": arguments,
142 "action": name,
143 "action_input": arguments,
144 }
145
146
147# ── Small-model schema-echo recovery ───────────────────────────────────
148# A small GGUF model sometimes echoes a tool's JSON-Schema declaration back
149# as the VALUE of an argument instead of the actual value — e.g.
150#
151# web_search(query={'type': 'string', 'description': 'What is the
152# capital of France?'})
153#
154# where the description field carries what the model actually wanted to
155# search for. Without recovery the tool receives a dict where a string is
156# expected and the step hard-fails with an argument-type error, burning a
157# whole slow generation (and, repeated, tripping the fail-fast abort). The
158# helpers below unwrap schema-shaped values back into the real value the
159# model meant.
160
161
162def _looks_like_schema(value) -> bool:
163 """True when `value` is a dict that reads as a JSON-Schema declaration
164 (a 'type' key plus schema-ish metadata). Ordinary dict arguments
165 (retrieved chunks, API params, query results…) never match."""
166 return (
167 isinstance(value, dict)
168 and isinstance(value.get("type"), str)
169 and any(key in value for key in ("description", "title", "default", "properties"))
170 )
171
172
173def _unwrap_schema_value(value):
174 """Recover the intended value from a schema-shaped value. Small models
175 put the REAL content into the schema's description (the observed
176 shape), so description wins; a title is the next best string hint;
177 a default is an actual value too (any JSON type) but only as a LAST
178 resort — a schema echo with an empty default (e.g. DraftCodexSkillTool's
179 `skill_name` carries `"default": ""`) must never swallow the real
180 content sitting in the description. Non-schema values pass through
181 unchanged."""
182 if not _looks_like_schema(value):
183 return value
184 for key in ("description", "title"):
185 v = value.get(key)
186 if isinstance(v, str) and v.strip():
187 return v
188 v = value.get("default")
189 if v is not None:
190 return v
191 return value
192
193
194def _normalize_schema_echo(arguments) -> dict:
195 """Undo a small model echoing a tool's JSON Schema as its arguments.
196
197 Two shapes are handled:
198 1. Whole-schema arguments — the ENTIRE arguments dict is a schema
199 object ({type: 'object', properties: {...}}); rebuild the real
200 arguments by unwrapping each property.
201 2. Per-value schema echo — one argument's VALUE is a schema dict
202 (web_search(query={'type': 'string', 'description': '…'})).
203
204 Anything not schema-shaped passes through untouched."""
205 if not isinstance(arguments, dict):
206 return arguments
207 props = arguments.get("properties")
208 if (
209 arguments.get("type") == "object"
210 and isinstance(props, dict)
211 and any(_looks_like_schema(v) for v in props.values())
212 ):
213 return {
214 k: (_unwrap_schema_value(v) if isinstance(v, dict) else v)
215 for k, v in props.items()
216 }
217 out = dict(arguments)
218 for key in out:
219 out[key] = _unwrap_schema_value(out[key])
220 return out
221
222
223def _match_braces(text: str, start: int) -> Optional[str]:
224 """Return the balanced {...} block starting at text[start] (must be
225 '{'), or None if the block is unterminated (truncated model output)."""
226 depth = 0
227 i = start
228 while i < len(text):
229 ch = text[i]
230 if ch == "{":
231 depth += 1
232 elif ch == "}":
233 depth -= 1
234 if depth == 0:
235 return text[start:i + 1]
236 i += 1
237 return None
238
239
240def _recover_answer_from_args(args_blob: str) -> Optional[dict]:
241 """Last-resort extraction of the 'answer' string from a malformed
242 arguments object. Tolerates unescaped inner quotes by taking everything
243 up to the LAST quote in the fragment — the corrupted inner quotes all
244 come BEFORE the real closing quote of the answer string."""
245 m = re.search(r'''["']?answer["']?\s*:\s*["']''', args_blob)
246 if not m:
247 return None
248 tail = args_blob[m.end():]
249 close = tail.rfind('"')
250 if close == -1:
251 close = tail.rfind("'")
252 if close == -1:
253 return None
254 return {"answer": tail[:close].strip()}
255
256
257def _parse_react_envelope(text: str) -> Optional[dict]:
258 """Best-effort recovery of a ReAct 'Action:' tool-call envelope whose
259 inner JSON the primary parser rejected. Returns a normalized tool-call
260 dict ({name, arguments, action, action_input}) or None if `text` is not
261 a recoverable envelope.
262
263 The marker is searched ANYWHERE (the LAST 'Action:' line wins), not just
264 at the start of the text: a small model narrates first ('Final answer:
265 …'), leaks search-result fragments BEFORE the envelope, and smolagents'
266 own step log ('[Step 2: Duration …]') trails AFTER it — the observed
267 E2B final-output shape. Requiring the envelope at position 0 made all
268 of those leak the raw narration + envelope as the final answer."""
269 m = None
270 for _m in re.finditer(r'(?ism)^\s*Action:?\s*(\{.*)$', text):
271 m = _m # keep the LAST 'Action:' marker — the final action
272 if m is None:
273 return None
274 blob = m.group(1).strip()
275 end = blob.rfind("}")
276 if end == -1:
277 return None
278 blob = blob[:end + 1].strip()
279 if not blob:
280 return None
281
282 # 1. Clean JSON — the primary parser normally handles this before the
283 # fallback runs, but stay robust anyway.
284 try:
285 data = json.loads(blob, strict=False)
286 if isinstance(data, dict):
287 name = str(data.get("name") or data.get("action") or "final_answer")
288 arguments = data.get("arguments")
289 if arguments is None:
290 arguments = {"answer": data.get("answer", "")}
291 if isinstance(arguments, str):
292 arguments = {"answer": arguments}
293 return _tool_call_dict(name, arguments)
294 except Exception:
295 pass
296
297 # 2. Bare "{tool_name: <value>}" shape — Gemma's unquoted-key style,
298 # e.g. Action: {final_answer: "..."}
299 m_bare = re.match(r'^\{\s*([a-zA-Z0-9_]+)\s*:\s*(.*)\}\s*$', blob, re.DOTALL)
300 if m_bare:
301 name = m_bare.group(1).strip()
302 val = m_bare.group(2).strip()
303 try:
304 parsed = json.loads(val, strict=False)
305 except Exception:
306 parsed = None
307 if isinstance(parsed, dict):
308 return _tool_call_dict(name, parsed)
309 if val.lstrip().startswith("{"):
310 # Value is itself an object with (probably unquoted) keys —
311 # recover its 'answer' field the same way step 3 does.
312 recovered = _recover_answer_from_args(val)
313 if recovered:
314 return _tool_call_dict(name, recovered)
315 return _tool_call_dict(name, {"answer": val.strip('"\'')})
316
317 # 3. Malformed multi-key JSON — recover name and arguments separately.
318 name = None
319 m_name = re.search(r'''["']?name["']?\s*:\s*["']([^"']+)["']''', blob)
320 if m_name:
321 name = m_name.group(1).strip()
322 arguments = None
323 m_args = re.search(r'''["']?arguments["']?\s*:\s*(\{)''', blob)
324 if m_args:
325 args_blob = _match_braces(blob, m_args.start(1))
326 if args_blob:
327 try:
328 arguments = json.loads(args_blob, strict=False)
329 except Exception:
330 arguments = _recover_answer_from_args(args_blob)
331 if name and arguments:
332 if isinstance(arguments, str):
333 arguments = {"answer": arguments}
334 return _tool_call_dict(name, arguments)
335 return None
336
337
338_INVALID_CODE_CHAR_MAP = {
339 # Dashes and hyphens
340 "\u2010": "-", # hyphen
341 "\u2011": "-", # non-breaking hyphen
342 "\u2012": "-", # figure dash
343 "\u2013": "-", # en dash
344 "\u2014": "-", # em dash
345 "\u2015": "-", # horizontal bar
346 "\u2212": "-", # minus sign
347 # Quotation marks
348 "\u2018": "'", # left single quote
349 "\u2019": "'", # right single quote
350 "\u201a": "'", # single low-9 quotation mark
351 "\u201b": "'", # single high-reversed-9 quotation mark
352 "\u201c": '"', # left double quote
353 "\u201d": '"', # right double quote
354 "\u201e": '"', # double low-9 quotation mark
355 "\u201f": '"', # double high-reversed-9 quotation mark
356 "\u00ab": '"', # left-pointing double angle quotation mark
357 "\u00bb": '"', # right-pointing double angle quotation mark
358 "\u2039": "'", # single left-pointing angle quotation mark
359 "\u203a": "'", # single right-pointing angle quotation mark
360 # Punctuation & spaces
361 "\u2026": "...", # ellipsis
362 "\u00a0": " ", # non-breaking space
363 "\u202f": " ", # narrow no-break space
364 "\u2009": " ", # thin space
365 "\u2002": " ", # en space
366 "\u2003": " ", # em space
367 # Zero-width / control noise
368 "\u200b": "", # zero width space
369 "\u200c": "", # zero width non-joiner
370 "\u200d": "", # zero width joiner
371 "\ufeff": "", # zero width no-break space / BOM
372}
373
374
375def sanitize_python_text(text: str) -> str:
376 """Best-effort ASCII normalization of characters that CPython's
377 tokenizer REJECTS outright when they appear in generated code:
378 em/en dashes (a Gemma-style CodeAgent frequently copies a search
379 result title like '2026 FIFA World Cup — Wikipedia' straight into
380 its Python block), typographic quotes, ellipses, NBSP. Returns the
381 text unchanged when it contains nothing problematic. NEVER raises.
382
383 This is NOT an attempt to fix genuinely broken Python — it only
384 replaces characters that are categorically invalid as tokens, so a
385 small model's "code" that is really just scraped prose degrades to
386 a parseable string instead of a hard 'Code parsing failed on line
387 N due to: SyntaxError: invalid character' step failure."""
388 if not text or not any(ch in text for ch in _INVALID_CODE_CHAR_MAP):
389 return text
390 for ch, repl in _INVALID_CODE_CHAR_MAP.items():
391 if ch in text:
392 text = text.replace(ch, repl)
393 return text
394
395
396def _strip_code_xml_tags(text: str) -> str:
397 """Strip leaked tool-call XML tags (e.g. `</arg_value></tool_call>`,
398 `<tool_call>`, `<|tool_call|>`, `</tool_call>`, `</arg_value>`, etc.)
399 that models (e.g. GLM-4.7-Flash, Qwen, Gemma) append or wrap around
400 Python code blocks or `final_answer(...)` calls. Returns the text
401 unchanged when no XML/tool tags are found. NEVER raises."""
402 if not text or "<" not in text:
403 return text
404 # Strip trailing XML/tool-call tags (e.g. </arg_value></tool_call>, </tool_call>)
405 cleaned = re.sub(r"(?:\s*</?[a-zA-Z0-9_\-|:]+>\s*)+$", "", text).rstrip()
406 # Strip leading XML/tool-call tags (e.g. <tool_call><arg_name>...</arg_name><arg_value>)
407 cleaned = re.sub(r"^(?:\s*</?[a-zA-Z0-9_\-|:]+>\s*)+", "", cleaned).lstrip()
408 return cleaned
409
410
411# ── Windows-path escape recovery ─────────────────────────────────────
412# A raw single-backslash Windows path (e.g. G:\...\uploads\...) inside a
413# Python string literal is NOT valid source: '\u' in '\uploads' trips
414# "SyntaxError: (unicode error) 'unicodeescape' codec can't decode bytes
415# ... truncated \uXXXX escape" (and '\l' / '\d' / octal digits silently
416# corrupt the string value on some Python versions). This is the #1
417# reason a REAL code block — the Data Analysis manager delegating to
418# data_worker with the dataset path — fails ast.parse() and the smart
419# fallback mis-wraps the whole program as a "final answer" instead of
420# running it. The fix doubles every backslash that is NOT an
421# unambiguous, well-formed escape ('\\', quotes, valid \x/\u/\U/\N,
422# line-continuation), so 'G:\...\uploads\...' becomes the valid literal
423# 'G:\\...\\uploads\\...' with the SAME raw path value. Only ever a
424# fallback for code that already failed to parse; valid escapes are
425# preserved verbatim.
426_VALID_STRING_ESCAPE_RE = re.compile(
427 r"\\[\\'\"]"
428 r"|\\x[0-9a-fA-F]{2}"
429 r"|\\u[0-9a-fA-F]{4}"
430 r"|\\U[0-9a-fA-F]{8}"
431 r"|\\N\{[^}]*\}"
432 r"|\\\r?\n"
433)
434
435
436def _neutralize_invalid_escapes(text: str) -> str:
437 """Double every backslash that is not part of an unambiguous valid
438 Python string escape, turning a raw Windows path into a valid string
439 literal with the same value. Well-formed escapes ('\\n', '\\t', '\\"',
440 '\\\\', '\\u0041', ...) pass through untouched. Returns the text
441 unchanged when there is nothing to fix. NEVER raises."""
442 if not text or "\\" not in text:
443 return text
444 out = []
445 i = 0
446 n = len(text)
447 while i < n:
448 ch = text[i]
449 if ch != "\\":
450 out.append(ch)
451 i += 1
452 continue
453 m = _VALID_STRING_ESCAPE_RE.match(text, i)
454 if m:
455 out.append(m.group(0))
456 i = m.end()
457 else:
458 out.append("\\\\")
459 i += 1
460 fixed = "".join(out)
461 return fixed if fixed != text else text
462
463
464# ── DeepSeek DSML code-fence recovery ─────────────────────────────────
465# DeepSeek-V4 / DeepSeek-R1 family models (HF Inference API) emit their
466# Python code inside DeepSeek's OWN code fence instead of the ```python
467# fence smolagents' CodeAgent expects:
468#
469# <|DSML|python>
470# import pandas as pd
471# df = pd.read_excel(path)
472# print(df.shape)
473# </|DSML|python>
474#
475# The fence markers use the full-width vertical bar '|' (U+FF5C), so
476# smolagents' extract_code_from_text() (which looks for ```python) never
477# matches, ast.parse() on the raw fence text fails, and the smart fallback
478# used to wrap the WHOLE fence (markers + code) into final_answer("""…""").
479# On the Data Analysis tab that wrapped string then blows up with
480# "SyntaxError: (unicode error) 'unicodeescape' … truncated \uXXXX escape"
481# (a raw Windows path like G:\...\uploads\… inside the triple-quoted
482# string) — the analysis never executes and the model burns its whole step
483# budget retrying, then surrenders with raw code as the "final answer".
484# Unwrapping the fence back to its inner code is the root-cause fix.
485
486
487def _unwrap_dsml_fence(text: str):
488 """Extract Python code from a DeepSeek '<|DSML|python>…</|DSML|python>'
489 fence (tolerating ASCII '|' markers too). Returns the inner code, or
490 None when no DSML fence is present (so the caller falls through to the
491 other recovery paths)."""
492 if not text or "<" not in text:
493 return None
494 m = _DSML_FENCE_RE.search(text)
495 if not m:
496 return None
497 code = m.group(1).strip()
498 return code or None
499
500
501# ── Qwen / Gemma native tool-call recovery ──────────────────────────────
502# Qwen and Gemma-family local GGUFs frequently emit their OWN native tool
503# call syntax instead of smolagents' ReAct envelope or a ```python fence:
504#
505# thought
506# thought
507# <|tool_call|>call:list_columns(file_path='G:\\...\\file.xlsx')<tool_call|>
508#
509# Sometimes the call is even wrapped inside a final_answer("""…""") string
510# (the model echoes the CodeAgent idiom it was trained on). On the
511# CodeAgent path (Data Analysis) that wrapped shape is VALID Python, so
512# smolagents' parse_code_blobs returns the leaked call as the final answer
513# and the tool never runs — the model then wastes the next step narrating
514# "Let me see the output of the list_columns call…" with no output to see,
515# and eventually surrenders with raw code as the "final answer". The
516# helpers below detect the native blocks and rewrite them into plain
517# Python tool calls (a bare `tool(args)` expression resolves the tool in
518# the executor and its result becomes the step's observation), or recover
519# them as normalized tool-call dicts for the ToolCallingAgent JSON path.
520
521# The Qwen token renders as <|tool_call|> on BOTH sides (no closing
522# slash), while Gemma-style output closes with <tool_call|> — the pipe is
523# optional on either side, so both shapes are tolerated.
524_NATIVE_TOOL_CALL_RE = re.compile(
525 r"<\|?tool_call\|?>\s*(.*?)\s*<\|?/?tool_call\|?>",
526 re.DOTALL,
527)
528
529# Gemma-family `call:` envelope closed by EITHER a `</tool_call>`-style
530# tag or (the observed E4B shapes) a closing ``` code fence — see
531# _rewrite_native_tool_calls. Two bodies, both of which the old
532# _NATIVE_TOOL_CALL_RE (tag-closing-only) leaked:
533# * `call:python\n<whole program>` — the body is executable Python
534# (assignment + print of a managed-agent / tool call);
535# * `call:list_columns(file_path='…')` — a BARE call whose envelope is
536# closed by a fence instead of a tag.
537_CALL_BLOCK_RE = re.compile(
538 r"<\|?tool_call\|?>\s*call:([a-zA-Z0-9_]+)\s*(.*?)\s*(?:<\|?/?tool_call\|?>|```)",
539 re.DOTALL,
540)
541
542
543def _py_literal(value) -> str:
544 """Render a JSON-ish value as a valid Python literal expression
545 (JSON's true/false/null become True/False/None; Khmer strings stay
546 readable via ensure_ascii=False)."""
547 if isinstance(value, bool):
548 return "True" if value else "False"
549 if value is None:
550 return "None"
551 if isinstance(value, str):
552 return json.dumps(value, ensure_ascii=False)
553 if isinstance(value, (int, float)):
554 return repr(value)
555 if isinstance(value, dict):
556 return "{" + ", ".join(
557 f"{_py_literal(k)}: {_py_literal(v)}" for k, v in value.items()
558 ) + "}"
559 if isinstance(value, (list, tuple)):
560 return "[" + ", ".join(_py_literal(x) for x in value) + "]"
561 return json.dumps(value, ensure_ascii=False)
562
563
564def _quote_unquoted_keys(text: str) -> str:
565 """Quote unquoted JSON-ish keys (Gemma's call:tool{key: value}) so the
566 blob becomes parseable JSON."""
567 return re.sub(r"([\"']?)([a-zA-Z0-9_]+)([\"']?)\s*:", r'"\2":', text)
568
569
570def _braces_to_dict(brace_args: str) -> Optional[dict]:
571 """Parse Gemma-style '{key: value, ...}' tool arguments into a dict:
572 strict JSON first, then single-quoted / unquoted keys, then a
573 last-resort regex pair split. Returns None when nothing parses."""
574 if not brace_args.strip():
575 return {}
576 for attempt in (brace_args, _quote_unquoted_keys(brace_args)):
577 try:
578 parsed = json.loads("{" + attempt + "}", strict=False)
579 if isinstance(parsed, dict):
580 return parsed
581 except Exception:
582 continue
583 pairs = re.findall(r"""["']?([a-zA-Z0-9_]+)["']?\s*:\s*(".*?"|'.*?'|[^,}]+)""", brace_args, re.DOTALL)
584 if not pairs:
585 return None
586 out = {}
587 for key, val in pairs:
588 out[key] = val.strip().strip('\"\'')
589 return out
590
591
592def _parse_native_call(blob: str):
593 """Parse ONE native tool-call body into (name, args_text, args_dict):
594 - args_dict is a dict when the arguments were written in JSON-ish
595 braces form ({"file_path": "..."} or call:tool{key: 'val'});
596 - args_text is the verbatim Python kwargs text when the model wrote
597 key='val' style inside parens (the observed Qwen shape).
598 Returns None when the body isn't a parseable native call."""
599 blob = blob.strip()
600 if not blob:
601 return None
602 # GLM-4.7-Flash (the llama-server native tool-call format) appends a
603 # stray `</arg_value>` XML fragment AFTER the call's close paren, before
604 # the `</tool_call>` close tag (observed end-to-end: the manager's
605 # intended `data_worker(task='…')` delegation carried it, which broke
606 # the anchored `\s*$` parse below and leaked the whole envelope). Strip
607 # trailing tag-ish garbage so the call still parses cleanly.
608 blob = re.sub(r"(?:</?[a-zA-Z_][a-zA-Z0-9_]*>\s*)+$", "", blob).rstrip()
609 if not blob:
610 return None
611 if blob.startswith("{"):
612 try:
613 data = json.loads(blob, strict=False)
614 except Exception:
615 data = None
616 if not isinstance(data, dict):
617 return None
618 name = data.get("name") or data.get("action")
619 arguments = data.get("arguments")
620 if not name or not isinstance(arguments, dict):
621 return None
622 return (str(name), "", dict(arguments))
623 m = re.match(
624 r"^(?:call\s*:\s*)?([a-zA-Z0-9_]+)\s*(?:\((.*)\)|\{(.*)\})\s*$",
625 blob,
626 re.DOTALL,
627 )
628 if not m:
629 return None
630 name = m.group(1).strip()
631 brace_args = m.group(3)
632 if brace_args is not None:
633 args_dict = _braces_to_dict(brace_args)
634 if args_dict is None:
635 return None
636 return (name, "", args_dict)
637 args_text = (m.group(2) or "").strip()
638 # A JSON object inside the parens (call:tool({"key": "v"})) isn't the
639 # Python kwargs the tool expects — convert it to kwargs.
640 if args_text.startswith("{"):
641 try:
642 data = json.loads(args_text, strict=False)
643 if isinstance(data, dict):
644 return (name, "", dict(data))
645 except Exception:
646 pass
647 return (name, args_text, None)
648
649
650def _native_expr_parses(expr: str) -> bool:
651 try:
652 ast.parse(expr)
653 return True
654 except SyntaxError:
655 return False
656
657
658def _native_call_to_python(blob: str) -> Optional[str]:
659 """Rewrite ONE native tool-call body into a plain Python expression
660 ('list_columns(file_path="...")') the CodeAgent executor can run. The
661 tools live in the executor's environment, so the bare call executes the
662 tool and its result becomes the step's observation. Returns None when
663 the body isn't a parseable native call."""
664 parsed = _parse_native_call(blob)
665 if parsed is None:
666 return None
667 name, args_text, args_dict = parsed
668 if name == "final_answer":
669 # A native envelope that CALLS final_answer (Gemma-family
670 # `call:final_answer(report="...")` and the nested-inside-string
671 # variant) is a final-answer delivery, not a tool call. The
672 # FinalAnswerTool only accepts a POSITIONAL `answer` — a model that
673 # emits `final_answer(report="...")` (or answer=/text=) hard-fails
674 # with "unexpected keyword argument 'report'". Normalize the payload
675 # to the positional triple-quoted form so the report text survives.
676 payload = None
677 if args_dict is not None:
678 for val in args_dict.values():
679 if isinstance(val, str):
680 payload = val
681 break
682 else:
683 m_kw = re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*(.*)$", args_text.strip(), re.DOTALL)
684 raw = m_kw.group(1) if m_kw else args_text.strip()
685 raw = raw.strip()
686 if raw.startswith('"""') and raw.endswith('"""'):
687 payload = raw[3:-3]
688 elif len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'":
689 payload = raw[1:-1]
690 else:
691 payload = raw
692 if payload is not None:
693 payload = sanitize_python_text(payload)
694 payload = _neutralize_invalid_escapes(payload)
695 escaped = payload.replace('"""', '\\"\\"\\"')
696 expr = f'final_answer("""{escaped}""")'
697 if _native_expr_parses(expr):
698 return expr
699 if args_dict is not None:
700 kwargs = ", ".join(
701 f"{key}={_py_literal(val)}" for key, val in args_dict.items()
702 )
703 expr = f"{name}({kwargs})"
704 else:
705 # The parens form is Python-kwargs text (task='…'). Re-render it
706 # as a dict FIRST: the verbatim text can carry raw NEWLINES (the
707 # observed GLM-4.7-Flash delegation task spans several lines — a
708 # literal newline inside a '…' string is a SyntaxError), embedded
709 # quotes, and double-backslash Windows paths that a verbatim
710 # re-wrap can never survive. json.dumps escaping (via _py_literal)
711 # renders the SAME value as a valid literal.
712 kwargs_dict = _kwargs_text_to_dict(args_text)
713 if kwargs_dict is not None:
714 kwargs = ", ".join(
715 f"{key}={_py_literal(val)}" for key, val in kwargs_dict.items()
716 )
717 expr = f"{name}({kwargs})"
718 if _native_expr_parses(expr):
719 return expr
720 expr = f"{name}({args_text})"
721 if not _native_expr_parses(expr):
722 # A raw Windows path (single backslashes, e.g. G:\user_data\...)
723 # is not valid Python source — '\u' trips a 'truncated \uXXXX'
724 # unicodeescape SyntaxError. Doubling the backslashes makes the
725 # string literal valid while keeping the SAME raw path value.
726 fixed_args = args_text.replace("\\", "\\\\")
727 expr = f"{name}({fixed_args})"
728 if not _native_expr_parses(expr):
729 return None
730 return expr
731
732
733def _kwargs_text_to_dict(args_text: str) -> Optional[dict]:
734 """Parse Python-kwargs-style call arguments ('task="…", file_path="…"'
735 — with raw newlines, embedded quotes and Windows paths) into a dict of
736 string values, tolerantly: strict JSON first, then a regex pair split
737 (same pattern the ToolCallingAgent path uses). Returns None when
738 nothing parses. Values keep their exact bytes — the caller re-renders
739 them with proper Python-literal escaping."""
740 if not args_text or not args_text.strip():
741 return {}
742 try:
743 data = json.loads("{" + args_text + "}", strict=False)
744 if isinstance(data, dict):
745 return data
746 except Exception:
747 pass
748 pairs = re.findall(
749 r'([a-zA-Z0-9_]+)\s*[:=]\s*(".*?"|\'.*?\'|[^,}]+)', args_text, re.DOTALL
750 )
751 if not pairs:
752 return None
753 out = {}
754 for key, val in pairs:
755 out[key] = val.strip().strip('\"\'')
756 return out
757
758
759# Some models (Qwen3-4B-Q8_0 observed end-to-end) emit a `from tools
760# import list_columns` line — the smolagents tutorial idiom for running
761# tool calls. This sandbox exposes tools as PLAIN CALLABLES in the
762# executor's static_tools (there is no `tools` module), so that import
763# raises `InterpreterError: Module tools has no attribute list_columns`
764# and the worker's step fails. The names ARE available bare, so stripping
765# the import (and the equivalent `tools.list_columns(...)` attribute
766# access) lets the code run — the same tool, reached the way the sandbox
767# actually provides it.
768_TOOLS_IMPORT_LINE_RE = re.compile(r"(?m)^[ \t]*from[ \t]+tools[ \t]+import[ \t]+[^\r\n]*$")
769_TOOLS_ATTR_CALL_RE = re.compile(r"\btools\.([a-zA-Z_][a-zA-Z0-9_]*)\s*(\()")
770
771
772def _normalize_tools_module_idiom(text: str) -> str:
773 """Rewrite the `from tools import X` / `tools.X(...)` idiom into plain
774 bare-name calls the sandbox's static_tools resolve. Pure text
775 normalization; never raises. Leaves every other import untouched."""
776 if not text:
777 return text
778 text = _TOOLS_IMPORT_LINE_RE.sub("", text)
779 return _TOOLS_ATTR_CALL_RE.sub(r"\1\2", text)
780
781
782def _unwrap_nested_final_answer(text: str) -> Optional[str]:
783 """Detect an INNER final_answer(...) call embedded inside an OUTER
784 final_answer wrapper string (the E2B manager shape observed end-to-end:
785 the model narrates its reasoning as a `final_answer` triple-quoted
786 string and then delivers the REAL report as a nested
787 `final_answer(report=...)` call before the closing fence/quote). The
788 outer call is VALID Python, so
789 the original parser returns the whole leaked narration as the final
790 answer (the leaked-code validator then rejects it). Extract the
791 innermost call and normalize it — the same kwargs-to-positional handling
792 _native_call_to_python applies — so the real report text survives.
793 Returns None when there is no nested call."""
794 if not text or text.count("final_answer(") < 2:
795 return None
796 inner = text.rfind("final_answer(")
797 tail = text[inner:]
798 # The inner call is closed by a ``` fence (observed) or the outer
799 # string's own quotes — cut the tail there so rfind(')') in the
800 # final_answer recovery sees the INNER call's close, not the outer
801 # wrapper's.
802 fence = tail.find("```")
803 if fence != -1:
804 tail = tail[:fence]
805 expr = _native_call_to_python(tail)
806 if expr is not None and _native_expr_parses(expr):
807 return expr
808 return None
809
810
811def _recover_final_answer_call(text: str) -> Optional[str]:
812 """Recover a final_answer(...) call whose string argument contains
813 unescaped quotes (the E4B failure: a report body with `24" Monitor` or
814 quoted Khmer column names inside `final_answer("...")`, which is an
815 unterminated string literal and fails ast.parse). The smart fallback's
816 non-greedy final_answer-regex would cut the payload at the
817 FIRST ')' inside the report, so this helper takes everything up to the
818 LAST ')' and re-wraps it with triple-quote escaping — the answer text
819 is preserved verbatim and the emitted code always parses. Returns None
820 when the text holds no recoverable final_answer call (normal parsing
821 proceeds)."""
822 if not text:
823 return None
824 start = text.find("final_answer(")
825 if start == -1:
826 return None
827 open_idx = start + len("final_answer(")
828 end = text.rfind(")")
829 if end <= open_idx:
830 return None
831 payload = text[open_idx:end]
832 # Strip a wrapping quote layer the model added (the call was
833 # final_answer("...") or final_answer("""...""")), so the payload is
834 # the raw report text.
835 payload = payload.strip()
836 if payload.startswith('"""') and payload.endswith('"""'):
837 payload = payload[3:-3]
838 elif payload.startswith('"') and payload.endswith('"'):
839 payload = payload[1:-1]
840 elif payload.startswith("'") and payload.endswith("'"):
841 payload = payload[1:-1]
842 payload = _strip_code_xml_tags(payload)
843 payload = sanitize_python_text(payload)
844 payload = _neutralize_invalid_escapes(payload)
845 escaped = payload.replace('"""', '\\"\\"\\"')
846 expr = f'final_answer("""{escaped}""")'
847 try:
848 import ast
849 ast.parse(expr)
850 return expr
851 except Exception:
852 return None
853
854
855def _payload_from_final_answer_args(args_dict, args_text: str) -> Optional[str]:
856 """Extract the report payload from a final_answer call's parsed
857 arguments, PRESERVING the text verbatim (no tokenizer sanitize — this is
858 a displayed answer, and em-dashes / typographic quotes are legitimate
859 there). Handles both the JSON-braces form (preferring the conventional
860 `answer` / `report` / `text` keys) and the Python-kwargs form
861 (`final_answer(report="…")` / positional quoted strings). Returns None
862 when no string payload is found."""
863 if args_dict is not None:
864 for key in ("answer", "report", "text"):
865 val = args_dict.get(key)
866 if isinstance(val, str) and val.strip():
867 return val
868 for val in args_dict.values():
869 if isinstance(val, str) and val.strip():
870 return val
871 return None
872 m = re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*(.*)$", (args_text or "").strip(), re.DOTALL)
873 raw = (m.group(1) if m else (args_text or "")).strip()
874 if raw.startswith('"""') and raw.endswith('"""'):
875 return raw[3:-3]
876 if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'":
877 return raw[1:-1]
878 return raw or None
879
880
881# ── Chat-template token leak recovery ─────────────────────────────────
882# A model can leak its chat template's control tokens into a final answer
883# instead of (or trailing) real content. The observed case: Muse Glimmer
884# (Meta's agent model — its template frames turns as
885# <|start|>role<|message|>…<|eot|> and the assistant writes answer
886# channels, "to=user" for the reply, "to=self" for reasoning) — a
887# community "-UD-" GGUF quant emitted its channel-close fragment
888# "to=user<|eot|>" as the WHOLE payload of a final_answer tool call. The
889# fragments below are tokenizer/control tokens and channel markers —
890# never legitimate answer text — so stripping them from a delivered
891# answer is safe, and the shared final-answer validator
892# (agent_factory.reject_leaked_or_empty_final_answer) rejects an answer
893# that becomes EMPTY after the strip (pure template garbage) instead of
894# shipping it as the answer bubble.
895
896_TEMPLATE_TOKEN_RE = re.compile(
897 r"<\|?(?:"
898 r"start|message|eot|im_start|im_end|endoftext|eos|eot_id|eos_id|"
899 r"start_of_turn|end_of_turn|tool_call|/?tool_call|think|/?think|"
900 r"reasoning|/?reasoning"
901 r")\|?>|</s>",
902 re.IGNORECASE,
903)
904
905# Muse Glimmer's channel markers ("to=user" / "to=assistant" / "to=self" /
906# "to=<tool>"). The fragment must be STANDALONE — preceded by whitespace/
907# start (so `--to=user` or `param.to=user` in a coding answer is never
908# touched) and NOT continuing into an identifier ('to=users', 'to=user_id'
909# survive) — but ANY non-identifier follower is allowed, so a BARE marker
910# with no trailing <|eot|> token is still caught: 'to=self```' (glued to a
911# code-fence opener), 'to=user###', 'to=self' at end of text.
912_CHANNEL_MARKER_RE = re.compile(
913 r"(?<![^\s])to=(?:user|assistant|self|<[a-z_][a-z0-9_]*>)(?![A-Za-z0-9_])",
914 re.IGNORECASE,
915)
916
917# After the marker is stripped, a marker glued to a code-fence opener
918# ('to=self```') leaves only the fence behind — pure-fence residue is
919# still the leak, never answer content.
920_FENCE_ONLY_RE = re.compile(r"^[\s`]+$")
921
922
923def strip_template_tokens(text: str) -> str:
924 """Remove chat-template control tokens (Qwen3's <|eot|>, ChatML's
925 <|im_start|>/<|im_end|>, Llama-3's <|eot_id|>/<|eos_id|>, Gemma's
926 <|start_of_turn|>/<|end_of_turn|>, <|endoftext|>, </s>, Qwen native
927 <|tool_call|>, Muse Glimmer's <|start|>/<|message|>/channel markers
928 — both with the <|eot|> token (to=user<|eot|>) and BARE with no token
929 at all (to=self```, to=user###, to=user at end of text; the <tool>
930 channel form to=<tool> is covered too). Real content around the
931 tokens survives ("…X.<|eot|>" → "…X."); the whole text disappears
932 only when it WAS the leak. Returns the text unchanged when nothing
933 matches. NEVER raises."""
934 if not text or not isinstance(text, str):
935 return text
936 if "<" not in text and "to=" not in text:
937 return text
938 # The channel-marker pass runs BEFORE and AFTER the token pass: a
939 # marker only becomes standalone once a preceding token is removed
940 # ("</s>to=assistant" → "to=assistant").
941 text = _CHANNEL_MARKER_RE.sub("", text)
942 text = _TEMPLATE_TOKEN_RE.sub("", text)
943 text = _CHANNEL_MARKER_RE.sub("", text)
944 # The removals can leave doubled spaces ("… X. <|eot|>" → "… X. ").
945 text = re.sub(r" {2,}", " ", text)
946 text = text.strip()
947 # A bare channel marker glued to a code-fence opener ('to=self```')
948 # leaves only the fence behind — pure-fence residue is still the leak.
949 if text and _FENCE_ONLY_RE.match(text):
950 return ""
951 return text
952
953
954# ── Degenerate-repetition detection ──────────────────────────────────
955# A broken generation — e.g. a TEXT request that landed on an EMBEDDING
956# model through a shared llama-server port (the observed "skills skills
957# skills …" failure) — produces one token repeated until the token budget
958# is exhausted. No real answer looks like that, so it can be detected
959# cheaply and rejected before it is shipped as an answer or fed back as a
960# giant garbage observation.
961_DEGEN_MIN_TOKENS = 100 # only judge answers with enough material
962_DEGEN_MAX_RUN = 30 # >30 consecutive identical tokens = loop
963_DEGEN_DOMINANT_RATIO = 0.5 # one token > half of a long answer = loop
964_DEGEN_MAX_CHAR_RUN = 200 # one letter/digit repeated 200+ times = loop
965
966# Alphanumeric only, so decorative rules ('──────', '══════') and table
967# borders are never flagged; U+1780–U+17FF covers Khmer letters.
968_DEGEN_CHAR_LOOP_RE = re.compile(
969 r"([A-Za-z0-9\u1780-\u17FF])\1{%d,}" % (_DEGEN_MAX_CHAR_RUN - 1)
970)
971
972
973def is_degenerate_repetition(text) -> bool:
974 """True when `text` is degenerate token repetition instead of an
975 answer: one word/token repeated over and over ("skills skills skills
976 …") until the budget ran out, or a single letter/digit repeated
977 hundreds of times in a row.
978
979 Deliberately conservative so real answers are never rejected:
980 - fewer than _DEGEN_MIN_TOKENS whitespace tokens is never judged
981 (Khmer prose splits into very few whitespace tokens);
982 - the word-level checks need _DEGEN_MAX_RUN consecutive identical
983 tokens, or one token filling more than half of a long answer;
984 - the character-level check fires only on ALPHANUMERIC runs of
985 _DEGEN_MAX_CHAR_RUN+ — decorative rules survive.
986 NEVER raises."""
987 try:
988 if not text or not isinstance(text, str):
989 return False
990 tokens = text.split()
991 if len(tokens) >= _DEGEN_MIN_TOKENS:
992 norm = [t.lower() for t in tokens]
993 run = best = 1
994 for i in range(1, len(norm)):
995 run = run + 1 if norm[i] == norm[i - 1] else 1
996 if run > best:
997 best = run
998 if best >= _DEGEN_MAX_RUN:
999 return True
1000 counts = {}
1001 for t in norm:
1002 counts[t] = counts.get(t, 0) + 1
1003 if max(counts.values()) / len(norm) > _DEGEN_DOMINANT_RATIO:
1004 return True
1005 return bool(_DEGEN_CHAR_LOOP_RE.search(text))
1006 except Exception:
1007 return False
1008
1009
1010def _payload_from_openai_call(call) -> Optional[str]:
1011 """Extract the payload of a final_answer call from ONE OpenAI-style
1012 tool-call dict (the shape smolagents prints in its 'Calling tools:'
1013 line). `arguments` may be a dict or a JSON-encoded string; the payload
1014 key may be `answer` / `report` / `text` / `answer_text`."""
1015 if not isinstance(call, dict):
1016 return None
1017 fn = call.get("function")
1018 if not isinstance(fn, dict):
1019 return None
1020 if str(fn.get("name", "")).strip() != "final_answer":
1021 return None
1022 arguments = fn.get("arguments")
1023 if isinstance(arguments, str):
1024 try:
1025 arguments = json.loads(arguments)
1026 except Exception:
1027 arguments = None
1028 if not isinstance(arguments, dict):
1029 return None
1030 payload = _payload_from_final_answer_args(arguments, "")
1031 return payload if payload and payload.strip() else None
1032
1033
1034_CALLING_TOOLS_ENVELOPE_RE = re.compile(
1035 r"Calling tools\s*:\s*(\[.*\])", re.DOTALL | re.IGNORECASE)
1036
1037
1038def _payload_from_calling_tools_envelope(text) -> Optional[str]:
1039 """Recover the final_answer payload from smolagents' OWN 'Calling
1040 tools:' line followed by a Python-repr list of OpenAI-style tool calls
1041 — the shape a ToolCallingAgent model (observed: Muse Glimmer via
1042 llama-server) emits when it writes the final_answer call as PLAIN TEXT
1043 instead of a structured tool call:
1044
1045 Calling tools:
1046 [{'id': 'a1b2c3d4-…', 'type': 'function',
1047 'function': {'name': 'final_answer',
1048 'arguments': {'answer_text': '### …'}}}]
1049
1050 The max-steps fallback then ships that text verbatim as the answer
1051 bubble. Returns the payload string, or None when the text isn't such
1052 an envelope (normal parsing proceeds). The literal_eval path handles
1053 the complete list; a tolerant scan handles a cut-off tail (the
1054 observed Muse Glimmer output is truncated mid-dict with NO closing
1055 ']')."""
1056 if not text or not isinstance(text, str):
1057 return None
1058 stripped = text.strip()
1059 m = _CALLING_TOOLS_ENVELOPE_RE.search(stripped)
1060 if m:
1061 block = m.group(1).strip()
1062 else:
1063 # No closing ']' — the output was cut mid-envelope. Everything
1064 # after the 'Calling tools:' label is the fragment to scan.
1065 head = re.split(r"Calling tools\s*:", stripped, maxsplit=1,
1066 flags=re.IGNORECASE)
1067 block = head[1].strip() if len(head) == 2 and head[1].strip() else None
1068 if not block:
1069 return None
1070 calls = None
1071 try:
1072 import ast
1073 calls = ast.literal_eval(block)
1074 except Exception:
1075 calls = None
1076 if isinstance(calls, list):
1077 for call in calls:
1078 payload = _payload_from_openai_call(call)
1079 if payload:
1080 return payload
1081 # Tolerant fallback: literal_eval fails on a truncated list tail —
1082 # scan the raw fragment for a final_answer call's arguments dict.
1083 if "final_answer" not in block:
1084 return None
1085 args_m = re.search(r"['\"]arguments['\"]\s*:\s*(\{.*)", block, re.DOTALL)
1086 if args_m:
1087 brace = args_m.group(1).strip()
1088 # Best-effort: pass the dict CONTENT (drop the outer brace and a
1089 # truncated trailing '}'/']') so _braces_to_dict sees clean pairs.
1090 if brace.startswith("{"):
1091 brace = brace[1:]
1092 brace = brace.rstrip("}]")
1093 args_dict = _braces_to_dict(brace)
1094 if args_dict:
1095 payload = _payload_from_final_answer_args(args_dict, "")
1096 if payload:
1097 return payload
1098 return None
1099
1100
1101_PREAMBLE_TRANSITION_RE = re.compile(
1102 r"(?:\b(?:I have sufficient info(?:rmation)?|I have enough info(?:rmation)?|"
1103 r"I will now write the (?:final )?answer|Here is the (?:final )?(?:answer|summary|response|report)|"
1104 r"I will compile the answer in Khmer|I will write the response in(?: natural)? Khmer|"
1105 r"Constructing (?:Final )?Output|Final Output)\b[.:\s]*)"
1106 r"(.*)$",
1107 re.IGNORECASE | re.DOTALL,
1108)
1109
1110_KHMER_CHAR_RE = re.compile(r"[\u1780-\u17FF]")
1111
1112_META_ENGLISH_STARTS = (
1113 "the user is", "the user wants", "the user asked", "user is asking", "the user's query",
1114 "the user's message", "the greeting is", "this is a", "this is simply", "since this is",
1115 "the search results provide", "search results show", "key themes:", "summary structure",
1116 "plan:", "refined summary:", "wait, i should", "i will compile the answer", "i will write the response",
1117 "i should respond", "i need to respond", "let's respond", "let me respond",
1118 "analyze the request", "analyzing the request", "analyze the input", "analyzing the input",
1119 "drafting the content", "input: ",
1120 # GLM-4.7-Flash and similar reasoning GGUFs often open their final answer
1121 # with a long English planning monologue instead of a <think> block. These
1122 # openings identify that monologue so the answer after it can be extracted.
1123 "i need to analyze", "let me understand", "let me analyze", "i need to understand",
1124 "let me think", "let me break", "the task is", "i'm being asked", "i should figure",
1125 "let's analyze", "i will analyze", "the instructions are", "the task says",
1126 "i'm supposed to", "here is my analysis", "my task is", "the prompt is",
1127 "step 1:",
1128 # GLM-4.7-Flash also opens with a third-person summary of what it was
1129 # given ("The user has provided a task in Khmer. The task appears to be
1130 # asking me to respond...") before either its answer or a full echo of
1131 # the task text.
1132 "the user has", "the task appears",
1133)
1134# NOTE: deliberately NOT meta-starts (too generic — legit answers open with
1135# them and stripping would chop real content): "looking at the", "first, i",
1136# "to answer this".
1137
1138# Khmer meta-reasoning / prompt-analysis phrases — third-person reference to
1139# the user's question, tool-usage decisions, or response planning in Khmer.
1140# When the model outputs its chain-of-thought in Khmer (instead of wrapping
1141# it in <think> tags), these patterns identify the reasoning preamble so the
1142# actual answer following it can be extracted.
1143_META_KHMER_PHRASES = (
1144 "សំណួររបស់អ្នកប្រើគឺ", # "The user's question is"
1145 "សំណួររបស់អ្នកគឺ", # "Your question is"
1146 "សំណួរនេះគឺ", # "This question is"
1147 "នេះគឺជាសំណួរ", # "This is a question"
1148 "នេះគឺជា", # "This is"
1149 "វាគឺជា", # "It is"
1150 "ដូច្នេះខ្ញុំគិតថា", # "So I think"
1151 "ដូច្នេះខ្ញុំគ្រាន់", # "So I just"
1152 "ដូច្នេះខ្ញុំនឹង", # "So I will"
1153 "ខ្ញុំមិនត្រូវការប្រើ", # "I don't need to use"
1154 "ខ្ញុំត្រូវតែឆ្លើយ", # "I need to answer"
1155 "ខ្ញុំនឹងឆ្លើយ", # "I will answer"
1156 "សម្រាប់សំណួរនេះ", # "For this question"
1157 "សម្រាប់សំណួរសាកល្បង", # "For this test question"
1158 "កុំប្រើ", # "Don't use"
1159 "មិនចាំបាច់ប្រើ", # "No need to use"
1160 "ខ្ញុំគិតថា", # "I think that"
1161 "ខ្ញុំនឹងផ្តល់", # "I will provide"
1162 "ដូច្នេះខ្ញុំគ្រាន់តែ", # "So I just"
1163 "នេះមិនមែនជា", # "This is not"
1164 "គ្មានការស្វែងរក", # "No search needed"
1165 "មិនត្រូវការស្វែងរក", # "No need to search"
1166)
1167
1168
1169# Deliberation-shaped SENTENCE markers for English-only leaks — third-person
1170# narration of what the user said / tool-usage decisions / output planning.
1171# Only whole leading sentences carrying one of these are ever dropped, so a
1172# legitimate answer sentence without them is never eaten.
1173_META_ENGLISH_SENTENCE_MARKERS = (
1174 "i should respond", "i should follow", "i should not include",
1175 "i don't need to use any tools", "no tools are needed", "i don't need any tool",
1176 "this is a simple greeting", "this is a straightforward",
1177 "the user has just said", "the user has provided a task",
1178 "the user wants me to", "they want me to respond",
1179 "i'll just give", "i will give a natural", "i will provide a direct",
1180 "not output internal plan", "internal planning or thinking",
1181)
1182
1183
1184def _strip_leading_english_meta_sentences(text, max_sentences=15):
1185 """Drop leading deliberation sentences from an ENGLISH-only leak (meta
1186 monologue followed by an English answer — no Khmer anywhere, so the
1187 Khmer-block extraction paths can never fire). Each dropped chunk must
1188 contain a deliberation marker and end at a real sentence/line terminator;
1189 the walk stops at the first sentence without one. Returns '' when nothing
1190 survives (caller keeps the original text)."""
1191 tail = text.strip()
1192 low = ""
1193 for _ in range(max_sentences):
1194 low = tail.lower()
1195 hit = -1
1196 for marker in _META_ENGLISH_SENTENCE_MARKERS:
1197 idx = low.find(marker)
1198 if idx != -1 and (hit == -1 or idx < hit):
1199 hit = idx
1200 if hit == -1:
