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, child_loop_id: Option<String>, // set by sub-agent tools }, } }
Create user messages easily:
#![allow(unused)] fn main() { let msg = Message::user("Hello, world!"); }
AgentMessage
Wraps Message with support for extension messages (UI-only, notifications, etc.):
#![allow(unused)] fn main() { pub enum AgentMessage { Llm(LlmMessage), Extension(ExtensionMessage), } pub struct LlmMessage { pub message: Message, /// Which turn produced this message. `None` for messages that predate /// turn tracking or are created outside the agent loop. pub turn_id: Option<TurnId>, } pub struct ExtensionMessage { pub role: String, pub kind: String, pub data: serde_json::Value, } }
Create extension messages with the convenience constructor:
#![allow(unused)] fn main() { let ext = ExtensionMessage::new("status_update", serde_json::json!({"status": "running"})); let msg = AgentMessage::Extension(ext); }
The kind field categorizes the extension (e.g., "status_update", "ui_event", "notification"). Use as_llm() to extract the Message if it's an LLM message. LlmMessage wraps a Message with an optional TurnId { loop_id, turn_index } for compaction tracking — this allows the compaction system to identify which turn produced each message. The default convert_to_llm function filters out Extension messages before sending to the provider.
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> }, ToolCall { id: String, name: String, arguments: serde_json::Value }, } }
An assistant message can contain multiple content blocks — e.g., thinking + text + tool calls.
The signature field on Content::Thinking is a cryptographic integrity token issued by the LLM provider (Anthropic calls it signature, OpenAI calls it encrypted_content, Gemini calls it thought_signature). It must be echoed back unmodified in multi-turn conversations — tampering or omitting it causes the provider to reject the request. It is None on providers that don't support extended thinking or on the first-turn generation.
StopReason
#![allow(unused)] fn main() { pub enum StopReason { Stop, // Natural completion Length, // Hit max tokens ToolUse, // Wants to call tools Error, // Provider error Aborted, // Cancelled by user MaxTurns, // Reached maximum allowed turns UserStop, // Explicit user stop command Handoff, // Handing off to a human operator GuardRail, // Stopped by content moderation / safety filter ContextCompacted, // Context was compacted to fit within limits Paused, // Paused waiting for external input } }
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:
| Event | When |
|---|---|
AgentStart { agent_id, session_id, loop_id, parent_loop_id, continuation_kind, config_snapshot, timestamp } | Loop begins. loop_id is "{session_id}.{config_id}.{N}". parent_loop_id is Some for continuations and sub-agents. continuation_kind is a ContinuationKind (Initial for first loops, Default/Rerun/Branch/Compaction for continuations). config_snapshot is Option<LoopConfigSnapshot> capturing model/provider settings for the loop. |
AgentEnd { messages, timestamp, rejection } | Loop finishes; rejection is Some when an InputFilter blocked input |
TurnStart { turn_index, timestamp, triggered_by } | New LLM call starting; turn_index is 0-based, triggered_by is User | SubAgent | Continuation | Branch |
TurnEnd { message, timestamp, tool_results } | LLM call + tool execution complete |
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, child_loop_id } | Tool finished. child_loop_id is Some when the tool was a sub-agent — it identifies the child loop that ran. |
ProgressMessage { tool_call_id, tool_name, text } | User-facing progress text from a tool |
InputRejected { reason } | Input filter rejected the user's message |
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.