mod langfuse;
mod types;
mod worker;
pub(crate) use worker::{AppendWorker, DEFAULT_DRAIN_CAPACITY};
pub use langfuse::{
LangfuseAuth, LangfuseClient, LangfuseScore, LangfuseScoreValue, LangfuseTraceConfig,
};
pub(crate) use langfuse::{clean_nulls, iso_ms};
pub use types::*;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use async_trait::async_trait;
use crate::error::Result;
use crate::harness::events::{AgentEvent, EventListener, EventRecord, HarnessRunStatus};
use crate::harness::ids::{CallId, RunId};
use crate::harness::store::{AppendStore, JsonlAppendStore};
impl AgentLatencyMetrics {
pub fn from_observations(observations: &[AgentObservation]) -> Self {
let mut metrics = Self::default();
let mut run_start: Option<u64> = None;
let mut model_starts: HashMap<CallId, (String, u64)> = HashMap::new();
let mut tool_starts: HashMap<CallId, (String, u64)> = HashMap::new();
for obs in observations {
match &obs.event {
AgentEvent::RunStarted { .. } if run_start.is_none() => {
run_start = Some(obs.ts_ms);
}
AgentEvent::RunStarted { .. } => {}
AgentEvent::RunCompleted { .. } | AgentEvent::RunFailed { .. } => {
if metrics.run_elapsed_ms.is_none()
&& let Some(start) = run_start
{
metrics.run_elapsed_ms = Some(obs.ts_ms.saturating_sub(start));
}
}
AgentEvent::ModelStarted { call_id, model } => {
model_starts.insert(call_id.clone(), (model.clone(), obs.ts_ms));
}
AgentEvent::ModelCompleted { call_id, .. } => {
if let Some((name, start)) = model_starts.remove(call_id) {
metrics.record_model_call(AgentCallLatency {
call_id: call_id.clone(),
kind: "model".to_string(),
name,
elapsed_ms: obs.ts_ms.saturating_sub(start),
});
}
}
AgentEvent::ToolStarted { call_id, tool_name } => {
tool_starts.insert(call_id.clone(), (tool_name.clone(), obs.ts_ms));
}
AgentEvent::ToolCompleted { call_id, .. } => {
if let Some((name, start)) = tool_starts.remove(call_id) {
metrics.record_tool_call(AgentCallLatency {
call_id: call_id.clone(),
kind: "tool".to_string(),
name,
elapsed_ms: obs.ts_ms.saturating_sub(start),
});
}
}
_ => {}
}
}
metrics
}
pub fn from_status(status: &HarnessRunStatus) -> Self {
let end = status.ended_at.unwrap_or(status.updated_at);
Self {
run_elapsed_ms: duration_ms(status.started_at, end),
..Self::default()
}
}
pub fn average_model_ms(&self) -> Option<u64> {
average(self.total_model_ms, self.model_calls.len())
}
pub fn average_tool_ms(&self) -> Option<u64> {
average(self.total_tool_ms, self.tool_calls.len())
}
fn record_model_call(&mut self, latency: AgentCallLatency) {
self.total_model_ms = self.total_model_ms.saturating_add(latency.elapsed_ms);
self.max_model_ms = self.max_model_ms.max(latency.elapsed_ms);
self.model_calls.push(latency);
}
fn record_tool_call(&mut self, latency: AgentCallLatency) {
self.total_tool_ms = self.total_tool_ms.saturating_add(latency.elapsed_ms);
self.max_tool_ms = self.max_tool_ms.max(latency.elapsed_ms);
self.tool_calls.push(latency);
}
}
impl InMemoryEventJournal {
pub fn new() -> Self {
Self::default()
}
pub fn with_max_runs(max_runs: usize) -> Self {
Self {
state: Arc::new(Mutex::new(EventJournalState::default())),
max_runs,
}
}
pub fn len(&self, run_id: &str) -> usize {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.streams
.get(run_id)
.map(|s| s.entries.len())
.unwrap_or(0)
}
pub fn is_empty(&self, run_id: &str) -> bool {
self.len(run_id) == 0
}
pub fn run_count(&self) -> usize {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.streams
.len()
}
}
#[async_trait]
impl HarnessEventJournal for InMemoryEventJournal {
async fn append(&self, obs: AgentObservation) -> Result<u64> {
let mut state = self
.state
.lock()
.map_err(|e| poisoned("InMemoryEventJournal", e))?;
let run_id = obs.run_id.as_str().to_string();
if !state.streams.contains_key(&run_id) {
state.order.push_back(run_id.clone());
if self.max_runs > 0 {
while state.order.len() > self.max_runs {
let Some(oldest) = state.order.pop_front() else {
break;
};
if let Some(stream) = state.streams.remove(&oldest) {
let next_offset = stream.base_offset + stream.entries.len() as u64;
state.evicted.insert(oldest.clone(), next_offset);
state.evicted_order.push_back(oldest);
while state.evicted_order.len() > self.max_runs {
let Some(stale) = state.evicted_order.pop_front() else {
break;
};
state.evicted.remove(&stale);
}
}
}
}
}
let base_offset = state.evicted.remove(&run_id).unwrap_or(0);
let stream = state.streams.entry(run_id).or_insert_with(|| EventStream {
base_offset,
entries: Vec::new(),
});
let offset = stream.base_offset + stream.entries.len() as u64;
stream.entries.push(obs);
Ok(offset)
}
async fn read_from(&self, run_id: &str, offset: u64) -> Result<Vec<AgentObservation>> {
let state = self
.state
.lock()
.map_err(|e| poisoned("InMemoryEventJournal", e))?;
match state.streams.get(run_id) {
Some(stream) => {
if offset < stream.base_offset {
return Err(crate::error::TinyAgentsError::Validation(format!(
"run `{run_id}` requested offset {offset} but entries before \
{} were evicted to bound memory; the caller's saved offset is stale",
stream.base_offset
)));
}
let skip = (offset - stream.base_offset) as usize;
Ok(stream.entries.iter().skip(skip).cloned().collect())
}
None => {
if let Some(&next_offset) = state.evicted.get(run_id)
&& offset < next_offset
{
return Err(crate::error::TinyAgentsError::Validation(format!(
"run `{run_id}` was evicted to bound memory; the caller's \
saved offset {offset} is stale"
)));
}
Ok(Vec::new())
}
}
}
}
impl<A: AppendStore> StoreEventJournal<A> {
pub fn new(store: A) -> Self {
Self { store }
}
pub fn store(&self) -> &A {
&self.store
}
}
#[async_trait]
impl<A: AppendStore + 'static> HarnessEventJournal for StoreEventJournal<A> {
async fn append(&self, obs: AgentObservation) -> Result<u64> {
let stream = obs.run_id.as_str().to_string();
let value = serde_json::to_value(&obs)?;
self.store.append(&stream, value).await
}
async fn read_from(&self, run_id: &str, offset: u64) -> Result<Vec<AgentObservation>> {
let raw = self.store.read_from(run_id, offset).await?;
let mut out = Vec::with_capacity(raw.len());
for (_offset, value) in raw {
out.push(serde_json::from_value(value)?);
}
Ok(out)
}
}
fn is_active_status(status: &HarnessRunStatus) -> bool {
use crate::harness::ids::ExecutionStatus;
matches!(
status.status,
ExecutionStatus::Pending | ExecutionStatus::Running | ExecutionStatus::Interrupted
)
}
impl InMemoryStatusStore {
pub fn new() -> Self {
Self::default()
}
pub fn with_max_runs(max_runs: usize) -> Self {
Self {
state: Arc::new(Mutex::new(StatusStoreState::default())),
max_runs,
}
}
pub fn len(&self) -> usize {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.statuses
.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[async_trait]
impl HarnessStatusStore for InMemoryStatusStore {
async fn put_status(&self, status: HarnessRunStatus) -> Result<()> {
let mut state = self
.state
.lock()
.map_err(|e| poisoned("InMemoryStatusStore", e))?;
let run_id = status.run_id.as_str().to_string();
if !state.statuses.contains_key(&run_id) {
state.order.push_back(run_id.clone());
}
state.statuses.insert(run_id, status);
if self.max_runs > 0 && state.statuses.len() > self.max_runs {
let mut requeue = Vec::new();
while state.statuses.len() > self.max_runs {
let Some(candidate) = state.order.pop_front() else {
break;
};
match state.statuses.get(&candidate) {
Some(s) if is_active_status(s) => requeue.push(candidate),
Some(_) => {
state.statuses.remove(&candidate);
}
None => {}
}
}
for id in requeue.into_iter().rev() {
state.order.push_front(id);
}
}
Ok(())
}
async fn get_status(&self, run_id: &str) -> Result<Option<HarnessRunStatus>> {
let state = self
.state
.lock()
.map_err(|e| poisoned("InMemoryStatusStore", e))?;
Ok(state.statuses.get(run_id).cloned())
}
async fn list_by_thread(&self, thread_id: &str) -> Result<Vec<HarnessRunStatus>> {
let state = self
.state
.lock()
.map_err(|e| poisoned("InMemoryStatusStore", e))?;
Ok(state
.statuses
.values()
.filter(|s| {
s.thread_id
.as_ref()
.is_some_and(|t| t.as_str() == thread_id)
})
.cloned()
.collect())
}
async fn list_by_root(&self, root_run_id: &str) -> Result<Vec<HarnessRunStatus>> {
let state = self
.state
.lock()
.map_err(|e| poisoned("InMemoryStatusStore", e))?;
Ok(state
.statuses
.values()
.filter(|s| s.root_run_id.as_str() == root_run_id)
.cloned()
.collect())
}
async fn list_active(&self) -> Result<Vec<HarnessRunStatus>> {
let state = self
.state
.lock()
.map_err(|e| poisoned("InMemoryStatusStore", e))?;
Ok(state
.statuses
.values()
.filter(|s| is_active_status(s))
.cloned()
.collect())
}
}
impl FanOutSink {
pub fn new() -> Self {
Self::default()
}
pub fn with(mut self, listener: Arc<dyn EventListener>) -> Self {
self.listeners.push(listener);
self
}
pub fn add(&mut self, listener: Arc<dyn EventListener>) -> &mut Self {
self.listeners.push(listener);
self
}
pub fn len(&self) -> usize {
self.listeners.len()
}
pub fn is_empty(&self) -> bool {
self.listeners.is_empty()
}
}
impl EventListener for FanOutSink {
fn on_event(&self, record: &EventRecord) {
for listener in &self.listeners {
listener.on_event(record);
}
}
}
impl RedactingSink {
pub const DEFAULT_MASK: &'static str = "[REDACTED]";
pub fn new(inner: Arc<dyn EventListener>, secrets: Vec<String>) -> Self {
Self {
inner,
secrets,
mask: Self::DEFAULT_MASK.to_string(),
}
}
pub fn with_mask(mut self, mask: impl Into<String>) -> Self {
self.mask = mask.into();
self
}
}
impl EventListener for RedactingSink {
fn on_event(&self, record: &EventRecord) {
if self.secrets.is_empty() {
self.inner.on_event(record);
return;
}
let Ok(mut value) = serde_json::to_value(&record.event) else {
return;
};
redact_value(&mut value, &self.secrets, &self.mask);
let Ok(event) = serde_json::from_value::<AgentEvent>(value) else {
return;
};
let redacted = EventRecord {
id: record.id.clone(),
offset: record.offset,
event,
};
self.inner.on_event(&redacted);
}
}
fn redact_value(value: &mut serde_json::Value, secrets: &[String], mask: &str) {
match value {
serde_json::Value::String(s) => {
for secret in secrets {
if !secret.is_empty() && s.contains(secret.as_str()) {
*s = s.replace(secret.as_str(), mask);
}
}
}
serde_json::Value::Array(items) => {
for item in items {
redact_value(item, secrets, mask);
}
}
serde_json::Value::Object(map) => {
for entry in map.values_mut() {
redact_value(entry, secrets, mask);
}
}
_ => {}
}
}
impl JournalSink {
pub fn new(journal: Arc<dyn HarnessEventJournal>, run_id: RunId) -> Self {
let worker = Arc::new(AppendWorker::spawn(
"journal-sink",
DEFAULT_DRAIN_CAPACITY,
move |obs: AgentObservation| {
let journal = Arc::clone(&journal);
async move { journal.append(obs).await.map(|_| ()) }
},
));
Self {
root_run_id: run_id.clone(),
run_id,
parent_run_id: None,
worker,
}
}
pub fn with_lineage(mut self, parent_run_id: Option<RunId>, root_run_id: RunId) -> Self {
self.parent_run_id = parent_run_id;
self.root_run_id = root_run_id;
self
}
pub fn flush(&self) {
self.worker.flush();
}
}
impl EventListener for JournalSink {
fn on_event(&self, record: &EventRecord) {
let obs = AgentObservation::from_record(
record,
self.run_id.clone(),
self.parent_run_id.clone(),
self.root_run_id.clone(),
);
self.worker.submit(obs);
}
}
impl JsonlSink {
pub fn new(store: JsonlAppendStore, stream: impl Into<String>) -> Self {
let stream = stream.into();
let worker = Arc::new(AppendWorker::spawn(
"jsonl-sink",
DEFAULT_DRAIN_CAPACITY,
move |value: serde_json::Value| {
let store = store.clone();
let stream = stream.clone();
async move { store.append(&stream, value).await.map(|_| ()) }
},
));
Self { worker }
}
pub fn flush(&self) {
self.worker.flush();
}
}
impl EventListener for JsonlSink {
fn on_event(&self, record: &EventRecord) {
let Ok(value) = serde_json::to_value(record) else {
return;
};
self.worker.submit(value);
}
}
fn average(total: u64, count: usize) -> Option<u64> {
(count > 0).then_some(total / count as u64)
}
fn duration_ms(start: SystemTime, end: SystemTime) -> Option<u64> {
end.duration_since(start)
.ok()
.map(|duration| duration.as_millis() as u64)
}
fn poisoned<E: std::fmt::Display>(what: &str, err: E) -> crate::error::TinyAgentsError {
crate::error::TinyAgentsError::Validation(format!("{what} lock poisoned: {err}"))
}
#[cfg(test)]
mod test;