Agent events

The typed AgentEvent taxonomy and ToolInvocationRecord structure.

Agent events

Every Forge agent emits a stream of typed events as it runs. These power observers, drive the harness terminal, and form the post-hoc record returned in AgentOutput.

AgentEvent

pub enum AgentEvent {
    /// The agent's run loop is starting iteration `n`.
    Iteration { n: usize },

    /// The agent has begun streaming from the model.
    ModelStart,

    /// A `StreamChunk::TextDelta` arrived.
    TextDelta { text: String },

    /// The model has produced a tool call.
    ToolCalled {
        invocation_id: String,
        tool_name: String,
        input: serde_json::Value,
    },

    /// A tool call completed (success or failure).
    ToolFinished {
        invocation_id: String,
        tool_name: String,
        status: ToolInvocationStatus,
        duration_ms: u64,
        output: Option<serde_json::Value>,
        error: Option<String>,
    },

    /// The model finished one iteration of streaming.
    ModelFinished {
        finish_reason: FinishReason,
        usage: Usage,
    },

    /// The agent's run loop stopped (success or bound).
    Stopped { reason: StopReason },
}

ToolInvocationRecord

Each tool execution produces one record:

pub struct ToolInvocationRecord {
    pub invocation_id: String,
    pub tool_name: String,
    pub input: serde_json::Value,
    pub status: ToolInvocationStatus,
    pub duration_ms: u64,
    pub output: Option<serde_json::Value>,
    pub error: Option<String>,
}

pub enum ToolInvocationStatus {
    Success,
    Failed,
    Rejected,    // approval handler rejected
    Timeout,
}

Iterating the post-hoc record

let output = agent.run("Look up the weather and write a summary.").await?;

// Typed iterator over the full event stream
for event in output.events() {
    match event {
        AgentEvent::ToolCalled { tool_name, input, .. } => {
            println!("→ {tool_name}({input})");
        }
        AgentEvent::ToolFinished { tool_name, status, duration_ms, .. } => {
            println!("← {tool_name} {:?} ({}ms)", status, duration_ms);
        }
        AgentEvent::Stopped { reason } => {
            println!("✓ stopped: {:?}", reason);
        }
        _ => {}
    }
}

// Compact tool-call log
for r in output.tool_invocations() {
    println!("{:>4}ms  {:<24}  {:?}", r.duration_ms, r.tool_name, r.status);
}

Symmetry with observers

The same events that fill output.events() are sent to any installed AgentLoopObserver in real time. The two views are guaranteed identical:

observer.on_event(&event);  // real-time
output.events.push(event);  // post-hoc

This means you can drive a live status panel from the observer and persist the full invocation log from output.tool_invocations() — both views match.

Filtering helpers

// Only the failures
let failures: Vec<_> = output
    .tool_invocations()
    .iter()
    .filter(|r| matches!(r.status, ToolInvocationStatus::Failed | ToolInvocationStatus::Timeout))
    .collect();

// Total tool wall time
let total_ms: u64 = output
    .tool_invocations()
    .iter()
    .map(|r| r.duration_ms)
    .sum();

Next