Messages & Events

Message Types

Message

The core LLM message type, tagged by role:

#![allow(unused)]
fn main() {
pub enum Message {
    User {
        content: Vec<Content>,
        timestamp: u64,
    },
    Assistant {
        content: Vec<Content>,
        stop_reason: StopReason,
        model: String,
        provider: String,
        usage: Usage,
        timestamp: u64,
        error_message: Option<String>,
    },
    ToolResult {
        tool_call_id: String,
        tool_name: String,
        content: Vec<Content>,
        is_error: bool,
        timestamp: u64,
    },
}
}

Create user messages easily:

#![allow(unused)]
fn main() {
let msg = Message::user("Hello, world!");
}

AgentMessage

Wraps Message with support for custom messages (UI-only, notifications, etc.) that stay in the history but never reach the model:

#![allow(unused)]
fn main() {
pub enum AgentMessage {
    Llm(Message),
    Custom(CustomMessage),
}

pub struct CustomMessage {
    pub role: String,
    pub kind: String,
    pub data: serde_json::Value,
}
}

Create custom messages with the convenience constructor:

#![allow(unused)]
fn main() {
let custom = CustomMessage::new("status_update", serde_json::json!({"status": "running"}));
let msg = AgentMessage::Custom(custom);
}

The kind field categorizes the message (e.g., "status_update", "ui_event", "notification"). Use as_llm() to extract the Message if it's an LLM message. The default convert_to_llm function filters out Custom messages before sending to the provider. Their role is "extension".

Before 0.25 these were AgentMessage::Extension(ExtensionMessage), renamed so they aren't confused with extensions, the plug-in contract. ExtensionMessage remains as a deprecated alias; saved sessions load unchanged, because the variant name is never serialized.

All core message types implement Serialize, Deserialize, Clone, and PartialEq, enabling state persistence and test assertions.

Content

Each message contains Vec<Content>:

#![allow(unused)]
fn main() {
pub enum Content {
    Text { text: String },
    Image { data: String, mime_type: String },
    Thinking {
        thinking: String,
        signature: Option<String>,
        redacted: Option<String>, // provider-encrypted reasoning (Anthropic `redacted_thinking`, Bedrock `redactedContent`, an OpenAI Responses / Azure reasoning item when encrypted reasoning is on)
        redacted_protocol: Option<ApiProtocol>, // the API that produced `redacted`
    },
    ToolCall {
        id: String,
        name: String,
        arguments: serde_json::Value,
        provider_metadata: Option<serde_json::Value>, // e.g. Gemini thought signatures
    },
}
}

An assistant message can contain multiple content blocks — e.g., thinking + text + tool calls.

Redacted (encrypted) reasoning is opaque. It is sent back unmodified, and only to the API protocol recorded in redacted_protocol; every other provider skips it. A block without a recorded protocol (a session saved before the field existed) is sent nowhere. Both fields are omitted from JSON when None.

Content is #[non_exhaustive] (match with a wildcard arm), and the ToolCall and Thinking variants are separately #[non_exhaustive] — construct them via Content::tool_call() / tool_call_with_metadata() / thinking() / thinking_signed() / thinking_redacted(). Message::Assistant is likewise #[non_exhaustive]; custom providers construct it via Message::assistant().

StopReason

#![allow(unused)]
fn main() {
pub enum StopReason {
    Stop,       // Natural completion
    Length,     // Hit max tokens
    ToolUse,    // Wants to call tools
    Error,      // Provider error
    Aborted,    // Run cancelled during an LLM call or a retry's backoff (on_error is not called)
    Refusal,    // Declined by the provider's safety system
}
}

Usage

Token usage from the provider:

#![allow(unused)]
fn main() {
pub struct Usage {
    pub input: u64,
    pub output: u64,
    pub cache_read: u64,
    pub cache_write: u64,
    pub total_tokens: u64,
}
}

AgentEvent

Events emitted during the agent loop for real-time UI updates:

EventWhen
AgentStartLoop begins
AgentEnd { messages, stats }Loop finishes: all new messages plus the run's SessionStats
TurnStartNew LLM call starting
TurnEnd { message, tool_results }LLM call + tool execution complete. Every TurnStart gets one: a turn stopped before its LLM call (an execution limit, on_before_turn returning false, an extension's Stop, a required extension's failure, or a cancel before the first request) ends with the history's last message and no tool results
MessageStart { message }A message is available
MessageUpdate { message, delta }Streaming delta arrived
MessageEnd { message }Message finalized
ToolExecutionStart { tool_call_id, tool_name, args }Tool about to run
ToolExecutionUpdate { tool_call_id, tool_name, partial_result }Tool progress
ToolExecutionEnd { tool_call_id, tool_name, result, is_error }Tool finished
ProgressMessage { tool_call_id, tool_name, text }User-facing progress text from a tool
InputRejected { reason }Input filter rejected the user's message
ProviderRetry { attempt, max_attempts, error, delay_ms }A provider attempt failed with a retryable error; the next starts after delay_ms
LoopDetected { tool_name, repetitions, aborted }Identical tool calls tripped loop detection (aborted: the run stopped)
ContextCompacted { method, messages_before, messages_after, tokens_before, tokens_after, summary }History was compacted before a turn (sent by LlmCompaction to the sender given to its with_event_sender, not by the loop itself)

Wire format

AgentEvent and StreamDelta serialize as internally-tagged camelCase JSON, so external frontends (a websocket fanout server, a TypeScript client, a JSONL pipe) can consume the event stream directly:

{"type":"messageUpdate","message":{...},"delta":{"type":"text","delta":"hi"}}
{"type":"toolExecutionEnd","toolCallId":"tc_1","toolName":"bash","result":{...},"isError":false}

This shape is a public contract frozen by snapshot tests — variant tags, field names, and the tagging scheme won't change in minor releases.

Streaming semantics: clients accumulate text from each MessageUpdate's delta; the message field during streaming is an empty-content placeholder (the complete message arrives as a new value in MessageEnd). Reset accumulation on each MessageStart.

Retries: when a provider attempt fails with a retryable error, every event it produced is delivered, then the attempt is closed and marked:

messageStart → messageUpdate… → messageEnd (stopReason "error",
  errorMessage "attempt 1 of 4 failed and will be retried: …") → providerRetry
messageStart → messageUpdate… → messageEnd (the retry's result)

Discard the partial text of a message that ends with stopReason: "error". That messageEnd is not the turn's result when providerRetry follows it, so a client that reports failures should look at the next event first. An attempt that failed before streaming anything has no messageStart, so it gets only the providerRetry. A final failure is closed with the error the turn returns and is never followed by providerRetry; one that streamed nothing (or a run cancelled before any output) is still announced with a messageStart and messageEnd, so every message in the history has its events. A run cancelled during an LLM call ends with stopReason: "aborted", including one cancelled during a retry's backoff. Cancelling between turns or while tools run ends the run without a new assistant message. A tool call the run has not started yet when it is cancelled is answered with an error result ("the run was cancelled") and never runs; a tool already running sees the cancel through its ToolContext. It appends a user message [Agent stopped: cancelled] (agent_loop::CANCELLED_MARKER, emitted as messageStart / messageEnd) when the run had produced an assistant message, so a last assistant message of ToolUse does not read as a normal stop. A sub-agent cancelled that way fails its delegation.

A Rust consumer that cannot discard text it already wrote (stdout, a log) can wrap its receiver in retry::retry_safe_events, which removes retried attempts before they reach it. See Retry: append-only consumers.

A client that misses events entirely (e.g. a lagged websocket subscriber) resyncs from the next MessageEnd without replay.

StreamDelta

Deltas within MessageUpdate:

#![allow(unused)]
fn main() {
pub enum StreamDelta {
    Text { delta: String },
    Thinking { delta: String },
    ToolCallDelta { delta: String },
}
}

Agent State

The Agent struct provides access to its current state:

#![allow(unused)]
fn main() {
// Check if the agent is currently streaming a response
if agent.is_streaming() {
    // Use steer() or follow_up() instead of prompt()
    agent.steer(AgentMessage::Llm(Message::user("New instruction")));
}

// Access the full message history
let messages: &[AgentMessage] = agent.messages();

// Check the last message
if let Some(last) = messages.last() {
    println!("Last message role: {}", last.role());
}
}

The is_streaming() flag is true between prompt()/continue_loop() call and completion. While streaming, calling prompt() will panic — use steer() or follow_up() instead.