swink-agent 0.8.0

Core scaffolding for running LLM-powered agentic loops
Documentation
# Agent Loop

**Source files:** `src/loop_/` module (`mod.rs`, `stream.rs`, `tool_dispatch.rs`, `turn.rs`)
**Related:** [PRD ยง12](../../planning/PRD.md#12-agent-loop), [PRD ยง8](../../planning/PRD.md#8-event-system)

The agent loop is the core execution engine of the harness. It drives turns, dispatches tool calls, manages steering and follow-up message injection, emits all lifecycle events, and handles error and abort conditions. The `Agent` struct is a stateful wrapper over it; the loop itself is stateless and pure.

---

## L2 โ€” Loop Structure

```mermaid
flowchart TB
    subgraph EntryPoints["๐Ÿ“ฅ Entry Points"]
        AgentLoop["agent_loop()<br/>new prompt messages โ†’ context"]
        AgentLoopContinue["agent_loop_continue()<br/>resume from existing context"]
    end

    subgraph Config["โš™๏ธ AgentLoopConfig"]
        Model["model: ModelSpec"]
        StreamOpts["stream_options: StreamOptions"]
        Retry["retry_strategy: Box&lt;dyn RetryStrategy&gt;"]
        StreamFnField["stream_fn: Arc&lt;dyn StreamFn&gt;"]
        ConvertFn["convert_to_llm: Box&lt;ConvertToLlmFn&gt;"]
        AsyncTransformFn["async_transform_context: Option&lt;Arc&lt;dyn AsyncContextTransformer&gt;&gt;<br/>(runs before sync transform โ€” async operations<br/>like RAG retrieval or summary fetching)"]
        TransformFn["transform_context: Option&lt;Arc&lt;dyn ContextTransformer&gt;&gt;<br/>(synchronous โ€” not async)"]
        ApiKey["get_api_key: Option&lt;Box&lt;GetApiKeyFn&gt;&gt;"]
        MsgProvider["message_provider: Option&lt;Arc&lt;dyn MessageProvider&gt;&gt;<br/>poll_steering() โ†’ Vec&lt;AgentMessage&gt;<br/>poll_follow_up() โ†’ Vec&lt;AgentMessage&gt;"]
        PreTurnPolicies["pre_turn_policies: Vec&lt;Arc&lt;dyn PreTurnPolicy&gt;&gt;"]
        PreDispatchPolicies["pre_dispatch_policies: Vec&lt;Arc&lt;dyn PreDispatchPolicy&gt;&gt;"]
        PostTurnPolicies["post_turn_policies: Vec&lt;Arc&lt;dyn PostTurnPolicy&gt;&gt;"]
        PostLoopPolicies["post_loop_policies: Vec&lt;Arc&lt;dyn PostLoopPolicy&gt;&gt;"]
    end

    subgraph Core["๐Ÿ”„ run_loop"]
        OuterLoop["Outer Loop<br/>(follow-up phase)"]
        InnerLoop["Inner Loop<br/>(turn + tool phase)"]
        ResolveApiKey["ApiKey Resolution<br/>(get_api_key before stream)"]
        TurnExec["Turn Execution<br/>(stream assistant response)"]
        ToolExec["Tool Execution<br/>(concurrent dispatch)"]
    end

    subgraph Events["๐Ÿ“ก AgentEvent Output"]
        AgentEvents["Stream&lt;AgentEvent&gt;"]
    end

    AgentLoop --> Core
    AgentLoopContinue --> Core
    Config --> Core
    Core --> AgentEvents

    classDef entryStyle fill:#e3f2fd,stroke:#1976d2,stroke-width:2px,color:#000
    classDef configStyle fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#000
    classDef coreStyle fill:#1976d2,stroke:#0d47a1,stroke-width:2px,color:#fff
    classDef eventStyle fill:#f5f5f5,stroke:#616161,stroke-width:2px,color:#000

    class AgentLoop,AgentLoopContinue entryStyle
    class Model,StreamOpts,Retry,StreamFnField,ConvertFn,AsyncTransformFn,TransformFn,ApiKey,MsgProvider,PreTurnPolicies,PreDispatchPolicies,PostTurnPolicies,PostLoopPolicies configStyle
    class OuterLoop,InnerLoop,TurnExec,ToolExec coreStyle
    class AgentEvents eventStyle
```

---

## L3 โ€” Nested Loop Phases

The loop is structured as two nested phases. The inner loop handles turns and tool execution. The outer loop handles follow-up messages that arrive after the agent would otherwise stop.

```mermaid
flowchart TB
    AgentStart(["AgentStart"])

    subgraph OuterLoop["๐Ÿ” Outer Loop โ€” follow-up phase"]
        OStart(["enter"])
        OPoll["poll message_provider.poll_follow_up()"]
        OHasMsg{"messages?"}

        subgraph InnerLoop["๐Ÿ” Inner Loop โ€” turn + tool phase"]
            IStart(["enter turn"])
            InjectPending["inject pending messages<br/>into context"]
            AsyncTransformCtx["async_transform_context()<br/>(if configured)"]
            TransformCtx["transform_context()"]
            ConvertLlm["convert_to_llm()"]
            PreTurnPolicy{"pre_turn_policies?"}
            ResolveKey["get_api_key()"]
            StreamTurn["call StreamFn<br/>(with retry)"]
            EmitMsgEvents["emit MessageStart<br/>MessageUpdate ร—N<br/>MessageEnd"]
            CheckStop{"stop_reason?"}
            ExtractTools["extract tool calls"]
            HasTools{"has tool calls?"}
            CheckLength{"stop_reason: length?"}
            MTRecovery["max tokens recovery<br/>(replace incomplete tool calls)"]
            ExecTools["execute tools concurrently<br/>(emit ToolExecution* events)"]
            PollSteer["poll message_provider.poll_steering()"]
            HasSteer{"steering?"}
            EmitTurnEnd["emit TurnEnd"]
            EmitTurnEndErr["emit TurnEnd"]
            IPoll["poll message_provider.poll_steering()"]
            IHasSteer{"steering?"}
        end

        AgentStart --> OStart
        OStart --> IStart
        IStart --> InjectPending
        InjectPending --> AsyncTransformCtx
        AsyncTransformCtx --> TransformCtx
        TransformCtx --> ConvertLlm
        ConvertLlm --> PreTurnPolicy
        PreTurnPolicy -->|"Continue"| ResolveKey
        PreTurnPolicy -->|"Stop / Inject"| EmitTurnEndErr
        ResolveKey --> StreamTurn
        StreamTurn --> EmitMsgEvents
        EmitMsgEvents --> CheckStop
        CheckStop -->|"error / aborted"| EmitTurnEndErr
        EmitTurnEndErr -->|"emit AgentEnd โ€” exit"| AgentEnd
        CheckStop -->|"stop / tool_use / length"| ExtractTools
        ExtractTools --> HasTools
        HasTools -->|"no"| EmitTurnEnd
        HasTools -->|"yes"| CheckLength
        CheckLength -->|"yes"| MTRecovery
        MTRecovery --> ExecTools
        CheckLength -->|"no"| ExecTools
        ExecTools --> PollSteer
        PollSteer --> HasSteer
        HasSteer -->|"yes โ€” skip remaining tools"| EmitTurnEnd
        HasSteer -->|"no"| EmitTurnEnd
        EmitTurnEnd --> IPoll
        IPoll --> IHasSteer
        IHasSteer -->|"yes โ€” new turn"| IStart
        IHasSteer -->|"no โ€” exit inner"| OPoll

        OPoll --> OHasMsg
        OHasMsg -->|"yes โ€” new turn"| IStart
        OHasMsg -->|"no"| AgentEnd
    end

    AgentEnd(["AgentEnd"])

    classDef phaseStyle fill:#1976d2,stroke:#0d47a1,stroke-width:2px,color:#fff
    classDef decisionStyle fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#000
    classDef termStyle fill:#e0e0e0,stroke:#424242,stroke-width:2px,color:#000
    classDef stepStyle fill:#f5f5f5,stroke:#616161,stroke-width:2px,color:#000

    class IStart,OStart,AgentStart phaseStyle
    class CheckStop,CheckLength,HasTools,HasSteer,IHasSteer,OHasMsg,PreTurnPolicy decisionStyle
    class AgentEnd termStyle
    class InjectPending,AsyncTransformCtx,TransformCtx,ConvertLlm,ResolveKey,StreamTurn,EmitMsgEvents,ExtractTools,MTRecovery,ExecTools,PollSteer,EmitTurnEnd,EmitTurnEndErr,IPoll,OPoll stepStyle
```

---

## L3 โ€” Event Emission Sequence

Every event emitted during a normal two-turn run with one tool call per turn.

```mermaid
sequenceDiagram
    participant RunLoop as run_loop
    participant Sub as Subscriber

    RunLoop->>Sub: AgentStart

    Note over RunLoop: โ€” Turn 1 โ€”
    RunLoop->>Sub: TurnStart
    RunLoop->>Sub: MessageStart (assistant)
    loop streaming
        RunLoop->>Sub: MessageUpdate (delta)
    end
    RunLoop->>Sub: MessageEnd (assistant)
    RunLoop->>Sub: ToolExecutionStart (tool_call_id, name, args)
    Note right of Sub: ToolExecutionUpdate is reserved<br/>for future use (never emitted today)
    RunLoop->>Sub: ToolExecutionEnd (result, is_error)
    RunLoop->>Sub: TurnEnd (assistant message + tool results)

    Note over RunLoop: โ€” Turn 2 โ€”
    RunLoop->>Sub: TurnStart
    RunLoop->>Sub: MessageStart (assistant)
    loop streaming
        RunLoop->>Sub: MessageUpdate (delta)
    end
    RunLoop->>Sub: MessageEnd (assistant)
    RunLoop->>Sub: TurnEnd (assistant message, no tool results)

    RunLoop->>Sub: AgentEnd (all new messages)
```

---

## L4 โ€” Steering Interrupt Flow

Steering messages cause the loop to abandon remaining tools in the current batch and inject the steering message before the next assistant turn.

```mermaid
sequenceDiagram
    participant App as Application
    participant Agent as Agent Struct
    participant RunLoop as run_loop
    participant Tools as Tool Executor

    Note over RunLoop: executing tool batch [A, B, C]...
    RunLoop->>Tools: execute tool A
    Tools-->>RunLoop: result A
    RunLoop->>Agent: poll message_provider.poll_steering()
    Note over App: App calls agent.steer(msg)
    Agent-->>RunLoop: [steering message]

    Note over RunLoop: cancel tools B and C via CancellationToken
    RunLoop->>RunLoop: emit ToolExecutionEnd(error: "tool call cancelled: user requested steering interrupt") for B, C
    RunLoop->>RunLoop: emit TurnEnd
    RunLoop->>RunLoop: push steering message to pending
    RunLoop->>RunLoop: new TurnStart
    RunLoop->>RunLoop: inject steering message into context
    Note over RunLoop: continues with next assistant turn
```

---

## L3 โ€” Event Dispatch System

The agent loop uses a synchronous fan-out dispatch system to deliver `AgentEvent` instances to all registered subscribers.

### Subscriber Registration

- **Subscribe:** `subscribe(callback) โ†’ SubscriptionId` โ€” registers a callback that receives events.
- **Unsubscribe:** `unsubscribe(id)` โ€” removes a previously registered subscriber.

### Internal Storage

```text
HashMap<SubscriptionId, Box<dyn Fn(&AgentEvent) + Send + Sync>>
```

### Dispatch Semantics

- **Synchronous fan-out:** each event is delivered to every registered subscriber before the loop proceeds.
- **Thread safety:** all callbacks must be `Send + Sync`.
- **Panic isolation:** if a subscriber panics, the panic is caught and does not crash the loop. The panicking subscriber is automatically unsubscribed.

```mermaid
flowchart LR
    Event["AgentEvent"] --> Dispatch["dispatch()"]
    Dispatch --> S1["Subscriber 1"]
    Dispatch --> S2["Subscriber 2"]
    Dispatch --> SN["Subscriber N"]
    S1 -->|"ok"| Next["continue loop"]
    S2 -->|"panic"| Catch["catch_unwind โ†’ unsubscribe"]
    SN -->|"ok"| Next
    Catch --> Next
```

---

## L4 โ€” Subscriber Lifecycle

Subscribers can be registered and unregistered at any point relative to the agent loop's execution.

- **Registration timing:** subscribers may be added before a run starts or while a run is in progress.
- **Unsubscription timing:** subscribers may be removed at any time, including from within a callback (takes effect after the current dispatch completes).
- **Mid-run registration:** a subscriber added during a run receives events only from the point of registration onward; it does not receive retroactive events.
- **Panic auto-unsubscription:** a subscriber whose callback panics is automatically unsubscribed. The panic is caught, the subscriber is removed, and dispatch continues to remaining subscribers.