tandem-core 0.4.20

Core types and helpers for the Tandem engine
Documentation
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tandem_types::EngineEvent;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolEffectLedgerPhase {
    Invocation,
    Outcome,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolEffectLedgerStatus {
    Started,
    Succeeded,
    Failed,
    Blocked,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolEffectLedgerRecord {
    pub session_id: String,
    pub message_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool_call_id: Option<String>,
    pub tool: String,
    pub phase: ToolEffectLedgerPhase,
    pub status: ToolEffectLedgerStatus,
    pub args_summary: Value,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub result_summary: Option<Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

pub fn tool_effect_ledger_event(record: ToolEffectLedgerRecord) -> EngineEvent {
    EngineEvent::new(
        "tool.effect.recorded",
        json!({
            "sessionID": record.session_id.clone(),
            "messageID": record.message_id.clone(),
            "tool": record.tool.clone(),
            "record": record,
        }),
    )
}

pub fn build_tool_effect_ledger_record(
    session_id: &str,
    message_id: &str,
    tool_call_id: Option<&str>,
    tool: &str,
    phase: ToolEffectLedgerPhase,
    status: ToolEffectLedgerStatus,
    args: &Value,
    metadata: Option<&Value>,
    output: Option<&str>,
    error: Option<&str>,
) -> ToolEffectLedgerRecord {
    ToolEffectLedgerRecord {
        session_id: session_id.to_string(),
        message_id: message_id.to_string(),
        tool_call_id: tool_call_id.map(str::to_string),
        tool: tool.to_string(),
        phase,
        status,
        args_summary: summarize_args(args),
        result_summary: summarize_result(metadata, output),
        error: error
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(|value| truncate(value, 500)),
    }
}

fn summarize_args(args: &Value) -> Value {
    let Some(object) = args.as_object() else {
        return json!({
            "type": value_type(args),
        });
    };

    let mut keys = object.keys().cloned().collect::<Vec<_>>();
    keys.sort();

    let path = first_string_field(object, &["path", "file_path", "target_file"]);
    let url = first_string_field(object, &["url"]);
    let cwd = first_string_field(object, &["cwd", "__effective_cwd"]);
    let workspace_root = first_string_field(object, &["__workspace_root"]);
    let query_hash = first_string_field(object, &["query", "q"])
        .as_deref()
        .map(stable_hash);
    let command_hash = first_string_field(object, &["command"])
        .as_deref()
        .map(stable_hash);

    json!({
        "type": "object",
        "keys": keys,
        "field_count": object.len(),
        "path": path,
        "url": url,
        "cwd": cwd,
        "workspace_root": workspace_root,
        "query_hash": query_hash,
        "command_hash": command_hash,
    })
}

fn summarize_result(metadata: Option<&Value>, output: Option<&str>) -> Option<Value> {
    if metadata.is_none() && output.is_none() {
        return None;
    }

    let metadata_summary = metadata.map(summarize_value_shape);
    let output_chars = output.map(|value| value.chars().count());
    let output_hash = output.map(stable_hash);

    Some(json!({
        "metadata": metadata_summary,
        "output_chars": output_chars,
        "output_hash": output_hash,
    }))
}

fn summarize_value_shape(value: &Value) -> Value {
    match value {
        Value::Object(map) => {
            let mut keys = map.keys().cloned().collect::<Vec<_>>();
            keys.sort();
            json!({
                "type": "object",
                "keys": keys,
                "field_count": map.len(),
            })
        }
        Value::Array(rows) => json!({
            "type": "array",
            "length": rows.len(),
        }),
        Value::String(text) => json!({
            "type": "string",
            "length": text.len(),
        }),
        Value::Number(_) => json!({"type": "number"}),
        Value::Bool(_) => json!({"type": "boolean"}),
        Value::Null => json!({"type": "null"}),
    }
}

fn value_type(value: &Value) -> &'static str {
    match value {
        Value::Object(_) => "object",
        Value::Array(_) => "array",
        Value::String(_) => "string",
        Value::Number(_) => "number",
        Value::Bool(_) => "boolean",
        Value::Null => "null",
    }
}

fn first_string_field(
    object: &serde_json::Map<String, Value>,
    candidates: &[&str],
) -> Option<String> {
    candidates
        .iter()
        .find_map(|key| object.get(*key).and_then(Value::as_str))
        .map(|value| truncate(value, 240))
}

fn truncate(value: &str, limit: usize) -> String {
    if value.chars().count() <= limit {
        value.to_string()
    } else {
        value.chars().take(limit).collect::<String>()
    }
}

fn stable_hash(value: &str) -> String {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};

    let mut hasher = DefaultHasher::new();
    value.hash(&mut hasher);
    format!("{:016x}", hasher.finish())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ledger_record_summarizes_sensitive_args_without_storing_command_text() {
        let record = build_tool_effect_ledger_record(
            "session-1",
            "message-1",
            Some("call-1"),
            "bash",
            ToolEffectLedgerPhase::Invocation,
            ToolEffectLedgerStatus::Started,
            &json!({
                "command": "cargo test -p tandem-core",
                "cwd": "/workspace",
            }),
            None,
            None,
            None,
        );

        assert_eq!(record.args_summary["command_hash"].as_str().is_some(), true);
        assert_eq!(record.args_summary["cwd"].as_str(), Some("/workspace"));
        assert!(record.args_summary.get("command").is_none());
    }

    #[test]
    fn ledger_event_contains_compact_result_summary() {
        let event = tool_effect_ledger_event(build_tool_effect_ledger_record(
            "session-1",
            "message-1",
            None,
            "write",
            ToolEffectLedgerPhase::Outcome,
            ToolEffectLedgerStatus::Succeeded,
            &json!({"path": "src/lib.rs"}),
            Some(&json!({"path": "src/lib.rs", "ok": true})),
            Some("ok"),
            None,
        ));

        assert_eq!(event.event_type, "tool.effect.recorded");
        assert_eq!(
            event.properties["record"]["result_summary"]["metadata"]["field_count"].as_u64(),
            Some(2)
        );
        assert_eq!(
            event.properties["record"]["args_summary"]["path"].as_str(),
            Some("src/lib.rs")
        );
    }
}