Skip to content

feat(openai-agents): promote handoffs to canonical subagent events (AI-471) - #60

Merged
alexeyzimarev merged 21 commits into
mainfrom
alexeyzimarev/ai-471-handoff-promotion
May 14, 2026
Merged

alexeyzimarev merged 21 commits into
mainfrom
alexeyzimarev/ai-471-handoff-promotion

Conversation

@alexeyzimarev

Copy link
Copy Markdown
Member

Summary

  • OpenAI Agents SDK handoffs (handoff_call / handoff_output items) now promote to canonical SubagentStarted / SubagentCompleted events written atomically to BOTH the parent AgentSession- stream and a dedicated AgentSubsession-{parent}-{agent_id} stream via multi_append_to_stream — matching Capacitor's projection expectations. The original SDK dict is preserved under extensions.openai.raw_item for lossless flat-replay.
  • KurrentDBSession now implements both SessionABC AND RunHooksBase, so the same object passes as Runner.run(..., session=s, hooks=s). Without hooks=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_items re-flattens the subsession transcript inline at each SubagentStarted marker so the SDK's flat-replay invariant is preserved across cross-framework reads.
  • New SCHEMA_v2 §2.4 solidifies 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.md
Plan: docs/superpowers/plans/2026-05-14-openai-agents-handoff-promotion.md
Linear: AI-471

Test plan

  • uv run pytest from openai-agents/python/: 118 passed, 2 skipped (live-LLM happy-path tests gated behind OPENAI_API_KEY)
  • Schema python tests pass (23/23) after the §2.4 / §3.5 doc edits
  • uv run ruff check clean on all new + modified files
  • Manual smoke: OPENAI_API_KEY=sk-... uv run python -m samples.handoff_demo.main produces parent AgentSession-... + a AgentSubsession-...-sub-<role>-<callid> stream
  • CI green
  • Capacitor end-to-end: write a session here, read via Capacitor's trace projector, confirm SubagentStarted shows up under the agent hierarchy

Out of scope (deferred)

  • Nested handoffs — schema's flat-only stance; on_handoff now logs a WARN when a second nested target is dropped
  • outcome="error" / "cancelled" detection on SubagentCompleted — defaults to "success"
  • _count_items is O(n) per add_items call; low impact (SDK is session-serial), but worth optimising later
  • MAF .NET stream-name conformance (AI-621)

🤖 Generated with Claude Code

alexeyzimarev and others added 16 commits May 14, 2026 10:30
…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.
@linear

linear Bot commented May 14, 2026 •

Copy link
Copy Markdown
AI-471 OpenAI Agents: promote handoffs to canonical subagent events

Follow-up to DEV-1530 (canonical schema v2 migration). Schema v2 (SCHEMA_v2.md §3.5) makes subagent lifecycle canonical via SubagentStarted / SubagentCompleted on the parent AgentSession- stream and a dedicated AgentSubsession-{parent_session_id}-{agent_id} stream for the subagent transcript. The DEV-1530 cutover deliberately defers the handoff promotion because it is a meaningful surface change beyond the mechanical schema swap.

Scope.

  • Map handoff_call → SubagentStarted on the parent stream (agent_id derived from the handoff target; subsession_stream populated with the canonical subsession name).
  • Open AgentSubsession-{parent_session_id}-{agent_id} and route the handoff target's items there instead of letting them land back in the parent item list.
  • Map handoff_output → SubagentCompleted on the parent stream.
  • Preserve the lossless extensions.openai.raw_item round-trip on the parent's handoff_call / handoff_output items.

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:

  1. A reliable boundary detector between parent and subagent items in the SDK's flat list (likely needs to wrap Runner or hook the handoff lifecycle).
  2. A join step on get_items that pulls subsession events back into the parent's item list at the right offset, so the SDK still sees a flat list it can replay.
  3. Cross-framework reader semantics: an ADK / MAF / Strands reader sees the subsession as a separate stream; an OpenAI reader needs the flattened view. The reconstruction logic is asymmetric.

None of this is required to make DEV-1530's mechanical migration correct, and shipping it inline would balloon that work item.

Open questions.

  • Does the SDK's handoff lifecycle expose enough hooks to detect the boundary, or do we need to subscribe to the handoff target's Runner events directly?
  • Should the subsession stream carry its own SessionStarted (per the SCHEMA_v2 §3.5 note that says it should not) or rely solely on the parent's SubagentStarted?
  • handoff_output may carry a filtered / compressed history the parent agent never saw — record under extensions.openai.handoff on SubagentCompleted so cross-framework readers can ignore the filtering rules cleanly.

References.

  • schema/SCHEMA_v2.md §3.5 — canonical subagent shape.
  • openai-agents/python/DESIGN.md §4 — handoff catalogue.
  • openai-agents/python/DESIGN.md §8 Q3 — the v0 deferral note.

Review in Linear

@qodo-code-review

Copy link
Copy Markdown

Review Summary by Qodo

Promote OpenAI handoffs to canonical subagent lifecycle events with dual-stream atomicity (AI-471)

✨ Enhancement

Grey Divider

Walkthroughs

Description
• 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
Diagram
flowchart 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
Loading

Grey Divider

File Changes

1. openai-agents/python/kurrent_openai_agents/_codec.py ✨ Enhancement +78/-0

Add handoff codec mappers for subagent lifecycle events

openai-agents/python/kurrent_openai_agents/_codec.py


2. openai-agents/python/kurrent_openai_agents/_handoffs.py ✨ Enhancement +253/-0

New module for handoff routing and ledger state management

openai-agents/python/kurrent_openai_agents/_handoffs.py


3. openai-agents/python/kurrent_openai_agents/_serialization.py ✨ Enhancement +35/-0

Add multi-append serializer with string-only metadata constraint

openai-agents/python/kurrent_openai_agents/_serialization.py


View more (16)
4. openai-agents/python/kurrent_openai_agents/_stream_names.py ✨ Enhancement +18/-4

Add subsession stream-name builder with id normalization

openai-agents/python/kurrent_openai_agents/_stream_names.py


5. openai-agents/python/kurrent_openai_agents/session.py ✨ Enhancement +207/-31

Implement RunHooksBase for handoff lifecycle and dual-stream writes

openai-agents/python/kurrent_openai_agents/session.py


6. openai-agents/python/samples/handoff_demo/main.py 📝 Documentation +76/-0

End-to-end demo of canonical handoff promotion with subagent streams

openai-agents/python/samples/handoff_demo/main.py


7. openai-agents/python/tests/test_codec.py 🧪 Tests +33/-0

Add fixture-based round-trip tests for handoff codec mappers

openai-agents/python/tests/test_codec.py


8. openai-agents/python/tests/test_handoff_session.py 🧪 Tests +225/-0

Integration tests for handoff promotion and subsession streaming

openai-agents/python/tests/test_handoff_session.py


9. openai-agents/python/tests/test_handoffs.py 🧪 Tests +285/-0

Unit tests for routing logic, ledger state, and stream-name helpers

openai-agents/python/tests/test_handoffs.py


10. openai-agents/python/tests/test_serialization.py 🧪 Tests +45/-0

Tests for multi-append serializer metadata string constraint

openai-agents/python/tests/test_serialization.py


11. openai-agents/python/tests/fixtures/subagent_started_openai.json 🧪 Tests +21/-0

Fixture for SubagentStarted with OpenAI extensions

openai-agents/python/tests/fixtures/subagent_started_openai.json


12. openai-agents/python/tests/fixtures/subagent_completed_openai.json 🧪 Tests +16/-0

Fixture for SubagentCompleted with OpenAI extensions

openai-agents/python/tests/fixtures/subagent_completed_openai.json


13. schema/SCHEMA_v2.md 📝 Documentation +29/-15

Document stream-name identifier conventions and dual-stream lifecycle

schema/SCHEMA_v2.md


14. openai-agents/python/DESIGN.md 📝 Documentation +4/-3

Update handoff mapping table and RunHooksBase wiring guidance

openai-agents/python/DESIGN.md


15. openai-agents/python/README.md 📝 Documentation +5/-4

Update status and usage examples for hooks parameter

openai-agents/python/README.md


16. openai-agents/python/samples/handoff_demo/README.md 📝 Documentation +22/-0

New demo documentation for canonical handoff promotion

openai-agents/python/samples/handoff_demo/README.md


17. CLAUDE.md 📝 Documentation +1/-1

Update OpenAI Agents integration description with handoff promotion

CLAUDE.md


18. openai-agents/python/samples/handoff_demo/__init__.py Additional files +0/-0

...

openai-agents/python/samples/handoff_demo/init.py


19. openai-agents/python/tests/fixtures/__init__.py Additional files +0/-0

...

openai-agents/python/tests/fixtures/init.py


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented May 14, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Subsession message_index duplicates 🐞 Bug ≡ Correctness
Description
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.
Code

openai-agents/python/kurrent_openai_agents/_handoffs.py[R170-199]

+    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)
+
Evidence
The codec explicitly treats start_index as a session-wide monotonic counter, but the session
computes it by scanning only the parent stream and excludes subagent lifecycle; route_items() then
assigns indices for items that may be written to the subsession stream, so incremental
subsession-only appends can reuse indices and collide.

openai-agents/python/kurrent_openai_agents/_codec.py[57-75]
openai-agents/python/kurrent_openai_agents/session.py[136-152]
openai-agents/python/kurrent_openai_agents/session.py[347-353]
openai-agents/python/kurrent_openai_agents/_handoffs.py[170-199]
ag-ui/agents/kurrentdb-replay/src/translator.ts[201-205]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## 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



Remediation recommended

2. Ledger desync on failures 🐞 Bug ☼ Reliability
Description
route_items() mutates the in-memory handoff ledger (expected/active/current_owner) while building
write ops, before any KurrentDB writes succeed. If append_to_stream/multi_append_to_stream fails
and the caller retries, subsequent items can be routed to the wrong stream because the ledger no
longer reflects persisted state.
Code

openai-agents/python/kurrent_openai_agents/_handoffs.py[R231-237]

+    ledger.active[call_id] = ActiveHandoff(
+        call_id=call_id, agent_id=agent_id, agent_type=expected.to_name,
+        subsession_stream=subsession_stream,
+    )
+    ledger.current_owner = subsession_stream
+    ledger.expected = None
+    return evt
Evidence
The routing layer explicitly mutates ledger state (active/current_owner/expected) during op
creation, while the write layer performs network I/O and can raise. With no rollback/commit
boundary, a failure can leave the in-memory state ahead of the persisted streams.

openai-agents/python/kurrent_openai_agents/_handoffs.py[128-141]
openai-agents/python/kurrent_openai_agents/_handoffs.py[231-237]
openai-agents/python/kurrent_openai_agents/session.py[161-199]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The ledger (`_HandoffLedger`) is mutated during planning (`route_items`, `_emit_subagent_started`) rather than after confirmed persistence. Exceptions in `_write_ops` can leave the session object in a state that does not match what was actually written, causing misrouting on retries or continued runs.

## Issue Context
- `route_items()` documents that it mutates `ledger` in place.
- `_emit_subagent_started()` updates `ledger.active`, `ledger.current_owner`, and clears `ledger.expected`.
- `_write_ops()` performs I/O without guarding/rollback.

## Fix Focus Areas
- Make `route_items()` pure (operate on a copy) and return the resulting ledger state; only assign `self._ledger = new_ledger` after `_write_ops()` completes successfully.
- Alternatively, snapshot the ledger before planning and restore it on write failure.
- Consider applying ledger mutations incrementally only after each op is successfully written.

### Code pointers
- openai-agents/python/kurrent_openai_agents/_handoffs.py[128-141]
- openai-agents/python/kurrent_openai_agents/_handoffs.py[204-237]
- openai-agents/python/kurrent_openai_agents/session.py[146-199]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Missing call_id crashes ✓ Resolved 🐞 Bug ☼ Reliability
Description
When hooks are wired and ledger.expected is set, a function_call item without call_id will be
promoted to SubagentStarted, but derive_agent_id() raises ValueError on empty call_id,
causing add_items() to fail. Previously, such an item would fall through and still be persisted as
a canonical tool call with an empty call_id.
Code

openai-agents/python/kurrent_openai_agents/_handoffs.py[R217-220]

+    call_id = item.get("call_id") or ""
+    agent_id = derive_agent_id(expected.to_name, call_id)
+    subsession_stream = for_subsession(session_id, agent_id)
+
Evidence
derive_agent_id() explicitly raises on empty call_id, and _emit_subagent_started() passes an
empty string when the input item is missing call_id, turning a previously tolerant path into a
hard failure.

openai-agents/python/kurrent_openai_agents/_handoffs.py[33-47]
openai-agents/python/kurrent_openai_agents/_handoffs.py[215-220]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Handoff promotion assumes `function_call.call_id` is present; if it is missing/empty, `derive_agent_id()` throws and aborts the whole `add_items()` call.

## Issue Context
- `_emit_subagent_started()` uses `item.get('call_id') or ''`.
- `derive_agent_id()` rejects empty `call_id`.

## Fix Focus Areas
- In `route_items()` and/or `_emit_subagent_started()`, if `call_id` is falsy, do not promote to `SubagentStarted`; instead fall through to `items_to_canonical()`.
- Ensure ledger state doesn’t get stuck (e.g., clear `ledger.expected` on malformed input, or keep it but add a stricter match guard using the function name).

### Code pointers
- openai-agents/python/kurrent_openai_agents/_handoffs.py[33-47]
- openai-agents/python/kurrent_openai_agents/_handoffs.py[204-237]
- openai-agents/python/kurrent_openai_agents/_handoffs.py[170-185]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

4. Metadata hook bypassed 🐞 Bug ⚙ Maintainability
Description
The new _write_ops() path serializes events without calling _event_metadata_for(), so subclasses
can no longer attach per-item metadata despite the hook remaining on the class. This is a behavioral
regression for extensibility (e.g., attaching $usage metadata) even if the base implementation
returns None.
Code

openai-agents/python/kurrent_openai_agents/session.py[R176-183]

+        for op in ops:
+            if isinstance(op, SingleAppend):
+                new_events = [_serialization.serialize(evt) for evt in op.events]
+                await self._client.append_to_stream(
+                    op.stream,
+                    events=new_events,
+                    current_version=StreamState.ANY,
+                )
Evidence
The session still defines _event_metadata_for() for subclassing, and the serializer supports
metadata, but _write_ops() now serializes without passing any metadata, making the hook
ineffective.

openai-agents/python/kurrent_openai_agents/session.py[161-183]
openai-agents/python/kurrent_openai_agents/session.py[374-385]
openai-agents/python/kurrent_openai_agents/_serialization.py[65-87]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`KurrentDBSession._event_metadata_for()` is still present (and imports `USAGE_METADATA_KEY` for subclassers), but `_write_ops()` now calls `serialize(evt)` without passing any metadata. This removes an extension point without deprecating it.

## Issue Context
The previous implementation built `NewEvent` objects with optional per-item metadata; the new route/write op structure dropped the item↔event pairing needed to compute it.

## Fix Focus Areas
- Extend `SingleAppend` to carry `(item,event)` pairs (or a parallel metadata list) so `_write_ops()` can call `serialize(evt, metadata=self._event_metadata_for(item, evt))`.
- For `DualAppend`, either keep metadata empty (if not needed), or pass only string-valued metadata via `serialize_for_multi_append`.
- If intentional, consider removing `_event_metadata_for` or documenting that it’s no longer invoked.

### Code pointers
- openai-agents/python/kurrent_openai_agents/session.py[161-183]
- openai-agents/python/kurrent_openai_agents/session.py[374-385]
- openai-agents/python/kurrent_openai_agents/_serialization.py[65-87]
- openai-agents/python/kurrent_openai_agents/_handoffs.py[93-114]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +170 to +199
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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.
@alexeyzimarev

Copy link
Copy Markdown
Member Author

Addressed Qodo's review in ef58b5f and 5a9734a:

✅ Bug 1 (message_index duplicates). Replaced the parent-only _count_items() with a session-wide _next_item_index counter, lazily seeded from the union of parent + referenced subsession non-lifecycle events on first add_items, advanced by len(items) after each successful _write_ops. Regression test test_message_index_is_session_wide_monotonic_across_handoffs asserts no duplicates across the union after a handoff round-trip.

✅ Bug 3 (missing call_id crashes). route_items now guards the handoff-promotion branch: if call_id is empty/missing, log a WARN and fall through to the canonical codec (leaving expected pending for a later well-formed call). Restores the pre-AI-471 tolerant behavior. Regression test test_function_call_missing_call_id_falls_through_to_canonical covers it.

✅ Bug 4 (metadata hook bypassed). Removed the dead _event_metadata_for method and the now-unused USAGE_METADATA_KEY import. The integration is alpha (no external subclassers yet); keeping a hook that nothing calls is misleading. A subclasser needing per-event metadata later can extend _write_ops directly.

⏭️ Bug 2 (ledger desync on failures). Acknowledged but deferred. route_items does mutate the in-memory ledger before _write_ops succeeds, so a write failure followed by retry could leave the ledger ahead of persisted state. This was the same retry semantic as the pre-AI-471 add_items (the OpenAI SDK's own dedup via save_result_to_session._current_turn_persisted_item_count is the primary defense). Cleaning this up properly means making route_items pure (operate on a ledger copy, commit only after _write_ops) — a worthwhile refactor but larger than the other three fixes. Filed as a follow-up.

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).
@alexeyzimarev

Copy link
Copy Markdown
Member Author

Addressed the three findings in 41d6d24:

✅ P1A — Target transcript on subsession. Restructured the handoff close flow:

  • handoff_output no longer terminates the handoff; it flows through _map_function_call_output and lands on the subsession as ToolResultReceived (with the original SDK dict under extensions.openai.raw_item). The subagent stays open.
  • on_agent_end(target, output) no longer writes anything synchronously. It stashes (call_id → summary) in a new _HandoffLedger.pending_close field.
  • route_items drains pending_close at the end of its op list, so SubagentCompleted lands after the target's final-batch items are routed to the subsession.
  • SubagentCompleted is now synthetic — no underlying SDK item — so it carries no extensions.openai.raw_item. get_items skips it for flat-replay reconstruction. The subagent_completed_openai.json fixture is updated to match.

✅ P1B — message_index seeding. _init_next_index_if_needed now counts each SubagentStarted on the parent stream as one consumed flat-list slot (the handoff_call item that has no message_index event of its own). Recreating KurrentDBSession after a handoff no longer reuses indices already claimed on subsession streams.

✅ P2 — Tool-name scoped match. Added tool_name: str | None to ExpectedHandoff. on_handoff now calls _resolve_handoff_tool_name(from_agent, to_agent) which walks from_agent.handoffs to extract the actual Handoff.tool_name (defaults to transfer_to_<slug> via the SDK's convention when only an Agent was registered). route_items only promotes a function_call whose name equals the expected tool_name; otherwise it falls through to the canonical codec. Unrelated tool calls emitted in the same response as a handoff are no longer mis-promoted.

Tests updated to use the real SDK item order [handoff_call, handoff_output, sub_msg] instead of the synthetic order that hid the bug. New coverage:

  • test_route_items_preserves_inter_op_ordering_real_sdk_order
  • test_route_items_skips_unrelated_function_call (tool-name mismatch)
  • test_route_items_drains_pending_close
  • Integration round-trip through add_items → on_agent_end → next add_items close
  • Seed-after-handoff regression covering P1B

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.
@alexeyzimarev

Copy link
Copy Markdown
Member Author

Folded in the stronger match per the reviewer's on_llm_end suggestion in cdc2640.

Two-stage capture is now:

  1. on_llm_end(agent, response) walks response.output for function calls whose name is in agent.handoffs' registered tool map (_handoff_tool_map mirrors the SDK's own Handoff.default_tool_name for bare-Agent entries). Each match becomes a PendingHandoffCall(from_name, target_name_hint, tool_name, call_id) on _HandoffLedger.pending_calls.

  2. on_handoff(from, to) then pops the entry matching (from.name, target_name_hint == to.name) and copies its exact (tool_name, call_id) into ExpectedHandoff. If no match (e.g. tests that wire on_handoff directly), falls back to the previous name-only resolver.

route_items now has a layered match:

  • Strictest (production via on_llm_end): call_id AND name must both equal expected.call_id/expected.tool_name.
  • Middle (test path with tool_name only): name must equal expected.tool_name.
  • Loose (no resolver succeeded): transfer_to_ prefix heuristic.

To address the leak concern I'd raised, pending_calls is cleared at the start of every on_llm_end, so unchosen candidates from a previous response don't drift forward. Tests cover capture, stale-cleanup, on_handoff consumption, and the mismatched-call_id-same-name rejection case.

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.
@alexeyzimarev
alexeyzimarev merged commit 669f00e into main May 14, 2026
2 checks passed
@alexeyzimarev
alexeyzimarev deleted the alexeyzimarev/ai-471-handoff-promotion branch May 14, 2026 14:23
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