Skip to content

fix(tools): recover forced-tool arguments when the backend ignores tool_choice - #72

Open
walcz-de wants to merge 3 commits into
mudler:mainfrom
walcz-de:upstream-pr/forced-toolchoice-fallback
Open

fix(tools): recover forced-tool arguments when the backend ignores tool_choice#72
walcz-de wants to merge 3 commits into
mudler:mainfrom
walcz-de:upstream-pr/forced-toolchoice-fallback

Conversation

@walcz-de

Copy link
Copy Markdown

What

Some OpenAI-compatible backends do not honor a named/required tool_choice at generation time. We hit this with llama.cpp: Qwen3-family chat templates with thinking disabled via chat_template_kwargs ignore the constraint entirely and free-run plain text (reported upstream to llama.cpp with a minimal repro). For cogito this was fatal: every forced decision in decision()/decisionWithStreaming() came back without tool calls, surfaced as no parameters generated for tool X, and retried until the generation cap — the whole tool loop died in minutes-long narration loops.

Three backend-agnostic hardening steps, none of which change behavior on well-behaved backends:

  1. Schema fallback (forcedToolParamsViaSchema): when a forced decision yields no tool call, re-ask with a response_format JSON schema built from the forced tool's parameter schema — the plain structured-output grammar path, which the affected templates do honor — and parse the arguments from the content.
  2. Early abort: a honored forced call streams tool-call deltas, not prose. Once 512 bytes of plain content arrive without a single tool-call delta, cancel the stream and go straight to the fallback instead of free-running to the cap (minutes at local inference speeds).
  3. Think-aware: prose inside a leading <think>…</think> block does not count toward the abort threshold — thinking models that legitimately reason at length before an honored forced call are unaffected (reasoning deltas never counted anyway; this covers pipelines that deliver reasoning as content).

Testing

Unit tests cover: fallback fires exactly once for a forced no-call decision and recovers valid arguments; unforced text answers never take the fallback; the think-block prose counter. Verified end-to-end on a LocalAI + llama.cpp deployment where the agent tool loop went from all-red (retry-to-cap on every task) to 8/10 tasks passing with 10/10 clean finalizations.

Assisted-by: Claude (Anthropic) — implementation done with AI assistance; the failure analysis, repro and end-to-end verification were performed against real hardware.

…ol_choice

Some llama.cpp chat templates do not honor a named/required tool_choice at
generation time (observed with Qwen3-family templates when thinking is
disabled via chat_template_kwargs): the forced decision free-runs plain text
and returns without any tool call, which callers surface as 'no parameters
generated for tool X' and retry until the generation cap.

Add a backend-agnostic fallback in decision()/decisionWithStreaming(): when a
forced decision yields no tool call, re-ask with a response_format JSON schema
built from the forced tool's parameter schema — the plain structured-output
grammar path, which those templates do honor — and parse the arguments from
the content. Well-behaved backends never take the fallback (gate-tested both
ways).

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: stefanwalcz <stefan.walcz@walcz.de>
…ree-running to the cap

A backend that ignores a named/required tool_choice streams prose instead of
tool-call deltas — previously the doomed generation ran all the way to the
per-completion cap (minutes at local speeds) before the schema fallback could
recover. Detect the violation after 512 bytes of plain content with no
tool-call delta, cancel the stream, and go straight to the response_format
schema fallback. Reasoning deltas do not trigger the abort, so thinking models
that reason before a honored forced call are unaffected.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: stefanwalcz <stefan.walcz@walcz.de>
…tent

Some pipelines deliver a thinking model's <think> block as plain content
deltas, not reasoning events — the early-abort then misfired on models that
DO honor forced tool_choice (they reason at length before the call) and sent
them into the fallback needlessly. Count only prose outside a leading think
block toward the abort threshold.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: stefanwalcz <stefan.walcz@walcz.de>
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.

1 participant