@silverprotocol/openai-agents
v0.12.11
Published
AgJSON normalizer for the OpenAI Agents SDK (RunItemStreamEvent / Responses streaming → AgEvent[]) — fixture-tested.
Readme
@silverprotocol/openai-agents
AgJSON normalizer for the OpenAI Agents SDK (RunItemStreamEvent / Responses
streaming → AgEvent[]) — fixture-tested. It sits on top of your OpenAI
Agents run and translates its native stream into framework-neutral AgJSON;
it doesn't replace the SDK you already use.
Install
npm install @silverprotocol/openai-agents @openai/agents@silverprotocol/core is a required peer (pulled in automatically).
@openai/agents is the OpenAI Agents SDK itself — you need it to produce the
stream. (The normalizer handles the native events structurally, so if you only
feed it already-captured events it's just a type-level peer.)
Upstream compatibility
Supported peer range: @openai/agents >=0.2.0 <0.22 (npm-enforced, optional — this package never imports the SDK).
Wire shapes outside the verified set never crash the normalizer, and the
fixture-drift gate (scripts/check-fixture-drift.mjs) turns every newly-appeared
upstream field into a tracked triage disposition rather than an unnoticed drop —
carried losslessly where a carry channel exists (see sdk-surface.json for this
facet's per-field dispositions, including its disclosed silently-dropped gaps).
| @openai/agents | verified | with | evidence |
| --- | --- | --- | --- |
| 0.21.0 | 2026-10-25 | 0.12.8 | @openai/agents 0.19.0 -> 0.21.0 with agents-core, agents-openai and agents-realtime 0.21.0 (the transitive openai stays 7.22.0); peer range widened from >=0.2.0 <0.20 to >=0.2.0 <0.22, so 0.20 and 0.21 are both in range. Two carries land with the widen. 0.20's optional toolSearchMcpToolNames on a tool-search output item (the hosted MCP tool names a server-executed search established) rides that search's tool.done providerMetadata beside toolSearchAgentName; without it the value would be dropped. Detail keys of input_tokens_details / output_tokens_details beyond the three mapped ones (cached_tokens, cache_write_tokens, reasoning_tokens) ride one ext.openai.unparsed {name: "usage-details", raw} per response before the round's terminal, and a mapped key whose value is not a number sets no AgUsage counter (it used to land null inside usage); openai 7.43.0's types define no other detail keys, so this serves OpenAI-compatible providers, whose nested breakdowns 0.21 keeps through a streamed run. Host-side, a model call after an approval resume counts toward maxTurns from 0.20, and RunState moves to schema 1.22 (0.20) and 1.23 (0.21): a state saved by 0.19 or 0.20 resumes on 0.21, but a state saved by 0.21 does not resume on 0.20. The SDK's own streams for the 17 engine-built openai-hosted scenarios are byte-identical on 0.19.0, 0.20.0 and 0.21.0. The facet's tests and the corpus replay pass at 0.21.0. Live: echo-gpt61sol (gpt-6.1-sol) captured green at 0.21.0 on this facet with no drops and no new fields; the model did not reason this run, so the committed cassette, which carries a reasoning item, was kept. |
| 0.19.0 | 2026-10-07 | 0.12.4 | @openai/agents 0.18.0 -> 0.19.0 (agents-core, agents-openai and agents-realtime with it); the peer range widens to <0.20. No facet change. The consumed openai-node client stays 7.22.0 (7.30.0 was not adopted). agents-core 0.19.0 declares @modelcontextprotocol/client as an optional ^2.0.0; an override floors it at 2.2.0 for GHSA-6qxp-vccf-f47h (resolves 2.3.1). The 17 engine-built openai-hosted natives regenerate byte-identical on 0.19.0. Live: echo-gpt61sol (gpt-6.1-sol) captured green with the same event shape as echo-gpt6sol, reasoning item included. |
| 0.18.0 | 2026-09-23 | 0.6.3 | Re-attestation at the SAME peer (@openai/agents 0.18.0 is still npm latest) because the consumed transitive openai-node client floated 7.15.0 -> 7.22.0 (agents-openai declares ^7.2.0; 7.22.0 was pinned with a release-age exclusion and a fresh resolution - without the exclusion the range silently resolves an older client). The SSE ResponseStreamEvent union goes 58 -> 59; the only new member, response.compaction.compacting, fires only with compaction, which the capture path never requests. Every type the facet consumes is byte-identical. One runtime change on the HTTP path: from 7.17.0 the SSE parser flushes the event still in progress at EOF, so a complete trailing record is delivered where 7.15.0 dropped it, and a record truncated mid-JSON now surfaces a SyntaxError where agents-core used to report ModelBehaviorError('Model did not produce a final response!'); it never duplicates an event. That changes host-error TEXT only, not the mapping. LIVE at 0.18.0 / 7.22.0: echo-gpt56 and echo-gpt6astra refreshed; app-spec-structured-result refreshed and MIGRATED gpt-5.5 at agents 0.12.0 -> gpt-5.6-sol, re-proving the MCP structuredContent -> item.customData channel six minors later; NEW echo-gpt6sol and echo-gpt6luna (GA 2026-09-22). The Agents SDK has no model-specific settings for either, so both run at the API-default effort medium. echo-gpt6sol is the FIRST openai seed that actually reasons (30 reasoning tokens), and it exposes a pre-existing facet gap: the reasoning item arrives as response.output_item.done BEFORE response.completed while the SDK's reasoning_item_created arrives AFTER it, so the facet surfaces the item only as ext.openai.late-reasoning {itemId, encryptedContent} and emits no first-class reasoning block. The carried blob is the RIGHT one: OpenAI re-encrypts at each stage (three distinct ciphertexts on this seed), and the facet carries the final blob from response.completed / response_done, the one stateless replay sends back. access_programs now arrives as null on every model (allowlisted null-only beside the existing .cyber entry). echo-gpt55 is kept deliberately as the historical 0.12.0 record. Census ZERO drops / ZERO new fields after triage. |
| 0.18.0 | 2026-09-15 | 0.6.2 | peer 0.17.0->0.18.0 across 0.17.1 and 0.17.2 (0.17.1 alone is a ~40-fix batch), adopted with the declared peer range widened >=0.2.0 <0.18 -> <0.19 (closes typescript-sdk#32; ceiling widened, floor kept, per precedent). TYPED SURFACE is a no-op for this facet: agents-core events.d.ts and result.d.ts are byte-identical to 0.17.0, RunItemStreamEventName (10) and protocolItem (17) inventories unchanged, and types/protocol.d.ts's only delta across the whole span is one optional toolSearchAgentName on ToolSearchOutputItem (absent from ToolSearchCallItem) -> CARRIED on the tool lifecycle following the existing executionStatus precedent; reachable only through tool_search, which no corpus scenario configures, so it is fixture-tested. The 0.18.0 headline changes (Docker file APIs moving inside the container, UnixLocalSandboxClient fileIOProtection, imageGenerationTool action) are all off this facet's Responses streaming path. CORRECTNESS FIX, the real finding: openai 7.15.0 types Response.status as the full ResponseStatus union ('completed'|'failed'|'in_progress'|'cancelled'|'queued'|'incomplete', responses.d.ts:6186) and ResponseCompletedEvent carries a full Response (:1652-1656), but this facet's local type declared only 'completed'|'incomplete', so a response.completed whose response.status was 'failed' or 'cancelled' fell through the decision tree to a SUCCESSFUL turn close - a WRONG MAPPING, not a drop. agents-openai 0.17.1+ independently agrees the status is authoritative (it now rejects unsuccessful terminal states) but it yields the raw model carrier BEFORE throwing, so the facet has already decided the close by then and must decide it correctly itself. The local type is widened to the wire union and an unsuccessful terminal status now closes as turn.error carrying the status as the code, placed LAST so refusal, content_filter and response.incomplete keep their exact prior precedence and output; one deliberate sharpening - a self-contradictory completed/status:'incomplete' that also carries incomplete_details.reason prefers the reason, so the code matches byte-for-byte what the response.incomplete branch would have emitted. Absent or 'completed' status is byte-identical to 0.17.0, proven by negative controls over absent / completed / in_progress / queued / an unrecognized future value. Also corrected: the doc block invalidated by 0.17.1 is the bare-error arm (a bare error is now terminal upstream, so the facet's non-terminal advisory degrades to ext.openai.error plus an engine-synthesized turn.abort{stream-truncated}, with turn.error materializing only once the host feeds host_error), NOT the response.failed block, which was already terminal at 0.17.0. TRANSITIVE openai-node floated 7.10.0->7.15.0 by fresh resolution: the SSE ResponseStreamEvent union is IDENTICAL at 58 members and every consumed type is byte-identical. LIVE at 0.18.0 / openai 7.15.0: echo-gpt56 and echo-gpt6astra refreshed - census ZERO drops / ZERO new fields on both. MODEL-GATED REQUEST-SHAPE CHANGE observed on Astra: the SDK's reasoning.effort and text.verbosity echoes moved medium -> low (both were PRESENT at 0.17.0, not absent); normalized usage is byte-identical (reasoningTokens stays 0 on the echo), so this is cassette-visible only. Facet tests 127 -> 129. |
| 0.17.0 | 2026-09-05 | 0.6.1 | Re-attestation at the SAME peer (0.17.0 still latest) with the transitive openai-node client deliberately floated 7.8.0->7.10.0 (fresh resolution): the SSE ResponseStreamEvent union is IDENTICAL (58 members) across 7.8/7.9/7.10 over the HTTP transport the facet and capture agent use; 7.10.0 adds WebSocket-only steering events (response.steer.), an input-only configuration_update item, FunctionToolCall.async, incomplete_details.reason 'max_messages' (7.9.0) / 'steered' (7.10.0) - the response.incomplete arm goes straight to turn.error{code: reason, message: reason}, so the raw reason survives with zero code change (regression pin added) - and ResponseError.misalignment {detailed_explanation, error_type, steer{message}} with code misalignment_policy_violation (GPT-6 Astra misalignment monitoring). CARRY added: the response.failed arm now emits a turn-scoped ext.openai.misalignment {responseId, misalignment} verbatim immediately before turn.error (turn.error has no providerMetadata slot per spec section 4); absent => byte-identical; synthetic-tested only - documented auto-stop applies to persisted-reasoning / WebSocket / compaction requests, none used by the e2e HTTP path. compute_units watch item CLOSED: added in 7.8.0 types, removed in 7.9.0, never on the wire. 7.10.0 also adds a resources/safety SafetyAlert surface (webhook plane, out of scope). MODEL RELEASE: gpt-6-astra (GA 2026-09-03; sole id, no dated snapshot; effort low..max, 'none' -> 400; temperature/top_p rejected; $10/$1 cached/$50) captured live as echo-gpt6astra through @openai/agents 0.17.0 (no temperature/top_p/effort sent by the runner for this model) - census: ONE new response-level attribute response.access_programs.cyber = 'standard' at three norm-paths (allowlisted as router-plane program-tier metadata beside service_tier); zero drops otherwise; 0 reasoning tokens on the echo (Astra chose not to reason). DEFAULT_MODEL.openai/vercel stay gpt-5.6-sol (a decision still open). |
| 0.17.0 | 2026-09-02 | 0.5.4 | Re-attestation at the SAME peer (0.17.0 is still latest; published 2026-08-19) after the cohort's fresh lockfile resolution floated the transitive openai client 7.4.0->7.8.0 (agents-openai depends on openai ^7.2.0): openai-node 7.5.0+ adds five response.shell_call_command./shell_call_output_content.* stream events (hosted shell tool; ignored by the facet's default arm, cannot appear in MCP-only scenarios) and 7.8.0 adds ResponseUsage.compute_units?: number|null ('Currently null when available'; already gone again from 7.9.0's types) - census watch item. LIVE: echo-gpt56/openai RE-CAPTURED (gpt-5.6-sol) - census ZERO drops / ZERO new fields; the gpt-5.6 alias still routes to gpt-5.6-sol (OpenAI docs re-checked 2026-09-02; no newer public model family - new ids are the Daybreak-gated gpt-5.6-cyber / gpt-daybreak--latest). |
| 0.17.0 | 2026-08-28 | 0.5.3 | 0.17.0 MINOR study (typescript-sdk#20/#24, downstream guuey#338): all seven consumed agents-core dist files BYTE-IDENTICAL to 0.16.0 (events.d.ts, types/protocol.d.ts, items.d.ts, result.d.ts, usage.d.ts, runner/streaming.mjs, runner/modelOutputs.mjs; RunItemStreamEventName 10, protocolItem 17) and agents-openai's only change is websocket replay-safety bookkeeping in openaiResponsesModel.mjs (no event emission touched) -- wire-no-op on every consumed surface; drift gate zero member drift. LIVE capture: echo-gpt56 standing seed REFRESHED at 0.17.0 (gpt-5.6-sol), 46 native events, event-type census identical to the 0.14.2 capture. The census caught ONE wrapper-level field the d.ts-pair inventory cannot see: RunToolCallOutputItem.executionStatus ('executed', introduced in agents-core 0.15.0 -- present since the 0.15/0.16 studies, first observed live here). Disposition handled: carried verbatim on tool.done providerMetadata alongside caller (set only when the runner actually invoked the function tool; absent on guardrail/cancellation-synthesized results); 3 facet tests, transforms + registry entries, snapshot regenerated. Peer range widened >=0.2.0 <0.17 -> <0.18; e2e pin 0.17.0; full gate suite green. |
| 0.16.0 | 2026-08-15 | 0.4.4 | 0.16.0 MINOR study: both consumed agents-core dist files BYTE-IDENTICAL to 0.15.0 (events.d.ts and types/protocol.d.ts; RunItemStreamEventName 10 members, protocolItem 17, full zod-literal inventory zero-delta) - the minor is agent/handoff/runner internals the facet does not ingest. Peer range widened >=0.2.0 <0.16 -> <0.17 (ships as 0.4.4 so consumers installing 0.16.x stop getting npm peer warnings); e2e pin 0.16.0, forced gate suite green. |
| 0.15.0 | 2026-08-13 | 0.4.2 | 0.15.0 MINOR study (typescript-sdk#15, filed as [peer-drift] — the nightly's forced-0.15.0 gate suite was already green): RunItemStreamEventName unchanged (10 members incl. compaction_item_created), protocolItem unchanged (17) — both drift-gate-verified; wire-no-op on every consumed surface. Peer range widened >=0.2.0 <0.15 -> <0.16 (ships as 0.4.3 so consumers installing latest stop getting npm peer warnings); e2e pin 0.15.0. |
| 0.14.3 | 2026-08-09 | 0.4.1 | peer sweep: 0.14.2->0.14.3 d.ts diff — ONE new RunItemStreamEventName member (compaction_item_created, drift-gate-caught), protocolItem union unchanged (17). Disposition handled: mapped to the first-class compaction content.block converging with the claude facet (synthetic test pins the mapping + no-double-carry + clean fold). Full gate suite green at the new pin (110 facet tests, 949 workspace). |
| 0.14.2 | 2026-08-04 | 0.3.9 | 0.14.0→0.14.2 verified a run-stream wire no-op: agents-core events.d.ts / types/protocol.d.ts / items.d.ts / result.d.ts byte-identical across 0.14.0/0.14.1/0.14.2 (both hops diffed); agents-openai 0.14.2 metadata-only. Non-wire: 0.14.1 = type re-exports + sandbox runtime; 0.14.2 = RunState CURRENT_SCHEMA_VERSION 1.15 (serialized state, not streamed), MCP credential redaction, realtime transport_event now preserving raw fields (realtime-only — this facet does not normalize realtime transports; noted for any future realtime facet). Pin bumped; live re-capture in this cohort's capture step. |
| 0.14.0 | 2026-07-29 | 0.3.7 | Live capture landed (echo-gpt56 @ gpt-5.6-sol, agents-core 0.14.0): gpt-5.6-sol populates AssistantMessageItem.phase='final_answer' on the real wire, and the census caught that the synthetic-frame text.end carry never fires live — message_output_created for the final round arrives after response_done closes the message. Fixed with ext.openai.late-phase {itemId, phase} on the id'd post-close path (late-citations degrade convention); 2 unit tests added (carry + 0.13.5-shaped negative control); census transforms/registry updated ([].data.response.output[].phase, [].item.rawItem.phase); replay + census gates green. program/program_output and caller remain synthetic-only (programmatic tool calling needs the hosted tool enabled in an e2e scenario — future capture). |
| 0.14.0 | 2026-07-29 | 0.3.7 | fixture suite + drift gate + full 0.13.5->0.14.0 agents-core d.ts diff audit: envelopes unchanged (RunItemStreamEventName still the same 9 names; raw StreamEvent kinds identical) — all five wire additions carried at the facet layer: program/program_output protocol items (openai >=6.46 programmatic tool calling, SUPERSEDES the 0.13.2 KNOWN-DEFERRED bullet — the SDK's converters now push them as RunToolCallItem/RunToolCallOutputItem, dist/runner/modelOutputs.mjs; tool.start name:'builtin:program' with {code,fingerprint} verbatim args, callId-correlated tool.done, status incomplete->error), optional ToolCaller caller provenance on function/hosted/shell/apply-patch call+result items (tool.start/tool.done providerMetadata, camelCase verbatim; the authoritative raw output_item.added path normalizes openai-node's snake_case caller_id), AssistantMessageItem.phase (matching text.end providerMetadata, per-part + the id-less late-message ext payload), ShellCallResultItem.status (NEW optional enum — 'incomplete' joins the error mapping even on all-clean exits; carried verbatim on tool.done providerMetadata); synthetic-frame unit tests incl. 0.13.5-shaped negative controls (output unchanged); live capture pending (echo-gpt56 refresh) |
| 0.13.5 | 2026-07-22 | 0.3.5 | LIVE capture: echo-gpt56 standing seed REFRESHED at 0.13.5 (gpt-5.6) — the new itemId OBSERVED live on the flattened output_text_delta arm and triaged (census allowlist + field registry, first-observed provenance recorded); replay + census gates green (zero untriaged drops) |
| 0.13.5 | 2026-07-22 | 0.3.5 | fixture suite + drift gate + full 0.13.2->0.13.5 agents-core d.ts/dist diff audit: events/items/usage/protocol item shapes and the stream emitters (runner/streaming.mjs, runner/modelOutputs.mjs) byte-identical; sole wire-relevant delta is an optional camelCase itemId on the SDK-flattened output_text_delta arm (protocol.d.ts StreamEventTextStream) — deliberately no-op'd as the documented duplicate of the authoritative model:response.output_text.delta path, whose snake_case item_id is unchanged; the rest is request-side tool-definition plumbing (FunctionTool/SerializedFunctionTool providerData, explicitly stripped from tool_search_output payloads by serializeFunctionToolForToolSearchOutput) and sandbox/tracing infra |
| 0.13.2 | 2026-07-13 | 0.3.3 | LIVE capture: echo-gpt56 corpus (gpt-5.6) — landed as a standing replay/census seed; cache_write_tokens mapping confirmed on real wire (turn.done usage.cacheWriteTokens); zero unparsed events |
| 0.13.2 | 2026-07-13 | 0.3.3 | fixture suite + drift gate + adversarially-verified 0.12.0->0.13.2 wire-surface audit (GPT-5.6 era: cache_write_tokens mapped, invalidFinalOutput late-message rescue; hosted multi-agent + programmatic tool calling documented KNOWN-DEFERRED); live capture pending (echo-gpt56 scenario ready) |
| 0.12.0 | 2026-07-03 | 0.1.0 | live captures: echo-gpt55 + app-spec-structured-result corpus (gpt-5.5) |
Usage
Usage semantics (spec draft.5).
usage.inputTokensis OpenAI'sinput_tokens, which includes cached input:cacheReadTokens(input_tokens_details.cached_tokens) andcacheWriteTokens(input_tokens_details.cache_write_tokens) are breakdowns of it, so never add them toinputTokens. This follows OpenAI's documentation of those fields as a detailed breakdown of the input tokens; every capture in this package's corpus reports zero cached tokens. LikewiseoutputTokensincludes reasoning, andreasoningTokensis its breakdown. Usage is reported per response, on the round'sturn.done(cumulative: false), so a provider-reported cost such as OpenRouter'susage.cost, carried ascostUsd, is per response and sums across turns;costScopeis not set. A round closed atflush()withturn.abortcarries that usage, cost included, on itsmessage.end. OpenAI's own wire has no other detail keys (openai 7.43.0 types); any other key an OpenAI-compatible provider sends ininput_tokens_detailsoroutput_tokens_details(a flat counter or a nested breakdown) maps to noAgUsagefield, so it rides oneext.openai.unparsedevent,{name: "usage-details", raw}, emitted before the round's terminal, withrawholding only those entries as received. A mapped detail key whose value is not a number sets no counter and rides there too. Nothing from that event is added tousage.
import { Agent, run } from "@openai/agents";
import { createOpenaiNormalizer } from "@silverprotocol/openai-agents";
const agent = new Agent({ name: "Assistant", instructions: "Use the echo tool." });
const n = createOpenaiNormalizer();
const agEvents = [];
// `run(..., { stream: true })` is the OpenAI Agents SDK's own streamed run —
// you keep using your framework as-is; this package normalizes what it emits.
const stream = await run(agent, "call the echo tool", { stream: true });
for await (const native of stream) agEvents.push(...n.push(native));
await stream.completed; // let the run finish
agEvents.push(...n.flush()); // seal anything still openpush() returns the AgEvent[] synthesized from each native stream event
(RunItemStreamEvents or raw Responses-API events — both handled); flush()
drains buffered end-of-stream state. Any async/sync iterable of those native
events works — a live streamed run, or events you captured earlier.
Then fold the resulting AgEvents into messages and turns with
@silverprotocol/core's reduce() — the same client code regardless of which
framework produced the stream.
Spec: silverprotocol.io/AgJSON — canonical
in silverprotocol/AgJSON; wire
version 1.0.0-draft.8.
MCP tool results: structuredContent, _meta, uiData
The OpenAI Agents SDK does not put an MCP result's structuredContent or
_meta in the tool output (by default the model sees only the content
text). To keep them, opt in on each MCPServer with customDataExtractor
(@openai/agents ≥ 0.12). The SDK delivers its return value on the tool
output item as customData, and the normalizer reads it there:
new MCPServerStreamableHttp({
url,
customDataExtractor: (ctx) =>
ctx.structuredContent !== undefined || ctx.resultMeta !== undefined
? {
...(ctx.structuredContent !== undefined ? { structuredContent: ctx.structuredContent } : {}),
...(ctx.resultMeta !== undefined ? { _meta: ctx.resultMeta } : {}),
}
: undefined,
});customData.structuredContentbecomestool.done.structuredContent.customData._metabecomes the tool-result block's_meta, verbatim.- When
_meta.uiis present (MCP Apps), the same payload also lands onuiData, the model-hidden surface channel.structuredContentis left as it is.
Without _meta the output is unchanged.
Ids across invokes
Build one normalizer per invoke. The facet uses ids from the wire where it has
them (turn_<response id>). Where it has to mint its own, it uses a per-invoke
stem, so ids stay unique when a host folds every invoke of a conversation into
one reducer:
- a response with no
response.created, or a host error with no turn open, gets a fallback turnturn_<stem>_<n>; - a handoff gets a subagent turn
turn_<stem>_handoff_<n>.
A resumed invoke can stream a handoff before any turn of the invoke has
opened. The facet then opens the resumed invoke's own turn,
turn_resume_<callId> (an id from the wire), at that handoff, and the handoff's
subagent turn is parented to it, so the subagent turn carries the host's thread.
By default the stem is a random openai_<16 hex> drawn once per normalizer.
Pass createOpenaiNormalizer({ invokeId }) to make the output deterministic
(replay, tests). An invokeId you pass must be unique per invoke within a fold.
Thread id
Pass the host's own thread id as createOpenaiNormalizer({ threadId }). The
normalizer stamps it as the threadId of every turn and message it opens,
handoff turns included, and never places it in a turn id: it is the partition
root a key-value store writes each unit under (spec §1.2). The OpenAI stream
carries no host thread id (conversationId and previousResponseId are run
options the host holds), so with no threadId option the facet stamps the
fixed label "openai" as a facet-local placeholder, not a partition root; a
host that persists by threadId supplies its own.
Handoffs
An Agents SDK handoff is a transfer: the source agent does not resume, and the
target agent's model rounds are ordinary top-level turns. The facet brackets
the transfer itself, from handoff_requested to handoff_occurred, as a nested
turn under the source round. At handoff_occurred it emits:
- the nested turn's
turn.done(success,finishReason: "unknown", no usage), then itssubagent.done; handoff(kind: "transfer",toAgentName);tool.donefor the transfer call (transfer_to_<agent>), whose result the SDK delivers only onhandoff_occurred. This closes the source round with its ownturn.done.
A transfer whose call gets an ordinary tool result instead of
handoff_occurred never ran (the SDK ignored it, up to 0.8.0; see below). Its
nested turn closes at that result with turn.abort (no reason), then
subagent.done, and the result lands as the call's tool.done. Brackets are
matched to their transfer by call id.
When the model requests several handoffs in one response, the SDK runs only
the first. What happens to the others depends on the @openai/agents version:
- From 0.8.1, nothing about them reaches the stream. The SDK drops them from
the run's history; only when the conversation is server-managed
(
conversationIdorpreviousResponseId) does it send the model a synthetic result for each. Athandoff_occurred, each call of the source round that still has no result, is a function call, and has had no run-item naming it stops being pending: it gets notool.doneand is carried asext.openai.dropped-call(reason: "no-run-item-at-handoff"), and the source round closes with its ownturn.donein the same batch. A call the SDK acknowledged (atool_called, an approval request, or any run-item naming it) stays pending, and the round closes when its result lands. A result that still arrives for a released call is carried the same way (reason: "result-after-release"), never as atool.done. - Up to 0.8.0, the SDK streams a result for each ignored call ("Multiple
handoffs detected, ignoring this one."), which lands as that call's
tool.done, and the source round closes normally. The ignored handoff's nested turn, opened at itshandoff_requested, closes at that result withturn.abort, as above.
The source round is still closed at flush() when:
- a handoff input filter removes
handoff_occurredor the round's tool results from the stream (for exampleremoveAllTools):turn.abort(stream-truncated); - an approval is pending beside the transfer (reachable on 0.8.x):
turn.done(paused), naming that approval; - a program, hosted-shell or tool-search call of the round is still pending.
A call replayed on a resumed invoke gets no tool.start from the facet, so it
is never released this way.
