sim-lib-agent 0.1.3

Agent runtime surfaces for SIM.
Documentation
use super::super::model::{AgentComponent, RecorderBackend};
use crate::{
    memory::{
        append_memory_log, flatten_expr_text, lock_entries, memory_entries_append,
        memory_entries_recent, memory_entries_search, memory_entries_snapshot,
    },
    util::{symbol_of, u32_from_expr},
};
use sim_kernel::{Cx, Error, Expr, ReadPolicy, Result, Symbol, Value};
use sim_lib_server::{FrameKind, ServerFrame, eval_request_from_frame};

pub(in crate::components) fn answer_recorder(
    cx: &mut Cx,
    _component: &AgentComponent,
    backend: &RecorderBackend,
    frame: ServerFrame,
) -> Result<ServerFrame> {
    match frame.kind {
        FrameKind::Notify => {
            let trace = direct_trace_entry(cx, &frame)?.unwrap_or(frame_trace_expr(cx, &frame)?);
            recorder_append(backend, trace)?;
            Ok(frame)
        }
        FrameKind::Request => {
            let consistency = frame.envelope.consistency;
            let request = eval_request_from_frame(cx, &frame)?;
            let value = recorder_request_value(cx, backend, request.expr)?;
            crate::reply::reply_frame(cx, &frame, value, consistency)
        }
        _ => Err(Error::Eval(
            "recorder expects request or notify frames".to_owned(),
        )),
    }
}

fn direct_trace_entry(cx: &mut Cx, frame: &ServerFrame) -> Result<Option<Expr>> {
    let payload = frame.decode_expr(cx, ReadPolicy::default())?;
    Ok(match &payload {
        Expr::Map(entries)
            if entries.iter().any(|(key, value)| {
                matches!(key, Expr::Symbol(symbol) if symbol.name.as_ref() == "trace-entry")
                    && matches!(value, Expr::Bool(true))
            }) =>
        {
            Some(crate::model_privacy::redact_trace_entry(payload)?)
        }
        _ => None,
    })
}

fn recorder_append(backend: &RecorderBackend, expr: Expr) -> Result<()> {
    match backend {
        RecorderBackend::Journal { path, entries } | RecorderBackend::Audit { path, entries } => {
            memory_entries_append(entries, expr.clone())?;
            crate::agents::remember_recorded_trace(&expr)?;
            if let Some(path) = path {
                append_memory_log(path, &expr)?;
            }
            Ok(())
        }
        RecorderBackend::Prometheus { entries, .. } => {
            memory_entries_append(entries, expr.clone())?;
            crate::agents::remember_recorded_trace(&expr)
        }
    }
}

fn frame_trace_expr(cx: &mut Cx, frame: &ServerFrame) -> Result<Expr> {
    let payload = frame.decode_expr(cx, ReadPolicy::default())?;
    crate::model_privacy::redact_trace_entry(Expr::Map(vec![
        (
            Expr::Symbol(Symbol::new("kind")),
            Expr::Symbol(frame.kind.as_symbol()),
        ),
        (
            Expr::Symbol(Symbol::new("codec")),
            Expr::Symbol(frame.codec.clone()),
        ),
        (
            Expr::Symbol(Symbol::new("role")),
            frame
                .envelope
                .role
                .clone()
                .map(Expr::Symbol)
                .unwrap_or(Expr::Nil),
        ),
        (Expr::Symbol(Symbol::new("payload")), payload),
    ]))
}

fn recorder_request_value(cx: &mut Cx, backend: &RecorderBackend, expr: Expr) -> Result<Value> {
    match backend {
        RecorderBackend::Prometheus { namespace, entries } => {
            let _ = expr;
            cx.factory().string(render_prometheus_scrape(
                namespace,
                &lock_entries(entries, "recorder metrics")?,
            )?)
        }
        RecorderBackend::Journal { entries, .. } | RecorderBackend::Audit { entries, .. } => {
            match expr {
                Expr::List(items) | Expr::Vector(items) => {
                    let Some((head, tail)) = items.split_first() else {
                        return cx.factory().list(Vec::new());
                    };
                    let op = symbol_of(head, "recorder request expects a symbol op")?;
                    match op.to_string().as_str() {
                        "snapshot" | "journal" => {
                            cx.factory().expr(memory_entries_snapshot(entries)?)
                        }
                        "recent" => {
                            let count = tail
                                .first()
                                .map(|expr| u32_from_expr(expr, "recorder recent expects a count"))
                                .transpose()?
                                .unwrap_or(10);
                            let recent = memory_entries_recent(cx, entries, count)?;
                            cx.factory().list(recent)
                        }
                        "audit" | "search" => {
                            let query = tail.first().cloned().unwrap_or(Expr::Nil);
                            let count = tail
                                .get(1)
                                .map(|expr| u32_from_expr(expr, "recorder audit expects a count"))
                                .transpose()?
                                .unwrap_or(10);
                            let found = memory_entries_search(cx, entries, query, count)?;
                            cx.factory().list(found)
                        }
                        _ => cx.factory().expr(memory_entries_snapshot(entries)?),
                    }
                }
                _ => cx.factory().expr(memory_entries_snapshot(entries)?),
            }
        }
    }
}

fn render_prometheus_scrape(namespace: &str, entries: &[Expr]) -> Result<String> {
    let mut out = String::new();
    let total = u64::try_from(entries.len()).unwrap_or(u64::MAX);
    out.push_str(&format!(
        "# HELP {namespace}_frames_total Frames recorded by the agent fabric.\n"
    ));
    out.push_str(&format!("# TYPE {namespace}_frames_total counter\n"));
    out.push_str(&format!("{namespace}_frames_total {total}\n"));

    let mut roles = std::collections::BTreeMap::<String, u64>::new();
    let mut tools = std::collections::BTreeMap::<String, u64>::new();
    for entry in entries {
        if let Some(role) = trace_field(entry, "role").and_then(expr_string_label) {
            *roles.entry(role).or_default() += 1;
        }
        if let Some(tool) = extract_tool_label(entry) {
            *tools.entry(tool).or_default() += 1;
        }
    }
    for (role, count) in roles {
        out.push_str(&format!(
            "{namespace}_frames_total{{role=\"{}\"}} {count}\n",
            escape_prometheus_label(&role)
        ));
    }
    out.push_str(&format!(
        "# HELP {namespace}_tool_calls_total Tool calls recorded.\n"
    ));
    out.push_str(&format!("# TYPE {namespace}_tool_calls_total counter\n"));
    for (tool, count) in tools {
        out.push_str(&format!(
            "{namespace}_tool_calls_total{{tool=\"{}\"}} {count}\n",
            escape_prometheus_label(&tool)
        ));
    }
    Ok(out)
}

fn trace_field<'a>(entry: &'a Expr, key: &str) -> Option<&'a Expr> {
    let Expr::Map(entries) = entry else {
        return None;
    };
    entries.iter().find_map(|(field, value)| match field {
        Expr::Symbol(symbol) if symbol.name.as_ref() == key => Some(value),
        _ => None,
    })
}

fn expr_string_label(expr: &Expr) -> Option<String> {
    match expr {
        Expr::Nil => None,
        Expr::String(text) => Some(text.clone()),
        Expr::Symbol(symbol) => Some(symbol.to_string()),
        _ => Some(flatten_expr_text(expr)),
    }
}

fn extract_tool_label(entry: &Expr) -> Option<String> {
    let payload = trace_field(entry, "payload")?;
    if let Some(tool) = payload_map_field(payload, "tool").and_then(expr_string_label) {
        return Some(tool);
    }
    match payload {
        Expr::List(items) | Expr::Vector(items) => items.first().and_then(expr_string_label),
        Expr::Symbol(symbol) => Some(symbol.to_string()),
        _ => None,
    }
}

fn payload_map_field<'a>(expr: &'a Expr, key: &str) -> Option<&'a Expr> {
    let Expr::Map(entries) = expr else {
        return None;
    };
    entries.iter().find_map(|(field, value)| match field {
        Expr::Symbol(symbol) if symbol.name.as_ref() == key => Some(value),
        _ => None,
    })
}

fn escape_prometheus_label(label: &str) -> String {
    let mut escaped = String::with_capacity(label.len());
    for ch in label.chars() {
        match ch {
            '\\' => escaped.push_str("\\\\"),
            '"' => escaped.push_str("\\\""),
            '\n' => escaped.push_str("\\n"),
            _ => escaped.push(ch),
        }
    }
    escaped
}