Merge commit 'refs/phase3/pre-ajax/publication-tip' into integration/pre-ajax-release

# Conflicts:
#	routes/chat_routes.py
#	routes/session_routes.py
#	src/agent_loop.py
#	src/agent_tools/filesystem_tools.py
#	src/teacher_escalation.py
#	src/tool_capabilities.py
#	src/tool_execution.py
#	tests/test_mcp_add_server_args_validation.py
#	tests/test_token_cache_atomic_swap.py
This commit is contained in:
Alexandre Teixeira
2026-10-05 15:59:59 +01:00
1395 changed files with 360455 additions and 105938 deletions
+562 -20
View File
@@ -10,9 +10,11 @@ import bisect
import json
import logging
import re
from typing import List, Optional, Tuple
import shlex
import warnings
from typing import Iterable, List, Optional, Tuple
from src.agent_tools import ToolBlock, TOOL_TAGS
from src.tool_types import ToolBlock, TOOL_TAGS
from src.tool_security import BUILTIN_EMAIL_TOOLS
logger = logging.getLogger(__name__)
@@ -36,6 +38,31 @@ _TOOL_BLOCK_RE = re.compile(
re.IGNORECASE,
)
def _tool_block_re(additional_tool_names: Optional[Iterable[str]] = None):
if not additional_tool_names:
return _TOOL_BLOCK_RE
extra = {
name
for raw_name in additional_tool_names
if isinstance(raw_name, str)
and (name := raw_name.strip())
and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]{0,63}", name)
}
if not extra:
return _TOOL_BLOCK_RE
# ``json`` is only executable here as an envelope naming one of the
# request-declared tools. It remains inert for ordinary agent turns.
tags = sorted(
set(TOOL_TAGS) | extra | {"json", "function_name"},
key=lambda value: (-len(value), value),
)
return re.compile(
r"```(" + "|".join(re.escape(tag) for tag in tags) + r")(?![\w-])"
r"[ \t]*([{\[][^\n]*?)?[ \t]*(?=\r?\n|```)\r?\n?([\s\S]*?)```",
re.IGNORECASE,
)
# Tags whose fenced content is raw code, not JSON args. Same-line text after
# these tags is Markdown fence metadata on a real language (```bash {title=
# "setup"}), never inline tool args — only the classic tag-then-newline form
@@ -196,6 +223,10 @@ _QWEN_BARE_MARKER_RE = re.compile(
r"(?:^|[\r\n])[ \t]*assistan(?:t)?[ \t]*(?=[\r\n]|$)",
re.IGNORECASE,
)
_QWEN_OPEN_TOOLS_RE = re.compile(
r"<\|open\|>\s*tools\b[\s\S]*?(?:<\|close\|>\s*message\s*<\|sep\|>|$)",
re.IGNORECASE,
)
# Pattern 5: DeepSeek DSML markup leaking into content. When deepseek
@@ -212,14 +243,36 @@ _QWEN_BARE_MARKER_RE = re.compile(
# never show the garbage to the user). The pipe run is tolerant of
# fullwidth (U+FF5C) and ascii '|' in any count.
_DSML_PIPES = r"[||]+"
def _contains_explicit_tool_markup(text: str) -> bool:
"""Return whether text contains a non-fenced, explicit call envelope.
If a model mixes a Markdown example with a real call envelope, the
envelope is authoritative. Executing the first fence and then skipping
the explicit call both runs the wrong command and loses the requested one.
The explicit parsers below still inspect the full text when fences are
skipped, so real leaked markup inside a response remains recoverable.
"""
return bool(re.search(
r"(?:\[TOOL_CALL\]|<\s*(?:[\w]+:)?(?:tool_call|function_call|invoke|tool_code)\b|"
r"<\|?tool_call\|?>|<|tool▁call▁begin|>|<function_model>)",
str(text or ""),
re.IGNORECASE,
))
def _normalize_dsml(text: str) -> str:
if not isinstance(text, str):
return ""
if "DSML" not in text:
return text
t = text
t = re.sub(rf"<\s*{_DSML_PIPES}\s*DSML\s*{_DSML_PIPES}\s*tool_calls\s*>", "<tool_call>", t, flags=re.IGNORECASE)
t = re.sub(rf"<\s*/\s*{_DSML_PIPES}\s*DSML\s*{_DSML_PIPES}\s*tool_calls\s*>", "</tool_call>", t, flags=re.IGNORECASE)
# Hosted DeepSeek variants use both ``tool_calls`` and the shorter
# ``calls`` wrapper. Treat them identically; otherwise the inner invoke is
# parsed but the outer DSML tags leak into the user-visible response.
t = re.sub(rf"<\s*{_DSML_PIPES}\s*DSML\s*{_DSML_PIPES}\s*(?:tool_calls|calls)\s*>", "<tool_call>", t, flags=re.IGNORECASE)
t = re.sub(rf"<\s*/\s*{_DSML_PIPES}\s*DSML\s*{_DSML_PIPES}\s*(?:tool_calls|calls)\s*>", "</tool_call>", t, flags=re.IGNORECASE)
t = re.sub(rf"<\s*{_DSML_PIPES}\s*DSML\s*{_DSML_PIPES}\s*invoke\s+name=", "<invoke name=", t, flags=re.IGNORECASE)
t = re.sub(rf"<\s*/\s*{_DSML_PIPES}\s*DSML\s*{_DSML_PIPES}\s*invoke\s*>", "</invoke>", t, flags=re.IGNORECASE)
# parameter open tag — drop any extra attrs (e.g. string="true").
@@ -251,6 +304,8 @@ _TOOL_NAME_MAP = {
"read": "read_file",
"read_file": "read_file",
"cat": "read_file",
"list_files": "ls",
"list_directory": "ls",
"write": "write_file",
"write_file": "write_file",
"save": "write_file",
@@ -339,6 +394,137 @@ _MISFENCED_WEB_TOOL_NAMES = {
"fetch_url": "web_fetch",
}
# Media-capable local models often fall back from structured tool calls to a
# language fence, e.g. `````python\ninspect_media('/workspace/a.mp4')````` or
# `````bash\ninspect_media /workspace/a.mp4`````. These are unambiguous calls,
# but treating them as Python/bash leaves the media evidence gate permanently
# unsatisfied. Keep the rescue exact: one media command only, with literal
# arguments, never an arbitrary script.
_MISFENCED_MEDIA_TOOL_NAMES = {
"inspect_media": "inspect_media",
"extract_text": "extract_text",
"transcribe_media": "transcribe_media",
# A common local-model alias; Odysseus has no separate translate-media
# tool, and inspection is the only safe semantic target.
"translate_media": "inspect_media",
}
_MISFENCED_MEDIA_ALLOWED_KEYS = {
"inspect_media": {
"path", "file", "filename", "input", "start", "end", "duration",
"frames", "frame_count", "sampling", "max_dimension", "query", "page", "pages",
"timestamp", "output_path", "speed", "segments", "exports", "caption",
"crop", "timestamp_path",
},
"extract_text": {"path", "file", "filename", "input", "mode", "include_layout", "min_confidence", "max_results"},
"transcribe_media": {
"path", "file", "filename", "input", "language", "force_language",
"start", "end", "model", "output_path", "timestamp_precision",
# Some local models describe the requested media operation rather
# than using the native schema. It is harmless metadata; the native
# transcriber still receives the validated path/options.
"transcription_type",
},
}
def _parse_misfenced_media_lookup(content: str) -> Optional[ToolBlock]:
"""Recover one literal media-tool call from a python/bash fence.
This intentionally does not execute or interpret general code. It only
accepts a single function call or a single shell-style command whose name
is an explicit media tool (or its narrow alias), then serializes literal
arguments for the normal tool validation/execution path.
"""
stripped = str(content or "").strip()
if not stripped:
return None
# Bash models sometimes prepend a harmless background marker. Remove
# only shebang/comment lines; any other extra statement remains rejected.
lines = [line.strip() for line in stripped.splitlines() if line.strip()]
lines = [line for line in lines if not line.startswith("#!")]
if len(lines) != 1:
return None
candidate = lines[0]
try:
module = _parse_python_like_content(candidate)
except SyntaxError:
module = None
if module is not None and len(module.body) == 1 and isinstance(module.body[0], ast.Expr):
call = module.body[0].value
if isinstance(call, ast.Call) and isinstance(call.func, ast.Name):
raw_name = call.func.id.lower()
tool_type = _MISFENCED_MEDIA_TOOL_NAMES.get(raw_name)
if tool_type and len(call.args) <= 1:
args = {}
if call.args:
try:
args["path"] = ast.literal_eval(call.args[0])
except (ValueError, SyntaxError, TypeError):
return None
if not isinstance(args["path"], str) or not args["path"].strip():
return None
for keyword in call.keywords:
if keyword.arg is None:
return None
key = keyword.arg
if key not in _MISFENCED_MEDIA_ALLOWED_KEYS[tool_type]:
return None
try:
value = ast.literal_eval(keyword.value)
except (ValueError, SyntaxError, TypeError):
return None
normalized_key = {
"file": "path",
"filename": "path",
"input": "path",
# ``frame_count`` is a common textual spelling of the
# native ``frames`` field. Normalize only this
# unambiguous scalar alias; conflicting duplicate
# fields remain invalid below.
"frame_count": "frames",
}.get(key, key)
if normalized_key in args:
return None
args[normalized_key] = value
if args.get("path"):
return ToolBlock(tool_type, json.dumps(args, ensure_ascii=False))
# Shell-style JSON fallback: exactly ``tool {object}``. This is common
# when a model knows the native JSON contract but emits it inside a bash
# fence. Decode one object only, validate every key, and send it through
# the normal tool validator; never execute the surrounding shell.
name, separator, tail = candidate.partition(" ")
tool_type = _MISFENCED_MEDIA_TOOL_NAMES.get(name.lower()) if separator else None
if tool_type and tail.lstrip().startswith("{"):
try:
arguments, consumed = json.JSONDecoder().raw_decode(tail.lstrip())
except (TypeError, ValueError):
arguments, consumed = None, 0
remainder = tail.lstrip()[consumed:].strip() if consumed else tail
if isinstance(arguments, dict) and not remainder:
normalized = {}
for key, value in arguments.items():
if key not in _MISFENCED_MEDIA_ALLOWED_KEYS[tool_type]:
return None
normalized["path" if key in {"file", "filename", "input"} else key] = value
if isinstance(normalized.get("path"), str) and normalized["path"].strip():
return ToolBlock(tool_type, json.dumps(normalized, ensure_ascii=False))
# Shell-style fallback: exactly ``tool path`` (optionally quoted). Keep
# options out of this rescue; callers needing them can use JSON/function
# syntax and the normal parser will retain the strict boundary.
try:
tokens = shlex.split(candidate)
except ValueError:
return None
if len(tokens) == 2:
tool_type = _MISFENCED_MEDIA_TOOL_NAMES.get(tokens[0].lower())
if tool_type and tokens[1].strip() and not tokens[1].startswith("-"):
return ToolBlock(tool_type, json.dumps({"path": tokens[1]}, ensure_ascii=False))
return None
_RAW_WEB_JSON_TOOL_RE = re.compile(
r"\b(?:web_search|websearch|google_search|google_search_retrieval|google_search_grounding)\b",
re.IGNORECASE,
@@ -351,8 +537,9 @@ _RAW_WEB_JSON_ALLOWED_KEYS = {"query", "queries", "time_filter", "freshness", "m
# be unsafe.
_PLAIN_UI_OPEN_PANEL_RE = re.compile(
r"(?im)^\s*(?:`{1,3})?\s*ui_control\s+open_panel\s+"
r"(documents?|library|gallery|images?|email|inbox|mail|sessions?|chats?|history|"
r"notes?|brain|memor(?:y|ies)|skills?|settings|preferences|cookbook|models?)"
r"(documents?|library|gallery|images?|calendar|schedule|email|inbox|mail|sessions?|chats?|history|"
r"notes?|brain|memor(?:y|ies)|skills?|settings|preferences|themes?|appearance|cookbook|models?)"
r"((?:\s+(?:day|week|month|year|agenda)(?:\s+view)?(?:\s+\d{4}-\d{2}(?:-\d{2})?)?)?)"
r"\s*(?:`{1,3})?\s*$"
)
@@ -361,6 +548,13 @@ _PLAIN_UI_OPEN_PANEL_RE = re.compile(
# Parsing functions
# ---------------------------------------------------------------------------
def _parse_python_like_content(content: str):
"""Parse fallback syntax without leaking invalid-escape warnings to traces."""
with warnings.catch_warnings():
warnings.simplefilter("ignore", SyntaxWarning)
return ast.parse(content, mode="exec")
def _literal_string(value) -> Optional[str]:
"""Return a string from a small literal AST node, or None."""
try:
@@ -389,7 +583,7 @@ def _parse_misfenced_web_lookup(content: str) -> Optional[ToolBlock]:
narrow: only a single bare function call to a known web tool alias converts.
"""
try:
module = ast.parse(content.strip(), mode="exec")
module = _parse_python_like_content(content.strip())
except SyntaxError:
return None
if len(module.body) != 1 or not isinstance(module.body[0], ast.Expr):
@@ -456,7 +650,7 @@ def _parse_misfenced_read_file_lookup(content: str, *, allow_shell_style: bool =
return None
try:
module = ast.parse(stripped, mode="exec")
module = _parse_python_like_content(stripped)
except SyntaxError:
module = None
if module and len(module.body) == 1 and isinstance(module.body[0], ast.Expr):
@@ -788,7 +982,6 @@ def _strip_raw_openai_tool_call_json(text: str) -> str:
pieces.append(text[pos:start])
pos = end
changed = True
# Common broken local-model suffix: a standalone ] before a role marker.
while pos < len(text) and text[pos] in " \t\r\n":
pos += 1
if pos < len(text) and text[pos] == "]":
@@ -798,6 +991,117 @@ def _strip_raw_openai_tool_call_json(text: str) -> str:
pieces.append(text[pos:])
return "".join(pieces)
def _parse_qwen3_native_text_call(
text: str,
additional_tool_names: Optional[Iterable[str]] = None,
) -> Optional[ToolBlock]:
"""Parse textual call shapes emitted by Qwen3.x MLX adapters.
This deployment is intentionally run without OpenAI tool schemas. It
commonly emits one call several times as one response, using either
``{"tool": ..., "parameters": ...}``, ``{"function": ..., ...}``,
``manage_notes(action=...)``, or a bare MCP tool name. Return only the
first valid call so duplicate renderings cannot execute repeatedly.
"""
if not isinstance(text, str) or not text.strip():
return None
from src.tool_schemas import function_call_to_tool_block
declared_names = _declared_tool_name_map(additional_tool_names)
def declared_or_builtin(name: str, args: dict) -> Optional[ToolBlock]:
normalized_name = name.strip().lower()
if declared_name := declared_names.get(normalized_name):
return ToolBlock(declared_name, json.dumps(args, ensure_ascii=False))
return function_call_to_tool_block(name.strip(), json.dumps(args))
# Qwen native text rendering:
# <tool_call><function=manage_notes><parameter=action>list</parameter>...
fn_match = re.search(r"<function=([A-Za-z_][\w:.-]*)>\s*([\s\S]*?)\s*</function>", text)
if fn_match:
name, raw_body = fn_match.groups()
args = {}
for key, raw_value in re.findall(
r"<parameter=([A-Za-z_]\w*)>\s*([\s\S]*?)\s*</parameter>",
raw_body,
):
value = raw_value.strip()
if value and value[0] in "[{\"":
try:
args[key] = json.loads(value)
continue
except (TypeError, json.JSONDecodeError):
pass
args[key] = value
block = declared_or_builtin(name, args)
if block:
return block
decoder = json.JSONDecoder()
for match in re.finditer(r"[\[{]", text):
try:
value, _end = decoder.raw_decode(text[match.start():])
except json.JSONDecodeError:
continue
if not isinstance(value, dict):
continue
name = value.get("tool")
args = value.get("parameters")
if isinstance(name, str):
if name.strip().lower() in declared_names and set(value) != {"tool", "parameters"}:
continue
if not isinstance(args, dict):
args = {}
block = declared_or_builtin(name, args)
if block:
return block
name = value.get("function")
args = value.get("arguments")
if isinstance(name, str):
if name.strip().lower() in declared_names and set(value) != {"function", "arguments"}:
continue
if isinstance(args, str):
try:
args = json.loads(args)
except (TypeError, json.JSONDecodeError):
args = {}
if not isinstance(args, dict):
args = {}
block = declared_or_builtin(name, args)
if block:
return block
# Python-like single-call rendering: manage_notes(action="list").
match = re.search(r"\b([A-Za-z_][\w:.-]*)\s*\(([^()]*)\)", text)
if match:
name, raw_args = match.groups()
normalized_name = name.strip().lower()
mapped_name = _TOOL_NAME_MAP.get(normalized_name, normalized_name)
if (
mapped_name in TOOL_TAGS
or mapped_name.startswith("mcp__")
or normalized_name in BUILTIN_EMAIL_TOOLS
or normalized_name in declared_names
):
args = {}
for key, raw_value in re.findall(r"([A-Za-z_]\w*)\s*=\s*(['\"].*?['\"]|[^,]+)", raw_args):
try:
args[key] = ast.literal_eval(raw_value.strip())
except (ValueError, SyntaxError):
args[key] = raw_value.strip().strip("'\"")
block = declared_or_builtin(normalized_name, args)
if block:
return block
# Some MCP calls are emitted as the bare name, repeated once per format.
names = re.findall(r"(?m)^\s*(mcp__[A-Za-z0-9_.-]+)\s*$", text)
if names and len(set(names)) == 1:
block = function_call_to_tool_block(names[0], "{}")
if block:
return block
return None
def _parse_tool_call_block(raw: str) -> Optional[ToolBlock]:
"""Parse a [TOOL_CALL] block into a ToolBlock.
@@ -1244,7 +1548,7 @@ def _iter_xml_invoke(text):
return _iter_named_blocks(text, _XML_INVOKE_OPEN_RE, _XML_INVOKE_CLOSE_RE)
def _iter_backref_blocks(text, open_re, close_any_re, ci=False):
def _iter_backref_block_spans(text, open_re, close_any_re, ci=False):
"""Forward-only equivalent of an ``<tag>([\\s\\S]*?)</tag>`` backreference
finditer (same-name open/close): yield ``(name, body)``, pairing each opener
with the nearest following matching closer and skipping an opener whose
@@ -1273,18 +1577,147 @@ def _iter_backref_blocks(text, open_re, close_any_re, ci=False):
if starts:
i = bisect.bisect_left(starts, om.end())
if i < len(starts):
yield name, text[om.end():starts[i]]
yield name, text[om.end():starts[i]], om.start(), closer_ends[k][i]
resume = closer_ends[k][i]
om = open_re.search(text, resume)
def _iter_backref_blocks(text, open_re, close_any_re, ci=False):
for name, body, _start, _end in _iter_backref_block_spans(
text, open_re, close_any_re, ci=ci
):
yield name, body
def _iter_xml_direct(text):
"""Forward-only equivalent of ``_XML_DIRECT_TOOL_RE.finditer`` (see
_iter_backref_blocks)."""
return _iter_backref_blocks(text, _XML_DIRECT_OPEN_RE, _XML_DIRECT_CLOSE_ANY_RE, ci=True)
def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
def _declared_tool_name_map(additional_tool_names: Optional[Iterable[str]]) -> dict[str, str]:
return {
name.lower(): name
for raw_name in (additional_tool_names or ())
if isinstance(raw_name, str)
and (name := raw_name.strip())
and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]{0,63}", name)
}
def _unique_declared_schema_match(
arguments: object,
declared_names: dict[str, str],
additional_tool_schemas: Optional[Iterable[dict]],
) -> Optional[str]:
"""Return one declared function whose object schema accepts every key."""
if not isinstance(arguments, dict) or not arguments:
return None
argument_keys = set(arguments)
matches: list[str] = []
for raw_schema in additional_tool_schemas or ():
if not isinstance(raw_schema, dict):
continue
function = raw_schema.get("function", raw_schema)
if not isinstance(function, dict):
continue
raw_name = function.get("name")
if not isinstance(raw_name, str):
continue
declared_name = declared_names.get(raw_name.strip().lower())
parameters = function.get("parameters")
if not declared_name or not isinstance(parameters, dict):
continue
properties = parameters.get("properties")
required = parameters.get("required") or []
if not isinstance(properties, dict) or not isinstance(required, list):
continue
if argument_keys <= set(properties) and set(required) <= argument_keys:
matches.append(declared_name)
return matches[0] if len(set(matches)) == 1 else None
def _parse_declared_direct_xml_calls(
text: str,
declared_names: dict[str, str],
) -> list[tuple[ToolBlock, int, int]]:
"""Parse direct XML only for request-declared tools with object arguments."""
calls = []
if not declared_names:
return calls
for raw_name, body, start, end in _iter_backref_block_spans(
text, _XML_DIRECT_OPEN_RE, _XML_DIRECT_CLOSE_ANY_RE, ci=True
):
declared_name = declared_names.get(raw_name.lower())
if not declared_name:
continue
try:
arguments = json.loads(body.strip())
except (TypeError, ValueError):
continue
if isinstance(arguments, dict):
calls.append((
ToolBlock(declared_name, json.dumps(arguments, ensure_ascii=False)),
start,
end,
))
return calls
def _parse_adjacent_declared_tool_fences(
text: str,
declared_names: dict[str, str],
additional_tool_names: Optional[Iterable[str]],
) -> Optional[ToolBlock]:
"""Recover ``tool-name`` and JSON fences emitted as one textual call."""
if not declared_names:
return None
matches = list(_tool_block_re(additional_tool_names).finditer(text))
for index, match in enumerate(matches):
call = _fenced_tool_call(match)
if call is None:
continue
tag, content = call
declared_name = declared_names.get(content.strip().lower())
if not declared_name or tag not in {"bash", "python", "json", "function_name"}:
continue
if index > 0:
previous_match = matches[index - 1]
between = text[previous_match.end():match.start()]
previous_call = _fenced_tool_call(previous_match)
if not between.strip() and previous_call is not None and previous_call[0] == "json":
try:
arguments = json.loads(previous_call[1])
except (TypeError, ValueError):
arguments = None
if isinstance(arguments, dict):
return ToolBlock(declared_name, json.dumps(arguments, ensure_ascii=False))
tail = text[match.end():]
if index + 1 < len(matches):
next_match = matches[index + 1]
if tail[:next_match.start() - match.end()].strip():
continue
next_call = _fenced_tool_call(next_match)
if next_call is not None and next_call[0] == "json":
try:
arguments = json.loads(next_call[1])
except (TypeError, ValueError):
arguments = None
if isinstance(arguments, dict):
return ToolBlock(declared_name, json.dumps(arguments, ensure_ascii=False))
if not tail.strip():
return ToolBlock(declared_name, "{}")
return None
def parse_tool_blocks(
text: str,
skip_fenced: bool = False,
additional_tool_names: Optional[Iterable[str]] = None,
additional_tool_schemas: Optional[Iterable[dict]] = None,
) -> List[ToolBlock]:
"""Extract executable tool blocks from LLM response text.
Supports multiple formats:
@@ -1315,8 +1748,18 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
text = _normalize_dsml(text)
# Pattern 1: fenced code blocks (skipped when `skip_fenced` — see docstring).
if not skip_fenced:
for m in _TOOL_BLOCK_RE.finditer(text):
# Explicit envelopes take precedence over Markdown fences. A fence in the
# same response is commonly an example or scratch work, while the
# envelope is the model's actual invocation.
skip_fenced_for_mixed = skip_fenced or _contains_explicit_tool_markup(text)
if not skip_fenced_for_mixed:
additional_names = _declared_tool_name_map(additional_tool_names)
adjacent_declared_call = _parse_adjacent_declared_tool_fences(
text, additional_names, additional_tool_names
)
if adjacent_declared_call is not None:
return [adjacent_declared_call]
for m in _tool_block_re(additional_tool_names).finditer(text):
call = _fenced_tool_call(m)
if call is None:
continue
@@ -1329,8 +1772,75 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
# silently dropping the call left models concluding email was
# broken. Other tags (bash, python, ...) keep skipping: empty
# content is nothing to run.
if tag in BUILTIN_EMAIL_TOOLS:
blocks.append(ToolBlock(tag, ""))
if tag in BUILTIN_EMAIL_TOOLS or tag in additional_names:
declared_tag = additional_names.get(tag, tag)
blocks.append(ToolBlock(declared_tag, "{}" if tag in additional_names else ""))
continue
if tag in {"bash", "python"} and additional_names:
raw_name, separator, raw_arguments = content.partition("\n")
declared_name = additional_names.get(raw_name.strip().lower())
if declared_name and separator and raw_arguments.strip():
blocks.append(ToolBlock(declared_name, raw_arguments.strip()))
continue
if tag == "json" and additional_names:
try:
flat_envelope = json.loads(content)
except (TypeError, ValueError):
flat_envelope = None
if (
isinstance(flat_envelope, dict)
and set(flat_envelope) == {"function", "arguments"}
and isinstance(flat_envelope.get("function"), str)
and isinstance(flat_envelope.get("arguments"), dict)
and (
declared_name := additional_names.get(
flat_envelope["function"].strip().lower()
)
)
):
blocks.append(ToolBlock(
declared_name,
json.dumps(flat_envelope["arguments"], ensure_ascii=False),
))
continue
if (
declared_name := _unique_declared_schema_match(
flat_envelope,
additional_names,
additional_tool_schemas,
)
):
blocks.append(ToolBlock(
declared_name,
json.dumps(flat_envelope, ensure_ascii=False),
))
continue
raw_name, separator, raw_arguments = content.partition("\n")
declared_name = additional_names.get(raw_name.strip().lower())
if declared_name and separator:
try:
arguments = json.loads(raw_arguments)
except (TypeError, ValueError):
arguments = None
if isinstance(arguments, dict):
blocks.append(ToolBlock(declared_name, json.dumps(arguments, ensure_ascii=False)))
continue
elif declared_name:
# Some local models close the JSON fence after the function
# name, then emit the argument object immediately after it:
# ```json\nfunction\n```\n{"arg": 1}\n```. Decode the object
# structurally and require that no prose follows it.
tail = text[m.end():].lstrip()
try:
arguments, consumed = json.JSONDecoder().raw_decode(tail)
except (TypeError, ValueError):
arguments, consumed = None, 0
remainder = tail[consumed:].strip() if consumed else tail
if isinstance(arguments, dict) and remainder in {"", "```"}:
blocks.append(ToolBlock(declared_name, json.dumps(arguments, ensure_ascii=False)))
continue
# ``json`` is only an envelope for a declared function, never
# a dispatchable tool in its own right.
continue
# If a code block's content is an <invoke> XML call (some models wrap
# tool calls in ```python or ```xml fences), parse the invoke instead.
@@ -1345,7 +1855,8 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
# _XML_INVOKE_RE's \w+ can't match would otherwise be executed as code.
continue
if tag in ("python", "bash"):
block = (_parse_misfenced_web_lookup(content)
block = (_parse_misfenced_media_lookup(content)
or _parse_misfenced_web_lookup(content)
or _parse_misfenced_read_file_lookup(content, allow_shell_style=(tag == "bash")))
if block:
blocks.append(block)
@@ -1428,6 +1939,13 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
block = _parse_xml_invoke(inv_name, inv_body)
if block:
blocks.append(block)
if not blocks:
blocks.extend(
block
for block, _start, _end in _parse_declared_direct_xml_calls(
text, _declared_tool_name_map(additional_tool_names)
)
)
# Pattern 4: <tool_code> blocks (MiniMax-M2.5 style)
if not blocks:
@@ -1463,6 +1981,13 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
if block:
blocks.append(block)
# Pattern 4e: Qwen3.x MLX textual call fallback. This must run after the
# explicit markup parsers but before the response is treated as prose.
if not blocks:
block = _parse_qwen3_native_text_call(text, additional_tool_names)
if block:
blocks.append(block)
# Pattern 6: local text-model web_search call leaked as prose + bare JSON.
if not blocks and not skip_fenced:
raw_web_json = _parse_raw_web_json_lookup(text)
@@ -1475,12 +2000,16 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
if not blocks:
m = _PLAIN_UI_OPEN_PANEL_RE.search(text)
if m:
blocks.append(ToolBlock("ui_control", f"open_panel {m.group(1).lower()}"))
blocks.append(ToolBlock("ui_control", f"open_panel {m.group(1).lower()}{m.group(2).lower()}".strip()))
return blocks
def strip_tool_blocks(text: str, skip_fenced: bool = False) -> str:
def strip_tool_blocks(
text: str,
skip_fenced: bool = False,
additional_tool_names: Optional[Iterable[str]] = None,
) -> str:
"""Remove executable tool blocks from text for clean display.
`skip_fenced`: when True, fenced ```bash/```python/```json code blocks
@@ -1499,7 +2028,11 @@ def strip_tool_blocks(text: str, skip_fenced: bool = False) -> str:
# Keep the executed-vs-illustrative fence distinction (only strip fences
# that actually dispatched; leave example fences from native models inert
# but visible), then remove [TOOL_CALL]{...}[/TOOL_CALL] markup.
cleaned = text if skip_fenced else _TOOL_BLOCK_RE.sub(_strip_executed_fence, text)
cleaned = (
text
if (skip_fenced or _contains_explicit_tool_markup(text))
else _tool_block_re(additional_tool_names).sub(_strip_executed_fence, text)
)
# Forward-only removal mirrors parse_tool_blocks: _strip_delimited pairs each
# opener with a later closer and stops when none is reachable, so untrusted
# output can't drive the O(n^2) lazy-rescan (ReDoS); see _iter_delimited.
@@ -1511,6 +2044,15 @@ def strip_tool_blocks(text: str, skip_fenced: bool = False) -> str:
cleaned = _GEMMA_TOOL_CALL_RE.sub('', cleaned)
cleaned = _strip_delimited(cleaned, _FUNCTION_MODEL_OPEN_RE, _FUNCTION_MODEL_CLOSE_RE)
cleaned = _strip_raw_openai_tool_call_json(cleaned)
declared_xml_calls = _parse_declared_direct_xml_calls(
cleaned, _declared_tool_name_map(additional_tool_names)
)
if declared_xml_calls:
cleaned = _strip_spans(
cleaned,
[(start, start, end, end) for _block, start, end in declared_xml_calls],
)
cleaned = _QWEN_OPEN_TOOLS_RE.sub('', cleaned)
cleaned = _QWEN_ROLE_MARKER_RE.sub('', cleaned)
cleaned = _QWEN_BARE_MARKER_RE.sub(' ', cleaned)
if not skip_fenced: