Skip to content

[Bugfix][Responses API] Render namespace tool declarations instead of dropping them - #39174

Open
hawkli-1994 wants to merge 1 commit into
sgl-project:mainfrom
hawkli-1994:fix/responses-custom-namespace-tools
Open

hawkli-1994 wants to merge 1 commit into
sgl-project:mainfrom
hawkli-1994:fix/responses-custom-namespace-tools

Conversation

@hawkli-1994

@hawkli-1994 hawkli-1994 commented Sep 12, 2026 •

Copy link
Copy Markdown

Motivation

A namespace tool declaration groups inner function schemas — Codex sends its multi-agent tool families exactly this way (ProviderCapabilities::namespace_tools defaults to true, so custom providers receive them un-flattened). /v1/responses accepts the declaration but nothing executes it:

This is the namespace half of #35216. The custom-tool half landed on main via #38690; this PR builds directly on that work (same responses_adapters.py module, same patterns) so the two read as one coherent story. Scope relative to #38359 (also namespace-only, open): same wire contract, plus harmony-path support, the streaming added/done split, and the pydantic union serialization that otherwise silently drops namespace.

Modifications

All changes are stateless HTTP-serving-layer translation; no tokenizer, template, engine, or scheduler changes. Namespace helpers join responses_adapters.py next to the custom-tool ones:

  • Declaration flattening — each member becomes a {namespace}.{name} chat function (inner schema/description/strict preserved; the namespace description fills members that omit one). Shared by the chat path, the harmony developer message, and (via namespace_members_to_chat_tools) future callers.
  • Output split — every emission path maps the qualified name back to name + namespace on the item: non-streaming, the required-JSON fallback, and the streaming output_item.added / .done events.
  • Replay — a replayed function_call with name + namespace re-qualifies to the flattened name for the chat template, and the harmony recipient likewise. Only declared namespace prefixes split; an exact declaration outranks the split, so a plain function whose name contains a dot is never reinterpreted.
  • Serialization — namespaced calls travel through widened pydantic unions (ResponseNamespacedFunctionToolCall, matching output_item.added/done event subclasses, and the ResponsesResponse.output union). Without the widened arm leading, the SDK's ResponseFunctionToolCall arm serializes the subclass instance and silently drops namespace — a trap any subclass-based approach hits.
  • tool_choice — "required" accepts namespace declarations, and a namespace field on a named choice re-qualifies it to the flattened name instead of degrading to "auto".

Accuracy Tests

No kernel or model-forward changes; wire behavior only. Namespace cases join the existing custom-tools suite (test/registered/unit/entrypoints/openai/test_responses_custom_tools.py, registered in base-a-test-cpu):

cd test/registered/unit/entrypoints/openai
python3 test_responses_custom_tools.py       # 29 cases (12 new namespace cases)
python3 test_serving_responses.py            # 43 cases
python3 test_serving_responses_stream.py     # 9 cases
python3 test_responses_protocol.py           # 22 cases

All four green on this branch. Regression verification: with the source changes stashed (pre-fix main), the suite goes red at import (cannot import name 'split_namespaced_call'); each namespace guard targets a distinct failure mode (declaration drop, split precedence, replay re-qualification, output emission, streaming wire events, union serialization, harmony rendering).

Live verification against real servers is recorded in the comments below: pre-fix stock sglang 0.5.19 (declaration accepted, inner tools never reachable), and the same release with this PR's files overlaid (qualified calls split correctly on a live Qwen3-1.7B). The overlay there predates this rebase onto #38690; happy to re-run the record against this exact head on request.

Speed Tests and Profiling

Not applicable — no inference-path changes. Runtime cost is one pass over request.tools per request and a set lookup per parsed call.

Checklist

Note for reviewers: the head branch moved from fix/responses-custom-namespace-tools to fix/responses-namespace-tools (rebased onto main after #38690 landed) — the comment history below, including the live verification records, refers to that earlier iteration which covered custom + namespace together.

🤖 Generated with Claude Code


CI States

Latest PR Test (Base): ❌ Run #34702424926
Latest PR Test (Extra): ❌ Run #34702424700
Latest PR Test (AMD ROCm 10): ❌ Run #34702424829

@hawkli-1994

Copy link
Copy Markdown
Author

Hi @JustinTong0323 — requesting a review when you have a moment, and the run-ci label if the change looks reasonable (external contributors can't self-apply it).

To make coordination against the neighboring PRs easy, the scope here is deliberately narrow — strictly the silently-dropped-declaration bug for custom + namespace tools, both halves in one focused change:

Verification so far: 81 unit cases green across the four responses suites; each new guard was verified red against pre-fix serving code before being trusted; all applicable pre-commit hooks pass (enumerated in the checklist). I'm glad to run a live-server / Codex CLI conformance pass and post the record in the same format as your #38690 verification — pointers to the preferred harness welcome.

Will rebase promptly whenever main moves.

@hawkli-1994

Copy link
Copy Markdown
Author

Live repro record (stock release, pre-fix) — the silent drop on a real server, matching the Motivation section:

Setup: sglang 0.5.19 (stock release), Qwen/Qwen3.8-27B-Zero-FP8 on an L20 host, --reasoning-parser qwen3 --tool-call-parser qwen3_coder. Harness: two-case conformance script driving POST /v1/responses (non-streaming, store=false).

Case 1 — custom tool declaration silently dropped ({"type": "custom", "name": "apply_patch", ...}, prompt asks the model to use it):

  • HTTP 200, status=completed, output items: ["reasoning", "message"] — no custom_tool_call, ever.
  • The model clearly wanted the tool; having never been shown a callable declaration, it emitted the call as literal message text:
{"name":"apply_patch","arguments":{"patch":"*** Begin Patch\n*** Add File: README.md\n+hello\n*** End Patch"}}

So the request "succeeds" while the tool loop is dead — the client can never parse this into a call item. This is the exact Codex-killing failure mode from #35216.

Case 2 — namespace declaration accepted, inner tools never reach the model ({"type": "namespace", "name": "weather", "tools": [{"type": "function", "name": "lookup", ...}]}):

  • HTTP 200, output contains zero function_call items — the inner weather.lookup function is dropped before the chat tools are built, so the model cannot call it at all.

(Side observation: 0.5.19's request schema requires inner namespace members to carry "type": "function"; main's Dict[str, Any] and this PR both accept members with or without it.)

Next: same script in fix mode against a server built from this branch, record to follow in the same format.

@hawkli-1994

Copy link
Copy Markdown
Author

Live fix-mode verification record — same release, same wire behavior, only this PR's files changed.

Setup — an internal 1× L20 (48GB) host, sglang 0.5.19 (stock release wheel) with this PR's four changed files overlaid verbatim (responses_tool_adapters.py new + protocol.py / serving_responses.py / harmony_utils.py from the PR branch at 3bfba754). Model Qwen/Qwen3-1.7B, --tool-call-parser qwen25 --reasoning-parser qwen3. Harness: the same conformance script as the repro record above, in fix mode. Three lines of this PR's serving_responses.py that call main-only APIs absent from the 0.5.19 parent class (_engine_prompt, set_request_reasoning_end_token_ids, get_serving) were shimmed to their 0.5.19 equivalents on the test host's copy only — none of them are touched by the PR's diff; everything else is byte-identical to the branch.

Results — 4/4 PASS

Case Result
custom declaration → model calls it PASS — output ['custom_tool_call'], input carries the full freeform patch text the model produced (it wrote a complete unified diff into the one-string argument)
Replay custom_tool_call + custom_tool_call_output PASS — status=completed, no 400; the conversation continues with the supplied tool result
namespace declaration → qualified call splits PASS — emitted call is ('lookup', 'weather'): name="lookup", namespace="weather"
Streaming custom-tool events PASS — response.custom_tool_call_input.delta and .done both present

Regression check — a plain function tool on the same server still round-trips: ('get_weather', '{"city": "Tokyo"}').

Alongside the pre-fix repro on stock 0.5.19 (previous comment: declaration 200 but no call ever emitted; namespace members never reachable), this pins the fix end to end on a real model: declaration reaches the model through the shim, calls come back on the right wire items with the right fields, replay no longer breaks the loop, streaming sends the custom-tool event stream, and plain function tools are unaffected.

Happy to re-run the same suite against any model/parser combo the reviewers prefer.

@hawkli-1994

Copy link
Copy Markdown
Author

Rebased onto main after #38690 landed: the head branch is now fix/responses-namespace-tools, scoped to the namespace half of #35216 (the custom half is main's now). The namespace translation moved into the same responses_adapters.py module #38690 introduced, so the two shims read side by side. All four responses suites green on this head (103 cases total); the new namespace guards verified red against pre-fix main. @JustinTong0323 — re-review whenever convenient; run-ci still needed when you have a moment.

@hawkli-1994 hawkli-1994 changed the title [Bugfix][Responses API] Stop silently dropping custom and namespace tools [Bugfix][Responses API] Render namespace tool declarations instead of dropping them Sep 12, 2026
@hawkli-1994
hawkli-1994 force-pushed the fix/responses-custom-namespace-tools branch from 3bfba75 to 982a34e Compare September 12, 2026 13:48
@hawkli-1994

Copy link
Copy Markdown
Author

Live verification record against the rebased head (982a34e9, namespace-only on top of main+#38690) — same setup as the earlier records: internal 1× L20 host, sglang 0.5.19 with this branch's four changed files overlaid (the same three 0.5.19-compat shims for main-only parent APIs applied to the host's copy, none touching this PR's diff), Qwen/Qwen3-1.7B, --tool-call-parser qwen25 --reasoning-parser qwen3.

Results — 4/4 PASS on the conformance script, now doubly meaningful:

Case Result
custom declaration → custom_tool_call PASS — exercised #38690's implementation on main, confirming the rebase did not regress it (input '+ hello', the freeform payload the model produced)
Replay custom_tool_call/_output PASS — also #38690's path, unregressed
namespace declaration → split call PASS — this PR's path: emitted call is ('lookup', 'weather'), name="lookup" + namespace="weather"
Streaming custom-tool events PASS — #38690's path, unregressed

Namespace streaming additionally verified live: a tool_choice="required" namespace request streams to completion with output_item.added for both the reasoning and the namespaced function_call item, ending in response.completed carrying the split pair.

So on a real model: main's custom-tool support is intact under this diff, and the namespace half now behaves identically on the wire.

… dropping them

A `namespace` declaration groups inner function schemas (Codex sends its
multi-agent tool families this way; ProviderCapabilities::namespace_tools
defaults to true). The protocol accepts the declaration but nothing
executes it: the inner functions never reach the chat tools the model sees,
so the model never calls them, and a replayed call carrying a `namespace`
field loses its qualification.

Members now flatten to `{namespace}.{name}` chat function tools (inner
description and strict flags preserved; the namespace description fills
members that omit one), and calls translate back on every output path:

- non-streaming and the required-JSON fallback split the qualified name
  into `name` plus a `namespace` field on the item
- streaming `output_item.added/done` carry the split pair
- replayed namespaced calls re-qualify for the chat template; only
  declared namespace prefixes split, so a plain function whose name
  contains a dot is never reinterpreted
- the harmony developer message renders the flattened members, and
  harmony output parsing splits qualified recipients the same way

Namespaced items travel through widened pydantic unions
(`ResponseNamespacedFunctionToolCall` plus matching event subclasses);
without the widened arm leading, the SDK's `ResponseFunctionToolCall` arm
serializes the subclass and silently drops `namespace`.

`tool_choice="required"` accepts namespace declarations, and a `namespace`
field on a named choice re-qualifies it to the flattened name instead of
degrading to "auto".

Custom tools (the other half of sgl-project#35216) landed on main via sgl-project#38690; this
PR builds on that work and shares its adapters module.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@hawkli-1994

Copy link
Copy Markdown
Author

Gentle follow-up on this one, @JustinTong0323 — happy to make any changes if the approach needs adjusting.

Where it stands since the last note: rebased cleanly onto current main (single commit), the CI lint job is green, and all four responses suites pass (103 cases). The live check in the comment above doubles as a no-regression proof for the custom-tool paths you validated in #38690, with the namespace half as the only behavioral addition. Main serving_responses.py still has zero namespace handling, so this stays strictly additive.

Would appreciate the run-ci label when you get a chance — thanks!

This branch has not been deployed

No deployments
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