Skip to main content

bob_runtime/
agent_loop.rs

1//! # Agent Loop
2//!
3//! High-level orchestration loop that unifies slash command routing, tape
4//! recording, and LLM pipeline execution.
5//!
6//! ## Architecture
7//!
8//! ```text
9//! ┌─────────────┐       ┌────────────┐       ┌─────────────────┐
10//! │  Channel     │ ───→  │ AgentLoop  │ ───→  │  AgentRuntime   │
11//! │ (transport)  │ ←───  │ (routing)  │ ←───  │  (LLM pipeline) │
12//! └─────────────┘       └────────────┘       └─────────────────┘
13//!                           │
14//!                           ├─→ Router (slash commands)
15//!                           ├─→ TapeStorePort (recording)
16//!                           └─→ ToolPort (tool listing for /tools)
17//! ```
18//!
19//! The `AgentLoop` wraps an `AgentRuntime` and adds:
20//!
21//! 1. **Slash command routing** — deterministic commands bypass the LLM
22//! 2. **Tape recording** — conversation history is persisted to the tape
23//! 3. **System prompt override** — load custom system prompts from workspace files
24
25use std::sync::Arc;
26
27use bob_core::{
28    error::AgentError,
29    ports::{EventSink, ToolPort},
30    tape::{TapeEntryKind, TapeSearchResult},
31    types::{AgentRequest, AgentRunResult, RequestContext},
32};
33
34// Re-export for convenience.
35pub use crate::router::help_text;
36use crate::{
37    AgentRuntime,
38    router::{self, RouteResult, SlashCommand},
39};
40
41/// Output from the agent loop after processing a single input.
42#[derive(Debug)]
43pub enum AgentLoopOutput {
44    /// A response from the LLM pipeline (normal conversation).
45    Response(AgentRunResult),
46    /// A deterministic command response (no LLM involved).
47    CommandOutput(String),
48    /// The user requested to quit the session.
49    Quit,
50}
51
52/// High-level agent orchestration loop.
53///
54/// Wraps an `AgentRuntime` with slash command routing, tape recording,
55/// and optional system prompt overrides.
56///
57/// ## Construction
58///
59/// ```rust,ignore
60/// let agent_loop = AgentLoop::new(runtime, tools)
61///     .with_tape(tape_store)
62///     .with_system_prompt("You are a helpful assistant.".to_string());
63/// ```
64pub struct AgentLoop {
65    runtime: Arc<dyn AgentRuntime>,
66    tools: Arc<dyn ToolPort>,
67    tape: Option<Arc<dyn bob_core::ports::TapeStorePort>>,
68    events: Option<Arc<dyn EventSink>>,
69    system_prompt_override: Option<String>,
70}
71
72impl std::fmt::Debug for AgentLoop {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.debug_struct("AgentLoop")
75            .field("has_tape", &self.tape.is_some())
76            .field("has_system_prompt_override", &self.system_prompt_override.is_some())
77            .finish_non_exhaustive()
78    }
79}
80
81impl AgentLoop {
82    /// Create a new agent loop wrapping the given runtime and tool port.
83    #[must_use]
84    pub fn new(runtime: Arc<dyn AgentRuntime>, tools: Arc<dyn ToolPort>) -> Self {
85        Self { runtime, tools, tape: None, events: None, system_prompt_override: None }
86    }
87
88    /// Attach a tape store for persistent conversation recording.
89    #[must_use]
90    pub fn with_tape(mut self, tape: Arc<dyn bob_core::ports::TapeStorePort>) -> Self {
91        self.tape = Some(tape);
92        self
93    }
94
95    /// Attach an event sink for observability.
96    #[must_use]
97    pub fn with_events(mut self, events: Arc<dyn EventSink>) -> Self {
98        self.events = Some(events);
99        self
100    }
101
102    /// Set a system prompt that overrides the default runtime prompt.
103    ///
104    /// This is typically loaded from a workspace file (e.g. `.agent/system-prompt.md`)
105    /// at the composition root. The content is passed as a pre-loaded string to
106    /// keep the runtime free from filesystem dependencies.
107    #[must_use]
108    pub fn with_system_prompt(mut self, prompt: String) -> Self {
109        self.system_prompt_override = Some(prompt);
110        self
111    }
112
113    /// Process a single user input and return the appropriate output.
114    ///
115    /// Slash commands are handled deterministically. Natural language input
116    /// is forwarded to the LLM pipeline.
117    pub async fn handle_input(
118        &self,
119        input: &str,
120        session_id: &str,
121    ) -> Result<AgentLoopOutput, AgentError> {
122        let sid = session_id.to_string();
123
124        // Record user input to tape (if configured).
125        if let Some(ref tape) = self.tape {
126            let _ = tape
127                .append(
128                    &sid,
129                    TapeEntryKind::Message {
130                        role: bob_core::types::Role::User,
131                        content: input.to_string(),
132                    },
133                )
134                .await;
135        }
136
137        match router::route(input) {
138            RouteResult::SlashCommand(cmd) => self.execute_command(cmd, &sid).await,
139            RouteResult::NaturalLanguage(text) => self.execute_llm(&text, &sid).await,
140        }
141    }
142
143    /// Execute a deterministic slash command.
144    async fn execute_command(
145        &self,
146        cmd: SlashCommand,
147        session_id: &String,
148    ) -> Result<AgentLoopOutput, AgentError> {
149        match cmd {
150            SlashCommand::Help => Ok(AgentLoopOutput::CommandOutput(help_text())),
151
152            SlashCommand::Tools => {
153                let tools = self.tools.list_tools().await?;
154                let mut out = String::from("Registered tools:\n");
155                for tool in &tools {
156                    out.push_str(&format!("  - {}: {}\n", tool.id, tool.description));
157                }
158                if tools.is_empty() {
159                    out.push_str("  (none)\n");
160                }
161                Ok(AgentLoopOutput::CommandOutput(out))
162            }
163
164            SlashCommand::ToolDescribe { name } => {
165                let tools = self.tools.list_tools().await?;
166                let found = tools.iter().find(|t| t.id == name);
167                let out = match found {
168                    Some(tool) => {
169                        format!(
170                            "Tool: {}\nDescription: {}\nSource: {:?}\nSchema:\n{}",
171                            tool.id,
172                            tool.description,
173                            tool.source,
174                            serde_json::to_string_pretty(&tool.input_schema).unwrap_or_default()
175                        )
176                    }
177                    None => {
178                        format!("Tool '{}' not found. Use /tools to list available tools.", name)
179                    }
180                };
181                Ok(AgentLoopOutput::CommandOutput(out))
182            }
183
184            SlashCommand::TapeSearch { query } => {
185                let out = if let Some(ref tape) = self.tape {
186                    let results = tape.search(session_id, &query).await?;
187                    format_search_results(&results)
188                } else {
189                    "Tape not configured.".to_string()
190                };
191                Ok(AgentLoopOutput::CommandOutput(out))
192            }
193
194            SlashCommand::TapeInfo => {
195                let out = if let Some(ref tape) = self.tape {
196                    let entries = tape.all_entries(session_id).await?;
197                    let anchors = tape.anchors(session_id).await?;
198                    format!("Tape: {} entries, {} anchors", entries.len(), anchors.len())
199                } else {
200                    "Tape not configured.".to_string()
201                };
202                Ok(AgentLoopOutput::CommandOutput(out))
203            }
204
205            SlashCommand::Anchors => {
206                let out = if let Some(ref tape) = self.tape {
207                    let anchors = tape.anchors(session_id).await?;
208                    if anchors.is_empty() {
209                        "No anchors in tape.".to_string()
210                    } else {
211                        let mut buf = String::from("Anchors:\n");
212                        for a in &anchors {
213                            if let TapeEntryKind::Anchor { ref name, .. } = a.kind {
214                                buf.push_str(&format!("  [{}] {}\n", a.id, name));
215                            }
216                        }
217                        buf
218                    }
219                } else {
220                    "Tape not configured.".to_string()
221                };
222                Ok(AgentLoopOutput::CommandOutput(out))
223            }
224
225            SlashCommand::Handoff { name } => {
226                let handoff_name = name.unwrap_or_else(|| "manual".to_string());
227                if let Some(ref tape) = self.tape {
228                    let all = tape.all_entries(session_id).await?;
229                    let _ = tape
230                        .append(
231                            session_id,
232                            TapeEntryKind::Handoff {
233                                name: handoff_name.clone(),
234                                entries_before: all.len() as u64,
235                                summary: None,
236                            },
237                        )
238                        .await;
239                    Ok(AgentLoopOutput::CommandOutput(format!(
240                        "Handoff '{}' created. Context window reset.",
241                        handoff_name
242                    )))
243                } else {
244                    Ok(AgentLoopOutput::CommandOutput("Tape not configured.".to_string()))
245                }
246            }
247
248            SlashCommand::Quit => Ok(AgentLoopOutput::Quit),
249
250            SlashCommand::Shell { command } => {
251                // Shell execution is a convenience fallback.
252                // In production, this should be gated by approval policies.
253                Ok(AgentLoopOutput::CommandOutput(format!(
254                    "Shell execution not yet implemented: {}",
255                    command
256                )))
257            }
258        }
259    }
260
261    /// Forward natural language input to the LLM pipeline.
262    async fn execute_llm(
263        &self,
264        text: &str,
265        session_id: &String,
266    ) -> Result<AgentLoopOutput, AgentError> {
267        let mut context = RequestContext::default();
268        if let Some(ref prompt) = self.system_prompt_override {
269            context.system_prompt = Some(prompt.clone());
270        }
271
272        let req = AgentRequest {
273            input: text.to_string(),
274            session_id: session_id.clone(),
275            model: None,
276            context,
277            cancel_token: None,
278        };
279
280        let result = self.runtime.run(req).await?;
281
282        // Record assistant response to tape.
283        if let Some(ref tape) = self.tape {
284            let AgentRunResult::Finished(ref resp) = result;
285            let _ = tape
286                .append(
287                    session_id,
288                    TapeEntryKind::Message {
289                        role: bob_core::types::Role::Assistant,
290                        content: resp.content.clone(),
291                    },
292                )
293                .await;
294        }
295
296        Ok(AgentLoopOutput::Response(result))
297    }
298}
299
300/// Format tape search results into a human-readable string.
301fn format_search_results(results: &[TapeSearchResult]) -> String {
302    if results.is_empty() {
303        return "No results found.".to_string();
304    }
305    let mut buf = format!("{} result(s):\n", results.len());
306    for r in results {
307        buf.push_str(&format!("  [{}] {}\n", r.entry.id, r.snippet));
308    }
309    buf
310}