Skip to main content

car_engine/
builtin_agents.rs

1//! Built-in agent vocabulary — single source of truth across FFI surfaces.
2//!
3//! Holds:
4//!
5//! - The list of v1 built-in agents (`Researcher`, `Summarizer`,
6//!   `Verifier`, `Transcriber`, `NoteTaker`)
7//! - Each agent's identity (system prompt / display name / description)
8//! - The capability ids each agent advertises
9//! - Per-capability payload-format adapters (caller-supplied JSON →
10//!   prompt text)
11//!
12//! Why this lives in `car-engine` and not in any single FFI crate:
13//! NAPI (`car-ffi-napi`), PyO3 (`car-ffi-pyo3`), and UniFFI
14//! (`car-ffi-uniffi`) are sibling consumers of the same runtime —
15//! they MUST agree on the agent vocabulary or the surfaces drift.
16//! Per CLAUDE.md §2 (bindings parity), capability-shape changes
17//! land in all binding sites in the same change. Owning the data
18//! here makes that mechanically enforceable.
19//!
20//! When agent bundles ship via `car install`, the bundle loader
21//! registers additional agents on top of these. The built-ins are
22//! the floor — the runtime always offers them even before any
23//! bundle is installed.
24
25use serde::Serialize;
26
27/// One built-in agent's static metadata.
28#[derive(Debug, Clone, Copy, Serialize)]
29pub struct BuiltinAgent {
30    /// Stable identifier. `agent_hint` parameters reference this.
31    pub id: &'static str,
32    /// Short human-readable name for UI surfaces (Apple
33    /// `DynamicOptionsProvider`, Shortcuts pickers, settings).
34    pub display_name: &'static str,
35    /// One-sentence description that surfaces in pickers as the
36    /// agent's subtitle.
37    pub description: &'static str,
38    /// Capability ids this agent serves. Order is informational
39    /// only; the registry is the authoritative dispatcher.
40    pub capabilities: &'static [&'static str],
41    /// System-prompt prefix that drives the agent's behavior.
42    /// A richer impl pulls identity from `car-memgine` once
43    /// `identity.md`-shipping bundles land; for v1 the static
44    /// system prompt is enough to differentiate verbs.
45    pub system_prompt: &'static str,
46}
47
48/// V1 built-in roster. New entries are additive; ids are
49/// stable wire format (host UI persists them as user pinning).
50pub const BUILTIN_AGENTS: &[BuiltinAgent] = &[
51    BuiltinAgent {
52        id: "researcher",
53        display_name: "Researcher",
54        description: "Finds factual answers and cites sources. Flags uncertainty.",
55        capabilities: &["research", "search-knowledge"],
56        system_prompt: "You are a research assistant. Answer factually, cite sources \
57                        when possible, and flag uncertainty explicitly.",
58    },
59    BuiltinAgent {
60        id: "summarizer",
61        display_name: "Summarizer",
62        description: "Concise summaries — bullet points by default, prose on request.",
63        capabilities: &["summarize"],
64        system_prompt: "You are a summarization agent. Produce a concise, accurate \
65                        summary of the input. Default to bullet points unless the \
66                        user requests prose.",
67    },
68    BuiltinAgent {
69        id: "verifier",
70        display_name: "Verifier",
71        description: "Evaluates the truthfulness of a claim with reasoning.",
72        capabilities: &["verify-claim"],
73        system_prompt: "You are a verifier. Given a claim, evaluate whether it's \
74                        likely true, likely false, or uncertain. Show your reasoning.",
75    },
76    BuiltinAgent {
77        id: "transcriber",
78        display_name: "Transcriber",
79        description: "Cleans up transcripts: punctuation, mis-recognitions, speaker turns.",
80        capabilities: &["transcribe-audio"],
81        system_prompt: "You are a transcription assistant. Clean up the input \
82                        transcript: fix obvious mis-recognitions, restore punctuation, \
83                        and preserve speaker turns when present.",
84    },
85    BuiltinAgent {
86        id: "note-taker",
87        display_name: "Note Taker",
88        description: "Captures decisions, action items, and open questions as bullets.",
89        capabilities: &["create-note", "summarize"],
90        system_prompt: "You are a note-taking assistant. Capture decisions, action \
91                        items, and open questions from the input as terse bullet lists.",
92    },
93];
94
95/// Look up a built-in agent by id. `None` for unknown ids.
96pub fn agent_metadata(id: &str) -> Option<&'static BuiltinAgent> {
97    BUILTIN_AGENTS.iter().find(|a| a.id == id)
98}
99
100/// Register every built-in agent's capabilities with the registry.
101/// Called by FFI surfaces during runtime construction; idempotent.
102pub fn register_builtins(registry: &super::AgentCapabilityRegistry) {
103    for agent in BUILTIN_AGENTS {
104        for cap in agent.capabilities {
105            registry.register(*cap, agent.id);
106        }
107    }
108}
109
110/// Errors from [`format_capability_payload`]. Surfaced to the FFI
111/// crate as `CarError::InvalidArgument` so Swift / Kotlin / JS
112/// callers see a structured failure when their JSON contract drifts.
113#[derive(Debug, thiserror::Error)]
114pub enum CapabilityPayloadError {
115    #[error("payload_json is not valid JSON: {0}")]
116    InvalidJson(String),
117
118    #[error("capability '{capability}' requires JSON object with string field '{field}'")]
119    MissingField {
120        capability: String,
121        field: &'static str,
122    },
123}
124
125/// Format the per-capability JSON payload into a natural-language
126/// task for the selected agent. The contract is per-capability and
127/// documented in `docs/agent-bundle-spec.md`.
128///
129/// This is intentionally NOT a silent fallback. Callers send this
130/// JSON from a structured intent definition (Swift `@AppIntent`
131/// parameters → JSON encode); a missing field means the host's
132/// intent shape drifted from the runtime's expectation, and we'd
133/// rather hard-error at the FFI boundary than send raw JSON to a
134/// model that will dutifully summarize the JSON itself.
135pub fn format_capability_payload(
136    capability: &str,
137    payload_json: &str,
138) -> Result<String, CapabilityPayloadError> {
139    let v: serde_json::Value = serde_json::from_str(payload_json)
140        .map_err(|e| CapabilityPayloadError::InvalidJson(e.to_string()))?;
141
142    let pull = |field: &'static str| -> Result<String, CapabilityPayloadError> {
143        v.get(field)
144            .and_then(|t| t.as_str())
145            .map(|s| s.to_string())
146            .ok_or_else(|| CapabilityPayloadError::MissingField {
147                capability: capability.to_string(),
148                field,
149            })
150    };
151
152    Ok(match capability {
153        "summarize" => format!("Summarize the following:\n\n{}", pull("text")?),
154        "transcribe-audio" => format!("Transcribe the audio file at: {}", pull("audio_path")?),
155        "research" | "search-knowledge" => format!("Research and answer: {}", pull("question")?),
156        "verify-claim" => format!("Verify whether this claim is accurate: {}", pull("claim")?),
157        "create-note" => format!("Capture notes for:\n\n{}", pull("content")?),
158        // Unknown capability: registered in the registry but no
159        // payload formatter here. This is a programmer error in this
160        // crate, not a caller error. Fall through to raw JSON so the
161        // call still produces something during follow-up bundle work.
162        _ => payload_json.to_string(),
163    })
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn every_builtin_has_unique_id() {
172        let mut ids: Vec<_> = BUILTIN_AGENTS.iter().map(|a| a.id).collect();
173        ids.sort();
174        let n = ids.len();
175        ids.dedup();
176        assert_eq!(ids.len(), n, "duplicate id in BUILTIN_AGENTS");
177    }
178
179    #[test]
180    fn register_builtins_populates_registry() {
181        let r = crate::AgentCapabilityRegistry::new();
182        register_builtins(&r);
183        // summarize is implemented by both summarizer and note-taker.
184        let summarizers = r.agents_for("summarize");
185        assert!(summarizers.contains(&"summarizer".to_string()));
186        assert!(summarizers.contains(&"note-taker".to_string()));
187    }
188
189    #[test]
190    fn format_payload_summarize_happy_path() {
191        let p = r#"{"text":"hello world"}"#;
192        let out = format_capability_payload("summarize", p).unwrap();
193        assert!(out.contains("hello world"));
194    }
195
196    #[test]
197    fn format_payload_summarize_missing_field_errors() {
198        let p = r#"{"txet":"typo"}"#; // misspelled key
199        let err = format_capability_payload("summarize", p).unwrap_err();
200        match err {
201            CapabilityPayloadError::MissingField { field, .. } => assert_eq!(field, "text"),
202            other => panic!("wrong error: {other:?}"),
203        }
204    }
205
206    #[test]
207    fn format_payload_invalid_json_errors() {
208        let err = format_capability_payload("summarize", "{").unwrap_err();
209        assert!(matches!(err, CapabilityPayloadError::InvalidJson(_)));
210    }
211
212    #[test]
213    fn agent_metadata_lookups() {
214        let r = agent_metadata("researcher").expect("present");
215        assert_eq!(r.display_name, "Researcher");
216        assert!(r.capabilities.contains(&"research"));
217        assert!(agent_metadata("nonexistent").is_none());
218    }
219}