fix(responses): pass through client-side tool_calls instead of dispatching them#1049
Conversation
…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.
Deploying control-layer with
|
| 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 |
There was a problem hiding this comment.
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_namesset into the transition logic and gate server-side tool dispatch on it. - Update warm-path setup to persist
resolved_tool_namesalongside 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. |
There was a problem hiding this comment.
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:
- Server-side tool dispatch: Tools registered on the server are automatically executed, with results fed back to the model
- Client-side tool calling: Client provides tool definitions in the request; the model returns
function_callitems 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:
- Add an integration test that exercises the full HTTP flow (POST /v1/responses with client-side tools → GET retrieval showing
function_callitems withoutfunction_call_output) - 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
- Registered tools being dispatched (
-
Assembly handles passthrough correctly: The
assemble_from_chainfunction already emitsfunction_callitems from model_call responses and only addsfunction_call_outputitems 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_streamingandrun_inline_blockingcallunregister_pending()after loop termination (Ok or Err), preventing memory leaks in thepending_inputsside-channel map.
Minor observations (non-blocking)
- The helper function
tool_call_nameattransition.rs:323could potentially returnNoneif the payload lacks a "name" field. Theis_some_andpattern 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:305— Nit: Consider adding a defensive check or warning iftool_call_namereturnsNone. While theis_some_andpattern 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:627— Nit: Consider documenting the edge case whereresolved.toolsis empty (no server-side tools registered). In this scenario,resolved_tool_nameswill 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:
🤖 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).
Summary
The
/v1/responseswarm path always engages the multi-step orchestration loop and dispatches everytool_callthe model returns throughHttpToolExecutor. 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-sidetool_sourcesregistry and the step fails withTool not found: <name>— the error showing up in Loki for/ai/v1/responsesrequests withservice_tier: flexand unregistered tool names likeread_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_calloutput items instead of being auto-dispatched.Root cause (traced from the SQL outward)
tool_injection.rs:107-134joinsapi_keys → user_groups → deployment_groups → deployment_tool_sources → tool_sourcesand only ever returns server-registered tools — client-supplied tools from the request body are not added.warm_path_setupalways returnedSome(...)even when this query yielded zero rows, building an emptyResolvedToolSet.decide_next_actioncreatedToolCallsteps for everytool_callin a model response without checking the registry.HttpToolExecutor::executelooked the name up, didn't find it, and returnedToolError::NotFound, failing the step.Fix
Plumb the resolved tool-name set into the transition function. When a
model_callreturnstool_callsand any name is missing from the set, complete the response with the model's payload instead of fanning out intoToolCallsteps. The existingassembly.rsalready emitsfunction_callitems from a model_call'stool_callsfield (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 withprevious_response_id.The whole
tool_callbatch 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.rs—decide_next_actiontakesresolved_tool_names: &HashSet<String>; returnsCompletewhen any tool_call's name is missingdwctl/src/responses/store.rs—PendingResponseInput.resolved_tool_namesfield; threaded throughnext_action_fordwctl/src/responses/middleware.rs—warm_path_setuppopulates the field fromresolved.tools.keys()dwctl/src/test/{multi_step_executor,responses}.rs— fixtures updated for the new fieldWhat this does NOT change
The warm path still engages for
service_tier: flexregardless of tier — flex is still inline, not async via the daemon. That's a separate architectural concern (the daemon'sDwctlRequestProcessorreadspending_inputwhich is only populated byregister_pendingin the warm path; making flex go tohandle_flexwould need either populating the side-channel from the daemon or changing the daemon to read the body off the fusillade row). The user-reportedTool not foundbug doesn't require that change, and this PR unblocks both flex and realtime client-side-tool requests.Test plan
model_call_with_unregistered_tool_completes_for_client_dispatch— exact reproduction of theread_pagesscenariomodel_call_with_mixed_registered_and_unregistered_completes— confirms whole-batch passthrough on partial registrationcargo test -p dwctl --lib -- responses tool_executor tool_injection— 85 passed, 0 failedcargo clippy -p dwctl --lib(withSQLX_OFFLINE=true) — cleancargo fmt --check -p dwctl— cleancargo metadata --locked— lockfile in sync/ai/v1/responseswithservice_tier: flexand a client-sidetools: [{name: "read_pages"}]body; confirm response containsoutput: [{"type":"function_call", ...}]instead of erroring🤖 Generated with Claude Code