CoolFace
Apppublic

localailb/assistant

sourceHugging Facemitupdated 1mo agoView on Hugging Face
0likes
agent_streaming.py3289 linesDownload Raw Back to agents
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:

Showing the first 1,200 of 3289 lines. Download the file for the rest.