Skip to content

Server-side tool-call emulation for models without native tool support - #946

Merged
michaelneale merged 6 commits into
mainfrom
feat/server-side-tool-call-emulation
Jul 3, 2026
Merged

Server-side tool-call emulation for models without native tool support#946
michaelneale merged 6 commits into
mainfrom
feat/server-side-tool-call-emulation

Conversation

@michaelneale

@michaelneale michaelneale commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Closes #944.

What you can now do

Point any OpenAI client — goose, curl, an SDK — at a mesh model that is not
tool-trained (e.g. a small model whose chat template ignores tools), pass the
standard tools field, and get back real tool_calls with
finish_reason: "tool_calls". Previously those models ignored the schemas or
looped re-issuing the same call.

The serving node is the only party that knows the loaded model's actual
chat-template capability, so it emulates tool calling when — and only when — the
template can't do it natively. Tool-capable models (including lean ones like
Qwen3-0.6B with a small toolset) keep native tool calling and see zero
behavior change
.

How it works

  1. Detection — gated on template capability, not model size. When the chat
    template is applied, the staged runtime returns metadata_json; native
    support means parse_tool_calls == true and a non-empty chat_parser
    (the same signal goose checks).
  2. Request adaptation — for a non-tool-capable template with tools, the
    prompt is re-rendered: tools/tool_choice stripped, a compact instruction
    injected (tool name + description + compact parameter schema with ? for
    optional args), and history rewritten so the template never sees tool roles
    (assistant tool_callsTOOL_CALL {json} text; role:"tool" → user
    "Tool result:" text). The compact schema matters: the live prototype showed
    name+description alone made a 0.6B model hallucinate argument names.
  3. Response parsing — output is scanned for TOOL_CALL {json} lines →
    OpenAI tool_calls, tolerant of surrounding prose and <think> blocks.
    Streaming holds back the trailing incomplete line and withholds calls until
    finalization, matching native tool-call streaming semantics. Emulated calls
    ride the same downstream assembly as native ones, so call_mesh_* ids and
    streaming behave identically.

Ported from goose's local-inference provider
(crates/goose-local-inference/src/tool_emulation.rs, tool_parsing.rs,
prompts/tiny_model_system.md).

Architecture

  • New self-contained crates/skippy-server/src/frontend/tool_emulation.rs
    (detection, request adaptation, response parsing).
  • frontend/prompting.rs wires it into prepare_chat_prompt (adaptation) and
    parse_chat_output (parsing), with the streaming-aware
    parse_emulated_chat_output bridge.
  • Detection needs no new state threading: emulation renders the prompt without
    tools, so the flowing metadata_json naturally reports non-native and the
    parse path branches on the same signal.
  • New spec: docs/specs/server-side-tool-call-emulation.md.

Validation

  • cargo test -p skippy-server --lib — 183 passed (19 new unit tests in
    tool_emulation, 5 new integration tests in frontend::tests).
  • cargo clippy -p skippy-server --all-targets -- -D warnings — clean.
  • cargo fmt --all --check — clean.
  • cargo check -p mesh-llm — clean (reachable from the shipped binary).

Tests cover: capability detection (both signals required), compact schema with
required/optional flags, single/multiple/multi-turn calls, <think>-block
tolerance, disallowed-name filtering, malformed JSON treated as prose, history
rewrite (system merge/insert, tool→user, assistant tool_calls→text), streaming
partial withholding, and parallel_tool_calls:false truncation.

Summary by CodeRabbit

  • New Features
    • Added server-side tool-call emulation for chat completions when the selected chat template doesn’t natively support tools.
    • Injects emulation guidance and rewrites prior tool interactions so models can return tool calls in a compatible format.
  • Bug Fixes
    • Improved parsing of emulated tool calls (including tolerant handling for partial/streaming output) and converts them into OpenAI-compatible tool calls.
    • Enables early streaming stop as soon as a complete emulated tool call is produced.
    • Enforces tool allowlisting and single-call behavior when parallel tool calls are disabled.
  • Documentation
    • Added a specification for server-side tool-call emulation behavior and response handling.

Small / non-tool-trained models served through the mesh /v1 endpoint
handle the OpenAI tools field poorly: the chat template ignores the
schemas or the model loops re-issuing the same call. goose solved this
in its local-inference provider, but that path is bypassed when a client
talks to a mesh, and every other OpenAI client hits the same wall.

The serving node is the only party that knows the loaded model's actual
chat-template capability, so it emulates tool calling when the template
cannot do it natively:

- Detect capability from the runtime chat-template metadata
  (parse_tool_calls + non-empty chat_parser), not model size. Tool-capable
  templates are unchanged.
- Adapt the request: strip tools/tool_choice, inject a compact instruction
  (name + description + compact parameter schema) teaching the
  TOOL_CALL {json} convention, and rewrite history so the template never
  sees tool roles.
- Parse the response: scan for TOOL_CALL lines into OpenAI tool_calls with
  finish_reason tool_calls, tolerant of prose and <think> blocks; hold back
  partial markers while streaming.

Ported from goose local-inference (tool_emulation.rs, tool_parsing.rs,
tiny_model_system.md). Adds 19 unit + 5 integration tests.
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

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: 6ecc23fc-c4f7-46ad-adcb-ed339a4900fd

📥 Commits

Reviewing files that changed from the base of the PR and between a728a6c and 662fb43.

📒 Files selected for processing (2)
  • crates/skippy-server/src/frontend.rs
  • crates/skippy-server/src/frontend/generation_flow.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/skippy-server/src/frontend/generation_flow.rs
  • crates/skippy-server/src/frontend.rs

📝 Walkthrough

Walkthrough

Adds server-side tool-call emulation for chat templates without native tool support in skippy-server. Introduces a tool_emulation module for capability detection, instruction building, history rewriting, and response parsing; wires it into prompt rendering, output parsing, streaming generation stop logic, tests, and a documentation spec.

Changes

Tool-call emulation feature

Layer / File(s) Summary
Emulation core: detection, instruction, history rewrite, parsing
crates/skippy-server/src/frontend/tool_emulation.rs
New module defines the TOOL_CALL marker, native-support detection via grammar_triggers, an env override, compact schema/instruction building, history rewriting to avoid native tool roles, tolerant TOOL_CALL parsing with <think> stripping and balanced-JSON scanning, early-completion detection, and unit tests.
Prompting wiring: render path and output parsing
crates/skippy-server/src/frontend/prompting.rs
Adds RenderedChatPrompt, refactors prepare_chat_prompt/render_chat_prompt to support native vs. emulated rendering paths, updates parse_chat_output to route to emulation parsing, and adds parse_emulated_chat_output for streaming-aware tool-call assembly.
Generation stop wiring and tests
crates/skippy-server/src/frontend.rs, crates/skippy-server/src/frontend/generation_flow.rs, crates/skippy-server/src/frontend/tests.rs
Declares the tool_emulation module, adds emulation_generation_active and TextGenerationCollector.with_emulation_stop early-stop logic, threads emulation_active through generation flows, and adds unit tests for parse_emulated_chat_output.
Emulation specification document
docs/specs/server-side-tool-call-emulation.md
Adds documentation describing the problem, implementation locations, template-capability detection, request adaptation, response parsing, and reference sources.

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

🚥 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 summarizes the main change: server-side tool-call emulation for models lacking native tool support.
Linked Issues check ✅ Passed The changes implement template-capability gating, request rewrites, tolerant TOOL_CALL parsing, and streaming behavior as requested.
Out of Scope Changes check ✅ Passed The docs, tests, and frontend plumbing are all directly tied to the tool-call emulation feature.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/server-side-tool-call-emulation

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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/skippy-server/src/frontend/prompting.rs`:
- Around line 26-41: The chat prompt rendering in prompting.rs is probing
template capability by rendering the full native prompt first, which can expose
unsupported tool roles before emulation is chosen. Update the flow around
render_chat_prompt, tool_emulation::template_supports_native_tool_calls, and
tool_emulation::rewrite_history_for_emulation so capability is checked using
sanitized/minimal data or metadata before any full render, then render only once
with either the native messages or the rewritten emulation history.
- Around line 35-41: The emulation path in render_chat_prompt currently rewrites
history with build_emulation_instruction(tools) but drops request.tool_choice
semantics, so the forced-tool requirement is lost. Update
tool_emulation::build_emulation_instruction (and any caller like
rewrite_history_for_emulation in prompting.rs) to encode tool_choice, especially
“required” and any explicit function selection, into the emulation instruction
so the model is constrained to the requested tool. Ensure the rewritten prompt
preserves the original tool_choice behavior instead of allowing free-form
answers or different tools.
- Around line 290-293: The partial parsing logic in prompting::scan_text
currently strips everything after the last newline whenever is_partial is true,
which suppresses normal one-line prose during streaming. Update the scan_text
handling so it only withholds the trailing segment when it might be an
incomplete TOOL_CALL marker, and otherwise lets prose flow through normally; use
the existing is_partial path in prompting.rs to distinguish tool-call fragments
from regular text.

In `@docs/specs/server-side-tool-call-emulation.md`:
- Around line 54-56: The fenced example in the docs spec is unlabeled and will
fail MD040; update the code block around the TOOL_CALL example to use a language
tag like text. Make the change in the markdown snippet itself so the example
stays CI-clean, and keep the TOOL_CALL sample content unchanged.
🪄 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: 32931fab-7309-4bbb-a255-c455032a0a48

📥 Commits

Reviewing files that changed from the base of the PR and between ea50fbb and 8143151.

📒 Files selected for processing (5)
  • crates/skippy-server/src/frontend.rs
  • crates/skippy-server/src/frontend/prompting.rs
  • crates/skippy-server/src/frontend/tests.rs
  • crates/skippy-server/src/frontend/tool_emulation.rs
  • docs/specs/server-side-tool-call-emulation.md

Comment on lines +26 to +41
let native = self.render_chat_prompt(request, &options, &marker, None, true)?;

// If the request carries tools but the template does not support native
// tool calling, re-render with server-side tool-call emulation: strip
// tools, inject a text-convention instruction, and rewrite history so
// the template never sees tool roles. Tool-capable templates keep the
// native prompt unchanged.
if tool_calls_requested(request)
&& !tool_emulation::template_supports_native_tool_calls(&native.metadata_json)
&& let Some(tools) = request.tools.as_ref()
&& let Some(instruction) = tool_emulation::build_emulation_instruction(tools)
{
let rewritten =
tool_emulation::rewrite_history_for_emulation(&request.messages, &instruction);
let emulated =
self.render_chat_prompt(request, &options, &marker, Some(&rewritten), false)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Avoid probing unsupported templates with the original tool-role history.

Line 26 renders the native prompt before emulation is selected, so non-tool-capable templates can still see role: "tool" / assistant tool_calls and fail before the rewrite at Lines 38-41 runs. Probe capability with sanitized/minimal messages or metadata first, then render either the native or rewritten prompt once.

🤖 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/skippy-server/src/frontend/prompting.rs` around lines 26 - 41, The
chat prompt rendering in prompting.rs is probing template capability by
rendering the full native prompt first, which can expose unsupported tool roles
before emulation is chosen. Update the flow around render_chat_prompt,
tool_emulation::template_supports_native_tool_calls, and
tool_emulation::rewrite_history_for_emulation so capability is checked using
sanitized/minimal data or metadata before any full render, then render only once
with either the native messages or the rewritten emulation history.

Comment on lines +35 to +41
&& let Some(tools) = request.tools.as_ref()
&& let Some(instruction) = tool_emulation::build_emulation_instruction(tools)
{
let rewritten =
tool_emulation::rewrite_history_for_emulation(&request.messages, &instruction);
let emulated =
self.render_chat_prompt(request, &options, &marker, Some(&rewritten), false)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve tool_choice semantics in the emulation instruction.

The emulation path strips tool_choice at Line 41, but build_emulation_instruction(tools) does not encode tool_choice: "required" or a forced function choice. This can let the model answer normally or call another tool even when the request requires a specific tool.

🤖 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/skippy-server/src/frontend/prompting.rs` around lines 35 - 41, The
emulation path in render_chat_prompt currently rewrites history with
build_emulation_instruction(tools) but drops request.tool_choice semantics, so
the forced-tool requirement is lost. Update
tool_emulation::build_emulation_instruction (and any caller like
rewrite_history_for_emulation in prompting.rs) to encode tool_choice, especially
“required” and any explicit function selection, into the emulation instruction
so the model is constrained to the requested tool. Ensure the rewritten prompt
preserves the original tool_choice behavior instead of allowing free-form
answers or different tools.

Comment on lines +290 to +293
let scan_text = if is_partial {
text.rsplit_once('\n')
.map(|(head, _)| head)
.unwrap_or_default()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not withhold all partial prose while streaming.

This drops every trailing line during partial parsing, so a normal one-line answer streams no content until finalization. Only hold the trailing segment when it could be a partial TOOL_CALL marker; otherwise parse/emit prose normally.

Suggested shape
-    let scan_text = if is_partial {
-        text.rsplit_once('\n')
-            .map(|(head, _)| head)
-            .unwrap_or_default()
+    let scan_text = if is_partial && trailing_segment_may_be_tool_call(text) {
+        text.rsplit_once('\n').map(|(head, _)| head).unwrap_or_default()
     } else {
         text
     };
🤖 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/skippy-server/src/frontend/prompting.rs` around lines 290 - 293, The
partial parsing logic in prompting::scan_text currently strips everything after
the last newline whenever is_partial is true, which suppresses normal one-line
prose during streaming. Update the scan_text handling so it only withholds the
trailing segment when it might be an incomplete TOOL_CALL marker, and otherwise
lets prose flow through normally; use the existing is_partial path in
prompting.rs to distinguish tool-call fragments from regular text.

Comment on lines +54 to +56
```
TOOL_CALL {"name": "the_tool_name", "arguments": {"arg": "value"}}
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Label the fenced example.

This block will trip MD040 in docs lint. Add a language tag (for example text) so the spec stays CI-clean.

♻️ Proposed fix
-```
+```text
 TOOL_CALL {"name": "the_tool_name", "arguments": {"arg": "value"}}
-```
+```
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
TOOL_CALL {"name": "the_tool_name", "arguments": {"arg": "value"}}
```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 54-54: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 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 `@docs/specs/server-side-tool-call-emulation.md` around lines 54 - 56, The
fenced example in the docs spec is unlabeled and will fail MD040; update the
code block around the TOOL_CALL example to use a language tag like text. Make
the change in the markdown snippet itself so the example stays CI-clean, and
keep the TOOL_CALL sample content unchanged.

Source: Linters/SAST tools

The skippy CI smoke already sends a tools request to SmolLM2-135M, whose
chat template does not support native tool calling, so it exercises the
new server-side tool-call emulation path. It previously only asserted
HTTP 200 and role==assistant.

Strengthen it: use a tool-forcing prompt and a real token budget, and
assert that any tool_calls returned by the emulation path are well-formed
(non-empty function name with arguments that parse as a JSON object).
Requiring a 135M model to reliably emit a tool call would be flaky, so a
call is not forced, but malformed emulated calls now fail the smoke.
@michaelneale

Copy link
Copy Markdown
Collaborator Author

Live spot check (local, real model)

Verified end-to-end against a real non-tool-trained model served through the mesh — Qwen/Qwen2.5-0.5B-Instruct-GGUF:q4_k_m on mesh-llm serve — driving real /v1/chat/completions requests:

  • Tools requestfinish_reason: "tool_calls" with a valid shell call ({"command":"ls"}) and a call_mesh_* id. ✅
  • No-tools control → normal prose (finish_reason: "stop", no tool_calls). Native path untouched. ✅
  • Multi-turn with a prior tool result → history-rewrite path (assistant tool_calls→text, role:"tool"→user) worked with no template mangling; model produced a valid follow-up call. ✅
  • Streaming → proper delta.tool_calls with index, then finish_reason: "tool_calls". ✅

CI coverage

The existing scripts/skippy-ci-smoke.sh already sends a tools request to SmolLM2-135M, whose template is not tool-capable — so it was already exercising this new emulation path, but only asserted HTTP 200 + role==assistant. This branch strengthens that row to assert any emulated tool_calls are well-formed (non-empty name + JSON-object arguments). A tool call is not forced (a 135M model can't reliably emit one without flakiness), but malformed emulated output now fails the smoke.

The ported goose heuristic (parse_tool_calls && non-empty chat_parser)
does not work against mesh-llm's patched llama.cpp: parse_tool_calls is
true for every tools request and chat_parser is always a non-empty PEG
structure, so the check was always true and emulation never fired.

Detect native support from grammar_triggers instead: a tool-capable
jinja template yields a tool-call grammar trigger (e.g. <tool_call>) when
applied with tools, while a template with no native tool support (e.g.
SmolLM2-135M) yields an empty grammar_triggers list.

Verified live: SmolLM2-135M routes to emulation (native_supported=false),
Qwen2.5-0.5B and Qwen3.5-0.8B keep native tool calling (grammar trigger
present).
@michaelneale

Copy link
Copy Markdown
Collaborator Author

Correction: detection signal fixed (grammar_triggers)

While verifying your question ("do real-tool-calling models skip this path?") I found a real bug in the first version: the ported goose heuristic — parse_tool_calls && non-empty chat_parserdoes not work against mesh-llm's patched llama.cpp. There, parse_tool_calls is true for every tools request and chat_parser is always a non-empty PEG structure, so the check was always true and emulation never fired.

Fixed to detect native support from grammar_triggers: a tool-capable jinja template yields a tool-call grammar trigger (e.g. <tool_call>) when applied with tools; a template with no native tool support yields an empty list.

Verified live with a temporary branch trace:

Model Template native_supported Path
SmolLM2-135M no tool grammar false emulation
Qwen2.5-0.5B-Instruct <tool_call> trigger true native (unchanged)
Qwen3.5-0.8B <tool_call> trigger true native get_weather call ✅

So the answer to your question is confirmed with the corrected detector: tool-capable templates do not trigger emulation. (Note: my earlier "spot check" comment mis-attributed Qwen2.5-0.5B as emulated — it was actually native. This commit corrects the detection so the routing is now right.)

Trace removed; unit test updated; clippy/fmt/tests clean.

- Add MESH_FORCE_TOOL_EMULATION override (goose's ToolCallingMode::
  ForceEmulated analogue) so emulation can be exercised against strong
  models and used as an escape hatch when a native template misbehaves.
  Routed through should_emulate_tool_calls().
- Revert scripts/skippy-ci-smoke.sh to main: the two-node/binary smoke
  runs on tiny models (SmolLM2-135M) that cannot reliably emit a tool
  call, so probing for emulated tool_calls there would be flaky. The
  emulation path is covered by unit/integration tests and verified live.

Verified end-to-end: with MESH_FORCE_TOOL_EMULATION=1, Qwen2.5-3B emitted
the raw text 'TOOL_CALL {"name": "get_weather", "arguments":
{"city": "Paris"}}', parsed into a real OpenAI tool_call with
finish_reason tool_calls.
…tool call

Three changes that make server-side emulation actually work for the weak /
non-tool-trained models it targets, proven live on gemma-4-E4B:

- Prompt dominance (goose-style, server-safe): the emulation instruction now
  leads the system message, with the client's original system content preserved
  below it under '# Task context'. This gives the tool-calling frame the
  dominant position goose gets by replacing the system prompt, without
  discarding the client's authoritative prompt (a serving node must not do
  that). Verified: gemma kept a pirate persona AND emitted the tool call.

- Robust parsing: scan for the TOOL_CALL marker anywhere (with balanced-JSON
  extraction) instead of only at line start, so a call emitted right after a
  reasoning marker (e.g. gemma's <|channel>thought...channel|>TOOL_CALL {...})
  is still parsed. Handles multiple calls and trailing prose.

- Early stop (Jasper's tool_call_emitted -> Stop): stop generation once a
  complete emulated tool call is produced, so the model does not ramble past
  the call. Only active on emulated tools requests; native paths unaffected.

Verified end-to-end with MESH_FORCE_TOOL_EMULATION=1: gemma-4-E4B emitted a
parseable get_weather call (78 tokens, early-stopped) under a competing system
prompt; native path (no force) unchanged.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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/skippy-server/src/frontend/generation_flow.rs`:
- Around line 387-389: The split multimodal generation path is missing the
emulation early-stop wiring that the non-split collector already uses. Update
generate_split_multimodal_text and the SplitMultimodalGeneration flow so
hook_request/emulation_active are carried through, then apply
TextGenerationCollector::with_emulation_stop(...) for the split collector as
well. This keeps split-path tool requests from continuing after an emulated
TOOL_CALL has completed.
🪄 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: cad5db15-3641-40c7-ba4d-c6a45bf2a441

📥 Commits

Reviewing files that changed from the base of the PR and between c4a98ca and a728a6c.

📒 Files selected for processing (4)
  • crates/skippy-server/src/frontend.rs
  • crates/skippy-server/src/frontend/generation_flow.rs
  • crates/skippy-server/src/frontend/prompting.rs
  • crates/skippy-server/src/frontend/tool_emulation.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/skippy-server/src/frontend/prompting.rs

Comment thread crates/skippy-server/src/frontend/generation_flow.rs
Thread emulation_active through SplitMultimodalGeneration so the split
multimodal generation path (embedded stage-0 with downstream lanes +
media + tools for a non-tool-capable model) also stops generating once a
complete emulated TOOL_CALL is produced, matching the local and
non-split multimodal paths. Computed from hook_request/prompt at the
construction site, which already has both in scope.
@michaelneale
michaelneale merged commit 9547ac2 into main Jul 3, 2026
22 checks passed
@michaelneale
michaelneale deleted the feat/server-side-tool-call-emulation branch July 3, 2026 01:50
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.

Serving-side tool-call emulation for models without native tool support (port of goose local-inference tool shim)

1 participant