Skip to content

feat(gateway): host the Responses beta multi_agent capability - #273

Draft
Menci wants to merge 12 commits into
mainfrom
responses-multi-agent
Draft

feat(gateway): host the Responses beta multi_agent capability#273
Menci wants to merge 12 commits into
mainfrom
responses-multi-agent

Conversation

@Menci

@Menci Menci commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Hosts the Responses beta multi_agent capability inside Floway.

The beta declares server-hosted multi-agent execution as a top-level multi_agent request field — not a tools[] entry, since the beta BetaTool union has no multi-agent variant — and records what the orchestrator does as multi_agent_call, multi_agent_call_output, and agent_message items. 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:

  • 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[], for a capability the request declares at top level.
  • Dynamic item publication and a terminal finalizer. A slot's deferred lifecycle now yields a discriminated ServerToolProgress, so items produced by agents land between the capability's call and call-output records, and its terminal carries endResponse so an agent reaching a client tool ends the turn exactly as a client tool call the model made directly does. finalizeOutput 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 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's arguments an 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-owned function_call it 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, and followup_task each drive their target's loop until it settles or parks on a client tool, and wait_agent drives 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_message whose single encrypted_content part holds raw base64url(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.inject

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 — for a parked subagent and for a client tool the root itself called.

Wire shapes are the official ones: response.inject carries {response_id, input}, response.inject.created acknowledges a commit, and response.inject.failed returns the uncommitted items with response_not_found or response_already_completed.

Two things make it work over one socket:

  • Injections dispatch off the message serialization queue. Queuing one behind the response it is meant to unblock would deadlock. They touch only the connection's bridge registry, so inline dispatch is safe.
  • The bridge is keyed by the response id the client actually sees, registered after every egress transform has run and before the frame pull that can block — that is the only id the client can address.

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 openai org nobody has implemented the server side — every third-party project is a client or a pass-through gateway, and no server anywhere accepts response.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.agent names the recipient, not the author.
  • Root items need agent.agent_name: /root, and root messages need phase. Both SDKs extract the answer as the /root message with phase: final_answer and drop every other message silently — omitting either yielded an empty finalOutput with no error anywhere. phase is the model's own channel output, so a Responses upstream's value is forwarded verbatim and only a translated target reconstructs one.
  • A parked call is awaited singly when a bridge can deliver it. agents-python pauses at the first function_call it 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_message queues without triggering a turn (followup_task is the one that assigns and runs, and it targets a subagent); list_agents reports each agent's last_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_agent no longer reaches the upstream that isn't hosting it; multi_agent_call_output gets its maco_ 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; and reasoning.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 key Object.entries never sees, the USAGE_BILLING metadata 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_text blocks that Floway constructs carry no annotations — one helper, shared/responses-via/responses-event-builder.ts, used by the *-via-responses pairs. agents-python validates the root final message against the stable schema, where annotations is 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

  • No CHANGELOG.md entry: no previously working behavior changes. A client sending multi_agent could not have succeeded against any upstream in the catalog before this. Say the word if you read that differently.
  • The per-provider flag defaults are on everywhere except codex, which talks to ChatGPT's own Responses backend.

Test Plan

  • pnpm run typecheck — clean across all 21 workspace projects
  • pnpm run lint — clean
  • pnpm run test — 424 files / 4945 tests passing
  • New: 38 tests over checkpoint codec and strict schema rejection, lowering and name-collision suffixing, spawn → subagent answer → agent message, subagent isolation from root history, checkpoint publication, client-tool parking, full cross-request resume with tool-output routing, each action's semantics, subagent turn failure, all three engagement paths, the injection bridge's delivery and failure semantics, in-place resume for both a parked subagent and a root-level client tool, root attribution and phase (synthesized and passed-through), recipient tagging on agent messages, id prefixes, instruction injection, and the unsupported-combination refusals

Menci added 11 commits July 28, 2026 03:00
…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.
@Menci
Menci marked this pull request as draft July 28, 2026 11:45
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.
@Menci

Menci commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Holding this in draft: nothing has exercised it yet.

The audit that shaped this PR established that outside the openai org nobody has implemented the server side — every third-party project is either a client or a gateway that forwards to a real OpenAI upstream, and no server anywhere accepts response.inject. So there is no reference implementation to compare behaviour against, only the guide and the two reference SDK clients' source and tests.

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 agents-python or agents-js's experimental hosted multi-agent model at Floway and run a real workflow through it. GPT-5.6 is reachable through both Copilot and Azure, so the model is not the blocker — the upstreams simply do not serve the hosted capability, which is the premise for hosting it here.

The contract-derived parts are the ones worth the most scrutiny in review: the per-action argument schemas (only spawn_agent's are documented; the other five are transcribed from Codex CLI collaboration traffic, a different feature reusing the same vocabulary), and the eager serial scheduler, which deliberately diverges from the upstream's asynchronous one because there is no coordinator to run subagents in.

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