RFC-0040: Subagent Display Compatibility — qwen-code Meta Protocol¶
Overview¶
ACP clients (SEED, qwen-code SDK) display subagent "agent cards" by reading _meta fields (parentToolCallId, subagentType, provenance) from session/update notifications. AgentPool's zed mode uses draft ACP PR #855 fields (kind="subagent", SubagentRunInfo) which these clients don't support, causing cards to disappear. This RFC proposes a "qwen" display mode and evaluates how to pass parent context to child session converters.
Background¶
Key architectural fact¶
SessionNotification inherits from AnnotatedObject, which has a field_meta field (serialized as _meta in JSON-RPC). This means _meta is on the notification wrapper, not on individual update types. The handler constructs SessionNotification(session_id=..., update=...) — adding field_meta there stamps _meta on all notifications regardless of update type.
Current event flow¶
create_child_session()
→ emits SpawnSessionStart(parent_session_id, tool_call_id, source_name, ...)
→ EventBus (parent session scope)
→ Parent consumer: _on_spawn_session_start()
→ start_event_consumer(child_sid)
→ Child consumer loop (fresh converter, no parent context)
→ _handle_event(child_sid, PartDeltaEvent, ...)
→ converter.convert(event) → SessionUpdate
→ SessionNotification(session_id, update) → client
The gap¶
The child converter is created in _before_consumer_loop(child_sid) with no knowledge of:
- parentToolCallId — the tool call that spawned this subagent
- subagentType — the agent name (e.g., "librarian")
- provenance — should be "subagent" for all child events
SpawnSessionStart carries all this info, but it's consumed by the parent's consumer loop — the child consumer never sees it.
qwen-code's approach¶
qwen-code stamps _meta: { parentToolCallId, subagentType, provenance: "subagent" } on every session/update notification from a subagent session. This is done via a SubAgentTracker that stores parent context and a ToolCallEmitter that reads it.
Display modes¶
| Mode | kind | SubagentRunInfo | _meta on notification |
Target client |
|---|---|---|---|---|
legacy |
No ToolCallStart | ❌ | ❌ | Plain text |
zed |
"subagent" |
✅ | ❌ | Zed (draft PR #855) |
qwen (proposed) |
"other" |
❌ | parentToolCallId + subagentType + provenance |
SEED / qwen-code SDK |
Problem Statement¶
zedmode breaks SEED —kind="subagent"is not in the ACP spec, clients silently drop theToolCallStart- Reverting to
kind="other"(hotfix) restores basic functionality but loses subagent card display - The child session converter has no way to know its parent's
tool_call_idorsource_name— this info exists only in theSpawnSessionStartevent, consumed by the parent's consumer loop
Goals¶
- Add
"qwen"display mode that stamps_metafields on all subagent notifications - Pass parent context from
_on_spawn_session_startto the child converter - Zero changes to
EventBus,EventEnvelope, orRichAgentStreamEvent - Support nested subagents (depth > 1)
Non-Goals¶
- Migrating SEED to support
kind="subagent" - Changing the
zedmode behavior - Adding
_metato the framework event layer - Multi-turn reprompting or foreground-to-background promotion
Evaluation Criteria¶
| Criterion | Weight | Description |
|---|---|---|
| Elegance | High | Minimal moving parts, easy to reason about |
| Protocol isolation | High | ACP concerns stay in ACP server |
| Simplicity | High | Few files changed, clear data flow |
| Correctness | High | Nested subagents, concurrent children, error paths |
Options Analysis¶
Option A: Handler dict + converter constructor¶
Handler stores SubagentContext in a dict in _on_spawn_session_start. _before_consumer_loop pops it and passes to converter constructor.
Advantages: Follows existing _parent_of dict pattern. No mixin changes. Protocol-layer only.
Disadvantages: Handler accumulates dicts. Dict entries can leak on error paths. _before_consumer_loop is a mixin method — override works but the pattern is indirect.
Effort: ~3 files, ~40 lines
Option B: ContextVar propagation¶
Set a ContextVar[SubagentContext | None] in _on_spawn_session_start before calling start_event_consumer. The child task (created via asyncio.ensure_future) inherits the context via contextvars.copy_context().
Advantages: No handler-level mutable state. Implicit flow. No mixin changes.
Disadvantages: Implicit data flow — hard to debug. Relies on asyncio.ensure_future copying context (implementation detail). Debuggability is poor — contextvar value is invisible in handler state.
Effort: ~2 files, ~25 lines
Option C: Mixin parameter¶
Change start_event_consumer to accept an optional spawn_envelope parameter, passed through to _before_consumer_loop.
Advantages: Explicit parameter passing. No mutable state.
Disadvantages: Changes mixin signature — affects all protocols (OpenCode, AG-UI, OpenAI API). Couples mixin to "spawn" concept. Other protocols must accept and ignore the parameter.
Effort: ~5 files, ~60 lines
Option D: Converter self-population¶
Child converter inspects first event for SpawnSessionStart and extracts parent context.
Disadvantages: Fatal flaw — child consumer subscribes with scope="session", never receives SpawnSessionStart (published to parent's session stream). Architecturally broken.
Effort: N/A
Option E: Handler creates converter directly (recommended)¶
_on_spawn_session_start creates the child converter with SubagentContext extracted from the SpawnSessionStart event, stores it in _converters[child_sid]. _before_consumer_loop checks if a converter already exists; if so, skips creation.
_on_spawn_session_start(parent_sid, envelope):
event = envelope.event # SpawnSessionStart
converter = ACPEventConverter(
subagent_display_mode=...,
subagent_context=SubagentContext(
parent_tool_call_id=event.tool_call_id,
subagent_type=event.source_name,
),
)
self._converters[child_sid] = converter
await self.start_event_consumer(child_sid)
_before_consumer_loop(child_sid):
if child_sid in self._converters:
return # Already created by _on_spawn_session_start
self._converters[child_sid] = ACPEventConverter(...)
_handle_event(session_id, envelope):
converter = self._converters.get(effective_sid)
async for update in converter.convert(envelope.event):
notification = SessionNotification(
session_id=effective_sid,
update=update,
field_meta=converter.subagent_meta, # None for root, dict for child
)
await self.client.session_update(notification)
Advantages:
- No dict — converter is stored in the existing _converters dict, no new dict needed
- No mixin changes — _before_consumer_loop just checks for existing converter
- One-line _meta stamping — field_meta=converter.subagent_meta on SessionNotification
- No convert() refactor — converter holds context, exposes property, handler stamps on notification
- Clear data flow — context goes event → handler → converter → notification, all visible
- Temporal ordering guaranteed — _on_spawn_session_start runs in parent's consumer loop before child task starts
Disadvantages:
- _before_consumer_loop gains an early-return check (minor complexity)
- _on_spawn_session_start takes on converter creation responsibility (previously only _before_consumer_loop did this)
- If start_event_consumer fails after converter creation, converter lingers in _converters (same issue as existing _parent_of dict — pre-existing pattern)
Effort: ~2 files, ~30 lines
Comparison Matrix¶
| Criterion | A (Dict) | B (ContextVar) | C (Mixin param) | D (Self-populate) | E (Direct create) |
|---|---|---|---|---|---|
| Elegance | Medium | High | Medium | N/A | High |
| Protocol isolation | ✅ | ✅ | ❌ | ✅ | ✅ |
| Simplicity | 3 files | 2 files | 5 files | N/A | 2 files |
| No new mutable state | ❌ (new dict) | ✅ | ✅ | N/A | ✅ (reuses _converters) |
| Correctness | ✅ | ✅ | ✅ | ❌ | ✅ |
| Debuggability | High | Low | High | N/A | High |
Recommendation¶
Option E — Handler creates child converter directly in _on_spawn_session_start.
This is the simplest approach because:
1. _meta is on SessionNotification, not on update objects — stamping is one line in _handle_event
2. The converter is stored in the existing _converters dict — no new dict needed
3. _before_consumer_loop just checks if converter exists — no mixin signature change
4. Context flows explicitly: SpawnSessionStart event → handler extracts → converter stores → handler stamps on notification
Technical Design¶
SubagentContext (new dataclass in event_converter.py)¶
@dataclass
class SubagentContext:
"""Parent context for a child session converter."""
parent_tool_call_id: str
subagent_type: str
ACPEventConverter changes¶
@dataclass
class ACPEventConverter:
# ... existing fields ...
subagent_context: SubagentContext | None = None
@property
def subagent_meta(self) -> dict[str, Any] | None:
"""Build _meta dict for subagent notifications. None for root sessions."""
if self.subagent_context is None:
return None
return {
"parentToolCallId": self.subagent_context.parent_tool_call_id,
"subagentType": self.subagent_context.subagent_type,
"provenance": "subagent",
}
ACPProtocolHandler changes¶
# _on_spawn_session_start: create child converter with context
async def _on_spawn_session_start(self, session_id, envelope):
event = envelope.event
if isinstance(event, SpawnSessionStart):
child_sid = event.child_session_id
if child_sid and child_sid != session_id:
# ... existing spawn_mechanism check ...
# Create child converter with subagent context
self._converters[child_sid] = ACPEventConverter(
subagent_display_mode=self._event_converter_template.subagent_display_mode,
client_supports_turn_complete=...,
subagent_context=SubagentContext(
parent_tool_call_id=event.tool_call_id or "",
subagent_type=event.source_name or "",
),
)
await self.start_event_consumer(child_sid)
# ... rest of existing logic ...
# _before_consumer_loop: skip if converter already exists
async def _before_consumer_loop(self, session_id):
if session_id in self._converters:
return # Created by _on_spawn_session_start
self._converters[session_id] = ACPEventConverter(...)
# _handle_event: stamp _meta on notification
async def _handle_event(self, session_id, envelope):
converter = self._converters.get(effective_sid) or self._converters.get(session_id)
if converter is None:
return
async for update in converter.convert(envelope.event):
notification = SessionNotification(
session_id=effective_sid,
update=update,
field_meta=converter.subagent_meta,
)
await self.client.session_update(notification)
SpawnSessionStart handler in converter (qwen mode)¶
case SpawnSessionStart(...):
if self.subagent_display_mode == "legacy":
# ... existing inline text ...
elif self.subagent_display_mode == "zed":
# ... existing ToolCallStart(kind="subagent") ...
elif self.subagent_display_mode == "qwen":
tool_call_id = event.tool_call_id or str(uuid.uuid4())
yield ToolCallStart(
tool_call_id=tool_call_id,
title=f"{source_name}: {description}" if description else source_name,
kind="other",
status="pending",
)
Files changed¶
| File | Changes |
|---|---|
event_converter.py |
Add SubagentContext, subagent_context field, subagent_meta property, "qwen" mode branch |
handler.py |
Create converter in _on_spawn_session_start, early-return in _before_consumer_loop, stamp field_meta in _handle_event |
Open Questions¶
-
Should
"qwen"become the default mode? SEED is the primary client today, butlegacyis the safe default for unknown clients. -
Client capability detection? Could the ACP
initializehandshake auto-selectzedvsqwenmode based on client capabilities. Separate concern from context passing. -
Nested subagents: Each level gets its own converter with its own
subagent_context.parentToolCallIdpoints to the immediate parent, not the root. This matches qwen-code's behavior.
Decision Record¶
Pending — awaiting stakeholder approval.
Errata & Fix (2026-06-28)¶
After implementing Option E and testing with SEED, two bugs were discovered that prevent subagent cards from rendering. Both are addressed in the fix-qwen-meta-stamping OpenSpec change.
Bug 1: _meta stamped on wrong object¶
Original design (wrong): The RFC states _meta is on SessionNotification (the wrapper), and the handler stamps field_meta=converter.subagent_meta on the notification. Section "Key architectural fact" (line 20) explicitly says: "_meta is on the notification wrapper, not on individual update types."
qwen-code's actual behavior (correct): qwen-code stamps _meta directly on the SessionUpdate object (update._meta), not on the notification wrapper. Specifically:
- ToolCallEmitter.emitStart() (ToolCallEmitter.ts:94) sets _meta: { toolName, ...subagentMeta, provenance } on the update payload
- Session.sendUpdate() (Session.ts:1890-1897) builds SessionNotification(sessionId, update) WITHOUT field_meta
- The normalizer's extractParentToolCallId(update) reads update._meta.parentToolCallId
Fix: Move _meta stamping from SessionNotification.field_meta (handler) to SessionUpdate.field_meta (converter). The converter's convert() method stamps field_meta=self.subagent_meta on each yielded update via a _stamp_meta() helper. The handler removes field_meta=converter.subagent_meta from SessionNotification construction.
RFC sections affected: "Key architectural fact" (line 20) is incorrect — _meta should go on the update, not the notification. Option E's _handle_event code (line 154) shows field_meta=converter.subagent_meta on the notification — this is wrong. The display mode table (line 51-55) column "_meta on notification" should be "_meta on update".
Bug 2: Tool call ID collision¶
Original design (wrong): The qwen mode SpawnSessionStart handler (line 280) uses tool_call_id = event.tool_call_id or str(uuid.uuid4()) — reusing the parent's tool call ID for the child's ToolCallStart.
qwen-code's actual behavior (correct): qwen-code's normalizer has a self-reference guard (rawParentToolCallId !== toolCallId ? rawParentToolCallId : undefined). When the child's toolCallId equals parentToolCallId, the normalizer drops the parentToolCallId — defeating the correlation. qwen-code uses the parent's callId only for SubAgentTracker.parentToolCallId, not for the child's own tool call IDs.
Fix: In qwen mode's SpawnSessionStart handler, always generate tool_call_id = str(uuid.uuid4()). The parent's event.tool_call_id is carried in SubagentContext.parent_tool_call_id → _meta.parentToolCallId, never as the child's tool_call_id.
RFC sections affected: Option E's qwen mode code (line 280) shows tool_call_id = event.tool_call_id or str(uuid.uuid4()) — this is wrong. Should be tool_call_id = str(uuid.uuid4()).
Updated display mode table¶
| Mode | kind | SubagentRunInfo | _meta on update |
tool_call_id source |
Target client |
|---|---|---|---|---|---|
legacy |
No ToolCallStart | N/A | N/A | N/A | Plain text |
zed |
"other" (hotfixed) |
✅ | subagent_session_info |
event.tool_call_id or UUID |
Zed (draft PR #855) |
qwen |
"other" |
❌ | parentToolCallId + subagentType + provenance |
Unique UUID (never parent's ID) | SEED / qwen-code SDK |
Updated data flow (post-fix)¶
_on_spawn_session_start(parent_sid, envelope):
event = envelope.event # SpawnSessionStart
converter = ACPEventConverter(
subagent_display_mode=...,
subagent_context=SubagentContext(
parent_tool_call_id=event.tool_call_id, # Parent's ID → _meta.parentToolCallId
subagent_type=event.source_name,
),
)
self._converters[child_sid] = converter
await self.start_event_consumer(child_sid)
_before_consumer_loop(child_sid):
if child_sid in self._converters:
return # Already created by _on_spawn_session_start
self._converters[child_sid] = ACPEventConverter(...)
_handle_event(session_id, envelope):
converter = self._converters.get(effective_sid)
async for update in converter.convert(envelope.event):
# update.field_meta already stamped by converter._stamp_meta()
notification = SessionNotification(
session_id=effective_sid,
update=update, # _meta is INSIDE the update, not on the notification
)
await self.client.session_update(notification)
# In converter.convert(), qwen mode SpawnSessionStart:
elif self.subagent_display_mode == "qwen":
tool_call_id = str(uuid.uuid4()) # Unique ID, NEVER event.tool_call_id
yield self._stamp_meta(ToolCallStart(
tool_call_id=tool_call_id,
title=f"{source_name}: {description}" if description else source_name,
kind="other",
status="pending",
))
# _stamp_meta helper:
def _stamp_meta(self, update):
if self.subagent_context is not None:
update.field_meta = self.subagent_meta
return update
zed mode compatibility note¶
The zed mode's ToolCallStart already sets field_meta with subagent_session_info at construction time (event_converter.py line 717). The _stamp_meta() helper must NOT overwrite this existing field_meta. The helper should either:
- Only stamp when update.field_meta is None (merge strategy), OR
- Only stamp for qwen mode (mode-gated strategy)
The fix-qwen-meta-stamping design uses the "only stamp when subagent_context is not None" approach, which works because zed mode's ToolCallStart is yielded from the parent converter (which has subagent_context=None), while child event updates flow through the child converter (which has subagent_context set but doesn't yield SpawnSessionStart).