refactor: drive gateway tool loop with LoopDecision (PR B)#83
Conversation
f66ae62 to
fb075f3
Compare
|
cc @maralbahari @franciscojavierarceo — PR B from the tool framework design (#67). Builds on the types/registry from #80. The main open question is the dispatch model conflict with #82 (raised there already). |
|
@ashwing Thank you for the PR. we have now a working web search tool support in main. I think instead of mock implementation would be best to refactor the current |
|
Agreed, that's the right move. Let me fold it into what's on main rather than the other way around. The way I see it there are two separate layers here, and only one of them is contested:
A few things I'd keep from #83 because they're behavior changes, not just refactor:
A couple of calls I'm making:
|
b1c0a90 to
46a53d2
Compare
|
@maralbahari — reworked the loop around Two behavior changes worth a look: the round cap now returns 283 tests, CI green. |
@ashwing I checked #84 the |
|
Ordering works — #84 → #83 → #89. #84's loop is still the old inline one, so I'll fold the consolidation into #83: On the pluggable per-tool output/emit — how firm is the shape from codex's side? I'll review #84 so it can go first — that's the long pole, and it's what lets #83 consolidate on real client behavior instead of a stub. |
@ashwing I dont have clear picture now I think once we have both MCP, and codex namespace we can identify the common path and build from there. but one thing I foresee is we need to make the follow similar pattern as ResponseAccumulator how it uses the normalized SSE for gateway public streaming shape. Gateway + Response Accumulator Flow |
bb09143 to
50a1122
Compare
Extract the gateway tool loop's per-round decision into a LoopDecision enum + classify_round() classifier (executor/gateway.rs) and drive run_until_gateway_tools_complete by matching on it, replacing the inline if/if/else branches. Reworked onto the merged per-call ToolRegistry::dispatch() (vllm-project#82/vllm-project#85) rather than a parallel dispatch surface, and consolidating vllm-project#84's inline client-action path onto LoopDecision::RequiresClientAction. Behavior: - Exhausting the round budget while the model still requests tools now returns status "incomplete" + incomplete_details.reason instead of Err, preserving accumulated output and recording the final round's gateway calls with their outputs so a continuation is never fed a dangling tool call. - Per-call timeout (GATEWAY_TOOL_TIMEOUT 60s; Duration::ZERO opts out): a hung tool becomes an error output fed back to the model, not a whole-request failure. - Replaced the hard >8 calls/round cap with a .buffered(5) sliding concurrency window that drains arbitrary fan-out without failing. - A gateway-owned tool declared with no registered handler now yields an error output too, making the "never a whole-request failure" contract total. Signed-off-by: Ashwin Giridharan <girida@amazon.com>
- gateway.rs unit tests: all four LoopDecision arms + final-round-no-work
is Done; per-call timeout (1ms budget vs 50ms tool -> error output);
missing-handler -> error output.
- dispatch_loop_cassette_test.rs: recorded OpenAI Responses bodies driven
through the loop (single client-owned terminates in one round, parallel
preserved, mixed gateway + client-owned).
- web_search_tool_test.rs: round cap asserts the status "incomplete"
contract (blocking and streaming); continuation test proves the
persisted incomplete turn pairs every gateway call with its output;
9-call fan-out proves all execute past the concurrency window of 5;
Flow C test covers a single turn mixing web_search (gateway) with a
Codex namespace call (client) -> RequiresClientAction in one round,
web_search resolved and the namespace call restored to {namespace,name}.
Signed-off-by: Ashwin Giridharan <girida@amazon.com>
50a1122 to
8ed29a1
Compare
|
@maralbahari Yeah, that settles the extension-point question — hold it until MCP and codex are both in, then pull the common path from three real cases. No guessing from two. The I'd keep that out of this PR though — it's a streaming-path refactor with its own surface, and #83 is already the loop consolidation. Once #89 (MCP) lands we'll have the two gateway frame types plus codex's client-owned case in hand, and the accumulator seam becomes concrete instead of a third guess. Happy to take that as the follow-up. PR's rebased onto current main and green now, if you get a chance to look at the loop itself. |
The framework shipped across vllm-project#80/vllm-project#82/vllm-project#84/vllm-project#85/vllm-project#91 (with vllm-project#83/vllm-project#89 in review) and diverged from the original proposal in several ways. Update the doc to the design-of-record: - Status: Proposal -> Accepted; add as-built preamble. - ToolHandler split into ToolHandler + GatewayExecutor (execute() only applies to gateway-owned types); Pin<Box<dyn Future>> not #[async_trait]; no discover()/event_prefix()/output_item_type() on the trait. - ToolType gains CodexNamespace (6 variants); ResponsesTool shown as the shipped #[non_exhaustive] 7-variant enum incl. namespace + Unknown catch-all and the web_search aliases. - ToolEntry carries handler: Option<Arc<dyn GatewayExecutor>>; dispatch is two layers (per-call registry.dispatch + multi-turn classify_round/ LoopDecision) with a diagram. - LoopDecision trimmed to 4 unit variants; mixed turns resolved by classify_round precedence, not ContinuePartial. - Add Implementation Status (PR mapping) and Future Work (layering ADR, GatewayAccumulator, per-type config, remaining handlers). - Mark Open Questions Q1-Q4 resolved; clarify collision handling is a hard error only for namespace-vs-top-level, warn-level last-write-wins otherwise. Rationale sections (Principles, Alternatives Considered) preserved. Signed-off-by: Ashwin Giridharan <girida@amazon.com>
| })); | ||
| } | ||
|
|
||
| #[cfg(test)] |
There was a problem hiding this comment.
I think the unit test here are not really necessary. I think with an integration test should be the best scenario to verify the loop. like we have cassettes for both web_search and codexnamespace could combine the two like one response/conversation turn from web_search cassettes another turn from codexnamespace cassettes. this would be good scenario for the integration test.
There was a problem hiding this comment.
Two things here — the integration test I'm on board with, the unit tests I'd keep.
Integration test: agreed, good use of the cassettes we've got. I'll add one that runs a web_search turn then a codexnamespace turn (codex-gateway-http-namespace-tool-*) through the loop in one conversation — gateway turn resolves server-side, namespace turn hands back RequiresClientAction with the call restored. web_search_tool_test.rs already has this shape inline (execute_runs_gateway_web_search_and_hands_back_codex_namespace_in_one_round), so I'll move it onto the cassette rather than keep both.
On the classify_round units though — I'd keep them. It's a pure function and they pin the whole decision table cheaply: the off-by-one at round + 1 >= max_rounds and "final round with no gateway work is still Done." Hitting those through cassettes means recording 10-round conversations just to reach the boundary. Feels cleaner to keep the table pinned at the unit level and let the integration test prove the wiring. Sound right?
There was a problem hiding this comment.
Done — added openai_gateway_web_search_then_codex_namespace_across_turns in dispatch_loop_cassette_test.rs: a gateway web_search turn then a namespace turn in one conversation. Round 0 executes web_search server-side and continues; round 1 restores the flat agentic_ns__…__add_numbers call to {namespace, name} and hands back RequiresClientAction. Moved the old inline single-turn test out since this covers it and the multi-round transition too. Kept the classify_round units for the boundary cases.
There was a problem hiding this comment.
great. then can remove this unit test here
…ion test Per review feedback on vllm-project#83, replace the inline single-turn mixed-tool test in web_search_tool_test.rs with a cassette-driven integration test in dispatch_loop_cassette_test.rs that runs a gateway web_search turn followed by a Codex namespace turn as one conversation: - round 0: model emits a gateway `web_search` call → loop executes it against the You.com mock and continues (LoopDecision::Continue); - round 1: model emits the flat, model-visible namespace call `agentic_ns__mcp__agentic_fixture__add_numbers` → loop restores it to {namespace, name}, classifies client-owned, and hands the turn back (RequiresClientAction). Exercises the gateway->client multi-round transition and the namespace restore path end-to-end — stronger than the removed single-turn inline test. The classify_round unit tests are kept: they pin the pure decision table (round-cap off-by-one, final-round-no-work) more cheaply than cassettes can. Signed-off-by: Ashwin Giridharan <girida@amazon.com>
| @@ -0,0 +1,65 @@ | |||
| # Composed multi-turn cassette for the gateway tool loop integration test. | |||
There was a problem hiding this comment.
I think there is no need to create this cassettes you can simply load https://github.com/vllm-project/agentic-api/blob/main/crates/agentic-core/tests/cassettes/codex/codex-gateway-http-namespace-tool-Qwen-Qwen3.6-35B-A3B-streaming.yaml
And
https://github.com/vllm-project/agentic-api/blob/main/crates/agentic-core/tests/cassettes/web_search/gpt_oss_web_search_streaming.yaml
for another turn
then test the loop.
There was a problem hiding this comment.
@maralbahari dug into this and the two cassettes won't chain through one loop run — they're recorded in different formats. The web_search one (openai_responses_tool_calls_3turn_streaming turn 2) is typed incremental SSE (response.output_item.added … response.completed), which the streaming accumulator parses via from_sse_lines. The codex namespace one is a single completed-response JSON blob on one data: line — the existing codex tests read it with from_json, not the streaming path. stream_upstream is fixed for the whole run, so a streaming loop can't consume the codex blob (I tried — the namespace turn drops out entirely) and a blocking loop can't consume the web_search SSE.
That mismatch is why I went with the composed cassette — it's real recorded shapes for both turns in one compatible format, and it does drive the actual paths: gateway web_search executes → Continue, then the flat agentic_ns__… call restores to {namespace, name} → RequiresClientAction. The only synthetic part is the YAML wrapper, not the traffic.
Happy to reshape the codex blob into typed SSE if you'd rather it come from a recording, but that's effectively re-recording for the same coverage. I'd keep the composed cassette unless you want to go that route.
Summary
Extracts the gateway tool loop's per-round decision into a
LoopDecisionenum +classify_round()classifier (inexecutor/gateway.rs), drivesrun_until_gateway_tools_completeby matching on it, and hardens the tool-execution path with a per-call timeout and bounded-concurrency fan-out.Reworked onto what's now on
main: rather than a parallel dispatch mechanism, it builds on the merged per-callToolRegistry::dispatch()(#82/#85) and layers multi-turn orchestration on top — the unification the MCP design doc (#82) calls for ("unified intoexecute_loop()"). Rebased onto the merged codex integration (#84) so its inline client-action path converges onto a singleLoopDecision::RequiresClientAction.What changed
Loop control —
executor/gateway.rs+engine.rsLoopDecision—#[non_exhaustive]:Continue/Done/RequiresClientAction/Incomplete(String)— andclassify_round(has_client_owned, gateway_results, round, max_rounds), one place that decides whether a turn loops, stops, hands back to the client, or exhausts the budget. Client-owned calls take precedence; the round cap only fires when gateway work remains.run_until_gateway_tools_completematchesclassify_round(...)instead of the previous inlineif/if/elsebranches. Per-call dispatch stays onregistry.dispatch(); SSE lifecycle emission is unchanged.status: "incomplete"+incomplete_details.reasoninstead ofErr, matching the Responses API and preserving accumulated tool output. The final round's gateway calls and their outputs are recorded, so a continuation is never fed a dangling tool call.RequiresClientActioncovers both plainfunctionand Codexnamespacecalls (is_gateway_owned() == false). In a mixed turn — a single response emitting both a gatewayweb_searchand a client-owned Codex call — the gateway call is executed this turn and the client call handed back in the sameRequiresClientActionround (no extra inference round-trip; theweb_searchresult comes back already resolved).Tool-execution hardening —
executor/gateway.rsGATEWAY_TOOL_TIMEOUT, 60s;Duration::ZEROopts out). A tool exceeding the budget becomes an error output fed back to the model — one hung provider cannot stall the round.>8 calls/roundcap that returned an error. The.buffered(MAX_CONCURRENT_GATEWAY_CALLS = 5)sliding window already bounds outbound fan-out (admits the next call as one finishes); arbitrary fan-out drains without failing the request. Call count is bounded upstream by model output size.Test Plan
cargo test --workspace— 325 pass, 0 fail (1 pre-existing vLLM cassette ignored)cargo clippy --workspace --all-targets -- -D warnings— cleancargo fmt --check— cleanCoverage:
gateway.rsunit tests — all fourLoopDecisionarms + the final-round-no-work-is-Doneedge; a per-call-timeout test (1ms budget vs a 50ms tool → error output fed back); a missing-handler test (gateway-owned tool with no executor → error output).tests/dispatch_loop_cassette_test.rs(new) — 3 integration tests driving recorded OpenAI Responses wire bodies through the loop: single client-owned call terminates in one round, parallel calls preserved, mixed gateway + client-owned.tests/web_search_tool_test.rs— round cap asserts thestatus: "incomplete"contract (blocking and streaming); a continuation test proves the persisted incomplete turn pairs every gateway call with its output (no dangling call); a 9-call fan-out test proves all execute despite the concurrency window of 5; a mixed-turn test coversweb_search(gateway) + a Codex namespace call (client) resolving in oneRequiresClientActionround, with the namespace call restored to{namespace, name}and the continuation carrying the persistedweb_searchoutput.Notes
Dispatch model: no separate dispatch surface — uses the merged
ToolRegistry::dispatch()for per-call resolution and owns only the multi-turn loop.LoopDecisionispub(super), sufficient for #84's consolidated path and PR C (both underexecutor).Follow-ups (non-blocking, out of scope here): the per-call timeout and concurrency window are file-private consts — promoting them to per-tool-type config (as varied gateway providers land via #82) is an additive change on the existing
execute_gateway_call_with_timeout(timeout)seam. There is no outer request-level deadline on the executor HTTP client yet; the per-call timeout bounds a single call, not total request time.AI assistance was used in drafting this PR.