Skip to content

fix(responses): pass through client-side tool_calls instead of dispatching them#1049

Merged
pjb157 merged 1 commit into
mainfrom
claude/cranky-blackwell-45716e
May 7, 2026
Merged

fix(responses): pass through client-side tool_calls instead of dispatching them#1049
pjb157 merged 1 commit into
mainfrom
claude/cranky-blackwell-45716e

Conversation

@pjb157
Copy link
Copy Markdown
Contributor

@pjb157 pjb157 commented May 6, 2026

Summary

The /v1/responses warm path always engages the multi-step orchestration loop and dispatches every tool_call the model returns through HttpToolExecutor. When a client supplies their own function tools in the request body (client-side tool calling per the OpenAI Responses spec), the executor can't find them in the server-side tool_sources registry and the step fails with Tool not found: <name> — the error showing up in Loki for /ai/v1/responses requests with service_tier: flex and unregistered tool names like read_pages.

This change makes the transition function aware of which tools are server-registered, so client-side tools get returned to the caller as function_call output items instead of being auto-dispatched.

Root cause (traced from the SQL outward)

  • The SQL in tool_injection.rs:107-134 joins api_keys → user_groups → deployment_groups → deployment_tool_sources → tool_sources and only ever returns server-registered tools — client-supplied tools from the request body are not added.
  • warm_path_setup always returned Some(...) even when this query yielded zero rows, building an empty ResolvedToolSet.
  • decide_next_action created ToolCall steps for every tool_call in a model response without checking the registry.
  • HttpToolExecutor::execute looked the name up, didn't find it, and returned ToolError::NotFound, failing the step.

Fix

Plumb the resolved tool-name set into the transition function. When a model_call returns tool_calls and any name is missing from the set, complete the response with the model's payload instead of fanning out into ToolCall steps. The existing assembly.rs already emits function_call items from a model_call's tool_calls field (it does this for completed multi-step chains), so the assembled response delivers the calls in OpenAI Responses shape and the client can execute them locally and submit results in a follow-up request with previous_response_id.

The whole tool_call batch passes through (not just the unregistered ones) because partial dispatch would leave the upstream conversation expecting tool results for calls the loop never ran — a state the model can't reason about.

Files changed:

  • dwctl/src/responses/transition.rsdecide_next_action takes resolved_tool_names: &HashSet<String>; returns Complete when any tool_call's name is missing
  • dwctl/src/responses/store.rsPendingResponseInput.resolved_tool_names field; threaded through next_action_for
  • dwctl/src/responses/middleware.rswarm_path_setup populates the field from resolved.tools.keys()
  • dwctl/src/test/{multi_step_executor,responses}.rs — fixtures updated for the new field

What this does NOT change

The warm path still engages for service_tier: flex regardless of tier — flex is still inline, not async via the daemon. That's a separate architectural concern (the daemon's DwctlRequestProcessor reads pending_input which is only populated by register_pending in the warm path; making flex go to handle_flex would need either populating the side-channel from the daemon or changing the daemon to read the body off the fusillade row). The user-reported Tool not found bug doesn't require that change, and this PR unblocks both flex and realtime client-side-tool requests.

Test plan

  • New unit test: model_call_with_unregistered_tool_completes_for_client_dispatch — exact reproduction of the read_pages scenario
  • New unit test: model_call_with_mixed_registered_and_unregistered_completes — confirms whole-batch passthrough on partial registration
  • Existing transition tests updated for the new parameter; all pass
  • cargo test -p dwctl --lib -- responses tool_executor tool_injection — 85 passed, 0 failed
  • cargo clippy -p dwctl --lib (with SQLX_OFFLINE=true) — clean
  • cargo fmt --check -p dwctl — clean
  • cargo metadata --locked — lockfile in sync
  • Manual verification: hit /ai/v1/responses with service_tier: flex and a client-side tools: [{name: "read_pages"}] body; confirm response contains output: [{"type":"function_call", ...}] instead of erroring

🤖 Generated with Claude Code

…ching them

The /v1/responses warm path always engages multi-step orchestration and
dispatches every tool_call returned by the model through HttpToolExecutor.
When a client supplies their own function tools in the request body
(client-side tool calling per the OpenAI Responses spec), the executor
looks them up in the server-side tool_sources registry, doesn't find
them, and fails the step with `Tool not found`.

Fix: thread the set of registered tool names from `resolve_tools_for_request`
through PendingResponseInput into the transition function. When a model_call
returns tool_calls and any name is missing from that set, complete the
response with the model's payload — assembly already emits `function_call`
output items in OpenAI Responses shape, so the client receives the calls
and can execute them locally.

The whole tool_call batch passes through (not just the unregistered ones)
because partial dispatch would leave the upstream conversation expecting
tool results for calls the loop never ran.
Copilot AI review requested due to automatic review settings May 6, 2026 16:48
@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying control-layer with  Cloudflare Pages  Cloudflare Pages

Latest commit: 4dccf7d
Status: ✅  Deploy successful!
Preview URL: https://2f85c47e.control-layer.pages.dev
Branch Preview URL: https://claude-cranky-blackwell-4571.control-layer.pages.dev

View logs

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes /v1/responses warm-path multi-step orchestration to correctly support client-side tool calling (per the OpenAI Responses API) by not attempting server-side dispatch for tool calls that aren’t registered in the server tool registry (tool_sources). Instead, such tool calls are passed through to the client as function_call output items by completing with the model payload.

Changes:

  • Thread a per-request resolved_tool_names set into the transition logic and gate server-side tool dispatch on it.
  • Update warm-path setup to persist resolved_tool_names alongside the pending response input.
  • Extend/update unit tests to cover unregistered and mixed tool-call batches.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
dwctl/src/responses/transition.rs Adds resolved_tool_names awareness to decide_next_action; completes (passthrough) when any tool call name isn’t server-registered.
dwctl/src/responses/store.rs Extends PendingResponseInput with resolved_tool_names and threads it into next_action_for.
dwctl/src/responses/middleware.rs Populates resolved_tool_names from the resolved server tool set during warm-path setup.
dwctl/src/test/multi_step_executor.rs Updates fixture pending input to include the new resolved_tool_names field.
dwctl/src/test/responses.rs Updates fixture pending input to include the new resolved_tool_names field.

Copy link
Copy Markdown

@doubleword-code doubleword-code Bot left a comment

Choose a reason for hiding this comment

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

Summary

This PR fixes a critical bug in the /v1/responses warm path where client-side tool calls (functions supplied by the client in the request body per the OpenAI Responses API spec) were incorrectly being dispatched through HttpToolExecutor, which would fail with "Tool not found" since those tools aren't registered in the server-side tool_sources registry. The fix threads the set of registered server-side tool names through the transition function and completes the response (passing through tool_calls as function_call output items) when any tool_call references an unregistered tool.

Verdict: Ready to approve — the implementation is correct, well-tested, and follows the OpenAI Responses API contract for client-side tool calling.

Research Notes

The OpenAI Responses API supports two tool-calling modes:

  1. Server-side tool dispatch: Tools registered on the server are automatically executed, with results fed back to the model
  2. Client-side tool calling: Client provides tool definitions in the request; the model returns function_call items that the client must execute locally and submit via follow-up requests

This PR correctly implements mode #2 by detecting when tool_calls reference tools outside the server's registry and completing the response so the client receives the function_call items.

Suggested Next Steps

No blocking issues. Consider these optional follow-ups:

  1. Add an integration test that exercises the full HTTP flow (POST /v1/responses with client-side tools → GET retrieval showing function_call items without function_call_output)
  2. Document the client-side tool calling behavior in user-facing docs

General Findings

Positive observations

  • Correct all-or-nothing semantics: The fix wisely passes through the entire batch of tool_calls rather than attempting partial dispatch. This avoids leaving the conversation in an inconsistent state where the model expects results for tool_calls the loop never executed.

  • Comprehensive test coverage: Four new unit tests cover the key scenarios:

    • Registered tools being dispatched (model_call_with_registered_tool_calls_emits_fan_out)
    • Unregistered tools triggering passthrough (model_call_with_unregistered_tool_completes_for_client_dispatch)
    • Mixed registered/unregistered tools completing (model_call_with_mixed_registered_and_unregistered_completes)
    • Existing tests updated to pass the new parameter
  • Assembly handles passthrough correctly: The assemble_from_chain function already emits function_call items from model_call responses and only adds function_call_output items when ToolCall steps exist. When we complete without dispatching, this produces exactly the shape the OpenAI Responses API expects.

  • Cleanup pattern is sound: Both run_inline_streaming and run_inline_blocking call unregister_pending() after loop termination (Ok or Err), preventing memory leaks in the pending_inputs side-channel map.

Minor observations (non-blocking)

  • The helper function tool_call_name at transition.rs:323 could potentially return None if the payload lacks a "name" field. The is_some_and pattern at line 305 handles this gracefully (treats missing name as unregistered), but adding a debug log for malformed payloads might aid troubleshooting.

Inline findings (could not anchor to diff)

  • Nit dwctl/src/responses/transition.rs:305Nit: Consider adding a defensive check or warning if tool_call_name returns None. While the is_some_and pattern handles this gracefully (treating missing names as unregistered), a malformed tool_call payload would silently trigger client-side passthrough. A debug-level trace here could help troubleshoot unexpected behavior:
  • Nit dwctl/src/responses/middleware.rs:627Nit: Consider documenting the edge case where resolved.tools is empty (no server-side tools registered). In this scenario, resolved_tool_names will be an empty HashSet, causing ALL tool_calls to be treated as client-side. This is correct behavior, but a brief comment would clarify intent for future readers:

@pjb157 pjb157 merged commit 88ac176 into main May 7, 2026
11 checks passed
pjb157 added a commit that referenced this pull request May 7, 2026
🤖 I have created a release *beep* *boop*
---


##
[8.46.1](v8.46.0...v8.46.1)
(2026-05-07)


### Bug Fixes

* pass through client-side tool_calls instead of dispatching them
([#1049](#1049))
([88ac176](88ac176))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
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