FORM NOT VOID, MIND NO CORE

Chapter 3: Event-Driven Architecture and Message Model

2026.08.10

The agent kernel is not as simple as "the user asks a question, the agent answers" -- it must simultaneously handle multiple message sources, respond to cancellation instructions, and manage sub-agent lifecycles.


Pattern Layer

3.1 Two Paradigms of the Agent Kernel

The architectural choice of the agent kernel determines the behavioral characteristics of the entire system. As described in Chapter 1, the agent kernel has evolved through three paradigms: the REPL loop, event-driven architecture, and the micro-operating system. Chapter 1 presented these three paradigms from a panoramic perspective; this chapter expands on the event-driven model from an implementation detail standpoint.

Event-driven -- flexible but complex.

The event-driven kernel replaces the REPL "four-step loop" with an "event loop." The kernel no longer blocks waiting for a single input source -- it listens to multiple sources (user messages, tool results, system signals) and responds based on event type.

Event-driven architecture is not a new feature -- it changes the interaction model between the agent and the external world. Under REPL, the agent is a "passive responder" -- it does whatever the user says. Under event-driven architecture, the agent can "initiate proactively" -- it can check for cancellation signals during tool execution, and it can continue processing other events before a sub-agent returns.

The cost is debugging complexity. Debugging under REPL only requires examining input and output; event-driven architecture requires tracing the timing and source of messages. "Why did this event arrive before that one?" -- under REPL this is not a problem because they execute sequentially.

Request-response -- intermediate state.

There is a common intermediate form between REPL and event-driven: request-response. The user sends a request, the agent processes it, and returns a response. The difference from REPL is that request-response typically supports streaming output (SSE, Server-Sent Events / WebSocket), whereas REPL output is delivered as a complete block. However, request-response, like REPL, does not support cancellation or nesting.

Each of the three paradigms has its applicable scenarios:

ParadigmSuitable ForNot Suitable For
REPL LoopSingle-turn Q&A, short tasksMulti-step tasks, cancellation required
Event-DrivenMulti-step tasks, interactive workSingle-turn Q&A (over-engineering)
Request-ResponseParallel processing, streaming outputPersistent state required

3.2 OS Threads + Channels Instead of Async Runtime

Having settled on the event-driven direction, the next question is: use an async runtime (tokio / async-std) or OS threads + channels?

The case for tokio is strong: it is the most mature async runtime in the Rust ecosystem, enjoys wide community adoption, and has extensive third-party library support. Most Rust agent projects choose tokio.

But CodeCoder ultimately chose OS threads + channels. Three reasons:

First, tool calls are blocking.

Agent tool calls -- read_file, run_command, glob, web_fetch -- are mostly synchronous blocking operations. In an async runtime, a blocking operation blocks the entire event loop; all other tasks must wait for it. To make tool calls non-blocking, you need async APIs for every tool -- which is extremely difficult in practice. Not every third-party library provides async interfaces. std::process::Command is synchronous, std::fs::read is synchronous, and std::net::TcpStream is also synchronous.

OS threads do not have this problem. One thread can block on read_file while other threads continue running.

Second, sub-agents need independent blocking.

When a parent agent creates a sub-agent via the agent tool, the parent must wait for the sub-agent to return its result. In an async runtime, this waiting should be done via await -- but the parent agent's turn loop itself may not be in an async context. Mixing synchronous and asynchronous code is notoriously difficult in Rust.

The OS threads solution: the parent agent runs on thread A, the sub-agent runs on thread B. When the parent calls the agent tool, it blocks on channel receive on thread A, waiting for thread B's return. This blocking does not affect other threads.

Third, the cancellation path is clearer.

Cancellation in async runtimes is typically done via abort() or drop(). But abort() does not guarantee proper cleanup of child processes -- if the sub-agent is running a run_command, abort() simply drops the future, and the child process may become an orphan.

The cancellation path with OS threads is: flip the shared CancelToken, and the tool execution loop checks this token at safe points. If flipped, first gracefully terminate the child process, then return a "cancelled" status. Every step in the cancellation path is explicit and traceable.

The cost is context switching overhead. OS thread context switching is an order of magnitude more expensive than async task yielding. But for CodeCoder's workload -- typically tens to hundreds of agent tool calls per day, not millions per second -- this cost is negligible.

3.3 Dual-Channel Topology and Provider Neutrality

An event-driven kernel needs a message model to pass information between components. CodeCoder's design uses a dual-channel topology:

graph LR
    subgraph "Command Channel cmd_tx"
        PM[ProcessMessage<br/>new user message]
        SD[Shutdown<br/>shut down]
        CN[Cancel<br/>cancel]
    end

    subgraph "Event Channel event_rx"
        NT[NewToken<br/>streaming token]
        TS[ToolStarted<br/>tool execution started]
        TF[ToolFinished<br/>tool execution completed]
        SU[StatusUpdate<br/>status change]
        DN[Done<br/>turn completed]
    end

    subgraph "Special Channel reply_tx"
        AK[AskUser<br/>ask user]
        CF[ConfirmRequest<br/>confirmation request]
        RP[user reply<br/> oneshot]
    end

    PM -->|low traffic| AGENT
    SD -->|low traffic| AGENT
    CN -->|low traffic| AGENT

    AGENT -->|high traffic| NT
    AGENT -->|medium traffic| TS
    AGENT -->|medium traffic| TF
    AGENT -->|low traffic| SU
    AGENT -->|low traffic| DN

    AGENT -->|confirmation needed| AK
    AGENT -->|confirmation needed| CF
    AK --> RP
    CF --> RP
    RP -->|directly into tool executor| AGENT

    style PM fill:#e1f5fe
    style SD fill:#e1f5fe
    style CN fill:#e1f5fe
    style NT fill:#f3e5f5
    style TS fill:#f3e5f5
    style TF fill:#f3e5f5
    style AK fill:#fff3e0
    style CF fill:#fff3e0
    style RP fill:#fff3e0

Command channel (cmd_tx): carries only the user's active intent. Three variants:

  • ProcessMessage: the user sent a new message
  • Shutdown: the user requested shutdown
  • Cancel: the user requested cancellation of the current operation

Event channel (event_rx): carries streaming deltas and structured state. Five main variants:

  • NewToken: new tokens generated by the LLM (streaming output)
  • ToolStarted: a tool began execution
  • ToolFinished: a tool completed execution (with result or error)
  • StatusUpdate: agent status changes (e.g., "thinking", "executing tool")
  • Done: the current turn completed

The separation of the two channels is a key design decision. The command channel has very low traffic (only user-triggered instructions), while the event channel can have high traffic (every token is an event). If they shared a single channel, a cancellation instruction could be queued behind a stream of token events -- the user presses Ctrl+C, but the agent must output hundreds more tokens before receiving the cancellation instruction.

Beyond the channel topology, the complete turn lifecycle also follows the event-driven pattern. From the user sending a message to the agent autonomously deciding output completion, the full flow is:

sequenceDiagram
    participant User as User
    participant Cmd as cmd_tx
    participant Loop as AgentLoop
    participant LLM as LLM Provider
    participant Tool as Tool Executor
    participant Evt as event_rx
    participant Client as Client

    User->>Cmd: send message
    Cmd->>Loop: ProcessMessage
    Loop->>Loop: build_request<br/>(assemble system prompt + history + current message)
    Loop->>LLM: provider.send()
    LLM-->>Loop: streaming response

    loop Process LLM Response Stream
        LLM-->>Loop: NewToken
        Loop->>Evt: NewToken
        Evt->>Client: display token

        LLM-->>Loop: ToolCall
        Loop->>Evt: ToolStarted
        Evt->>Client: show tool executing

        Loop->>Loop: check CancelToken
        alt cancelled
            Loop->>Evt: ToolFinished { cancelled }
            Evt->>Client: show cancelled
            Loop->>Evt: Done { cancelled }
        else not cancelled
            Loop->>Tool: execute()
            Tool-->>Loop: ToolResult
            Loop->>Evt: ToolFinished { result }
            Evt->>Client: show tool result
        end
    end

    LLM-->>Loop: Done
    Loop->>Evt: Done { cancelled: false }
    Evt->>Client: show completed

    Note over Loop: If the LLM calls a new tool<br/>in its last response,<br/>return to the "Process LLM Response Stream" phase

Provider neutrality means the kernel's message model is not tied to any specific LLM provider. In CodeCoder, the kernel defines its own message types:

  • Message: a complete conversation message (role + items)
  • MessageItem: components of a message -- Text, Reasoning, ToolCall, ToolResult
  • ToolCall: a tool invocation request (name + args + id)
  • ToolResult: a tool invocation result (content + status + tool_call_id)

The kernel and provider are isolated through a ProviderClient trait. Switching providers does not require modifying kernel code -- only a new ProviderClient implementation. This design is codified in ADR 0017.


Case Layer

3.4 AgentLoop::process_turn Core Flow

The following pseudocode illustrates the core flow of AgentLoop::process_turn:

fn process_turn(&mut self) -> Result<AgentEvent> {
    // 1. Receive user message from cmd_tx
    let msg = self.cmd_rx.recv()?;

    // 2. Build LLM request (system prompt + history + current message)
    let request = self.build_request(msg);

    // 3. Send request to LLM provider, stream response
    let response = self.provider.send(request)?;

    // 4. Process LLM response stream
    for event in response.stream() {
        match event {
            StreamEvent::Token(t) => self.event_tx.send(AgentEvent::NewToken(t)),
            StreamEvent::ToolCall(tc) => {
                // 5. Check cancellation token
                if self.cancel_token.is_cancelled() {
                    self.event_tx.send(AgentEvent::ToolFinished {
                        id: tc.id,
                        result: Err("cancelled".into()),
                    });
                    return Ok(AgentEvent::Done { cancelled: true });
                }

                // 6. Execute tool
                self.event_tx.send(AgentEvent::ToolStarted { id: tc.id });
                let result = self.toolbox.execute(&tc);
                self.event_tx.send(AgentEvent::ToolFinished {
                    id: tc.id,
                    result,
                });
            }
            StreamEvent::Done => break,
        }
    }

    // 7. Notify completion
    self.event_tx.send(AgentEvent::Done { cancelled: false });
    Ok(AgentEvent::Done { cancelled: false })
}

Key points in this flow:

  • Step 5's cancellation token check: the tool execution loop checks the token at each iteration, not after the entire response stream is processed. The complete design of CancelToken and the cancellation path are covered in Chapter 5.
  • Step 6's tool execution is serial: one ToolCall completes before processing the next, no parallelism.
  • Step 2's build_request handles the assembly of the system prompt -- including AGENTS.md, CONTEXT.md, and registry content from skills/.

3.5 Message / MessageItem Type Design

Message and MessageItem are the core types of the kernel message model:

struct Message {
    role: Role,           // User | Assistant | Tool | System
    items: Vec<MessageItem>,
    id: MessageId,        // u64, unique within the session
}

enum MessageItem {
    Text(String),
    Reasoning(String),
    ToolCall(ToolCall),
    ToolResult(ToolResult),
}

struct ToolCall {
    id: ToolCallId,       // provider-side tool_use id
    name: String,
    args: serde_json::Value,
}

struct ToolResult {
    tool_call_id: ToolCallId,
    content: Vec<ContentItem>,
    status: ToolResultStatus,  // Success | Error | Cancelled
}

The polymorphic design of MessageItem allows a single assistant message to contain multiple tool calls and multiple text segments -- this is important when the LLM outputs both text and tool calls simultaneously. The association between ToolCall.id and ToolResult.tool_call_id is ensured by the provider -- this is an invariant not handled within the kernel.

3.6 AgentCommand / AgentEvent Variants

The enum variants for the command and event channels are as follows:

enum AgentCommand {
    ProcessMessage(Message),
    Shutdown,
    Cancel,
}

enum AgentEvent {
    NewToken(String),
    ToolStarted { id: ToolCallId },
    ToolFinished {
        id: ToolCallId,
        result: Result<ToolResult, ToolError>,
    },
    StatusUpdate(AgentStatus),
    Done { cancelled: bool },
}

AgentCommand contains only user intent. AgentEvent contains all streaming deltas. Note that result in ToolFinished is of type Result<ToolResult, ToolError> -- when tool execution fails (e.g., permission denied), no ToolResult is generated; instead, a ToolError is returned directly. This design separates the handling path of tool execution failures from the path where tool execution succeeds but the result content is problematic.

3.7 Turn Lifecycle

A complete turn, from the user sending a message to the agent deciding not to invoke further tools, includes the following phases:

User sends a message
    |
cmd_tx -> AgentLoop receives it
    |
build_request -> assemble system prompt + history + current message
    |
provider.send() -> LLM streaming response
    |
[Loop] Process LLM response stream
    |-> NewToken -> event_tx -> client display
    |-> ToolCall -> execute tool -> event_tx -> client display
    |    |-> check cancel_token, return if cancelled
    |    \-> tool result -> add to tool call history
    \-> Done -> break out of loop
    |
[Condition] If the LLM invoked a new tool in its last reply
    |
    Return to "Process LLM response stream" phase
    |
[No new tool calls] -> complete current turn -> wait for next round

Multi-round tool calling (the LLM continuously calling multiple tools, feeding results back each time) is the normal working pattern of an agent -- it is not "calling multiple tools in one response," but rather a loop of "call one tool per response -> feed the result back -> call the next one." This loop is autonomously controlled by the LLM (the request to the LLM includes all historical tool call results).

3.8 reply_tx Oneshot

There is a special mechanism within the event channel: the reply_tx oneshot channel.

When the agent executes a tool that requires user confirmation (such as ask_user or confirm), the tool executor creates a oneshot channel, sending it to the client via AgentEvent::AskUser or AgentEvent::ConfirmRequest. The client displays the prompt in the terminal, collects user input, and sends the reply through reply_tx.

The key point of this design: user confirmation replies do not go through cmd_tx.

The cmd_tx is the channel for "user active intent" -- the user actively sending a message, actively requesting shutdown, actively cancelling. A user's reply to an agent's question is not "active intent" -- it is a "response to an agent's request." If it went through cmd_tx, the receiver of cmd_tx would need to distinguish between "a new message actively sent by the user" and "the user's reply to the agent's previous question" -- which adds complexity to state management.

The reply_tx oneshot design avoids this problem: after the tool executor issues a confirmation request, it blocks on reply_tx receive. The user's reply goes directly to the tool executor, bypassing cmd_tx. This keeps cmd_tx focused -- it only handles the user's active intent.


ADR Deep Dive

The History of tokio / lunatic Being Rejected

ADR 0016 documents CodeCoder's decision-making process on the question of "how to build the kernel."

The initial prototype used tokio as the async runtime. The reasons were straightforward: community standard, rich documentation, mature ecosystem. But after several months of development, three problems emerged:

  1. Tool stack async pollution. Every tool needed to implement async fn execute(). If a tool internally called a synchronous third-party library, you had to wrap it with tokio::task::spawn_blocking -- adding boilerplate to every tool. Among CodeCoder's 26 tools, the vast majority were synchronous.

  2. Sub-agent cancellation issues. When a sub-agent was running as a tokio task, cancelling it required tokio::task::abort(). But abort() only dropped the future -- if the sub-agent was executing run_command, the child process was not terminated along with the future drop. This led to orphan processes.

  3. Debugging difficulties. Tokio's task stack traces were not clear enough when problems arose -- "which task originated from which request" was hard to trace in deeply nested tasks.

Another option considered was lunatic -- a Rust runtime based on the Erlang actor model. Lunatic's actor model provided process-level isolation, where each actor had its own memory space and crashes would not affect other actors. However, lunatic's ecosystem was far less mature than tokio's, and its actor model had friction with Rust's ownership system -- data passed between actors required serialization/deserialization.

The final decision was to abandon the async runtime and return to OS threads + channels. This is not a statement that "tokio is bad" -- it is an engineering decision that "for our tool load characteristics, OS threads are a better match."

The History of PermissionResponse

Whether PermissionResponse should be a variant of AgentCommand was an early design dispute in CodeCoder.

In the initial implementation, PermissionResponse was a variant of AgentCommand -- the user's reply to a permission confirmation was sent to the agent thread via cmd_tx. The rationale was: keep all user-to-agent communication on the same channel.

The problem was that the receiver of cmd_tx (AgentLoop), when processing AgentCommand, needed to distinguish between "a new message" and "a reply to a permission request." If PermissionResponse and ProcessMessage traveled on the same channel, the receiver needed to maintain a state machine tracking "whether there is currently a pending permission request."

Moving this state machine out of AgentLoop and into the reply_tx oneshot simplified the cmd_tx processing logic in AgentLoop -- the cmd_tx receiver only does three things: process new messages, shutdown, and cancel. It does not need to know "whether there is currently a pending permission request."


Next chapter: the tool system and permission model -- the granularity of 26 tools and the lookup chain of four permission levels.