car-engine 0.7.0

Core runtime engine for Common Agent Runtime
Documentation
//! Built-in agent vocabulary — single source of truth across FFI surfaces.
//!
//! Holds:
//!
//! - The list of v1 built-in agents (`Researcher`, `Summarizer`,
//!   `Verifier`, `Transcriber`, `NoteTaker`)
//! - Each agent's identity (system prompt / display name / description)
//! - The capability ids each agent advertises
//! - Per-capability payload-format adapters (caller-supplied JSON →
//!   prompt text)
//!
//! Why this lives in `car-engine` and not in any single FFI crate:
//! NAPI (`car-ffi-napi`), PyO3 (`car-ffi-pyo3`), and UniFFI
//! (`car-ffi-uniffi`) are sibling consumers of the same runtime —
//! they MUST agree on the agent vocabulary or the surfaces drift.
//! Per CLAUDE.md §2 (bindings parity), capability-shape changes
//! land in all binding sites in the same change. Owning the data
//! here makes that mechanically enforceable.
//!
//! When agent bundles ship via `car install`, the bundle loader
//! registers additional agents on top of these. The built-ins are
//! the floor — the runtime always offers them even before any
//! bundle is installed.

use serde::Serialize;

/// One built-in agent's static metadata.
#[derive(Debug, Clone, Copy, Serialize)]
pub struct BuiltinAgent {
    /// Stable identifier. `agent_hint` parameters reference this.
    pub id: &'static str,
    /// Short human-readable name for UI surfaces (Apple
    /// `DynamicOptionsProvider`, Shortcuts pickers, settings).
    pub display_name: &'static str,
    /// One-sentence description that surfaces in pickers as the
    /// agent's subtitle.
    pub description: &'static str,
    /// Capability ids this agent serves. Order is informational
    /// only; the registry is the authoritative dispatcher.
    pub capabilities: &'static [&'static str],
    /// System-prompt prefix that drives the agent's behavior.
    /// A richer impl pulls identity from `car-memgine` once
    /// `identity.md`-shipping bundles land; for v1 the static
    /// system prompt is enough to differentiate verbs.
    pub system_prompt: &'static str,
}

/// V1 built-in roster. New entries are additive; ids are
/// stable wire format (host UI persists them as user pinning).
pub const BUILTIN_AGENTS: &[BuiltinAgent] = &[
    BuiltinAgent {
        id: "researcher",
        display_name: "Researcher",
        description: "Finds factual answers and cites sources. Flags uncertainty.",
        capabilities: &["research", "search-knowledge"],
        system_prompt: "You are a research assistant. Answer factually, cite sources \
                        when possible, and flag uncertainty explicitly.",
    },
    BuiltinAgent {
        id: "summarizer",
        display_name: "Summarizer",
        description: "Concise summaries — bullet points by default, prose on request.",
        capabilities: &["summarize"],
        system_prompt: "You are a summarization agent. Produce a concise, accurate \
                        summary of the input. Default to bullet points unless the \
                        user requests prose.",
    },
    BuiltinAgent {
        id: "verifier",
        display_name: "Verifier",
        description: "Evaluates the truthfulness of a claim with reasoning.",
        capabilities: &["verify-claim"],
        system_prompt: "You are a verifier. Given a claim, evaluate whether it's \
                        likely true, likely false, or uncertain. Show your reasoning.",
    },
    BuiltinAgent {
        id: "transcriber",
        display_name: "Transcriber",
        description: "Cleans up transcripts: punctuation, mis-recognitions, speaker turns.",
        capabilities: &["transcribe-audio"],
        system_prompt: "You are a transcription assistant. Clean up the input \
                        transcript: fix obvious mis-recognitions, restore punctuation, \
                        and preserve speaker turns when present.",
    },
    BuiltinAgent {
        id: "note-taker",
        display_name: "Note Taker",
        description: "Captures decisions, action items, and open questions as bullets.",
        capabilities: &["create-note", "summarize"],
        system_prompt: "You are a note-taking assistant. Capture decisions, action \
                        items, and open questions from the input as terse bullet lists.",
    },
];

/// Look up a built-in agent by id. `None` for unknown ids.
pub fn agent_metadata(id: &str) -> Option<&'static BuiltinAgent> {
    BUILTIN_AGENTS.iter().find(|a| a.id == id)
}

/// Register every built-in agent's capabilities with the registry.
/// Called by FFI surfaces during runtime construction; idempotent.
pub fn register_builtins(registry: &super::AgentCapabilityRegistry) {
    for agent in BUILTIN_AGENTS {
        for cap in agent.capabilities {
            registry.register(*cap, agent.id);
        }
    }
}

/// Errors from [`format_capability_payload`]. Surfaced to the FFI
/// crate as `CarError::InvalidArgument` so Swift / Kotlin / JS
/// callers see a structured failure when their JSON contract drifts.
#[derive(Debug, thiserror::Error)]
pub enum CapabilityPayloadError {
    #[error("payload_json is not valid JSON: {0}")]
    InvalidJson(String),

    #[error("capability '{capability}' requires JSON object with string field '{field}'")]
    MissingField {
        capability: String,
        field: &'static str,
    },
}

/// Format the per-capability JSON payload into a natural-language
/// task for the selected agent. The contract is per-capability and
/// documented in `docs/agent-bundle-spec.md`.
///
/// This is intentionally NOT a silent fallback. Callers send this
/// JSON from a structured intent definition (Swift `@AppIntent`
/// parameters → JSON encode); a missing field means the host's
/// intent shape drifted from the runtime's expectation, and we'd
/// rather hard-error at the FFI boundary than send raw JSON to a
/// model that will dutifully summarize the JSON itself.
pub fn format_capability_payload(
    capability: &str,
    payload_json: &str,
) -> Result<String, CapabilityPayloadError> {
    let v: serde_json::Value = serde_json::from_str(payload_json)
        .map_err(|e| CapabilityPayloadError::InvalidJson(e.to_string()))?;

    let pull = |field: &'static str| -> Result<String, CapabilityPayloadError> {
        v.get(field)
            .and_then(|t| t.as_str())
            .map(|s| s.to_string())
            .ok_or_else(|| CapabilityPayloadError::MissingField {
                capability: capability.to_string(),
                field,
            })
    };

    Ok(match capability {
        "summarize" => format!("Summarize the following:\n\n{}", pull("text")?),
        "transcribe-audio" => format!("Transcribe the audio file at: {}", pull("audio_path")?),
        "research" | "search-knowledge" => format!("Research and answer: {}", pull("question")?),
        "verify-claim" => format!("Verify whether this claim is accurate: {}", pull("claim")?),
        "create-note" => format!("Capture notes for:\n\n{}", pull("content")?),
        // Unknown capability: registered in the registry but no
        // payload formatter here. This is a programmer error in this
        // crate, not a caller error. Fall through to raw JSON so the
        // call still produces something during follow-up bundle work.
        _ => payload_json.to_string(),
    })
}

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

    #[test]
    fn every_builtin_has_unique_id() {
        let mut ids: Vec<_> = BUILTIN_AGENTS.iter().map(|a| a.id).collect();
        ids.sort();
        let n = ids.len();
        ids.dedup();
        assert_eq!(ids.len(), n, "duplicate id in BUILTIN_AGENTS");
    }

    #[test]
    fn register_builtins_populates_registry() {
        let r = crate::AgentCapabilityRegistry::new();
        register_builtins(&r);
        // summarize is implemented by both summarizer and note-taker.
        let summarizers = r.agents_for("summarize");
        assert!(summarizers.contains(&"summarizer".to_string()));
        assert!(summarizers.contains(&"note-taker".to_string()));
    }

    #[test]
    fn format_payload_summarize_happy_path() {
        let p = r#"{"text":"hello world"}"#;
        let out = format_capability_payload("summarize", p).unwrap();
        assert!(out.contains("hello world"));
    }

    #[test]
    fn format_payload_summarize_missing_field_errors() {
        let p = r#"{"txet":"typo"}"#; // misspelled key
        let err = format_capability_payload("summarize", p).unwrap_err();
        match err {
            CapabilityPayloadError::MissingField { field, .. } => assert_eq!(field, "text"),
            other => panic!("wrong error: {other:?}"),
        }
    }

    #[test]
    fn format_payload_invalid_json_errors() {
        let err = format_capability_payload("summarize", "{").unwrap_err();
        assert!(matches!(err, CapabilityPayloadError::InvalidJson(_)));
    }

    #[test]
    fn agent_metadata_lookups() {
        let r = agent_metadata("researcher").expect("present");
        assert_eq!(r.display_name, "Researcher");
        assert!(r.capabilities.contains(&"research"));
        assert!(agent_metadata("nonexistent").is_none());
    }
}