Observers

React to agent events with atomic-swap observers and full event symmetry.

Observers

Observers let you react to an agent's progress in real time without changing the agent's run loop. The streaming runtime emits structured events at every meaningful boundary: model start/finish, each token, each tool call's start and end, each loop iteration.

The trait

#[async_trait]
pub trait AgentLoopObserver: Send + Sync {
    async fn on_event(&self, event: &AgentEvent);
}

That's it. One method, async, fully typed events.

Installing an observer

let observer: Arc<dyn AgentLoopObserver> = Arc::new(MyObserver::new());
agent.set_observer(observer);

Or at construction time:

let agent = StreamingToolLoopAgent::new(/* ... */)
    .with_observer(observer);

Atomic swap

The observer is held behind an RwLock<Arc<dyn AgentLoopObserver>>, so you can replace it at any time without rebuilding the agent:

// Hot-swap during a long-running session
agent.set_observer(Arc::new(VerboseObserver::new()));

// Drop back to a no-op
agent.clear_observer();

Swaps complete atomically — in-flight events use the observer that was installed when the event was emitted; subsequent events use the new one.

Built-ins

  • NoOpObserver — installed by default. Drops every event.
  • PrintObserver (behind tracing feature) — logs events via tracing::info.
  • ChannelObserver — forwards every event into a tokio::sync::mpsc channel for use with custom UIs or long-haul observation pipelines.

Building your own

use forge::prelude::*;
use std::sync::atomic::{AtomicUsize, Ordering};

struct CountingObserver {
    iterations: AtomicUsize,
    tool_calls: AtomicUsize,
}

#[async_trait]
impl AgentLoopObserver for CountingObserver {
    async fn on_event(&self, event: &AgentEvent) {
        match event {
            AgentEvent::Iteration { .. } => {
                self.iterations.fetch_add(1, Ordering::Relaxed);
            }
            AgentEvent::ToolCalled { .. } => {
                self.tool_calls.fetch_add(1, Ordering::Relaxed);
            }
            _ => {}
        }
    }
}

Observer + tool invocation symmetry

Every AgentEvent::ToolCalled and AgentEvent::ToolFinished your observer sees corresponds 1-to-1 with a ToolInvocationRecord in the final AgentOutput. The observer is real-time; the records are post-hoc — but the two views are guaranteed symmetrical.

This means you can:

  • Drive a live status panel from the observer
  • Persist a complete invocation log from output.tool_invocations()
  • Reconstruct the same view either way

See Agent events for the full taxonomy.

Bridging to the harness

The harness-sdk-forge bridge will (in a follow-up release) expose an observer that translates AgentEvents into harness_sdk::AutonomyEvents for the terminal's activity feed. The current alpha translates the stream chunks directly; full observer bridging tracks harness-sdk-forge#1.

Performance

Observer dispatch happens on the agent's task. If your observer awaits I/O (writing to disk, posting to a webhook), wrap the work in tokio::spawn:

async fn on_event(&self, event: &AgentEvent) {
    let event = event.clone();
    let client = self.http_client.clone();
    tokio::spawn(async move {
        let _ = client.post(URL).json(&event).send().await;
    });
}

The RwLock is read-locked for the duration of on_event. Long-running observers should not block — a writer (a set_observer call) will be parked until the read completes.

Next