Repository navigation
feat(openai-agents): promote handoffs to canonical subagent events (AI-471) - #60
Conversation
…dual-stream subagent lifecycle (§3.5) Locks the cross-framework conventions for stream-name id components (safe-chars, GUID→dashless lowercase, compound separator rules) and mandates atomic dual-stream emission of SubagentStarted/Completed so Capacitor's trace projector reads subsessions self-describingly. Sets the contract that AI-471's OpenAI Agents implementation and AI-621's MAF .NET conformance fix consume.
…ntCompleted table Addresses code-review followups on commit b5672ca: removes the ambiguous "right-to-left parsing" sentence in favor of pointing consumers at SubagentStarted.subsession_stream; corrects §2.4 intro to scope canonical streams + reference §2.2 framework-specific ones; replaces {parent}/{app}/{user} shorthands with canonical names; lifts SubagentCompleted's table to the same 4-column shape as SubagentStarted.
Wraps kurrent_agent_schema.agent_subsession_stream with the same id normalisation as for_session, enforcing the SCHEMA_v2 §2.4 character set and length budget on parent_session_id and agent_id components.
Caught by code-review followup on 28f74c7 — for_session was imported but no test exercises it. ruff F401 would flag at CI time.
…rted/Completed (AI-471) Adds the pure-logic _handoffs.py (slug, derive_agent_id, ledger dataclasses, route_items splitter) and the corresponding _codec mappers (_map_handoff_call, _map_handoff_output) that build canonical subagent lifecycle events with extensions.openai.raw_item preserved for lossless round-trip. session.py integration follows in subsequent commits.
…am path (AI-471) KurrentDB's v2 AppendSession requires JSON metadata with string-only values. Adds a paired serializer that coerces $schema_version to "2" and rejects non-string caller metadata, leaving the existing single- stream serializer's int-typed metadata untouched.
Makes KurrentDBSession a single object that is both SessionABC and RunHooksBase, used as Runner.run(..., session=s, hooks=s). on_handoff stashes the next-handoff target in a per-session _HandoffLedger; duplicate hook delivery while expected is set is dropped. on_agent_end hooks the no-return-handoff case (write path lands in the next commit).
…al-stream multi-append (AI-471) add_items now consults the ledger via route_items, splitting items into per-stream segments. SubagentStarted / SubagentCompleted go through serialize_for_multi_append + multi_append_to_stream so the parent and subsession streams update atomically — matching Capacitor's projection expectation. _emit_deferred_subagent_completed closes the one-way-handoff case (target ends without a handoff_output).
get_items now reads the parent stream, inlines each subsession transcript at its SubagentStarted marker, and emits the original handoff_call/handoff_output dicts from extensions.openai.raw_item. Per-call caching avoids re-reading the same subsession stream within one get_items invocation. Missing subsession streams degrade gracefully with a warning.
Drives Runner.run with two Agents that hand off and asserts canonical SubagentStarted/Completed lifecycle on both the parent and subsession streams, plus get_items flat-replay of the original handoff dicts. Auto-skipped without OPENAI_API_KEY; Task 10 covers degradation cases that run unconditionally.
…n coverage (AI-471) Covers the four non-happy-path scenarios from the spec: one-way handoff (on_agent_end deferred completion), hooks-not-wired fall-through to canonical tool events, missing subsession stream during get_items, and duplicate on_handoff idempotency. Also scopes Task 9's OPENAI_API_KEY skip mark to only the LIVE-LLM cases so the four offline tests in this commit run unconditionally. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rip (AI-471) Integration-local fixtures exercise the extensions.openai.raw_item and extensions.openai.handoff.source_agent shape. Shared schema fixtures (Claude-Code-flavored) keep covering the canonical contract; OpenAI extension semantics ride here.
…t promotion (AI-471) Mirrors the basic_agent sample shape with a triage → specialist handoff. Documents the session=s, hooks=s wiring users need for canonical promotion vs. the (hooks-not-wired) degradation path where the handoff appears as a regular AssistantToolCallsGenerated but the SubagentStarted/Completed lifecycle is absent.
Updates DESIGN.md §3 write-path table, §4 handoff entry, and §8 Q3 deferral note; refreshes README's alpha-status paragraph; updates the monorepo CLAUDE.md OpenAI Agents row to describe the dual-stream SubagentStarted/Completed promotion and the session=hooks wiring. Corrects the (hooks-not-wired) degradation description: items surface as canonical AssistantToolCallsGenerated/ToolResultReceived, not as OpenAIItem.
Pre-existing stale lock — pyproject was at 0.4.0 but uv.lock pinned 0.3.1. Surfaced after uv sync during AI-471 implementation. No code change.
Cosmetic + safety-net fixes surfaced by the end-to-end review: - _handoffs.slug docstring updated (regex preserves underscore per §2.4). - _handoffs.route_items: inline note clarifying current_owner has flipped before the DualAppend reads it. - session.on_handoff: emit a WARN when a nested handoff target is dropped (schema's flat-only stance) so it's debuggable instead of silent. - README.md: usage example now passes `hooks=session` so canonical promotion is on by default, matching the status blurb. Deferred to follow-ups: _count_items O(n)-per-call (low impact since SDK is session-serial), pre-existing basic_agent ruff I001.
AI-471 OpenAI Agents: promote handoffs to canonical subagent events
Follow-up to DEV-1530 (canonical schema v2 migration). Schema v2 ( Scope.
Why this is its own issue, not part of DEV-1530. OpenAI's SDK currently writes the handoff target's items back into the parent session item list — there is no separate stream boundary in the upstream model. Promoting cleanly requires:
None of this is required to make DEV-1530's mechanical migration correct, and shipping it inline would balloon that work item. Open questions.
References.
|
Review Summary by QodoPromote OpenAI handoffs to canonical subagent lifecycle events with dual-stream atomicity (AI-471)
WalkthroughsDescription• Promote OpenAI handoffs to canonical SubagentStarted/SubagentCompleted events with atomic dual-stream writes • Implement KurrentDBSession as both SessionABC and RunHooksBase for integrated handoff lifecycle tracking • Add _handoffs module with pure routing logic to split flat items across parent and subsession streams • Extend schema §2.4 with identifier conventions and §3.5 with mandatory dual-stream lifecycle semantics • Add codec mappers _map_handoff_call and _map_handoff_output preserving original SDK dicts under extensions.openai.raw_item • Re-flatten subsession transcripts inline on get_items to preserve SDK's flat-replay invariant Diagramflowchart LR
A["OpenAI SDK<br/>handoff_call/output"] -->|"route_items<br/>with ledger"| B["DualAppend<br/>SubagentStarted"]
B -->|"multi_append_to_stream"| C["AgentSession-<br/>parent stream"]
B -->|"atomic write"| D["AgentSubsession-<br/>subsession stream"]
E["get_items"] -->|"re-flatten<br/>inline"| F["Flat SDK<br/>item list"]
D -->|"read subsession"| F
C -->|"read parent"| F
File Changes1. openai-agents/python/kurrent_openai_agents/_codec.py
|
Code Review by Qodo
1. Subsession message_index duplicates
|
| for offset, item in enumerate(items): | ||
| message_index = start_index + offset | ||
| kind = item.get("type") | ||
|
|
||
| if kind == "function_call" and ledger.expected is not None: | ||
| flush_pending() | ||
| evt = _emit_subagent_started(item, ledger, session_id, message_index, timestamp) | ||
| # NOTE: ledger.current_owner has just been flipped to the | ||
| # new subsession by _emit_subagent_started — that's the | ||
| # second stream we want on the DualAppend. | ||
| ops.append(DualAppend( | ||
| streams=(ledger.parent_stream, ledger.current_owner), | ||
| event=evt, | ||
| )) | ||
| continue | ||
|
|
||
| if kind == "function_call_output": | ||
| call_id = item.get("call_id") or "" | ||
| if call_id in ledger.active: | ||
| subsession = ledger.active[call_id].subsession_stream | ||
| flush_pending() | ||
| evt = _emit_subagent_completed(item, ledger, message_index, timestamp) | ||
| del ledger.active[call_id] | ||
| ledger.current_owner = ledger.parent_stream | ||
| ops.append(DualAppend(streams=(ledger.parent_stream, subsession), event=evt)) | ||
| continue | ||
|
|
||
| canonical_events = items_to_canonical([item], start_index=message_index, timestamp=timestamp) | ||
| queue_single(ledger.current_owner, canonical_events) | ||
|
|
There was a problem hiding this comment.
1. Subsession message_index duplicates 🐞 Bug ≡ Correctness
KurrentDBSession.add_items() derives start_index from only the parent AgentSession-* stream, but route_items() uses that single counter for events routed into AgentSubsession-* streams too. Across incremental appends during a handoff, this can produce duplicate/non-monotonic message_index values in subsession streams and break consumers that assume monotonic indices.
Agent Prompt
## Issue description
`route_items()` computes `message_index` from a `start_index` that `KurrentDBSession` currently derives by scanning only the parent stream. During an active handoff, subsequent `add_items()` calls may write only to the subsession stream, leaving the parent count unchanged; this causes `start_index` to repeat and `message_index` to collide in the subsession stream.
## Issue Context
- `_codec.items_to_canonical()` documents `start_index` as a session-wide monotonic counter.
- `session._count_items()` scans only the parent stream.
- `route_items()` uses the single `start_index` for items routed to either stream.
## Fix Focus Areas
- Introduce a session-wide monotonic counter on `KurrentDBSession` (e.g., `self._next_item_index`).
- Seed it once per process from persisted history (e.g., `len(await self.get_items())` or by scanning parent + referenced subsessions) and then increment by `len(items)` only after a successful write.
- Use this counter as `start_index` for `route_items()` instead of `_count_items()`.
### Code pointers
- openai-agents/python/kurrent_openai_agents/session.py[136-160]
- openai-agents/python/kurrent_openai_agents/session.py[347-353]
- openai-agents/python/kurrent_openai_agents/_handoffs.py[170-201]
- openai-agents/python/kurrent_openai_agents/_codec.py[57-101]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
…ffs (AI-471) Per Qodo PR #60 review. _count_items scanned only the parent stream, so message_index restarted from the parent count after subagent events landed on subsession streams. Replaces the per-call scan with a session-local counter seeded from the union of parent + referenced subsession non-lifecycle events; advances after each successful _write_ops. Regression test asserts no duplicate indices across the union after a handoff round-trip.
Per Qodo PR #60 review. derive_agent_id raises on empty call_id; a malformed function_call (no call_id) with a pending handoff would crash add_items. Now falls through to the canonical codec with a WARN log, preserving the original codec behavior. Regression test covers the no-call_id path.
|
Addressed Qodo's review in ✅ Bug 1 (message_index duplicates). Replaced the parent-only ✅ Bug 3 (missing call_id crashes). ✅ Bug 4 (metadata hook bypassed). Removed the dead ⏭️ Bug 2 (ledger desync on failures). Acknowledged but deferred. Final state: 120 passed, 2 skipped (live-LLM gated). Ruff clean. |
… scope handoff_call promotion (AI-471) Addresses three P1/P2 correctness bugs from external review on PR #60: * P1A: Target's transcript landed on parent stream instead of subsession. The SDK emits items in the order [handoff_call, handoff_output, target_items..., target_final]; our route_items was closing the handoff on handoff_output (the synthetic ACK that is part of handoff SETUP) and on_agent_end was writing SubagentCompleted before the final target message was persisted. Refactored: - handoff_output no longer closes the handoff; flows through as ToolResultReceived on the subsession. - on_agent_end defers via _HandoffLedger.pending_close; the close is emitted at the end of the next route_items call so the final target message lands on the subsession BEFORE SubagentCompleted. - SubagentCompleted is synthetic (no underlying SDK item), so it carries no extensions.openai.raw_item; get_items skips it for flat-replay reconstruction. * P1B: message_index seeding lagged by 1 per past handoff. Each SubagentStarted on the parent stream represents one consumed flat-list slot (the handoff_call) that does not have its own message_index event — include it in the seed count so resumed sessions don't reuse indices already claimed on subsession streams. * P2: Any function_call after on_handoff was promoted. on_handoff now resolves the Handoff.tool_name from from_agent.handoffs and stashes it on ExpectedHandoff; route_items only promotes function_call items whose name matches the expected tool_name (falling back to the transfer_to_ prefix when the Handoff config can't be resolved). Tests updated to use the real SDK item order; new coverage for tool-name scoping, pending_close drain, and seed-after-handoff correctness. Codec fixture for SubagentCompleted updated to reflect the synthetic-close shape (no extensions.openai.raw_item).
|
Addressed the three findings in ✅ P1A — Target transcript on subsession. Restructured the handoff close flow:
✅ P1B — message_index seeding. ✅ P2 — Tool-name scoped match. Added Tests updated to use the real SDK item order
122 passed, 2 skipped, ruff clean. Thanks for catching these — the synthetic test order really was hiding the worst part of the bug. |
…atch promotion (AI-471) Strengthens the function_call promotion guard per external reviewer's follow-up suggestion. The previous fix (commit 41d6d24) matched on tool_name alone, which is robust for vanilla single-handoff responses but ambiguous when the model emits multiple tool calls (a regular tool call AND a handoff, or parallel handoffs to the same target) in one response. Adds `on_llm_end(context, agent, response)` hook that inspects the raw ModelResponse output before the SDK's handoff resolution. For every function call whose name matches one of agent.handoffs' registered tool names (via the new _handoff_tool_map helper, mirroring Handoff.default_tool_name), a PendingHandoffCall(from_name, target_name_hint, tool_name, call_id) is appended to a per-LLM-response list. on_handoff pops the entry matching (from, to) and copies its exact (tool_name, call_id) into ExpectedHandoff. route_items now uses a layered match: 1. If ExpectedHandoff.call_id is set: function_call must match BOTH call_id and tool_name (the strictest, production path). 2. Else if ExpectedHandoff.tool_name is set: function_call.name must match tool_name (test-only path that bypasses on_llm_end). 3. Else: transfer_to_ prefix heuristic (final safety net). pending_calls is rebuilt at the start of every on_llm_end so unchosen candidates from a previous response don't drift forward — addresses the leak concern in the original review back-and-forth. Four new tests cover the on_llm_end capture, stale-candidate cleanup, on_handoff consumption, and the exact-match guard rejecting a mismatched call_id with a matching name.
|
Folded in the stronger match per the reviewer's Two-stage capture is now:
To address the leak concern I'd raised, 126 passed, 2 skipped, ruff clean. |
…doff lifecycle (AI-471) Following commits 41d6d24 + cdc2640, the handoff promotion is no longer a 1:1 map of handoff_call/handoff_output → SubagentStarted/Completed. Update user-facing docs: * DESIGN.md §3 write-path table: handoff_output now becomes ToolResultReceived on the subsession (it's part of handoff SETUP, not its close). SubagentCompleted is a separate row with no source item — emitted via on_agent_end's deferred close. * DESIGN.md §3 raw_item invariant: scoped to events derived from an SDK item; SubagentCompleted is synthetic and explicitly excluded. * DESIGN.md §3 read path: enumerate SubagentStarted-inlines-subsession and SubagentCompleted-as-skipped-lifecycle-marker handling. * DESIGN.md §4 Handoffs entry: spell out the three lifecycle hooks (on_llm_end captures call_id, on_handoff resolves target, on_agent_end defers close). * Repo CLAUDE.md OpenAI Agents row: replace the 1:1 mapping with the hook-driven flow.
Summary
handoff_call/handoff_outputitems) now promote to canonicalSubagentStarted/SubagentCompletedevents written atomically to BOTH the parentAgentSession-stream and a dedicatedAgentSubsession-{parent}-{agent_id}stream viamulti_append_to_stream— matching Capacitor's projection expectations. The original SDK dict is preserved underextensions.openai.raw_itemfor lossless flat-replay.KurrentDBSessionnow implements bothSessionABCANDRunHooksBase, so the same object passes asRunner.run(..., session=s, hooks=s). Withouthooks=s, handoffs still persist as canonical tool calls on the parent stream — only the subagent lifecycle and subsession transcript are missing (graceful degradation, logged).get_itemsre-flattens the subsession transcript inline at eachSubagentStartedmarker so the SDK's flat-replay invariant is preserved across cross-framework reads.SCHEMA_v2 §2.4solidifies cross-framework stream-name identifier conventions (ASCII safe-char set, GUID→dashless lowercase, compound suffix rules). §3.5 updated to mandate the atomic dual-stream lifecycle write. MAF .NET conformance follow-up split to AI-621.Spec:
docs/superpowers/specs/2026-05-13-openai-agents-handoff-promotion-design.mdPlan:
docs/superpowers/plans/2026-05-14-openai-agents-handoff-promotion.mdLinear: AI-471
Test plan
uv run pytestfromopenai-agents/python/: 118 passed, 2 skipped (live-LLM happy-path tests gated behindOPENAI_API_KEY)uv run ruff checkclean on all new + modified filesOPENAI_API_KEY=sk-... uv run python -m samples.handoff_demo.mainproduces parentAgentSession-...+ aAgentSubsession-...-sub-<role>-<callid>streamSubagentStartedshows up under the agent hierarchyOut of scope (deferred)
on_handoffnow logs a WARN when a second nested target is droppedoutcome="error"/"cancelled"detection onSubagentCompleted— defaults to"success"_count_itemsis O(n) peradd_itemscall; low impact (SDK is session-serial), but worth optimising later🤖 Generated with Claude Code