Skip to content

fix(openai): recover text-mode tool calls from native models that emit tags#76

Merged
graycyrus merged 6 commits into
tinyhumansai:mainfrom
oxoxDev:fix/recover-text-mode-tool-calls
Jul 24, 2026
Merged

fix(openai): recover text-mode tool calls from native models that emit tags#76
graycyrus merged 6 commits into
tinyhumansai:mainfrom
oxoxDev:fix/recover-text-mode-tool-calls

Conversation

@oxoxDev

@oxoxDev oxoxDev commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Recover DeepSeek/Hermes text-mode tool calls that leak into assistant content when a native-flagged model emits its call as <tool_call …> text instead of the structured tool_calls field.

Problem

opencompany's company agents run on OpenHuman → tinyagents. On staging the model is DeepSeek (via OpenRouter). DeepSeek/Hermes chat templates emit tool calls as text — <tool_call id="call_0">{...}</tool_call> — but the model is flagged as native structured tool-calling. Two failures compound:

  1. Attribute form defeats the matcher. The inline text parser matched only the exact <tool_call> literal, so <tool_call id="…"> (and the <tool_call|> / DeepSeek <|tool▁call▁begin|> variants) never matched.
  2. Recovery gated off in native mode. The three OpenAI recovery sites ran text recovery only when !profile.tool_calling. A native model that returned an empty structured tool_calls array but text markup got no recovery — so the raw <tool_call>…</tool_call> markup leaked into the assistant reply as literal text instead of executing.

Solution

Ported as a delta on top of what main already covers:

  • main already covered (kept, not duplicated): malformed argument JSON recovery for the structured function.arguments field (relaxed_json.rs, fix(openai): recover relaxed/malformed tool-call argument JSON #68) — a different concern (argument blob repair, not text-tag markup). The exact-literal <tool_call> prompt-guided path and its no-name drop also already existed.
  • This PR adds the still-missing pieces:
    • src/harness/tool/prompt.rs — a prefix-tolerant opening-delimiter scanner (next_open) handling <tool_call id="…">, <tool_call|>, and the DeepSeek <|tool▁call▁begin|>/end pair. The tag name must be properly delimited (>, |, or whitespace) so <tool_calls> / <tool_callable> don't match; a no-name body is dropped without leaking its raw markup; a prose <tool_call with no closing > is left verbatim.
    • should_recover(native, has_tools, structured_calls) gate, wired into all three OpenAI recovery sites (unary, non-stream, SSE terminal): prompt-guided models always recover; native models recover only as a fallback when tools were offered but the structured array came back empty.

Invariants preserved

  • Native structured tool-calling (real tool_calls present, structured_calls > 0) is byte-for-byte unchangedshould_recover returns false, so apply_prompt_tool_calls is never invoked.
  • No tools offered → stream/response forwarded untouched.
  • Plain prose mentioning <tool_call> without a full tag+body is not misparsed; <tool_calls> (plural) never matches.
  • Unterminated open tag is kept as text (existing behaviour retained).

Test coverage

Added to src/harness/tool/prompt_test.rs (9 new tests, all green):

  • attribute-form <tool_call id="x">{"name":"foo",…}</tool_call> parses to foo, markup does not survive into content
  • pipe variant <tool_call|> and DeepSeek <|tool▁call▁begin|> delimiters parse
  • malformed no-name body ({}) dropped without leaking <tool_call/{}
  • plural <tool_calls> not matched; prose open-tag without close not misparsed
  • apply_prompt_tool_calls recovers attribute markup from an empty-structured-calls response
  • should_recover gating matrix + native-with-structured-calls skip

cargo test green for harness::tool::prompt (22 passed) and harness::providers::openai (125 passed). cargo fmt --check clean on touched files; cargo clippy --lib clean.

Related

Fixes the DeepSeek text-mode tool-call leak observed in opencompany company agents on staging.

Summary by CodeRabbit

  • Bug Fixes

    • Improved tool-call recovery for both prompt-guided and native model outputs using smarter decision logic for when to convert text markup into structured tool calls.
    • Expanded recognition of tool-call text formats (attribute-tolerant tags, pipe variants, and DeepSeek-style delimiters) while avoiding false positives and preserving malformed markup as plain text.
    • Applied consistent recovery behavior across unary and streaming responses, including SSE/non-SSE handling.
  • Tests

    • Added regression tests covering new accepted formats, negative/malformed cases, and the recovery gating matrix for both unary and streaming flows.

oxoxDev added 2 commits July 24, 2026 17:19
The inline <tool_call> text parser matched only the exact <tool_call>
literal, so the attribute form <tool_call id="call_0"> emitted by
Hermes / DeepSeek chat templates (and the <tool_call|> / DeepSeek
begin/end variants) fell through and leaked as raw text. Match the tag
prefix tolerantly, verify the tag name is properly delimited (so
<tool_calls> is not matched), and drop a no-name body without leaking
its markup. Add should_recover(native, has_tools, structured_calls) so
callers can run text recovery as a native-mode fallback.
Native tool-calling models only read the structured tool_calls field, so
a model that emitted its call as text (DeepSeek via OpenRouter) leaked the
raw <tool_call> markup into assistant content. Gate the three recovery
sites (unary, non-stream, SSE) on should_recover: prompt-guided models
always recover; native models recover only when tools were offered but the
structured tool_calls array came back empty. Native responses that already
carry structured calls are returned byte-for-byte unchanged.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8090d3fe-a81b-4546-ae70-103cc77e897a

📥 Commits

Reviewing files that changed from the base of the PR and between 279d90a and d1325ac.

📒 Files selected for processing (4)
  • src/harness/providers/openai/test.rs
  • src/harness/providers/openai/transport.rs
  • src/harness/tool/prompt.rs
  • src/harness/tool/prompt_test.rs
📝 Walkthrough

Walkthrough

OpenAI response handling now uses shared recovery gating, while prompt tool-call parsing accepts additional provider-specific markup and avoids false positives. Tests cover supported delimiters, malformed input, structured recovery, and native fallback behavior.

Changes

Tool-call recovery

Layer / File(s) Summary
Parser and recovery rules
src/harness/tool/prompt.rs
Tool-call extraction supports attribute and pipe forms plus DeepSeek delimiters, validates openings, preserves unterminated text, and adds should_recover.
OpenAI response integration
src/harness/providers/openai/transport.rs, src/harness/providers/openai/test.rs
Unary, non-SSE, and SSE responses conditionally recover text-based tool calls based on native capability, supplied tools, and existing structured calls; one error test is reformatted without changing assertions.
Recovery regression coverage
src/harness/tool/prompt_test.rs
Tests cover supported markup, invalid or ambiguous tags, recovery, and the gating matrix.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OpenAiModel
  participant ResponseParser
  participant should_recover
  participant apply_prompt_tool_calls
  OpenAiModel->>ResponseParser: Parse response and count structured tool calls
  ResponseParser->>should_recover: Evaluate recovery conditions
  should_recover-->>OpenAiModel: Return recovery decision
  OpenAiModel->>apply_prompt_tool_calls: Recover terminal text when enabled
Loading

Possibly related PRs

  • tinyhumansai/tinyagents#55: Updates OpenAI transport handling for converting prompt-guided tool-call markup into structured calls.

Poem

I’m a rabbit parsing tags in the hay,
DeepSeek markers now join the play.
Native calls stay safely intact,
Loose tool markup gets neatly stacked.
Hop, hop—structured calls arise!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 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: recovering text-mode tool calls from native models that emit tags.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

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

@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: 1

🧹 Nitpick comments (1)
src/harness/providers/openai/transport.rs (1)

1406-1419: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Recovery-gating pattern is duplicated across three call sites.

invoke() (1411-1417), the non-SSE tolerant fallback (1481-1487), and the SSE terminal mapper (1535-1545) all repeat the same "should_recover(...)apply_prompt_tool_calls(...), else pass through" shape. Extracting a small helper (e.g. fn recover_if_needed(native: bool, has_tools: bool, response: ModelResponse) -> ModelResponse) would keep all three call sites in lock-step if should_recover's contract changes again.

♻️ Proposed helper
fn recover_if_needed(native: bool, has_tools: bool, response: ModelResponse) -> ModelResponse {
    if crate::harness::tool::should_recover(native, has_tools, response.message.tool_calls.len()) {
        crate::harness::tool::apply_prompt_tool_calls(response)
    } else {
        response
    }
}

Also applies to: 1481-1488, 1522-1549

🤖 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 `@src/harness/providers/openai/transport.rs` around lines 1406 - 1419, Extract
the duplicated recovery gating into a shared helper near the transport
response-mapping logic, using the native flag, whether request tools are
present, and the ModelResponse; have it call should_recover and
apply_prompt_tool_calls or return the response unchanged. Replace the recovery
blocks in invoke(), the non-SSE tolerant fallback, and the SSE terminal mapper
with this helper so all three paths remain consistent.
🤖 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 `@src/harness/providers/openai/transport.rs`:
- Around line 1522-1549: Ensure the SSE streaming path removes recovered
tool-call markup from streamed MessageDelta text, not only from the terminal
Completed response. Update the delta handling associated with sse_next and the
stream transformation around should_recover/apply_prompt_tool_calls so native
fallback responses do not expose raw markup to live consumers, while preserving
unchanged streaming when no tools are offered and structured native tool calls
are present.

---

Nitpick comments:
In `@src/harness/providers/openai/transport.rs`:
- Around line 1406-1419: Extract the duplicated recovery gating into a shared
helper near the transport response-mapping logic, using the native flag, whether
request tools are present, and the ModelResponse; have it call should_recover
and apply_prompt_tool_calls or return the response unchanged. Replace the
recovery blocks in invoke(), the non-SSE tolerant fallback, and the SSE terminal
mapper with this helper so all three paths remain consistent.
🪄 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

Run ID: bb42cc47-0a02-4c5f-9ebd-3b6215e056ea

📥 Commits

Reviewing files that changed from the base of the PR and between 1bab7e9 and 5a4bf49.

📒 Files selected for processing (3)
  • src/harness/providers/openai/transport.rs
  • src/harness/tool/prompt.rs
  • src/harness/tool/prompt_test.rs

Comment thread src/harness/providers/openai/transport.rs Outdated
@greptile-apps

greptile-apps Bot commented Jul 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes two compounding failures that caused DeepSeek/Hermes tool calls to leak as raw <tool_call> markup in assistant content: the opening-tag scanner only matched the exact <tool_call> literal (missing the attribute form and pipe/DeepSeek variants), and the three recovery sites were gated to prompt-guided mode only, leaving native models without a text-fallback path.

  • prompt.rs — adds next_open (tolerant tag scanner for <tool_call id=\"…\">, <tool_call|>, and DeepSeek <|tool▁call▁begin|> delimiters) and ToolCallStreamScrubber (stateful streaming scrubber that prevents raw markup from reaching live delta consumers), plus should_recover gate logic wired into all three recovery sites.
  • transport.rs — replaces the old three !self.profile.tool_calling && !request.tools.is_empty() guards with should_recover(native, has_tools, structured_calls) and introduces clean_stream_item to apply scrubbing to both live deltas and the terminal Completed response in the SSE path.
  • prompt_test.rs / test.rs — 9 new unit tests covering attribute/pipe/DeepSeek variants, the no-name drop, plural tag non-match, scrubber fragment-split scenarios, and the full gating matrix.

Confidence Score: 5/5

Safe to merge — the core recovery and scrubbing logic is correct for all realistic model outputs, and the new streaming scrubber path is properly guarded by the existing request.tools.is_empty() early-return.

The tag-scanner, scrubber, and gate logic are all well-reasoned, well-documented, and covered by 9 new targeted tests. The hold_from positional issue only triggers when a buffer contains two simultaneously open-but-unclosed tool_call prefixes — a scenario that does not arise with real model output.

src/harness/tool/prompt.rs — specifically the hold_from function's incomplete tracking loop, which should return the first eligible position rather than the last.

Important Files Changed

Filename Overview
src/harness/tool/prompt.rs Core logic change: adds tolerant tag scanner (next_open), hold_from for streaming hold-back, ToolCallStreamScrubber, and should_recover. hold_from has a minor positional issue when multiple openable-but-unclosed prefixes appear in the same buffer.
src/harness/providers/openai/transport.rs Replaces three identical guard conditions with should_recover, adds clean_stream_item wiring the scrubber into both live deltas and the terminal SSE response.
src/harness/tool/prompt_test.rs Adds 9 targeted regression tests covering attribute/pipe/DeepSeek open-tag forms, no-name body drop, plural-tag non-match, scrubber fragment-split scenarios, and the full should_recover gating matrix.
src/harness/providers/openai/test.rs Adds stream_cleanup_scrubs_leaked_markup_from_live_deltas exercising the full SSE path end-to-end. Minor formatting-only changes to an existing test.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[SSE fragment arrives] --> B{tools offered?}
    B -- no --> C[Forward stream untouched]
    B -- yes --> D[ToolCallStreamScrubber.feed]
    D --> E{complete block found?}
    E -- yes --> F[Drop block, emit prefix, loop]
    F --> E
    E -- no --> G{unclosed open tag found?}
    G -- yes --> H[Emit prefix, hold open tag in buf]
    G -- no --> I[hold_from: emit safe prefix, hold trailing partial]
    H --> J[Clean text emitted to delta consumer]
    I --> J
    K[Completed item arrives] --> L[scrubber.flush]
    L --> M{buffered tail?}
    M -- yes --> N[Emit as final MessageDelta before Completed]
    M -- no --> O[No extra delta]
    N --> P[should_recover: native + has_tools + structured_calls.len]
    O --> P
    P -- native AND structured_calls gt 0 --> Q[Response unchanged]
    P -- prompt-guided OR native fallback --> R[apply_prompt_tool_calls]
    Q --> S[Emit Completed]
    R --> S
Loading

Reviews (3): Last reviewed commit: "fix(openai): scrub leaked tool-call mark..." | Re-trigger Greptile

Comment thread src/harness/tool/prompt.rs
Comment thread src/harness/tool/prompt.rs
@graycyrus
graycyrus merged commit 29769ad into tinyhumansai:main Jul 24, 2026
3 checks passed
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.

2 participants