Skip to main content

cascade_agent/agent/
mod.rs

1//! Agent core: the main agentic loop with interrupt handling, tool execution,
2//! and memory compaction.
3
4pub mod state;
5
6use std::sync::Arc;
7
8use state::ConversationState;
9use tokio::sync::mpsc;
10
11use crate::config::AgentConfig;
12use crate::error::{AgentError, Result};
13use crate::knowledge::KnowledgeBase;
14use crate::memory::MemoryManager;
15use crate::orchestrator::types::OrchestratorMessage;
16use crate::orchestrator::{create_orchestrator_async, OrchestratorConnection};
17use crate::planning::PlanManager;
18use crate::skills::SkillManager;
19use crate::tools::ToolRegistry;
20
21/// User interrupt injected mid-loop.
22#[derive(Debug, Clone)]
23pub enum UserInterrupt {
24    /// A new user message to inject into the conversation.
25    NewMessage(String),
26    /// Cancel the current task.
27    Cancel,
28    /// Edit the current plan.
29    EditPlan(String),
30}
31
32/// The main agent struct — holds all subsystems and drives the loop.
33///
34/// `AgentLoop` coordinates between the LLM (via llm-cascade), tool execution,
35/// memory compaction, the orchestrator, and the plan manager.
36pub struct AgentLoop {
37    config: AgentConfig,
38    cascade_config: Arc<llm_cascade::AppConfig>,
39    db_conn: tokio::sync::Mutex<rusqlite::Connection>,
40    state: ConversationState,
41    memory: MemoryManager,
42    #[allow(dead_code)]
43    skill_manager: SkillManager,
44    tool_registry: ToolRegistry,
45    #[allow(dead_code)]
46    knowledge: Option<Arc<KnowledgeBase>>,
47    orchestrator: Box<dyn OrchestratorConnection>,
48    interrupt_rx: mpsc::Receiver<UserInterrupt>,
49    interrupt_tx: mpsc::Sender<UserInterrupt>,
50    #[allow(dead_code)]
51    plan_manager: Arc<std::sync::Mutex<PlanManager>>,
52}
53
54impl AgentLoop {
55    /// Create a new agent with all subsystems initialized.
56    pub async fn new(config: AgentConfig) -> Result<Self> {
57        // Load llm-cascade config
58        let cascade_config =
59            llm_cascade::load_config(std::path::Path::new(&config.agent.cascade_config_path))
60                .map_err(|e| {
61                    AgentError::ConfigError(format!("Failed to load cascade config: {}", e))
62                })?;
63
64        let cascade_config = Arc::new(cascade_config);
65
66        // Initialize SQLite for llm-cascade
67        let db_path = &cascade_config.database.path;
68        let cascade_db_conn = llm_cascade::db::init_db(db_path)
69            .map_err(|e| AgentError::ConfigError(format!("Failed to init DB: {}", e)))?;
70
71        // Load system prompt (SOUL.md)
72        let system_prompt = if std::path::Path::new(&config.agent.soul_md_path).exists() {
73            std::fs::read_to_string(&config.agent.soul_md_path)?
74        } else {
75            "You are Cascade Agent, an autonomous AI assistant.".to_string()
76        };
77
78        // Initialize memory (needs its own DB connection)
79        let memory_db_conn = llm_cascade::db::init_db(db_path)
80            .map_err(|e| AgentError::ConfigError(format!("Failed to init DB for memory: {}", e)))?;
81        let memory =
82            MemoryManager::new(&config.memory, Arc::clone(&cascade_config), memory_db_conn)?;
83
84        // Initialize skill manager + discover skills
85        let mut skill_manager =
86            SkillManager::new(std::path::PathBuf::from(&config.paths.skills_dir))?;
87        skill_manager.discover()?;
88
89        // Build tool registry
90        let tool_registry = ToolRegistry::new();
91
92        // Register built-in tools
93        tool_registry.register(crate::tools::builtin::EchoTool);
94        tool_registry.register(crate::tools::builtin::ReadFileTool);
95        tool_registry.register(crate::tools::builtin::WriteFileTool);
96        tool_registry.register(crate::tools::builtin::AskUserTool);
97
98        // Register search tools (optional, only if API keys are available)
99        if let Some(tavily) = crate::tools::search::TavilySearchTool::from_env(
100            &config.search.tavily_api_key_env,
101            config.search.max_results,
102        ) {
103            tool_registry.register(tavily);
104        }
105        if let Some(brave) = crate::tools::search::BraveSearchTool::from_env(
106            &config.search.brave_api_key_env,
107            config.search.max_results,
108        ) {
109            tool_registry.register(brave);
110        }
111
112        // Register skill tools
113        for skill_tool in skill_manager.all_tools() {
114            tool_registry.register_arc(std::sync::Arc::from(
115                skill_tool as Box<dyn crate::tools::Tool>,
116            ));
117        }
118
119        // Initialize plan manager
120        let plan_manager = Arc::new(std::sync::Mutex::new(PlanManager::new(
121            std::path::PathBuf::from(&config.paths.plans_dir),
122        )?));
123
124        let (interrupt_tx, interrupt_rx) = mpsc::channel::<UserInterrupt>(32);
125
126        let task_id = uuid::Uuid::new_v4().to_string();
127
128        // Register planning tools
129        tool_registry.register(crate::tools::planning_tools::CreatePlanTool::new(
130            Arc::clone(&plan_manager),
131            task_id.clone(),
132        ));
133        tool_registry.register(crate::tools::planning_tools::UpdatePlanStepTool::new(
134            Arc::clone(&plan_manager),
135        ));
136        tool_registry.register(crate::tools::planning_tools::ListPlansTool::new(
137            Arc::clone(&plan_manager),
138        ));
139        tool_registry.register(crate::tools::planning_tools::GetPlanTool::new(Arc::clone(
140            &plan_manager,
141        )));
142
143        // Register list_tools last (needs registry snapshot)
144        tool_registry.register_list_tools();
145
146        // Initialize knowledge base (may fail if ONNX Runtime is unavailable)
147        let knowledge = match KnowledgeBase::new(&config.knowledge).await {
148            Ok(kb) => {
149                tracing::info!(target: "agent", "Knowledge base initialized (collection: {})", config.knowledge.default_collection);
150                Some(Arc::new(kb))
151            }
152            Err(e) => {
153                tracing::warn!(target: "agent", "Knowledge base init failed, running without it: {}", e);
154                None
155            }
156        };
157
158        // Register knowledge query tool
159        if let Some(ref kb) = knowledge {
160            let kq_tool = crate::tools::knowledge_tool::KnowledgeQueryTool::with_defaults(
161                Arc::clone(kb) as Arc<dyn crate::tools::knowledge_tool::KnowledgeProvider>,
162                config.knowledge.default_collection.clone(),
163                config.knowledge.max_results,
164            );
165            tool_registry.register(kq_tool);
166        }
167
168        // Initialize orchestrator
169        let orchestrator = create_orchestrator_async(&config.orchestrator).await?;
170
171        let state = ConversationState::new(system_prompt, task_id);
172
173        Ok(Self {
174            config,
175            cascade_config,
176            db_conn: tokio::sync::Mutex::new(cascade_db_conn),
177            state,
178            memory,
179            skill_manager,
180            tool_registry,
181            knowledge,
182            orchestrator,
183            interrupt_rx,
184            interrupt_tx,
185            plan_manager,
186        })
187    }
188
189    /// Get a handle to send interrupts into the running loop.
190    pub fn interrupt_sender(&self) -> mpsc::Sender<UserInterrupt> {
191        self.interrupt_tx.clone()
192    }
193
194    /// Run the agent loop until completion, error, or cancellation.
195    ///
196    /// Returns the final assistant text output.
197    pub async fn run(&mut self, initial_prompt: String) -> Result<String> {
198        self.state.add_user_message(initial_prompt.clone());
199        self.orchestrator
200            .push(OrchestratorMessage::TaskStarted {
201                task_id: self.state.task_id.clone(),
202                description: initial_prompt,
203            })
204            .await;
205
206        let result = self.run_loop().await;
207
208        match &result {
209            Ok(_output) => {
210                self.orchestrator
211                    .push(OrchestratorMessage::TaskCompleted {
212                        task_id: self.state.task_id.clone(),
213                        output_path: None,
214                    })
215                    .await;
216            }
217            Err(e) => {
218                self.orchestrator
219                    .push(OrchestratorMessage::Error(e.to_string()))
220                    .await;
221            }
222        }
223
224        result
225    }
226
227    /// Internal agentic loop: LLM call → tool execution → repeat.
228    async fn run_loop(&mut self) -> Result<String> {
229        loop {
230            // 1. Check for pending user interrupts (non-blocking)
231            while let Ok(interrupt) = self.interrupt_rx.try_recv() {
232                match interrupt {
233                    UserInterrupt::NewMessage(msg) => {
234                        self.state.add_user_message(msg);
235                    }
236                    UserInterrupt::Cancel => {
237                        self.orchestrator
238                            .push(OrchestratorMessage::TaskCancelled)
239                            .await;
240                        return Ok("Task cancelled by user.".into());
241                    }
242                    UserInterrupt::EditPlan(content) => {
243                        self.state
244                            .add_user_message(format!("[Plan Edit]: {}", content));
245                    }
246                }
247            }
248
249            // 2. Check memory budget
250            let token_count = self.memory.count_tokens(&self.state);
251            if self.memory.should_compact(token_count) {
252                match self.memory.compact(&mut self.state).await {
253                    Ok(report) => {
254                        self.orchestrator
255                            .push(OrchestratorMessage::ContextCompacted {
256                                before: report.tokens_before,
257                                after: report.tokens_after,
258                            })
259                            .await;
260                        tracing::info!(
261                            target: "agent",
262                            "Context compacted: {} -> {} tokens ({} msgs -> {} msgs)",
263                            report.tokens_before,
264                            report.tokens_after,
265                            report.messages_before,
266                            report.messages_after
267                        );
268                    }
269                    Err(e) => {
270                        tracing::warn!(target: "agent", "Compaction failed: {}", e);
271                    }
272                }
273            }
274
275            // 3. Build conversation with tool definitions
276            let tool_defs = self.tool_registry.tool_definitions();
277            let conversation = self.state.to_conversation().with_tools(tool_defs);
278
279            // 4. Send to llm-cascade, while also listening for orchestrator messages
280            let cascade_name = self.config.agent.cascade_name.clone();
281            let config = Arc::clone(&self.cascade_config);
282
283            let cascade_future = async {
284                let conn_lock = self.db_conn.lock().await;
285                llm_cascade::run_cascade(&cascade_name, &conversation, &config, &conn_lock).await
286            };
287
288            let response = tokio::select! {
289                cascade_result = cascade_future => {
290                    cascade_result
291                }
292                Some(orch_msg) = self.orchestrator.recv() => {
293                    self.handle_orchestrator_message(orch_msg).await;
294                    continue;
295                }
296            };
297
298            let response = match response {
299                Ok(r) => r,
300                Err(cascade_err) => {
301                    let saved_path = self.state.to_json_file(&self.config.paths.outputs_dir)?;
302                    tracing::error!(
303                        target: "agent",
304                        "All cascade providers failed: {}. State saved to: {:?}",
305                        cascade_err.message,
306                        saved_path
307                    );
308                    return Err(AgentError::InferenceFailed(cascade_err.message));
309                }
310            };
311
312            // 5. Process response content blocks
313            let mut has_tool_calls = false;
314
315            for block in &response.content {
316                match block {
317                    llm_cascade::ContentBlock::Text { text } => {
318                        if !text.is_empty() {
319                            self.state.add_assistant_text(text.clone());
320                            self.orchestrator
321                                .push(OrchestratorMessage::AssistantText(text.clone()))
322                                .await;
323                        }
324                    }
325                    llm_cascade::ContentBlock::ToolCall {
326                        id,
327                        name,
328                        arguments,
329                    } => {
330                        has_tool_calls = true;
331                        let start = std::time::Instant::now();
332
333                        let args: serde_json::Value = serde_json::from_str(arguments)
334                            .unwrap_or(serde_json::json!({"raw": arguments}));
335
336                        tracing::info!(
337                            target: "agent",
338                            "Executing tool: {} (id={})",
339                            name,
340                            id
341                        );
342
343                        let tool_result = if name == "ask_user" {
344                            self.handle_ask_user(&args).await
345                        } else {
346                            self.tool_registry.execute(name, args.clone()).await
347                        };
348                        let duration_ms = start.elapsed().as_millis() as u64;
349
350                        let result_str = match &tool_result {
351                            Ok(r) => r.to_json_string(),
352                            Err(e) => serde_json::json!({
353                                "status": "error",
354                                "error": e.to_string()
355                            })
356                            .to_string(),
357                        };
358
359                        self.state.add_tool_result(id, &result_str);
360
361                        let status_str = match &tool_result {
362                            Ok(r) => r.status_str(),
363                            Err(_) => "error",
364                        };
365
366                        self.orchestrator
367                            .push(OrchestratorMessage::ToolExecuted {
368                                tool: name.clone(),
369                                status: status_str.to_string(),
370                                duration_ms,
371                            })
372                            .await;
373
374                        // Auto-store search results in knowledge base
375                        if (name == "tavily_search" || name == "brave_search")
376                            && tool_result.is_ok()
377                        {
378                            self.store_search_results(name, &args, &tool_result).await;
379                        }
380                    }
381                }
382            }
383
384            // 6. Check termination conditions
385            if !has_tool_calls {
386                break;
387            }
388            if self.state.turn_count >= self.config.agent.max_tool_rounds {
389                self.orchestrator
390                    .push(OrchestratorMessage::Warning(format!(
391                        "Max tool rounds ({}) reached",
392                        self.config.agent.max_tool_rounds
393                    )))
394                    .await;
395                break;
396            }
397        }
398
399        Ok(self.state.last_assistant_text().unwrap_or_default())
400    }
401
402    /// Handle an inbound message from the orchestrator.
403    async fn handle_orchestrator_message(&mut self, msg: OrchestratorMessage) {
404        match msg {
405            OrchestratorMessage::UserReply { content } => {
406                self.state.add_user_message(content);
407            }
408            OrchestratorMessage::PlanApproval { approved, feedback } => {
409                let feedback_text = feedback.unwrap_or_default();
410                let approval_msg = if approved {
411                    format!("[Plan Approved] {}", feedback_text)
412                } else {
413                    format!("[Plan Rejected] {}", feedback_text)
414                };
415                self.state.add_user_message(approval_msg);
416            }
417            OrchestratorMessage::CancelTask => {
418                self.orchestrator
419                    .push(OrchestratorMessage::TaskCancelled)
420                    .await;
421            }
422            _ => {
423                tracing::debug!(target: "agent", "Ignoring orchestrator message: {:?}", msg);
424            }
425        }
426    }
427
428    /// Intercept ask_user tool calls: push question to orchestrator, await reply.
429    async fn handle_ask_user(
430        &mut self,
431        args: &serde_json::Value,
432    ) -> crate::error::Result<crate::tools::ToolResult> {
433        let question = args
434            .get("question")
435            .and_then(|v| v.as_str())
436            .unwrap_or("(no question provided)");
437
438        if !self.orchestrator.is_connected() {
439            return Ok(crate::tools::ToolResult::ok(serde_json::json!({
440                "status": "no_orchestrator",
441                "question": question,
442                "answer": "No orchestrator connected. Use the interrupt channel to reply.",
443            })));
444        }
445
446        self.orchestrator
447            .push(OrchestratorMessage::UserQuestion {
448                question: question.to_string(),
449            })
450            .await;
451
452        tracing::info!(target: "agent", "Waiting for user reply to: {}", question);
453
454        match tokio::time::timeout(
455            std::time::Duration::from_secs(300),
456            self.orchestrator.recv(),
457        )
458        .await
459        {
460            Ok(Some(OrchestratorMessage::UserReply { content })) => {
461                Ok(crate::tools::ToolResult::ok(serde_json::json!({
462                    "status": "replied",
463                    "question": question,
464                    "answer": content,
465                })))
466            }
467            Ok(Some(other)) => {
468                self.handle_orchestrator_message(other).await;
469                Ok(crate::tools::ToolResult::ok(serde_json::json!({
470                    "status": "interrupted",
471                    "question": question,
472                    "answer": "User sent a different message instead of replying.",
473                })))
474            }
475            Ok(None) => Ok(crate::tools::ToolResult::ok(serde_json::json!({
476                "status": "timeout",
477                "question": question,
478                "answer": "Orchestrator disconnected before user replied.",
479            }))),
480            Err(_) => Ok(crate::tools::ToolResult::ok(serde_json::json!({
481                "status": "timeout",
482                "question": question,
483                "answer": "Timed out waiting for user reply (5 minutes).",
484            }))),
485        }
486    }
487
488    /// Store search results in the knowledge base for future retrieval.
489    async fn store_search_results(
490        &self,
491        tool_name: &str,
492        args: &serde_json::Value,
493        tool_result: &crate::error::Result<crate::tools::ToolResult>,
494    ) {
495        let kb = match &self.knowledge {
496            Some(kb) => kb,
497            None => return,
498        };
499
500        let query = match args.get("query").and_then(|v| v.as_str()) {
501            Some(q) => q.to_string(),
502            None => return,
503        };
504
505        let results = match tool_result {
506            Ok(r) => &r.data,
507            Err(_) => return,
508        };
509
510        let result_array = match results.get("results").and_then(|v| v.as_array()) {
511            Some(arr) => arr,
512            None => return,
513        };
514
515        let entries: Vec<crate::knowledge::vectordb::KnowledgeEntry> = result_array
516            .iter()
517            .filter_map(|item| {
518                let text = format!(
519                    "{}\n{}",
520                    item.get("title").and_then(|v| v.as_str()).unwrap_or(""),
521                    item.get("snippet").and_then(|v| v.as_str()).unwrap_or("")
522                );
523                if text.trim().is_empty() {
524                    return None;
525                }
526                Some(crate::knowledge::vectordb::KnowledgeEntry {
527                    text,
528                    source: tool_name.to_string(),
529                    metadata: serde_json::json!({
530                        "url": item.get("url").and_then(|v| v.as_str()).unwrap_or(""),
531                        "query": query,
532                    }),
533                    timestamp: chrono::Utc::now().timestamp(),
534                })
535            })
536            .collect();
537
538        if entries.is_empty() {
539            return;
540        }
541
542        let collection = &self.config.knowledge.default_collection;
543        match kb.store_results(collection, entries).await {
544            Ok(()) => {
545                tracing::info!(
546                    target: "agent",
547                    "Stored {} search results in knowledge base (collection: {})",
548                    result_array.len(),
549                    collection
550                );
551            }
552            Err(e) => {
553                tracing::warn!(
554                    target: "agent",
555                    "Failed to store search results in knowledge base: {}",
556                    e
557                );
558            }
559        }
560    }
561
562    /// Update the system prompt dynamically.
563    pub fn set_system_prompt(&mut self, prompt: String) {
564        self.state.system_prompt = prompt;
565    }
566
567    /// Get a reference to the current conversation state.
568    pub fn state(&self) -> &ConversationState {
569        &self.state
570    }
571}