feat(gateway): host the Responses beta multi_agent capability - #273
feat(gateway): host the Responses beta multi_agent capability#273Menci wants to merge 12 commits into
Conversation
…osted-capability runtime The multi-agent Responses beta declares server-hosted execution as a top-level `multi_agent` request field rather than a `tools[]` entry, and lowers to several injected functions rather than one replaced hosted declaration. Three capabilities the shim did not have: - Multiple base tool names per registration, resolved against every name claimed so far so two capabilities cannot collide on the wire. - Activation and function injection independent of `tools[]`. - A terminal finalizer that publishes items after the last upstream turn and before the synthesized terminal envelope. Registrations also receive a capability runtime whose `runTurn` drives one upstream turn against an explicit payload while snapshotting and restoring the outer loop's `ctx.payload`, so nested agent histories cannot leak into the root's next request body. Web search and image generation move onto the new shape unchanged.
A slot's deferred lifecycle previously yielded only lifecycle events for its own item, and its terminal could not stop the response. Both are required by a capability that orchestrates other agents: the items those agents produce belong between the capability's call and call-output records, and an agent reaching a client-owned function tool must end the turn exactly as a client tool call the model made directly does. `run()` now yields a discriminated `ServerToolProgress`, and its terminal carries `endResponse`.
The beta declares server-hosted multi-agent execution as a top-level `multi_agent` request field and records it as multi_agent_call / multi_agent_call_output / agent_message items. No non-OpenAI upstream in the catalog serves it, and Messages and Chat Completions have no orchestration wire at all, so the gateway hosts it. The capability lowers to six hidden function tools whose per-action schemas are transcribed from live OpenAI collaboration traffic — the beta publishes the item shapes but keeps `arguments` opaque and offers no per-action schema. It then runs the agent tree itself, attributing each subagent's items with `agent.agent_name`, and lowers replayed orchestration history back into the hidden call/output pairs the model originally saw. Scheduling is eager and serial rather than asynchronous: without a coordinator there is nowhere for a subagent to keep running once the response ends, and a nested turn borrows the interceptor chain, so two may not be in flight at once. Every action therefore reports a complete result, and the tree is always quiescent when state is captured.
…elope An affinity-wrapped agent-message blob is dropped when the request resolves to a different candidate. For an upstream's own opaque content that is the point; for Floway's checkpoint it would silently restart the agent tree instead of resuming it, so the carrier travels raw.
…sume Also adds the beta's `agent` attribution to the Responses function-call item types: the hosted multi-agent example stamps a subagent's `function_call` with the agent that produced it, which is exactly how a resumed request tells that call apart from one the root made itself.
Adds docs/MULTI-AGENT.md covering engagement, lowering, the eager serial scheduler and why it is not asynchronous, and the client-carried checkpoint; notes the affinity carrier exclusion in AFFINITY.md and points at the new document from AGENTS.md.
A client tool call previously always ended the response, even on a connection that was still open and could simply hand the output back. The beta's own hosted multi-agent client injects first and falls back to a continuation only once the response has reached a terminal event, so Floway now offers the same path. Injections dispatch off the message serialization queue — queuing one behind the response it is meant to unblock would deadlock — and commit into a connection-scoped bridge keyed by the response id the client actually sees, registered after every egress transform has run. Waiters always settle: the terminal event and socket close both release them with null, and the runtime then ends the response exactly as it does on HTTP, leaving the client-carried checkpoint as the resume path. Both the root loop and a parked subagent use the same bridge.
Covers the bridge's own semantics — partial delivery, an injection that lands before its call is reached, the two official failure codes, and release on close — plus the end-to-end paths: a parked subagent resuming inside the same response, a root-level client tool doing the same, and the fallback to ending with a checkpoint when the transport cannot deliver. Also records the injection path in docs/MULTI-AGENT.md.
An audit against the official guide and both reference SDK clients turned up several places where the emulation diverged from what the hosted service does, some of which would have silently produced an empty answer. Wire: - `agent_message.agent` names the RECIPIENT, not the author. - Root items carry `agent.agent_name: /root`, and root messages carry a `phase`. Both SDKs extract the run's answer as the `/root` message with `phase: final_answer` and drop every other message silently, so omitting either yielded an empty result with no error. An upstream that emits its own phase keeps it; only a translated target reconstructs one, which defers message frames to the end of their turn. - `multi_agent_call_output` gets its own `maco_` id prefix. - `multi_agent` no longer reaches the upstream that is not hosting it. - Injection acks draw their sequence number from the response's space, and a malformed injection is a generic 400 rather than one of the two codes, neither of which describes it. Semantics: - `send_message` queues without triggering a turn; `followup_task` is what assigns a task and runs it, and it targets a subagent. - `list_agents` reports each agent's last task message. - `wait_agent`'s timeout floor is gone: it was transcribed from Codex's own client-side collaboration tools, and the beta documents no timeout for this action at all. - The service's own developer instructions for the root agent and for subagents are vendored from the guide and injected per agent. - `reasoning.summary`, `max_tool_calls`, and compaction are refused, as the guide lists them unsupported here. - A parked call is awaited singly when a bridge can deliver it, since a client that pauses at the first function call cannot answer a second.
The multi-turn merge enumerated five fields and rebuilt the result from them, so anything not on that list was dropped. Two things were: `cache_write_tokens`, which the current upstream usage schema requires, and — because it hangs off a symbol key that Object.entries never sees — the billing metadata Floway threads alongside the native fields, which mispriced every multi-turn shim response. Usage is a bag of counters the upstream owns, so it now merges by shape: numbers add, nested breakdowns merge the same way, and anything that cannot be summed takes the later turn's value. The wire projection still fills the three required totals and forwards everything else as it arrived.
Resolves the server-tool shim's usage merge against the billing rework on main. The structural merge stays — it still preserves `cache_write_tokens` and anything an upstream adds later — but its rationale and its test no longer name the symbol key, which main removed: billing now reaches pricing on `EventResultMetadata` and never rides a usage object.
|
Holding this in draft: nothing has exercised it yet. The audit that shaped this PR established that outside the Everything here is therefore derived from the published contract rather than from observed behaviour, and it has never been driven end to end by a real client. That is the gap to close before this ships: point The contract-derived parts are the ones worth the most scrutiny in review: the per-action argument schemas (only |
Hosts the Responses beta
multi_agentcapability inside Floway.The beta declares server-hosted multi-agent execution as a top-level
multi_agentrequest field — not atools[]entry, since the betaBetaToolunion has no multi-agent variant — and records what the orchestrator does asmulti_agent_call,multi_agent_call_output, andagent_messageitems. No non-OpenAI upstream in the catalog serves it, and Messages and Chat Completions have no orchestration wire at all and reject the items outright, so the gateway hosts it.Shim generalization
The server-tool shim assumed one hosted family per registration, one injected function name matched out of
tools[], and one output item per dispatched call. Three capabilities were missing:tools[], for a capability the request declares at top level.ServerToolProgress, so items produced by agents land between the capability's call and call-output records, and its terminal carriesendResponseso an agent reaching a client tool ends the turn exactly as a client tool call the model made directly does.finalizeOutputpublishes items after the last upstream turn and before the synthesized terminal envelope.Registrations also receive a capability runtime whose
runTurndrives one upstream turn against an explicit payload while snapshotting and restoring the outer loop'sctx.payload, so a nested agent history cannot leak into the root's next request body. Web search and image generation move onto the new shapes unchanged.The capability
Lowers to six hidden function tools —
spawn_agent,send_message,followup_task,wait_agent,interrupt_agent,list_agents. The beta publishes the item shapes but keeps each action'sargumentsan opaque JSON string and offers no per-action schema, so the schemas the orchestrator model sees are transcribed from live OpenAI collaboration traffic captured in Codex CLI rollouts, which exercises the same six actions over the same/root/...agent-path namespace. The transcriptions and observed result shapes are recorded on the lowering.Replayed orchestration history is lowered back into the hidden call/output pairs the model originally saw. Items attributed to a subagent through
agent.agent_name— its own orchestration records, and any client-ownedfunction_callit is parked on together with that call's output — are lifted out of the root history; they belong to that subagent's transcript.Scheduling is eager and serial, not asynchronous
The upstream runs subagents behind its own coordinator. Floway has none — no Durable Object, no execution surviving the response — so
spawn_agent,send_message, andfollowup_taskeach drive their target's loop until it settles or parks on a client tool, andwait_agentdrives whatever a freshly supplied tool output just woke. Every action therefore reports a complete result and the tree is always quiescent when state is captured. Serialization is also what makes nested turns safe at all: a nested turn borrows the interceptor chain, so two may not be in flight at once.Client-carried checkpoint
When any agent reaches a client-owned function tool the response ends, and the tree's hidden state travels with the client in an
agent_messagewhose singleencrypted_contentpart holds rawbase64url(JSON)— no prefix, no signature, no encryption. Recognition is structural, so a foreign upstream blob in the same slot round-trips untouched. Nothing in a checkpoint is authority: model, tools, and concurrency limits are recomputed from the current request, the turn budget only tightens, and size, agent count, and path uniqueness are bounded on the way in.The carrier is excluded from the affinity envelope. An affinity-wrapped agent-message blob is dropped when the request resolves to a different candidate — correct for an upstream's own opaque content, but for a checkpoint it would silently restart the agent tree instead of resuming it.
response.injectA client tool call previously always ended the response, even on a connection that was still open and could simply hand the output back. The beta's own hosted multi-agent client injects first and falls back to a continuation only once the response has reached a terminal event, so Floway now offers the same path — for a parked subagent and for a client tool the root itself called.
Wire shapes are the official ones:
response.injectcarries{response_id, input},response.inject.createdacknowledges a commit, andresponse.inject.failedreturns the uncommitted items withresponse_not_foundorresponse_already_completed.Two things make it work over one socket:
A waiter always settles. The terminal event and socket close both release outstanding waiters with null, and the runtime then ends the response exactly as it does on HTTP, leaving the client-carried checkpoint as the resume path. An injection that lands before its call is reached is held rather than dropped, matching the beta's atomic-commit semantics.
Alignment audit
The wire was then audited against the official guide and reference, both Agents SDK clients' source and tests, the raw SDK beta types, and a sweep for any other implementation. Worth knowing: outside the
openaiorg nobody has implemented the server side — every third-party project is a client or a pass-through gateway, and no server anywhere acceptsresponse.inject. So there was no prior art to conform to, only the contract.The audit changed a dozen things. The ones that would have silently produced a broken run:
agent_message.agentnames the recipient, not the author.agent.agent_name: /root, and root messages needphase. Both SDKs extract the answer as the/rootmessage withphase: final_answerand drop every other message silently — omitting either yielded an emptyfinalOutputwith no error anywhere.phaseis the model's own channel output, so a Responses upstream's value is forwarded verbatim and only a translated target reconstructs one.agents-pythonpauses at the firstfunction_callit reads and stops reading the socket, so waiting on two at once deadlocked. Without a bridge the response ends with all parked calls outstanding, which is what the guide specifies for the HTTP boundary.Semantics corrected against the guide:
send_messagequeues without triggering a turn (followup_taskis the one that assigns and runs, and it targets a subagent);list_agentsreports each agent'slast_task_message;wait_agent's timeout floor is gone — it had been transcribed from Codex's own client-side collaboration tools, and the beta documents no timeout for this action at all.Also:
multi_agentno longer reaches the upstream that isn't hosting it;multi_agent_call_outputgets itsmaco_id prefix; injection acks draw their sequence number from the response's space; a malformed injection answers a generic 400 rather than one of the two defined codes, neither of which describes it; andreasoning.summary/max_tool_calls/ compaction are refused as the guide lists them unsupported.The service's own developer instructions are now vendored and injected. When multi-agent is enabled the hosted service appends unremovable instructions to the root and to every subagent, and the guide publishes both texts verbatim. Without them the model holds six tools with no contract for using them — this was the single largest behavioural gap.
Shim usage is now merged structurally
Chasing one of the audit findings turned up a live billing bug. The shim's multi-turn usage merge enumerated five fields and rebuilt the result from them, so anything off that list was dropped — including
cache_write_tokens, and, because it hangs off a symbol keyObject.entriesnever sees, theUSAGE_BILLINGmetadata Floway threads alongside the native fields. Every multi-turn shim response (web search, image generation, compaction, and now multi-agent) was mispriced.Usage is a bag of counters the upstream owns, so it now merges by shape: numbers add, nested breakdowns merge the same way, anything unsummable takes the later turn's value. This is a pre-existing defect that multi-agent makes routine, which is why it is here rather than in its own PR.
Known follow-up, deliberately not in this PR
output_textblocks that Floway constructs carry noannotations— one helper,shared/responses-via/responses-event-builder.ts, used by the*-via-responsespairs.agents-pythonvalidates the root final message against the stable schema, whereannotationsis required, and aborts the run. Native Responses passthrough is unaffected: those items are forwarded whole and keep the upstream's annotations. Adding the field touches every Responses-target translator and its snapshots, so it belongs in its own PR.Notes for review
CHANGELOG.mdentry: no previously working behavior changes. A client sendingmulti_agentcould not have succeeded against any upstream in the catalog before this. Say the word if you read that differently.codex, which talks to ChatGPT's own Responses backend.Test Plan
pnpm run typecheck— clean across all 21 workspace projectspnpm run lint— cleanpnpm run test— 424 files / 4945 tests passing