1use 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
34pub use crate::router::help_text;
36use crate::{
37 AgentRuntime,
38 router::{self, RouteResult, SlashCommand},
39};
40
41#[derive(Debug)]
43pub enum AgentLoopOutput {
44 Response(AgentRunResult),
46 CommandOutput(String),
48 Quit,
50}
51
52pub 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 #[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 #[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 #[must_use]
97 pub fn with_events(mut self, events: Arc<dyn EventSink>) -> Self {
98 self.events = Some(events);
99 self
100 }
101
102 #[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 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 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 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 Ok(AgentLoopOutput::CommandOutput(format!(
254 "Shell execution not yet implemented: {}",
255 command
256 )))
257 }
258 }
259 }
260
261 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 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
300fn 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}