picoclaw

🔄 SubTurn Mechanism

Back to README

Overview

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.

Core Capabilities

Configuration (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 Async flag does not make the call non-blocking. It only controls whether the result is also delivered to the parent’s pendingResults channel. 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.

Execution Modes

Synchronous (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

Asynchronous (Async: true)

Used for “fire-and-forget” operations or parallel processing where the parent turn collects results later.

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

Error Recovery and Retries

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

Truncation Recovery

When the LLM response is truncated (finish_reason="truncated"), SubTurn automatically:

  1. Detects the truncation from turnState.lastFinishReason
  2. Injects a recovery prompt: “Your previous response was truncated due to length. Please provide a shorter, complete response…”
  3. Retries up to 2 times

Context Error Recovery

When the provider returns a context length error (e.g., context_length_exceeded):

  1. Force compresses the message history (drops oldest 50% of conversation)
  2. Retries with the compressed context
  3. Up to 2 retries before failing

Lifecycle and Cancellation

SubTurns operate within an independent context but maintain a structural link to their parent turnState.

Graceful Parent Finish

When the parent task finishes naturally, the turn coordinator calls Finish(false) during turn teardown:

Hard Abort

When the parent task is forcefully aborted (e.g., user interrupts with /stop):

Agent Loop Integration

Message Routing and Steering

When a message enters the Run() loop, the agent determines whether to start a new worker or enqueue to steering:

This ensures that:

Pending Result Polling

The agent loop polls for async SubTurn results at two points per iteration:

  1. Before the LLM call: injects any arrived results as [SubTurn Result] messages into the conversation context.
  2. After all tool executions: polls again during the tool loop to catch results that arrived during tool execution.
  3. After the final iteration: one last poll before the turn ends to avoid losing late-arriving results.

Every turn owns its own 16-slot pendingResults channel and concurrency semaphore, including root turns created by the inbound message loop, so async delivery and the concurrency limit apply at every nesting level.

Turn State Tracking

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.

Runtime Event Integration

SubTurns emit runtime events through pkg/events for observability and debugging:

Event Kind When Emitted Payload
agent.subturn.spawn Sub-turn successfully initialized SubTurnSpawnPayload{AgentID, Label, ParentTurnID}
agent.subturn.end Sub-turn finishes (success or error) SubTurnEndPayload{AgentID, Status}
agent.subturn.result_delivered Async result successfully delivered to parent SubTurnResultDeliveredPayload{TargetChannel, TargetChatID, ContentLen}
agent.subturn.orphan Result cannot be delivered (parent finished or channel full) SubTurnOrphanPayload{ParentTurnID, ChildTurnID, Reason}

API Reference

SpawnSubTurn (Public Entry Point)

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:

Returns:

AgentLoopSpawner (Interface Implementation)

type 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.

NewSubTurnSpawner

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.

Continue

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.

Context Propagation

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

Injecting Dependencies

// Before calling tools that may spawn SubTurns
ctx = WithAgentLoop(ctx, agentLoop)
ctx = withTurnState(ctx, turnState)

Independent Child Context

Important: The child SubTurn uses an independent context derived from context.Background(), not from the parent context. This design choice:

Error Types

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

Thread Safety

SubTurns are designed for concurrent execution:

Orphan Results

An orphan result occurs when:

  1. The parent turn has finished before the SubTurn result could be delivered (parent_finished), including the case where the parent finishes while the sender is blocked waiting for room in a full channel (parent_finished_waiting).
  2. An unexpected channel state triggers a recovered panic during delivery (panic; safety net only).

A full pendingResults channel (buffer size: 16) does not orphan a result by itself: the sender blocks until space is available or the parent finishes. When a result becomes orphan:

Preventing Orphan Results

Tool Inheritance

When cfg.Tools is empty:

When cfg.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...",
}

Reference

Constant Value
maxSubTurnDepth 3
maxConcurrentSubTurns 5
concurrencyTimeout 30s
defaultSubTurnTimeout 5m
maxEphemeralHistorySize 50 messages
pendingResults buffer 16
MaxContextRunes default 75% of model context window