mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-10 12:17:11 +00:00
fix(agent): stop treating illustrative Markdown fences as tool calls for native function-calling models (#3356)
* fix(agent): stop executing illustrative Markdown fences as tool calls for native function-calling models _resolve_tool_blocks fell back to the textual parse_tool_blocks() fenced-block parser whenever a model produced no native tool_calls, regardless of whether that model has a reliable native function-calling channel. Native models (GPT/Claude/Grok/Qwen3/DeepSeek-V, etc. - _is_api_model true) commonly write illustrative ```bash/```python/```json examples in guide-only prose; the fallback parser matched these and executed them as real commands, sometimes looping for several rounds as the model tried to clarify with more examples (#3222). Restrict the textual fenced-block fallback to non-native models, which rely on it as their only tool-invocation channel. Native models are trusted to use their structured tool_calls channel for real invocations; when they don't emit one, a bare fence in their response is prose, not an action. The native tool_calls path itself is untouched. This sits one layer below #3088's guide-only policy enforcement: that PR blocks tool exposure/execution on explicit no-tools requests, while this fixes the parser so ordinary illustrative fences are never misread as calls in the first place, on any turn. * fix(agent): gate only the fenced-example pattern for native models, preserve DSML/invoke recovery and persistence _resolve_tool_blocks previously short-circuited the entire textual parser (tool_blocks = [] if is_api_model else parse_tool_blocks(...)) for native function-calling models with no native tool_calls. That also dropped Patterns 2-5 (explicit [TOOL_CALL]/<invoke>/<tool_code>/DSML markup leaked into content as text), which are real calls a model couldn't emit on its structured channel (e.g. DeepSeek-V falling back to DSML), not illustrative examples. parse_tool_blocks/strip_tool_blocks now take a skip_fenced flag that gates ONLY Pattern 1 (the fenced ```bash/```python/```json block matcher). _resolve_tool_blocks passes skip_fenced=is_api_model so fenced examples stop being executed for native models while [TOOL_CALL]/<invoke>/<tool_code>/DSML stay fully active and recoverable. cleaned_round mirrors the same gate when persisting round text, so an illustrative fence that wasn't executed isn't stripped from saved/reloaded history either (it was streaming once and then disappearing on reload).
This commit is contained in:
+23
-4
@@ -1111,7 +1111,7 @@ def _build_base_prompt(
|
||||
|
||||
|
||||
|
||||
def _resolve_tool_blocks(round_response: str, native_tool_calls: list, round_num: int):
|
||||
def _resolve_tool_blocks(round_response: str, native_tool_calls: list, round_num: int, is_api_model: bool = False):
|
||||
"""Choose native function calls or fenced code block parsing. Returns (tool_blocks, used_native)."""
|
||||
used_native = False
|
||||
if native_tool_calls:
|
||||
@@ -1128,7 +1128,21 @@ def _resolve_tool_blocks(round_response: str, native_tool_calls: list, round_num
|
||||
if tool_blocks:
|
||||
used_native = True
|
||||
if not used_native:
|
||||
tool_blocks = parse_tool_blocks(round_response)
|
||||
# Native function-calling models (GPT/Claude/Grok/Qwen3/DeepSeek-V, etc.)
|
||||
# have a reliable structured channel for real tool invocations. When such
|
||||
# a model emits no native tool_calls, any ```bash/```python/```json fence
|
||||
# in its prose is virtually always an illustrative example for the user
|
||||
# (e.g. "here's the command you'd run"), not an attempted tool call —
|
||||
# executing it causes accidental runs and clarification loops (#3222).
|
||||
#
|
||||
# Gate ONLY that fenced-block pattern for native models, not the whole
|
||||
# parser: explicit [TOOL_CALL]/<invoke>/<tool_code>/DSML markup that
|
||||
# leaks into content as text is never illustrative — it's a real call
|
||||
# the model couldn't emit on its structured channel (e.g. DeepSeek-V
|
||||
# falling back to DSML). Dropping the whole parser would silently lose
|
||||
# those too. Non-native / textual-only models keep every pattern,
|
||||
# fenced blocks included, since that's their *only* tool channel.
|
||||
tool_blocks = parse_tool_blocks(round_response, skip_fenced=is_api_model)
|
||||
if tool_blocks:
|
||||
logger.info(f"Agent round {round_num}: {len(tool_blocks)} fenced tool block(s) detected")
|
||||
|
||||
@@ -2053,7 +2067,7 @@ async def stream_agent_loop(
|
||||
yield chunk
|
||||
# Intercept [DONE] — don't forward until all rounds finish
|
||||
|
||||
tool_blocks, used_native = _resolve_tool_blocks(round_response, native_tool_calls, round_num)
|
||||
tool_blocks, used_native = _resolve_tool_blocks(round_response, native_tool_calls, round_num, is_api_model=_is_api_model)
|
||||
|
||||
# Force-answer round: we told the model to STOP calling tools and
|
||||
# answer. If it ignored that and emitted a (possibly DSML) tool
|
||||
@@ -2132,7 +2146,12 @@ async def stream_agent_loop(
|
||||
|
||||
# Save cleaned round text for history persistence
|
||||
# Keep <think> blocks so they render in the thinking section on reload
|
||||
cleaned_round = strip_tool_blocks(round_response).strip()
|
||||
# Mirror the same fenced-pattern gate used to resolve tool_blocks above:
|
||||
# an illustrative fence that wasn't executed (because this is a native
|
||||
# model with no real native_tool_calls) must not be stripped from the
|
||||
# persisted text either — otherwise it streams once and then disappears
|
||||
# on reload (#3222 follow-up).
|
||||
cleaned_round = strip_tool_blocks(round_response, skip_fenced=(_is_api_model and not used_native)).strip()
|
||||
round_texts.append(cleaned_round)
|
||||
|
||||
if not tool_blocks:
|
||||
|
||||
Reference in New Issue
Block a user