docs/architecture/subturn.md
Back to README
The SubTurn mechanism is a core feature in PicoClaw that allows tools to spawn isolated, nested agent loops to handle complex sub-tasks.
By using a SubTurn, an agent can break down a problem and run a separate LLM invocation in an independent, ephemeral session. This ensures that intermediate reasoning, background tasks, or sub-agent outputs do not pollute the main conversation history.
ephemeralSessionStore. Its message history does not leak into the parent task and is destroyed upon completion. The ephemeral session holds at most 50 messages; older messages are automatically truncated when this limit is reached.MaxContextRunes). It proactively truncates old messages (while preserving system prompts and recent context) before hitting the provider's hard context window limit.SubTurnConfig)When spawning a SubTurn, you must provide a SubTurnConfig:
| Field | Type | Description |
|---|---|---|
Model | string | The LLM model to use for the sub-turn (e.g., gpt-4o-mini). Required. |
Tools | []tools.Tool | Tools granted to the sub-turn. If empty, it inherits the parent's tools. |
SystemPrompt | string | The task description for the sub-turn. Sent as the first user message to the LLM (not as a system prompt override). |
ActualSystemPrompt | string | Optional explicit system prompt to replace the agent's default. Leave empty to inherit the parent agent's system prompt. |
MaxTokens | int | Maximum tokens for the generated response. |
Async | bool | Controls the result delivery mode (Synchronous vs. Asynchronous). |
Critical | bool | If true, the sub-turn continues running even if the parent finishes gracefully. |
Timeout | time.Duration | Maximum execution time (default: 5 minutes). |
MaxContextRunes | int | Soft context limit. 0 = auto-calculate (75% of model's context window, recommended), -1 = no limit (disable soft truncation, rely only on hard context error recovery), >0 = use specified rune limit. |
Note: The
Asyncflag does not make the call non-blocking. It only controls whether the result is also delivered to the parent'spendingResultschannel. Both modes block the caller until the sub-turn completes. For true non-blocking execution, the caller must spawn the sub-turn in a separate goroutine.
Async: false)This is the standard mode where the caller needs the result immediately to proceed.
Example:
cfg := agent.SubTurnConfig{
Model: "gpt-4o-mini",
SystemPrompt: "Analyze the provided codebase...",
Async: false,
}
result, err := agent.SpawnSubTurn(ctx, cfg)
// Process result immediately
Async: true)Used for "fire-and-forget" operations or parallel processing where the parent turn collects results later.
pendingResults channel.[SubTurn Result].Example:
cfg := agent.SubTurnConfig{
Model: "gpt-4o-mini",
SystemPrompt: "Run a background security scan...",
Async: true,
}
result, err := agent.SpawnSubTurn(ctx, cfg)
// The result will also be injected into the parent loop later via channel
SubTurns implement automatic retry mechanisms for transient errors:
| Error Type | Max Retries | Recovery Action |
|---|---|---|
| Context Length Exceeded | 2 | Force compress history and retry |
Response Truncated (finish_reason="truncated") | 2 | Inject recovery prompt and retry |
When the LLM response is truncated (finish_reason="truncated"), SubTurn automatically:
turnState.lastFinishReasonWhen the provider returns a context length error (e.g., context_length_exceeded):
SubTurns operate within an independent context but maintain a structural link to their parent turnState.
When the parent task finishes naturally (Finish(false)):
Critical: true) sub-turns continue running in the background. Once finished, their results are emitted as Orphan Results so the data is not lost.When the parent task is forcefully aborted (e.g., user interrupts with /stop):
initialHistoryLength), preventing dirty context. SubTurns are not affected by this rollback as they use ephemeral sessions that are discarded anyway.When a message enters the Run() loop, the agent determines whether to start a new worker or enqueue to steering:
processMessage → tool execution → steering drain → Continue for queued messages.This ensures that:
max_parallel_turns concurrent workers)The agent loop polls for async SubTurn results at two points per iteration:
[SubTurn Result] messages into the conversation context.All active turns are registered in AgentLoop.activeTurnStates (sync.Map, keyed by session key). A reservation sentinel is stored atomically via LoadOrStore before the worker starts, then replaced with the real *turnState when runTurn registers. This prevents a TOCTOU race where multiple messages for the same session could spawn concurrent workers. The sentinel is cleaned up by the worker's deferred cleanup. This allows HardAbort and /subagents observability commands to find and operate on active turns.
SubTurns emit specific events to the PicoClaw EventBus for observability and debugging:
| Event Kind | When Emitted | Payload |
|---|---|---|
subturn_spawn | Sub-turn successfully initialized | SubTurnSpawnPayload{AgentID, Label, ParentTurnID} |
subturn_end | Sub-turn finishes (success or error) | SubTurnEndPayload{AgentID, Status} |
subturn_result_delivered | Async result successfully delivered to parent | SubTurnResultDeliveredPayload{TargetChannel, TargetChatID, ContentLen} |
subturn_orphan | Result cannot be delivered (parent finished or channel full) | SubTurnOrphanPayload{ParentTurnID, ChildTurnID, Reason} |
func SpawnSubTurn(ctx context.Context, cfg SubTurnConfig) (*tools.ToolResult, error)
This is the exported package-level entry point for agent-internal code (e.g., tests, direct invocations). It retrieves AgentLoop and turnState from context and delegates to the internal spawnSubTurn.
Requirements:
AgentLoop must be injected into context via WithAgentLoop()turnState must exist in context (automatically set when called from tools)Returns:
*tools.ToolResult: Contains ForLLM field with the sub-turn's outputerror: One of the defined error types or context errorstype AgentLoopSpawner struct { al *AgentLoop }
func (s *AgentLoopSpawner) SpawnSubTurn(ctx context.Context, cfg tools.SubTurnConfig) (*tools.ToolResult, error)
This implements the tools.SubTurnSpawner interface for use by tools that need to spawn sub-turns without a direct import of the agent package (avoiding circular dependencies). It converts tools.SubTurnConfig → agent.SubTurnConfig before delegating to the internal spawnSubTurn.
func NewSubTurnSpawner(al *AgentLoop) *AgentLoopSpawner
Creates a new spawner instance for the given AgentLoop. Pass the returned value to SpawnTool.SetSpawner() or SubagentTool.SetSpawner() during tool registration.
func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID string) (string, error)
Resumes an idle agent turn by dequeuing steering messages for the given session and running them through the agent loop. Returns the response string if processing occurred, or empty string if no steering messages were pending. Uses session-aware active turn checking — it only blocks if a turn is active for the same session, not for unrelated sessions.
SubTurn relies on context values for proper operation:
| Context Key | Purpose |
|---|---|
agentLoopKey | Stores *AgentLoop for tool access and SubTurn spawning |
turnStateKey | Stores *turnState for hierarchy tracking and result delivery |
// Before calling tools that may spawn SubTurns
ctx = WithAgentLoop(ctx, agentLoop)
ctx = withTurnState(ctx, turnState)
Important: The child SubTurn uses an independent context derived from context.Background(), not from the parent context. This design choice:
Timeout config or 5 minutes default)| Error | Condition |
|---|---|
ErrDepthLimitExceeded | SubTurn depth exceeds 3 levels |
ErrInvalidSubTurnConfig | Required field Model is empty |
ErrConcurrencyTimeout | All 5 concurrency slots occupied for 30+ seconds |
| Context errors | Parent context cancelled during semaphore acquisition |
SubTurns are designed for concurrent execution:
parentTS.mu.Lock())sync.Map for concurrent access to activeTurnStatesatomic.Int64 for unique SubTurn IDs (format: subturn-N, globally monotonic per AgentLoop instance)An orphan result occurs when:
pendingResults channel is full (buffer size: 16)When a result becomes orphan:
SubTurnOrphanResultEvent is emitted to EventBusCritical: true for important SubTurns that must completeSubTurnOrphanResultEvent for observabilitycfg.Tools is empty:ToolRegistry instancecfg.Tools is specified:Example - Restricted SubTurn:
cfg := agent.SubTurnConfig{
Model: "gpt-4o-mini",
Tools: []tools.Tool{readOnlyTool}, // Only read-only access
SystemPrompt: "Analyze the file structure...",
}
| Constant | Value |
|---|---|
maxSubTurnDepth | 3 |
maxConcurrentSubTurns | 5 |
concurrencyTimeout | 30s |
defaultSubTurnTimeout | 5m |
maxEphemeralHistorySize | 50 messages |
pendingResults buffer | 16 |
MaxContextRunes default | 75% of model context window |