RFC-0014: Adding SpawnSessionStart Event for Explicit Subsession Creation¶
Overview¶
This RFC proposes adding a single new event type - SpawnSessionStart - to the AgentPool event system. This event provides an explicit signal when a subsession (spawn/subagent) is created, eliminating the need for protocol adapters (ACP, OpenCode, AG-UI) to hardcode detection of specific tool calls.
Note: After discussion, we determined that a close event is unnecessary because StreamCompleteEvent already implicitly signals subsession completion (see "Why No Close Event?" section).
Problem Statement¶
The current event system relies on SubAgentEvent wrappers to propagate subagent activity, but lacks an explicit lifecycle signal for when a new subsession is created. Protocol adapters currently work around this by:
- Hardcoding checks for tool names like
"task","spawn", or"subagent" - Inferring session boundaries from
RunStartedEventwithparent_session_id - Creating child sessions reactively on the first
SubAgentEventoccurrence
# Current workaround in event_processor.py (lines 652-702)
if child_session_id and child_ctx is None:
# Creating session because this is the first SubAgentEvent
# This is implicit, not explicit
await ctx.state.ensure_session(child_session_id, parent_id=ctx.session_id)
This approach is brittle and requires protocol adapters to have internal knowledge of tool implementation details.
Proposed Solution¶
Single Event: SpawnSessionStart¶
Rationale: Only a start event is needed because:
StreamCompleteEventalready handles completion: When a subagent finishes, it emitsStreamCompleteEventwrapped inSubAgentEvent.child_session_id. Protocol adapters can detect this as the close signal.- Simpler design: Fewer event types reduce complexity and cognitive load.
- Backward compatible: Adding one event is less disruptive than adding two.
Schema¶
@dataclass(kw_only=True)
class SpawnSessionStart:
"""Signals the creation of a new subsession (spawn).
Emitted BEFORE any content events from the spawned session.
Protocol adapters should use this to initialize child session state
and create container UI elements.
The subsession lifecycle is:
1. SpawnSessionStart -> Create child session
2. SubAgentEvent (with events like PartDeltaEvent, ToolCallStartEvent...) -> Route to child
3. SubAgentEvent.child_event=StreamCompleteEvent -> Finalize child session
"""
child_session_id: str
"""The unique ID of the newly created child session."""
parent_session_id: str
"""The ID of the parent session that spawned the child."""
tool_call_id: str | None
"""The tool call ID that triggered the spawn (if applicable)."""
spawn_mechanism: Literal["sync", "async_worker", "manual"]
"""The mechanism that created this spawn:
- "sync": Synchronous task tool execution (blocks until completion)
- "async_worker": Background/async worker execution (non-blocking)
- "manual": Manually triggered via code (e.g., internal delegation)
"""
source_name: str
"""Name of the agent/team that will execute in the child session."""
source_type: Literal["agent", "team_parallel", "team_sequential"]
"""Type of node executing in the child session."""
depth: int = 1
"""Nesting depth of this spawn (1 = direct child, 2 = grandchild, etc.).
Used for depth limitation and UI rendering hierarchy."""
description: str
"""Human-readable description of what the spawned session will do."""
metadata: dict[str, Any] = field(default_factory=dict)
"""Additional metadata for the spawn. May include:
- task_id: For async tasks
- prompt: Summary of instructions (truncated)
- user_message_id: ID of the prompting user message in parent session
- Other tool-specific metadata
"""
event_kind: Literal["spawn_session_start"] = "spawn_session_start"
"""Event type identifier for dispatch."""
Complete Event Flow¶
┌─────────────────────────────────────────────────────────────────────────┐
│ New Event Flow with SpawnSessionStart │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ task() starts │
│ │ │
│ ▼ │
│ SpawnSessionStart (NEW) <- Explicit signal to create child session │
│ │ ┌─────────────────────────────────────┐ │
│ ▼ │ Protocol adapters can now: │ │
│ │ - Create child session upfront │ │
│ SubAgentEvent │ - Create container UI element │ │
│ ├- PartStartEvent │ - Attach spawn metadata │ │
│ ├- PartDeltaEvent └─────────────────────────────────────┘ │
│ ├- ToolCallStartEvent │
│ ├- ToolCallProgressEvent │
│ ├- ... │
│ └- StreamCompleteEvent <- Implicit close signal │
│ (existing behavior, no change needed) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Why No Close Event?¶
After analysis, we determined SpawnSessionClose is unnecessary:
Existing Close Signal Already Works¶
# event_processor.py already handles this (lines 669-782)
async def _process_subagent_event(self, subagent_event, ctx):
# ... process child events ...
# Line 738: Detect completion via StreamCompleteEvent
if isinstance(wrapped_event, StreamCompleteEvent) and wrapped_event.message:
# This IS the close signal
content = str(msg.content)
# Finalize child session
# Update parent container to "completed" state
Information Already Available¶
| Information | Source | Notes |
|---|---|---|
| Child session ID | SubAgentEvent.child_session_id |
Same as SpawnSessionStart.child_session_id |
| Parent session ID | SubAgentEvent.parent_session_id |
Same as SpawnSessionStart.parent_session_id |
| Final content | StreamCompleteEvent.message.content |
Full output available |
| Token usage | StreamCompleteEvent.message.usage |
Usage stats available |
| Cost info | StreamCompleteEvent.message.cost_info |
Cost tracking available |
| Tool call ID | SubAgentEvent.tool_call_id |
Correlates with spawn |
Adding Close Event Would:¶
- Duplicate information: Same data available in
StreamCompleteEvent - Increase complexity: More event types to handle and document
- Risk inconsistency: Two ways to detect close (close event OR StreamCompleteEvent)
Alternatives Considered¶
Alternative 1: Extend RunStartedEvent¶
Description: Add is_spawn, spawn_metadata, etc. fields to RunStartedEvent.
| Criterion | Evaluation |
|---|---|
| Semantic Fit | Poor - RunStartedEvent is internally emitted by the child agent, not at spawn time from parent |
| Implementation | Complex - would require passing spawn context into child agent |
| Protocol Adapter | Still reactive detection needed |
| Verdict | Rejected - semantic mismatch, doesn't solve the explicit signaling problem |
Alternative 2: Add Metadata to SubAgentEvent¶
Description: Add first_event: bool, spawn_metadata fields to SubAgentEvent.
| Criterion | Evaluation |
|---|---|
| Semantic Fit | Partial - still requires reactive detection on first SubAgentEvent |
| Implementation | Simple - no new event type |
| Protocol Adapter | Still reactive - cannot preemptively create session before first content event |
| Verdict | Rejected - doesn't provide explicit signal before content events begin |
Alternative 3: The Chosen Approach (SpawnSessionStart)¶
| Criterion | Evaluation |
|---|---|
| Semantic Fit | Excellent - explicit "spawn created" signal |
| Implementation | Moderate - one new event type, single emission point |
| Protocol Adapter | Proactive - can create session upfront with full metadata |
| Verdict | Selected - best trade-off between explicitness and simplicity |
Implementation Plan¶
Estimated Timeline: ~5-6 days (revised based on complexity analysis)
| Phase | Description | Effort | Risk |
|---|---|---|---|
| 1 | Event Definition | 0.5 day | Low |
| 2 | Subagent Tools Integration | 1 day | Medium |
| 3 | Event Processor Update | 2 days | Medium |
| 4 | ACP Converter Update | 1 day | Low |
| 5 | Testing | 1-2 days | Medium |
| 6 | Storage Integration | 0.5 day | Low |
| Total | 5.5 - 6.5 days |
Phase 1: Event Definition (0.5 day)¶
File: src/agentpool/agents/events/events.py
@dataclass(kw_only=True)
class SpawnSessionStart:
child_session_id: str
parent_session_id: str
tool_call_id: str | None
spawn_mechanism: Literal["sync", "async_worker", "manual"]
source_name: str
source_type: Literal["agent", "team_parallel", "team_sequential"]
depth: int = 1
description: str
metadata: dict[str, Any] = field(default_factory=dict)
event_kind: Literal["spawn_session_start"] = "spawn_session_start"
# Update RichAgentStreamEvent union
type RichAgentStreamEvent[OutputDataT] = (
# ... existing events ...
| SpawnSessionStart
)
Phase 2: Subagent Tools Integration (1 day)¶
File: src/agentpool_toolsets/builtin/subagent_tools.py
Update _stream_task() function:
async def _stream_task(
ctx: AgentContext,
source_name: str,
source_type: Literal["agent", "team_parallel", "team_sequential"],
stream: AsyncIterator[RichAgentStreamEvent[Any]],
*,
prompt: str, # NEW PARAMETER
async_mode: bool = False,
task_id: str | None = None,
) -> dict[str, Any]:
"""Stream a task with SpawnSessionStart event."""
# Generate session IDs
child_session_id = identifier.ascending("session")
parent_session_id = ctx.node.session_id
tool_call_id = ctx.tool_call_id
# Calculate depth (increment from parent if available)
depth = 1
if hasattr(ctx, "current_depth"):
depth = ctx.current_depth + 1
# EMIT: SpawnSessionStart (before any content)
start_event = SpawnSessionStart(
child_session_id=child_session_id,
parent_session_id=parent_session_id,
tool_call_id=tool_call_id,
spawn_mechanism="async_worker" if async_mode else "sync",
source_name=source_name,
source_type=source_type,
depth=depth,
description=f"Run {source_name} task" + (f" (async: {task_id})" if async_mode else ""),
metadata={
"prompt": prompt[:200] + "..." if len(prompt) > 200 else prompt,
"task_id": task_id,
"max_depth": 5, # Protocol-level depth limitation
} if async_mode else {},
)
await ctx.events.emit_event(start_event)
# Stream wrapped events...
async for event in stream:
subagent_event = SubAgentEvent(
source_name=source_name,
source_type=source_type,
event=event,
depth=depth,
child_session_id=child_session_id,
parent_session_id=parent_session_id,
tool_call_id=tool_call_id,
)
await ctx.events.emit_event(subagent_event)
# Return existing result
return {"output": final_content, "metadata": {"sessionId": child_session_id}}
Note on Background Workers: Background workers (async_mode=True) currently write output to filesystem without emitting stream events. The RFC recommends:
- Emit SpawnSessionStart before spawning (for discovery)
- Document that real-time progress won't be available for background workers
- Consider adding polling-based completion events in future
Phase 3: Event Processor Update (2 days)¶
File: src/agentpool_server/opencode_server/event_processor.py
Add handler with duplicate session protection:
from collections.abc import Iterator
async def process(self, event, ctx):
match event:
# ... existing cases ...
case SpawnSessionStart():
async for e in self._process_spawn_start(event, ctx):
yield e
async def _process_spawn_start(
self,
event: SpawnSessionStart,
ctx: EventProcessorContext,
) -> AsyncIterator[Event]:
"""Handle SpawnSessionStart with duplicate guard."""
# CHECK: Prevent duplicate session creation
if event.child_session_id in self._child_contexts:
logger.debug(f"Session {event.child_session_id} already exists, ignoring duplicate")
return
# Create child session
await ctx.state.ensure_session(
event.child_session_id,
parent_id=event.parent_session_id,
)
# ... rest of creation logic ...
# KEEP fallback in _process_subagent_event
async def _process_subagent_event(self, subagent_event, ctx, depth=0):
"""Keep reactive fallback for backward compatibility."""
child_session_id = subagent_event.child_session_id
child_ctx = self._child_contexts.get(child_session_id)
if child_ctx is None and child_session_id:
# FALLBACK: Session not created via SpawnSessionStart
logger.warning(f"Reactive fallback for {child_session_id}")
await ctx.state.ensure_session(child_session_id, parent_id=ctx.session_id)
# ... fallback creation ...
Phase 4: ACP Converter Update (1 day)¶
OPTION A: Simple text output (backward compatible):
async def convert(self, event):
match event:
case SpawnSessionStart(source_name=name, description=desc, spawn_mechanism=mech):
icon = "🚀" if mech == "sync" else "⚡"
yield AgentMessageChunk.text(f"\n{icon} **`{name}`**: {desc}\n")
OPTION B: Tool call representation (richer UI):
async def convert(self, event):
match event:
case SpawnSessionStart():
yield ToolCallStart(
tool_call_id=f"spawn:{event.child_session_id}",
title=f"Spawned: {event.source_name}",
kind="other",
status="in_progress",
metadata={
"type": "spawn_start",
"child_session_id": event.child_session_id,
"spawn_mechanism": event.spawn_mechanism,
"depth": event.depth,
}
)
Phase 5: Testing (1-2 days)¶
async def test_spawn_session_start_before_content():
"""Verify SpawnSessionStart emits before SubAgentEvent."""
async with AgentPool() as pool:
agent = pool.get_agent("test_agent")
events = [e async for e in agent.run_stream("Use task tool")]
spawn_idx = events.index(next(e for e in events if isinstance(e, SpawnSessionStart)))
subagent_idx = events.index(next(e for e in events if isinstance(e, SubAgentEvent)))
assert spawn_idx < subagent_idx
async def test_duplicate_session_guard():
"""Verify duplicate SpawnSessionStart rejected."""
...
Phase 6: Storage Integration (0.5 day)¶
Update storage layer to persist SpawnSessionStart for analytics:
if isinstance(event, SpawnSessionStart):
await self.store_event(
session_id=event.parent_session_id,
event_type="spawn_start",
event_data={"child_session_id": event.child_session_id, ...}
)
Comparison: With vs Without SpawnSessionStart¶
Without (Current - Reactive)¶
- Session created on first SubAgentEvent
- No metadata available upfront
- May receive content before session ready
With (Proposed - Proactive)¶
- Session created before any content
- Rich metadata available for UI
- Self-documenting code
Backward Compatibility¶
- Consumers: 100% backward compatible - ignored if not handled
- Producers: Emitting SpawnSessionStart doesn't break existing adapters
- Migration: Gradual adoption via fallback in EventProcessor
Decision Record¶
| Date | Decision | Rationale |
|---|---|---|
| 2026-02-14 | Single event (start only) | StreamCompleteEvent handles close |
| 2026-02-14 | spawn_mechanism values: sync, async_worker, manual |
Clearer than task/worker/background |
| 2026-02-14 | depth as first-class field |
Needed for capping, UI, analytics |
| 2026-02-14 | Duplicate session guard | Prevents race condition issues |
| 2026-02-14 | Timeline: 5-6 days | Revised from 3 days based on complexity |