use lago_core::error::LagoResult;
use lago_core::event::{EventEnvelope, EventPayload, MemoryScope};
use lago_core::projection::Projection;
#[derive(Debug, Clone)]
pub struct Observation {
pub text: String,
pub source_seq: u64,
pub timestamp: u64,
}
pub struct Observer {
scope: MemoryScope,
observations: Vec<Observation>,
threshold: usize,
}
impl Observer {
pub fn new(scope: MemoryScope, threshold: usize) -> Self {
Self {
scope,
observations: Vec::new(),
threshold,
}
}
pub fn scope(&self) -> MemoryScope {
self.scope
}
pub fn count(&self) -> usize {
self.observations.len()
}
pub fn threshold_reached(&self) -> bool {
self.observations.len() >= self.threshold
}
pub fn drain(&mut self) -> Vec<Observation> {
std::mem::take(&mut self.observations)
}
pub fn observations(&self) -> &[Observation] {
&self.observations
}
fn extract_observation(payload: &EventPayload) -> Option<String> {
match payload {
EventPayload::Message {
role,
content,
model,
..
} => {
let model_info = model
.as_deref()
.map(|m| format!(" [{m}]"))
.unwrap_or_default();
Some(format!("{role}{model_info}: {content}"))
}
EventPayload::TextDelta { delta, .. } => Some(format!("assistant delta: {delta}")),
EventPayload::ToolCallCompleted {
tool_name, status, ..
} => Some(format!("tool:{tool_name} → {status:?}")),
EventPayload::StatePatched { patch, .. } => Some(format!("state patched: {patch}")),
EventPayload::ErrorRaised { message } => Some(format!("error: {message}")),
EventPayload::RunFinished {
reason,
total_iterations,
..
} => Some(format!(
"run finished: {reason} ({total_iterations} iterations)"
)),
_ => None,
}
}
}
impl Projection for Observer {
fn on_event(&mut self, event: &EventEnvelope) -> LagoResult<()> {
if let Some(text) = Self::extract_observation(&event.payload) {
self.observations.push(Observation {
text,
source_seq: event.seq,
timestamp: event.timestamp,
});
}
Ok(())
}
fn name(&self) -> &str {
"arcan::observer"
}
}
pub struct Reflector;
impl Reflector {
pub fn compact(observations: &[Observation]) -> String {
if observations.is_empty() {
return String::new();
}
let mut lines = Vec::with_capacity(observations.len() + 1);
lines.push(format!(
"Summary of {} observations (seq {}..{}):",
observations.len(),
observations.first().map(|o| o.source_seq).unwrap_or(0),
observations.last().map(|o| o.source_seq).unwrap_or(0),
));
for obs in observations {
lines.push(format!("- {}", obs.text));
}
lines.join("\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
use lago_core::event::*;
use lago_core::id::*;
use std::collections::HashMap;
fn make_envelope(seq: u64, payload: EventPayload) -> EventEnvelope {
EventEnvelope {
event_id: EventId::from_string("EVT001"),
session_id: SessionId::from_string("SESS001"),
branch_id: BranchId::from_string("main"),
run_id: None,
seq,
timestamp: 1_700_000_000_000_000 + seq,
parent_id: None,
payload,
metadata: HashMap::new(),
schema_version: 1,
}
}
#[test]
fn observer_extracts_from_message() {
let mut obs = Observer::new(MemoryScope::Session, 10);
let env = make_envelope(
1,
EventPayload::Message {
role: "user".to_string(),
content: "Hello world".to_string(),
model: None,
token_usage: None,
},
);
obs.on_event(&env).unwrap();
assert_eq!(obs.count(), 1);
assert!(obs.observations()[0].text.contains("Hello world"));
assert_eq!(obs.observations()[0].source_seq, 1);
}
#[test]
fn observer_extracts_from_tool_result() {
let mut obs = Observer::new(MemoryScope::Session, 10);
let env = make_envelope(
2,
EventPayload::ToolCallCompleted {
tool_run_id: aios_protocol::ToolRunId::default(),
call_id: Some("c1".to_string()),
tool_name: "read_file".to_string(),
result: serde_json::json!({"content": "data"}),
duration_ms: 42,
status: SpanStatus::Ok,
},
);
obs.on_event(&env).unwrap();
assert_eq!(obs.count(), 1);
assert!(obs.observations()[0].text.contains("read_file"));
}
#[test]
fn observer_extracts_from_error() {
let mut obs = Observer::new(MemoryScope::Session, 10);
let env = make_envelope(
3,
EventPayload::ErrorRaised {
message: "connection refused".to_string(),
},
);
obs.on_event(&env).unwrap();
assert_eq!(obs.count(), 1);
assert!(obs.observations()[0].text.contains("connection refused"));
}
#[test]
fn observer_extracts_from_state_patched() {
let mut obs = Observer::new(MemoryScope::Session, 10);
let env = make_envelope(
4,
EventPayload::StatePatched {
index: Some(1),
patch: serde_json::json!({"cwd": "/home"}),
revision: 1,
},
);
obs.on_event(&env).unwrap();
assert_eq!(obs.count(), 1);
assert!(obs.observations()[0].text.contains("state patched"));
}
#[test]
fn observer_ignores_lifecycle_events() {
let mut obs = Observer::new(MemoryScope::Session, 10);
obs.on_event(&make_envelope(1, EventPayload::StepStarted { index: 1 }))
.unwrap();
obs.on_event(&make_envelope(
2,
EventPayload::StepFinished {
index: 1,
stop_reason: "end_turn".to_string(),
directive_count: 1,
},
))
.unwrap();
obs.on_event(&make_envelope(
3,
EventPayload::BranchCreated {
new_branch_id: BranchId::from_string("feature").into(),
fork_point_seq: 0,
name: "feature".to_string(),
},
))
.unwrap();
obs.on_event(&make_envelope(
4,
EventPayload::SessionCreated {
name: "test".to_string(),
config: serde_json::json!({}),
},
))
.unwrap();
assert_eq!(obs.count(), 0);
}
#[test]
fn observer_tracks_source_seq() {
let mut obs = Observer::new(MemoryScope::Session, 10);
obs.on_event(&make_envelope(
42,
EventPayload::ErrorRaised {
message: "boom".to_string(),
},
))
.unwrap();
assert_eq!(obs.observations()[0].source_seq, 42);
}
#[test]
fn threshold_triggers() {
let mut obs = Observer::new(MemoryScope::Session, 3);
for i in 1..=3 {
obs.on_event(&make_envelope(
i,
EventPayload::ErrorRaised {
message: format!("err-{i}"),
},
))
.unwrap();
}
assert!(obs.threshold_reached());
}
#[test]
fn drain_clears_observations() {
let mut obs = Observer::new(MemoryScope::Session, 10);
obs.on_event(&make_envelope(
1,
EventPayload::ErrorRaised {
message: "boom".to_string(),
},
))
.unwrap();
assert_eq!(obs.count(), 1);
let drained = obs.drain();
assert_eq!(drained.len(), 1);
assert_eq!(obs.count(), 0);
assert!(!obs.threshold_reached());
}
#[test]
fn reflector_compact_works() {
let observations = vec![
Observation {
text: "user: hello".to_string(),
source_seq: 1,
timestamp: 100,
},
Observation {
text: "tool:read_file → Ok".to_string(),
source_seq: 2,
timestamp: 200,
},
Observation {
text: "error: boom".to_string(),
source_seq: 3,
timestamp: 300,
},
];
let summary = Reflector::compact(&observations);
assert!(summary.contains("Summary of 3 observations"));
assert!(summary.contains("seq 1..3"));
assert!(summary.contains("- user: hello"));
assert!(summary.contains("- error: boom"));
}
#[test]
fn reflector_compact_empty() {
let summary = Reflector::compact(&[]);
assert!(summary.is_empty());
}
}