Skip to content

refactor(openai): remove text-form tool-call rescue - #1145

Merged
i386 merged 3 commits into
mainfrom
agent/remove-tool-call-rescue
Aug 4, 2026
Merged

refactor(openai): remove text-form tool-call rescue#1145
i386 merged 3 commits into
mainfrom
agent/remove-tool-call-rescue

Conversation

@i386

@i386 i386 commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Problem

The OpenAI frontend had a second set of text scanners that tried to recover tool calls from generated prose after the model's native chat parser had already classified the output.

That creates two competing interpretations of the same response. Text that merely discusses or quotes tool-call syntax can be mistaken for an invocation, while each additional model dialect needs another heuristic parser.

Change

The model's native chat parser is now the only component allowed to identify a tool call. Text remains text unless that parser emits structured tool-call data.

Schema validation, retries, safe argument normalization, and thinking-block cleanup remain in place. Removing the rescue path avoids false invocations and gives every response one deterministic interpretation instead of extending a second parser indefinitely.

Use case

An assistant can explain, quote, or debug JSON and XML tool-call examples without the frontend unexpectedly executing them.

Validation

OpenAI, guardrail, and mixture-of-agents tests pass.

Summary by CodeRabbit

  • New Features

    • Added support for removing hidden reasoning blocks from model responses.
    • Native tool calls and structured outputs are now validated directly.
  • Bug Fixes

    • Prevented tool-like text from being incorrectly converted into tool calls.
    • Invalid responses now trigger validation and retry behavior consistently.
    • Simplified guardrail telemetry by removing rescue and parser-stage classifications.
  • Documentation

    • Updated guardrail, testing, and telemetry documentation to reflect native tool-call validation.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 09f45638-7b91-497f-9f68-3a912f32c3d1

📥 Commits

Reviewing files that changed from the base of the PR and between 37ab2fb and 4d99c94.

📒 Files selected for processing (20)
  • crates/mesh-llm-guardrails/src/content.rs
  • crates/mesh-llm-guardrails/src/lib.rs
  • crates/mesh-llm-guardrails/src/rescue.rs
  • crates/mesh-llm-host-runtime/src/inference/consult.rs
  • crates/mesh-llm-host-runtime/src/runtime/survey.rs
  • crates/mesh-mixture-of-agents/src/normalize.rs
  • crates/mesh-mixture-of-agents/tests/sim_tool_call_text_not_passed_as_content.rs
  • crates/openai-frontend/src/guardrails/engine.rs
  • crates/openai-frontend/src/guardrails/mod.rs
  • crates/openai-frontend/src/guardrails/retry.rs
  • crates/openai-frontend/src/guardrails/telemetry.rs
  • crates/openai-frontend/src/guardrails/tests.rs
  • crates/openai-frontend/src/guardrails/tests/response_validation.rs
  • crates/openai-frontend/src/guardrails/validation.rs
  • crates/openai-frontend/src/router.rs
  • crates/openai-frontend/tests/benchy_contract.rs
  • docs/design/OPENAI_GUARDRAILS.md
  • docs/design/TESTING.md
  • docs/plugins/telemetry.md
  • scripts/run-openai-guardrail-corpus.py
💤 Files with no reviewable changes (4)
  • crates/openai-frontend/tests/benchy_contract.rs
  • crates/mesh-mixture-of-agents/tests/sim_tool_call_text_not_passed_as_content.rs
  • crates/mesh-llm-guardrails/src/rescue.rs
  • crates/openai-frontend/src/guardrails/telemetry.rs
🚧 Files skipped from review as they are similar to previous changes (15)
  • docs/design/TESTING.md
  • crates/openai-frontend/src/guardrails/engine.rs
  • docs/design/OPENAI_GUARDRAILS.md
  • crates/mesh-llm-guardrails/src/lib.rs
  • crates/openai-frontend/src/guardrails/retry.rs
  • docs/plugins/telemetry.md
  • crates/mesh-llm-host-runtime/src/inference/consult.rs
  • scripts/run-openai-guardrail-corpus.py
  • crates/openai-frontend/src/guardrails/tests/response_validation.rs
  • crates/openai-frontend/src/guardrails/tests.rs
  • crates/mesh-llm-host-runtime/src/runtime/survey.rs
  • crates/mesh-mixture-of-agents/src/normalize.rs
  • crates/openai-frontend/src/guardrails/mod.rs
  • crates/openai-frontend/src/guardrails/validation.rs
  • crates/mesh-llm-guardrails/src/content.rs

📝 Walkthrough

Walkthrough

The guardrail stack now validates native tool calls and structured output without text-based rescue. Thinking-block stripping is exposed as a shared utility. Parser-stage and rescued telemetry are removed. Runtime integrations, tests, corpus reporting, and documentation now reflect native validation.

Changes

Native guardrail validation

Layer / File(s) Summary
Content sanitization API
crates/mesh-llm-guardrails/src/content.rs, crates/mesh-llm-guardrails/src/lib.rs, crates/mesh-llm-host-runtime/src/inference/consult.rs, crates/mesh-mixture-of-agents/src/normalize.rs
Added and re-exported strip_thinking_blocks. Runtime and normalization use shared guardrail utilities and structured tool_proposal envelopes instead of heuristic tool-call recovery.
Structured response validation
crates/openai-frontend/src/guardrails/{engine,mod,retry,telemetry,validation}.rs
Removed text-based rescue parsing and parser-stage metadata. Native tool calls and structured payloads continue through validation.
Runtime native tool-call handling
crates/openai-frontend/src/router.rs, crates/openai-frontend/tests/benchy_contract.rs
Removed guardrail rescue fixtures and integration coverage for text-based tool-call conversion.
Telemetry and coverage alignment
crates/mesh-llm-host-runtime/src/runtime/survey.rs, crates/openai-frontend/src/guardrails/tests*, docs/design/*, docs/plugins/telemetry.md, scripts/run-openai-guardrail-corpus.py
Removed rescued, parser-stage fields, rescue assertions, and rescue corpus counts. Tests and documentation now describe native validation and structured output.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: ndizazzo

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: removing text-form tool-call rescue from the OpenAI frontend.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/remove-tool-call-rescue

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

This pull request is currently a draft. Reviews will not take place until the PR is marked as ready for review.

@i386
i386 marked this pull request as ready for review August 2, 2026 06:28
@github-actions
github-actions Bot requested a review from ndizazzo August 2, 2026 06:28
@i386
i386 force-pushed the agent/remove-tool-call-rescue branch from 0a1d4ef to 7d3d1dc Compare August 2, 2026 07:37
@ndizazzo
ndizazzo force-pushed the agent/remove-tool-call-rescue branch from 7d3d1dc to 37ab2fb Compare August 4, 2026 00:48
@ndizazzo

ndizazzo commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (4)
crates/mesh-llm-guardrails/src/content.rs (1)

32-43: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add tests for the unterminated and multi-block cases.

The current test covers only one paired block per marker style. Add cases for an unterminated <think>, for text before and after a block, and for multiple blocks in one string. These cases guard the loop logic in strip_tag_pairs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/mesh-llm-guardrails/src/content.rs` around lines 32 - 43, Add cases to
strips_supported_thinking_blocks covering an unterminated <think> block, text
before and after a thinking block, and multiple thinking blocks in one input.
Assert the expected visible text for each case to exercise the loop behavior in
strip_tag_pairs while preserving the existing paired-marker coverage.
crates/mesh-llm-guardrails/src/lib.rs (1)

2-12: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider dropping the new crate-root re-export.

content is public, so consumers can use mesh_llm_guardrails::content::strip_thinking_blocks directly. The coding guidelines ask to minimize crate-root re-exports and to import from the owning module in new code. Keeping only the module declaration would match that rule.

As per coding guidelines: "Minimize crate-root re-exports. Temporary compatibility re-exports are allowed during refactors, but new code should import from the owning module directly."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/mesh-llm-guardrails/src/lib.rs` around lines 2 - 12, Remove the
crate-root re-export of strip_thinking_blocks from lib.rs, while keeping the
public content module declaration unchanged. Update any new callers to import
strip_thinking_blocks through mesh_llm_guardrails::content instead of the crate
root.

Source: Coding guidelines

crates/openai-frontend/src/router.rs (1)

1331-1333: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider keeping router-level coverage for the guarded backend.

The deleted integration test wrapped the router with GuardedOpenAiBackend in enforce mode. It covered more than rescue: request-ID propagation, the sanitized response shape, and the injected _mesh_respond tool. The remaining guardrail tests in crates/openai-frontend/src/guardrails/tests/response_validation.rs exercise the engine directly, not the router wiring.

Add a smaller replacement test that posts to /v1/chat/completions through a guarded router with a backend that returns a native _mesh_respond tool call, and assert the sanitized text output.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/openai-frontend/src/router.rs` around lines 1331 - 1333, Add a focused
router-level test near the existing tokio tests in the router module,
configuring GuardedOpenAiBackend in enforce mode with a backend that returns a
native _mesh_respond tool call. POST to /v1/chat/completions through the guarded
router and assert the response contains the expected sanitized text output,
preserving coverage of router wiring without restoring unrelated assertions.
crates/openai-frontend/src/guardrails/validation.rs (1)

97-310: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Split classify_tool_call_value into named decision helpers.

The function spans about 214 lines and repeats the ClassifiedGuardrailResponse literal in twelve branches. The coding guidelines set a Clippy line-count limit and a cognitive-complexity limit for Rust functions. This PR touches every branch, so this is a good point to split it.

Suggested decomposition, all in validation.rs:

  • parse_all_tool_calls(raw_calls, allowed) -> Result<Vec<ParsedToolCall>, GuardrailResponseCategory> for the loop at Lines 122-161.
  • classify_contract_violations(prepared, &parsed_calls) -> Option<GuardrailResponseCategory> for the checks at Lines 173-225.
  • classify_synthetic_respond(...) and classify_synthetic_structured(...) for Lines 227-300.

Add a small constructor such as rejected(category, finish_reason) and rejected_with_calls(category, calls, finish_reason) to remove the repeated struct literals.

As per coding guidelines: "Do not add Rust functions or methods exceeding the configured Clippy line-count limit; split long logic into semantically named helpers." and "Do not add Rust code over the configured cognitive-complexity limit; prefer small named decision helpers and clear control-flow phases."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/openai-frontend/src/guardrails/validation.rs` around lines 97 - 310,
Refactor classify_tool_call_value into smaller named phases to satisfy
line-count and cognitive-complexity limits: extract parsing into
parse_all_tool_calls, contract checks into classify_contract_violations, and
synthetic respond/structured handling into classify_synthetic_respond and
classify_synthetic_structured. Add rejected and rejected_with_calls constructors
to centralize repeated ClassifiedGuardrailResponse creation, while preserving
all existing categories, payloads, and finish-reason behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/mesh-llm-guardrails/src/content.rs`:
- Around line 11-26: Update the unterminated-tag branch in strip_tag_pairs so it
does not reappend the prefix already added to result; exit the loop with an
empty or otherwise consumed remainder, preserving the existing behavior for
terminated tag pairs and trailing content.

In `@crates/mesh-llm-host-runtime/src/inference/consult.rs`:
- Around line 215-221: Update tool_calls_as_consultation_text to preserve valid
named tool calls when arguments are missing or null: treat either case as an
empty JSON object before serializing, while continuing to normalize and
serialize provided arguments through the existing path.

In `@crates/openai-frontend/src/guardrails/tests/response_validation.rs`:
- Around line 15-28: Update the fenced JSON test case in the response
classification loop to use a multiline raw string with actual line breaks
between the opening fence, JSON payload, and closing fence; preserve the
existing payload and malformed-tool-text assertions.

---

Nitpick comments:
In `@crates/mesh-llm-guardrails/src/content.rs`:
- Around line 32-43: Add cases to strips_supported_thinking_blocks covering an
unterminated <think> block, text before and after a thinking block, and multiple
thinking blocks in one input. Assert the expected visible text for each case to
exercise the loop behavior in strip_tag_pairs while preserving the existing
paired-marker coverage.

In `@crates/mesh-llm-guardrails/src/lib.rs`:
- Around line 2-12: Remove the crate-root re-export of strip_thinking_blocks
from lib.rs, while keeping the public content module declaration unchanged.
Update any new callers to import strip_thinking_blocks through
mesh_llm_guardrails::content instead of the crate root.

In `@crates/openai-frontend/src/guardrails/validation.rs`:
- Around line 97-310: Refactor classify_tool_call_value into smaller named
phases to satisfy line-count and cognitive-complexity limits: extract parsing
into parse_all_tool_calls, contract checks into classify_contract_violations,
and synthetic respond/structured handling into classify_synthetic_respond and
classify_synthetic_structured. Add rejected and rejected_with_calls constructors
to centralize repeated ClassifiedGuardrailResponse creation, while preserving
all existing categories, payloads, and finish-reason behavior.

In `@crates/openai-frontend/src/router.rs`:
- Around line 1331-1333: Add a focused router-level test near the existing tokio
tests in the router module, configuring GuardedOpenAiBackend in enforce mode
with a backend that returns a native _mesh_respond tool call. POST to
/v1/chat/completions through the guarded router and assert the response contains
the expected sanitized text output, preserving coverage of router wiring without
restoring unrelated assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dd99995e-b71d-45b2-b567-800c559658df

📥 Commits

Reviewing files that changed from the base of the PR and between fc789c4 and 37ab2fb.

📒 Files selected for processing (20)
  • crates/mesh-llm-guardrails/src/content.rs
  • crates/mesh-llm-guardrails/src/lib.rs
  • crates/mesh-llm-guardrails/src/rescue.rs
  • crates/mesh-llm-host-runtime/src/inference/consult.rs
  • crates/mesh-llm-host-runtime/src/runtime/survey.rs
  • crates/mesh-mixture-of-agents/src/normalize.rs
  • crates/mesh-mixture-of-agents/tests/sim_tool_call_text_not_passed_as_content.rs
  • crates/openai-frontend/src/guardrails/engine.rs
  • crates/openai-frontend/src/guardrails/mod.rs
  • crates/openai-frontend/src/guardrails/retry.rs
  • crates/openai-frontend/src/guardrails/telemetry.rs
  • crates/openai-frontend/src/guardrails/tests.rs
  • crates/openai-frontend/src/guardrails/tests/response_validation.rs
  • crates/openai-frontend/src/guardrails/validation.rs
  • crates/openai-frontend/src/router.rs
  • crates/openai-frontend/tests/benchy_contract.rs
  • docs/design/OPENAI_GUARDRAILS.md
  • docs/design/TESTING.md
  • docs/plugins/telemetry.md
  • scripts/run-openai-guardrail-corpus.py
💤 Files with no reviewable changes (4)
  • crates/mesh-llm-guardrails/src/rescue.rs
  • crates/openai-frontend/src/guardrails/telemetry.rs
  • crates/openai-frontend/tests/benchy_contract.rs
  • crates/mesh-mixture-of-agents/tests/sim_tool_call_text_not_passed_as_content.rs

Comment thread crates/mesh-llm-guardrails/src/content.rs
Comment thread crates/mesh-llm-host-runtime/src/inference/consult.rs
Comment thread crates/openai-frontend/src/guardrails/tests/response_validation.rs
@michaelneale

Copy link
Copy Markdown
Collaborator

this is ok - but will basically stop small models working I think - so might want a bit more attention on that - can you try with with say qwen 8B families for evidence?

@michaelneale michaelneale left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think may want to reconsider this for v small models, and get some tests/harnesses in place first. ie needs to be some cut off?

@ndizazzo
ndizazzo force-pushed the agent/remove-tool-call-rescue branch from cf8133c to dc17d85 Compare August 4, 2026 02:42
@i386

i386 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

I think may want to reconsider this for v small models, and get some tests/harnesses in place first. ie needs to be some cut off?

ahh now I understand what its for. OK, I will restore that now.

@i386

i386 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed. Before deciding whether to restore the text-form rescue, I am adding and running tool-calling coverage across representative Qwen 8B GGUF families. The acceptance criterion is that the OpenAI endpoint emits native tool_calls, not template markup in message.content.

I will trace the llama.cpp chat-template/parser path at the same time. If native calls are absent because we are not applying the model parser infrastructure, the right fix is to wire that path correctly; a narrow compatibility shim remains reasonable while that lands. If the parser is working and smaller models still only emit text-form calls, we can make an explicit compatibility decision with evidence.

@ndizazzo
ndizazzo force-pushed the agent/remove-tool-call-rescue branch from dc17d85 to 21a3cc2 Compare August 4, 2026 03:36
@i386

i386 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Investigation result: the native llama.cpp chat-template/parser path is already active in serving; it is not being bypassed.

I ran scripts/qa-agent-tool-call-reliability.py twice each against Qwen3-8B Q4_K_M and Qwen2.5-7B Instruct Q2_K, using the built PR branch and direct local OpenAI endpoint. Both runs passed 8/8 phases: native non-streaming tool call, tool-result continuation, streamed tool call, and streamed continuation across two attempts. The responses used finish_reason: "tool_calls", message.content: null, and native OpenAI message.tool_calls (not Qwen <tool_call> text).

Based on this evidence, I do not recommend restoring the removed broad text-form rescue for these Qwen families. The separate serving-side emulation remains the compatibility path for templates that genuinely lack native tool support.

@i386

i386 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

To make the distinction explicit: this PR removes a last-resort, post-parse text rescue. It does not remove the normal Qwen tool-call path.

The serving path first gives llama.cpp the conversation, tool definitions, and tool choice through apply_chat_template_json; after generation it calls parse_chat_response_json. That parser produces the native OpenAI tool_calls object that guardrails then validate. The rescue removed here ran only if that path did not yield a native call, by trying to reinterpret text in message.content as a tool call.

For the two representative Qwen families I tested, the normal path is working end-to-end: Qwen3-8B Q4_K_M and Qwen2.5-7B Instruct Q2_K both returned finish_reason: "tool_calls", content: null, and structured message.tool_calls, including streaming and tool-result continuations (8/8 harness phases each). So the broad rescue is not carrying Qwen compatibility in these cases.

This does not mean every small model has native tool support. Models whose chat template genuinely has no native tool-call support still use the separate serving-side emulation path. Nor does it remove the need for guardrails to validate native calls, reject malformed arguments, and enforce policy. It only stops treating arbitrary text that the model/parser did not recognize as a call as though it were one.

My recommendation is therefore to retain the removal rather than add a size cutoff: model size is not the capability boundary; chat-template/parser support is. If there is a particular small-model family we need to support that emits only text-form calls despite a native template, we should add it to the harness and decide on a deliberately narrow compatibility shim for that family.

@i386

i386 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

@michaelneale The key conclusion is simpler than my previous wording: if the reason this guardrail was added was that Mesh was not correctly using llama.cpp chat-template parsing, then we no longer need the text-form tool-call rescue for models that ship a valid native tool-call template.

That is what the Qwen runs show. With the current Mesh serving path, Qwen3-8B and Qwen2.5-7B receive the tools via their chat template and llama.cpp parses their generated tool call back into native OpenAI tool_calls. The output is already structured before the guardrail-rescue code would run. I verified tool invocation, streamed tool invocation, and the follow-up after a tool result; all passed without the rescue.

So, for those models, the previous rescue was compensating for the broken/missing parser integration—not a model limitation. Restoring it would reintroduce a fallback that is unnecessary for a correctly templated model and can turn unrecognised plain text into an action.

The qualification is only this: a model with no valid native tool-call template/parser needs a compatibility path. That is the separate serving-side emulation path, not this broad post-hoc guardrail rescue. So I think the right rule is: valid native template → native parsing → no text rescue; no native template → explicit emulation.

I therefore recommend retaining this removal; no model-size cutoff is warranted.

@michaelneale

Copy link
Copy Markdown
Collaborator

@i386 is that something we can show a before/after proof of? As yeah if it was that then yes that could explain it

@i386

i386 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

@michaelneale I reran this as the A/B you asked for, with the guardrail wrapper disabled in both cases. The result corrects my earlier conclusion.

Build Parser-related change Text rescue / guardrails Qwen3-8B Q4_K_M harness
27a32e4fa (immediately before fix(skippy): preserve native tool-call semantics) absent disabled (OpenAiGuardrailsConfig::disabled_for_skippy()) 8/8 passed
PR #1145 head b816be873 present disabled; text rescue removed 8/8 passed

Each run used the same direct local OpenAI endpoint and qa-agent-tool-call-reliability.py, two attempts each. The eight phases are forced native tool call, tool-result continuation, streamed tool call, and streamed continuation, twice. Both builds returned native OpenAI tool calls and completed the follow-up.

So Qwen3-8B does not demonstrate that the removed guardrail rescue is needed—and it also does not demonstrate that the parser change is what fixed Qwen3. The pre-change build already works for this model without guardrails. My earlier comment attributing Qwen success to the parser change was too strong; this A/B falsifies that attribution.

The valid conclusion from this model is narrower: removing the broad rescue does not regress Qwen3-8B native tool calling. To establish why Michael originally added the rescue, we need the specific model/output shape that previously required it (or another reproducer that emits text-form calls with guardrails disabled). That is the right target for a compatibility test; a model-size cutoff is not evidenced by Qwen3.

@michaelneale

Copy link
Copy Markdown
Collaborator

🤖 Posted by Mic's AI agent (CrocDundee)

Concern: this PR removes the only test coverage for the "text-form tool intent must not leak as content" invariant, and there's no fail-closed replacement.

The direction of the PR is sound — a second text-scanning interpreter that can execute quoted/explained tool-call syntax is a real footgun, and the native chat parser (plus capability-gated skippy-server/src/frontend/tool_emulation.rs) is the right place for tool-call recognition to live.

But the MoA path loses a guarantee here. In crates/mesh-mixture-of-agents/src/normalize.rs, heuristic_classify no longer recognizes a text-form tool call, so a worker that emits tool intent as text (a small/older model, or a template mis-detected as native so emulation is off) now falls through to OutputKind::Answer and is returned to the agent as ordinary content. Goose / OpenCode / pi only act on real tool_calls, so the agent silently does nothing — no error, no retry, just a dropped turn.

That's precisely the failure mode pinned by crates/mesh-mixture-of-agents/tests/sim_tool_call_text_not_passed_as_content.rs (added off #566 review feedback), which this PR deletes without replacing. Native worker tool_calls are still fine — backend.rs::extract_text_from_response converts them to the KV envelope — so the gap is specifically text-form intent on the MoA path.

The execution-rescue can go, but the invariant it protected should survive: text-form tool intent must become an explicit error / reducer-escalation, never a silent successful content answer. Could we add a replacement MoA test asserting that fail-closed behavior before merge? Happy to write it if useful.

@i386

i386 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

@michaelneale I traced this back through the original guardrail work and reran the historical model family rather than relying on Qwen3-8B.

What the history says

  • The text rescue originated in your Forge-port commit on #614, later superseded by #645.
  • feature(tool-calling): harden tool calling for agent harnesses #645’s measured Qwen cases were Qwen3.5-0.8B UD-Q8_K_XL and Qwen3.5-4B Q4, especially the 256-token hard-schema cases. Its report records malformed/truncated arguments; it does not identify a Qwen 8B parser-integration regression as the reason for rescue.

Retest on this PR head

I downloaded and ran Qwen3.5-0.8B UD-Q8_K_XL and Qwen3.5-4B UD-Q4_K_XL against the current PR head, with the same direct local OpenAI endpoint. For each model I ran guardrails disabled and global guardrails enforce (verified from /api/status).

Model Disabled Enforce Result
Qwen3.5 0.8B UD-Q8_K_XL 6/9 6/9 all non-streaming native calls + tool-result continuations pass; all streamed tool-call deltas fail
Qwen3.5 4B UD-Q4_K_XL 6/9 6/9 same result

I also forced a nested tool schema at max_tokens: 256, the historical hard-case shape. On both models and in both modes, the first response returned valid native OpenAI arguments; later requests failed with llama.cpp native PEG parse 502 errors before a response reached guardrails. Enforcing guardrails did not change that outcome. Streaming is explicitly pass-through, so it cannot repair the streamed-call failure either.

Conclusion

For the original Qwen3.5 family, I cannot reproduce a case where the broad text rescue provides a benefit. The observed failures are below or outside that layer: native parser failure and streaming behavior. This supports retaining the rescue removal for Qwen, but it does not prove no model ever needs a compatibility shim. The separate GLM tagged-call history is the concrete remaining candidate. If there is a specific historical text-form output that rescue successfully converted into a valid call, that exact model/prompt/response is what we should add as a narrow regression fixture before retaining a shim.

@i386

i386 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

@michaelneale I agree this is a separate concern from the parser/guardrail investigation.

The Qwen A/B evidence supports removing broad text-to-executable-tool-call rescue: we should not scan arbitrary assistant text and synthesize tool_calls. But this PR also removes the MoA safety invariant from #566: when a worker returns plain text that clearly expresses intent to invoke a declared tool, treating it as a successful Answer can leak that text to the agent as content. The agent neither invokes a tool nor receives a failure, so the turn is silently dropped.

I think the safe replacement is a narrow, tool-context-aware MoA guard—not execution rescue:

  • detect clear text-form intent for one of the request’s declared tools;
  • classify it as unusable/uncertain and escalate to the reducer;
  • if no valid native tool call emerges, return an explicit MoA error;
  • add a regression test that this text never becomes a successful content response.

That preserves the fail-closed invariant without reconstructing or executing a call from text. The deleted inline-JSON success case should also fail closed unless the call arrived through the native tool-call channel.

How would you like to proceed—would you prefer to take that narrow MoA guard/test, or should I implement it here?

@ndizazzo
ndizazzo force-pushed the agent/remove-tool-call-rescue branch from b816be8 to 7aa1c51 Compare August 4, 2026 07:44
@michaelneale

michaelneale commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

I think a more narrow MoA guard makes sense here ... I think... not sure what we lose in process of that though. One thing to try is to try a small model with almost any agent harness - that is only way you see this (basically none of them work even a little bit), if it works reasonably now (which would be obvious), then that is good enough to go, and we can look at MoA as a follow on?

Base automatically changed from agent/skippy-native-tool-calls to main August 4, 2026 08:28

i386 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Ran the requested OpenCode tool-loop smoke test on the current PR head 7aa1c51f859edc8da022eaa95ef04bff1904133f (clean build) with the two models discussed here:

Model OpenCode tool use Result
Qwen3.5 0.8B UD-Q8_K_XL Yes — multiple filesystem bash / read calls completed Fail: stalled after tool results; the smoke fixture remained unimplemented.
Qwen3.5 4B UD-Q4_K_XL Yes — filesystem bash call (ls -la) completed Fail: same post-tool-call stall; the smoke fixture remained unimplemented.

The harness's direct OpenAI tool probe also returned finish_reason: "length" with no structured tool_calls for both models (the probe is capped at 8 output tokens).

This was a real local Metal run through the OpenAI surface and OpenCode 1.16.2. I disabled only the long-context soak portion (OPENCODE_SMOKE_LONG_PROMPT_CHARS=0); the tool-call loop itself ran. The traces show both models can emit a tool call, but neither completed the continuation after the tool result, so I would not call the agent-harness path working yet.

@ndizazzo
ndizazzo force-pushed the agent/remove-tool-call-rescue branch from 7aa1c51 to 4d99c94 Compare August 4, 2026 08:46

i386 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to my prior test report: the observed behavior is slow continuation, not a confirmed deadlock.

The capture proves OpenCode sent the post-tool request (assistant tool_calls plus role: tool result) to mesh. I replayed that exact 4B continuation directly against mesh, bypassing the capture proxy. Mesh emitted the assistant stream role immediately, then emitted the next native bash tool call and completed with finish_reason: tool_calls / [DONE] about 76 seconds later (roughly 11k prompt tokens).

I had interrupted the original OpenCode run at approximately that latency threshold, so the earlier "stalled" conclusion was premature. This shows the OpenCode → tool result → mesh → next tool-call path does work for the 4B replay; it is simply much slower than the bounded wait I used. I have not yet rerun the entire multi-step smoke to completion under a longer time budget.

@i386
i386 merged commit 934b8f9 into main Aug 4, 2026
45 checks passed
@i386
i386 deleted the agent/remove-tool-call-rescue branch August 4, 2026 09:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants