Skip to main content

cersei_agent/
lib.rs

1//! cersei-agent: The high-level Agent API with builder pattern, agentic loop,
2//! realtime event streaming, broadcast channels, and reporters.
3
4pub mod agent_tool;
5pub mod auto_dream;
6pub mod compact;
7pub mod context_analyzer;
8pub mod coordinator;
9pub mod delegate;
10pub mod delegate_tool;
11pub mod effort;
12pub mod events;
13pub mod reporters;
14mod runner;
15pub mod session_memory;
16pub mod system_prompt;
17
18// Re-export runner utilities
19pub use runner::apply_tool_result_budget;
20
21use cersei_hooks::Hook;
22use cersei_mcp::McpServerConfig;
23use cersei_memory::Memory;
24use cersei_provider::Provider;
25use cersei_tools::permissions::{AllowAll, PermissionPolicy};
26use cersei_tools::{CostTracker, Tool};
27use cersei_types::*;
28use events::AgentEvent;
29use std::path::PathBuf;
30use std::sync::Arc;
31use std::time::Duration;
32use tokio::sync::{broadcast, mpsc};
33
34// Re-exports
35pub use events::{AgentStream, CompactReason, WarningState};
36pub use reporters::Reporter;
37
38// ─── Agent output ────────────────────────────────────────────────────────────
39
40#[derive(Debug, Clone)]
41pub struct AgentOutput {
42    pub message: Message,
43    pub usage: Usage,
44    pub stop_reason: StopReason,
45    pub turns: u32,
46    pub tool_calls: Vec<ToolCallRecord>,
47}
48
49impl AgentOutput {
50    pub fn text(&self) -> &str {
51        self.message.get_text().unwrap_or("")
52    }
53}
54
55#[derive(Debug, Clone)]
56pub struct ToolCallRecord {
57    pub name: String,
58    pub id: String,
59    pub input: serde_json::Value,
60    pub result: String,
61    pub is_error: bool,
62    pub duration: Duration,
63}
64
65// ─── Agent ───────────────────────────────────────────────────────────────────
66
67#[allow(dead_code)]
68pub struct Agent {
69    provider: Box<dyn Provider>,
70    tools: Vec<Box<dyn Tool>>,
71    system_prompt: Option<String>,
72    append_system_prompt: Option<String>,
73    model: Option<String>,
74    max_turns: u32,
75    max_tokens: u32,
76    temperature: Option<f32>,
77    thinking_budget: Option<u32>,
78    working_dir: PathBuf,
79    permission_policy: Arc<dyn PermissionPolicy>,
80    memory: Option<Arc<dyn Memory>>,
81    session_id: Option<String>,
82    hooks: Vec<Arc<dyn Hook>>,
83    mcp_manager: Option<Arc<cersei_mcp::McpManager>>,
84    event_handler: Option<Box<dyn Fn(&AgentEvent) + Send + Sync>>,
85    broadcast_tx: Option<broadcast::Sender<AgentEvent>>,
86    reporters: Vec<Arc<dyn Reporter>>,
87    event_filter: Option<Box<dyn Fn(&AgentEvent) -> bool + Send + Sync>>,
88    cost_tracker: Arc<CostTracker>,
89    auto_compact: bool,
90    compact_threshold: f64,
91    tool_result_budget: usize,
92    /// Cadence (in turns) at which `HookEvent::TurnsElapsed` fires. Default
93    /// 10. Setting to 0 disables the event entirely. Used by the
94    /// `SkillNudgeHook` for agent-curated skill review.
95    pub(crate) turns_elapsed_cadence: u32,
96    pub(crate) compression_level: Arc<parking_lot::Mutex<cersei_compression::CompressionLevel>>,
97    pub benchmark_mode: bool,
98    messages: Arc<parking_lot::Mutex<Vec<Message>>>,
99    cumulative_usage: Arc<parking_lot::Mutex<Usage>>,
100    cancel_token: tokio_util::sync::CancellationToken,
101}
102
103impl Agent {
104    pub fn builder() -> AgentBuilder {
105        AgentBuilder::default()
106    }
107
108    /// Run a prompt through the agentic loop.
109    pub async fn run(&self, prompt: &str) -> cersei_types::Result<AgentOutput> {
110        runner::run_agent(self, prompt).await
111    }
112
113    /// Run with streaming — returns a stream of AgentEvents.
114    /// Takes `Arc<Self>` so the agent can safely outlive the caller in the spawned task.
115    pub fn run_stream(self: &Arc<Self>, prompt: &str) -> AgentStream {
116        let (event_tx, event_rx) = mpsc::channel(512);
117        let (control_tx, control_rx) = mpsc::channel(64);
118
119        let prompt = prompt.to_string();
120        let agent = Arc::clone(self);
121
122        tokio::spawn(async move {
123            let result =
124                runner::run_agent_streaming(&agent, &prompt, event_tx.clone(), control_rx).await;
125            match result {
126                Ok(output) => {
127                    let _ = event_tx.send(AgentEvent::Complete(output)).await;
128                }
129                Err(e) => {
130                    let _ = event_tx.send(AgentEvent::Error(e.to_string())).await;
131                }
132            }
133        });
134
135        AgentStream::new(event_rx, control_tx)
136    }
137
138    /// Multi-turn: send a follow-up message in the same conversation.
139    pub async fn reply(&self, message: &str) -> cersei_types::Result<AgentOutput> {
140        runner::run_agent(self, message).await
141    }
142
143    /// Access the conversation history.
144    pub fn messages(&self) -> Vec<Message> {
145        self.messages.lock().clone()
146    }
147
148    /// Get cumulative usage/cost.
149    pub fn usage(&self) -> Usage {
150        self.cumulative_usage.lock().clone()
151    }
152
153    /// Cancel a running agent.
154    pub fn cancel(&self) {
155        self.cancel_token.cancel();
156    }
157
158    /// Get the current tool-output compression level.
159    pub fn compression_level(&self) -> cersei_compression::CompressionLevel {
160        *self.compression_level.lock()
161    }
162
163    /// Change the tool-output compression level at runtime. Takes effect on
164    /// the next tool call.
165    pub fn set_compression_level(&self, level: cersei_compression::CompressionLevel) {
166        *self.compression_level.lock() = level;
167    }
168
169    /// Subscribe to the broadcast channel (requires enable_broadcast on builder).
170    pub fn subscribe(&self) -> Option<broadcast::Receiver<AgentEvent>> {
171        self.broadcast_tx.as_ref().map(|tx| tx.subscribe())
172    }
173
174    /// Emit an event to all listeners.
175    pub(crate) fn emit(&self, event: AgentEvent) {
176        // Apply filter
177        if let Some(filter) = &self.event_filter {
178            if !filter(&event) {
179                return;
180            }
181        }
182
183        // Callback handler
184        if let Some(handler) = &self.event_handler {
185            handler(&event);
186        }
187
188        // Broadcast channel
189        if let Some(tx) = &self.broadcast_tx {
190            let _ = tx.send(event.clone());
191        }
192
193        // Reporters
194        for reporter in &self.reporters {
195            let reporter = Arc::clone(reporter);
196            let event = event.clone();
197            tokio::spawn(async move {
198                reporter.on_event(&event).await;
199            });
200        }
201    }
202}
203
204// ─── Agent builder ───────────────────────────────────────────────────────────
205
206pub struct AgentBuilder {
207    provider: Option<Box<dyn Provider>>,
208    tools: Vec<Box<dyn Tool>>,
209    system_prompt: Option<String>,
210    append_system_prompt: Option<String>,
211    model: Option<String>,
212    max_turns: u32,
213    max_tokens: u32,
214    temperature: Option<f32>,
215    thinking_budget: Option<u32>,
216    working_dir: Option<PathBuf>,
217    permission_policy: Option<Arc<dyn PermissionPolicy>>,
218    memory: Option<Arc<dyn Memory>>,
219    session_id: Option<String>,
220    hooks: Vec<Arc<dyn Hook>>,
221    mcp_servers: Vec<McpServerConfig>,
222    event_handler: Option<Box<dyn Fn(&AgentEvent) + Send + Sync>>,
223    broadcast_capacity: Option<usize>,
224    reporters: Vec<Arc<dyn Reporter>>,
225    event_filter: Option<Box<dyn Fn(&AgentEvent) -> bool + Send + Sync>>,
226    cancel_token: Option<tokio_util::sync::CancellationToken>,
227    auto_compact: bool,
228    compact_threshold: f64,
229    tool_result_budget: usize,
230    turns_elapsed_cadence: u32,
231    compression_level: cersei_compression::CompressionLevel,
232    initial_messages: Option<Vec<Message>>,
233    benchmark_mode: bool,
234}
235
236impl Default for AgentBuilder {
237    fn default() -> Self {
238        Self {
239            provider: None,
240            tools: Vec::new(),
241            system_prompt: None,
242            append_system_prompt: None,
243            model: None,
244            max_turns: 10,
245            max_tokens: 16384,
246            temperature: None,
247            thinking_budget: None,
248            working_dir: None,
249            permission_policy: None,
250            memory: None,
251            session_id: None,
252            hooks: Vec::new(),
253            mcp_servers: Vec::new(),
254            event_handler: None,
255            broadcast_capacity: None,
256            reporters: Vec::new(),
257            event_filter: None,
258            cancel_token: None,
259            auto_compact: true,
260            compact_threshold: 0.9,
261            tool_result_budget: 50_000,
262            turns_elapsed_cadence: 10,
263            compression_level: cersei_compression::CompressionLevel::Off,
264            initial_messages: None,
265            benchmark_mode: false,
266        }
267    }
268}
269
270impl AgentBuilder {
271    pub fn provider(mut self, p: impl Provider + 'static) -> Self {
272        self.provider = Some(Box::new(p));
273        self
274    }
275
276    /// Accept a pre-boxed provider. Useful when the caller already has a
277    /// `Box<dyn Provider>` (e.g., the delegation primitive, which builds
278    /// child providers via a factory closure).
279    pub fn provider_boxed(mut self, p: Box<dyn Provider>) -> Self {
280        self.provider = Some(p);
281        self
282    }
283
284    pub fn tool(mut self, t: impl Tool + 'static) -> Self {
285        self.tools.push(Box::new(t));
286        self
287    }
288
289    pub fn tools(mut self, ts: Vec<Box<dyn Tool>>) -> Self {
290        self.tools.extend(ts);
291        self
292    }
293
294    pub fn system_prompt(mut self, s: impl Into<String>) -> Self {
295        self.system_prompt = Some(s.into());
296        self
297    }
298
299    pub fn append_system_prompt(mut self, s: impl Into<String>) -> Self {
300        self.append_system_prompt = Some(s.into());
301        self
302    }
303
304    pub fn model(mut self, m: impl Into<String>) -> Self {
305        self.model = Some(m.into());
306        self
307    }
308
309    pub fn max_turns(mut self, n: u32) -> Self {
310        self.max_turns = n;
311        self
312    }
313
314    pub fn max_tokens(mut self, n: u32) -> Self {
315        self.max_tokens = n;
316        self
317    }
318
319    pub fn temperature(mut self, t: f32) -> Self {
320        self.temperature = Some(t);
321        self
322    }
323
324    pub fn thinking_budget(mut self, tokens: u32) -> Self {
325        self.thinking_budget = Some(tokens);
326        self
327    }
328
329    pub fn working_dir(mut self, p: impl Into<PathBuf>) -> Self {
330        self.working_dir = Some(p.into());
331        self
332    }
333
334    pub fn permission_policy(mut self, p: impl PermissionPolicy + 'static) -> Self {
335        self.permission_policy = Some(Arc::new(p));
336        self
337    }
338
339    pub fn memory(mut self, m: impl Memory + 'static) -> Self {
340        self.memory = Some(Arc::new(m));
341        self
342    }
343
344    pub fn session_id(mut self, id: impl Into<String>) -> Self {
345        self.session_id = Some(id.into());
346        self
347    }
348
349    pub fn hook(mut self, h: impl Hook + 'static) -> Self {
350        self.hooks.push(Arc::new(h));
351        self
352    }
353
354    pub fn mcp_server(mut self, config: McpServerConfig) -> Self {
355        self.mcp_servers.push(config);
356        self
357    }
358
359    pub fn on_event(mut self, f: impl Fn(&AgentEvent) + Send + Sync + 'static) -> Self {
360        self.event_handler = Some(Box::new(f));
361        self
362    }
363
364    pub fn enable_broadcast(mut self, capacity: usize) -> Self {
365        self.broadcast_capacity = Some(capacity);
366        self
367    }
368
369    pub fn reporter(mut self, r: impl Reporter + 'static) -> Self {
370        self.reporters.push(Arc::new(r));
371        self
372    }
373
374    pub fn event_filter(mut self, f: impl Fn(&AgentEvent) -> bool + Send + Sync + 'static) -> Self {
375        self.event_filter = Some(Box::new(f));
376        self
377    }
378
379    pub fn cancel_token(mut self, token: tokio_util::sync::CancellationToken) -> Self {
380        self.cancel_token = Some(token);
381        self
382    }
383
384    pub fn auto_compact(mut self, enabled: bool) -> Self {
385        self.auto_compact = enabled;
386        self
387    }
388
389    pub fn compact_threshold(mut self, threshold: f64) -> Self {
390        self.compact_threshold = threshold;
391        self
392    }
393
394    pub fn tool_result_budget(mut self, chars: usize) -> Self {
395        self.tool_result_budget = chars;
396        self
397    }
398
399    /// Set the tool-output compression level (default `Off`). Compression is
400    /// applied to each tool result before the per-result cap and the overall
401    /// tool-result budget run.
402    /// How often `HookEvent::TurnsElapsed` fires (default 10). Set to 0 to
403    /// disable. Used by skill-nudge hooks for agent-curated skill review.
404    pub fn turns_elapsed_cadence(mut self, n: u32) -> Self {
405        self.turns_elapsed_cadence = n;
406        self
407    }
408
409    pub fn compression_level(mut self, level: cersei_compression::CompressionLevel) -> Self {
410        self.compression_level = level;
411        self
412    }
413
414    /// Pre-populate conversation history (for provider switching mid-session).
415    pub fn with_messages(mut self, msgs: Vec<Message>) -> Self {
416        self.initial_messages = Some(msgs);
417        self
418    }
419
420    /// Enable benchmark mode (self-verification loop for terminal-bench).
421    pub fn benchmark_mode(mut self, enabled: bool) -> Self {
422        self.benchmark_mode = enabled;
423        self
424    }
425
426    pub fn build(self) -> cersei_types::Result<Agent> {
427        let provider = self
428            .provider
429            .ok_or_else(|| CerseiError::Config("Provider is required".into()))?;
430
431        let working_dir = self
432            .working_dir
433            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
434
435        let broadcast_tx = self.broadcast_capacity.map(|cap| {
436            let (tx, _) = broadcast::channel(cap);
437            tx
438        });
439
440        Ok(Agent {
441            provider,
442            tools: self.tools,
443            system_prompt: self.system_prompt,
444            append_system_prompt: self.append_system_prompt,
445            model: self.model,
446            max_turns: self.max_turns,
447            max_tokens: self.max_tokens,
448            temperature: self.temperature,
449            thinking_budget: self.thinking_budget,
450            working_dir,
451            permission_policy: self.permission_policy.unwrap_or_else(|| Arc::new(AllowAll)),
452            memory: self.memory,
453            session_id: self.session_id,
454            hooks: self.hooks,
455            mcp_manager: None, // TODO: connect MCP servers
456            event_handler: self.event_handler,
457            broadcast_tx,
458            reporters: self.reporters,
459            event_filter: self.event_filter,
460            cost_tracker: Arc::new(CostTracker::new()),
461            auto_compact: self.auto_compact,
462            compact_threshold: self.compact_threshold,
463            tool_result_budget: self.tool_result_budget,
464            turns_elapsed_cadence: if self.turns_elapsed_cadence == 0 {
465                u32::MAX
466            } else {
467                self.turns_elapsed_cadence
468            },
469            compression_level: Arc::new(parking_lot::Mutex::new(self.compression_level)),
470            benchmark_mode: self.benchmark_mode,
471            messages: Arc::new(parking_lot::Mutex::new(
472                self.initial_messages.unwrap_or_default(),
473            )),
474            cumulative_usage: Arc::new(parking_lot::Mutex::new(Usage::default())),
475            cancel_token: self
476                .cancel_token
477                .unwrap_or_else(tokio_util::sync::CancellationToken::new),
478        })
479    }
480
481    /// Build + run in one shot.
482    pub async fn run_with(self, prompt: &str) -> cersei_types::Result<AgentOutput> {
483        self.build()?.run(prompt).await
484    }
485}