1use std::sync::Arc;
15
16use bob_core::{
17 error::AgentError,
18 ports::{EventSink, SessionStore, TapeStorePort, ToolPort},
19 types::{AgentRunResult, FinishReason, RequestContext, SessionState, TokenUsage},
20};
21use uuid::Uuid;
22
23use crate::{AgentRuntime, agent_loop::AgentLoop};
24
25#[derive(Debug, Clone)]
30pub struct AgentResponse {
31 pub content: String,
33 pub usage: TokenUsage,
35 pub finish_reason: FinishReason,
37 pub is_quit: bool,
39}
40
41impl AgentResponse {
42 #[must_use]
44 pub fn new(content: impl Into<String>, usage: TokenUsage, finish_reason: FinishReason) -> Self {
45 Self { content: content.into(), usage, finish_reason, is_quit: false }
46 }
47
48 #[must_use]
50 pub fn quit() -> Self {
51 Self {
52 content: String::new(),
53 usage: TokenUsage::default(),
54 finish_reason: FinishReason::Stop,
55 is_quit: true,
56 }
57 }
58
59 #[must_use]
61 pub fn command_output(output: impl Into<String>) -> Self {
62 Self {
63 content: output.into(),
64 usage: TokenUsage::default(),
65 finish_reason: FinishReason::Stop,
66 is_quit: false,
67 }
68 }
69}
70
71pub struct Agent {
76 runtime: Arc<dyn AgentRuntime>,
77 tools: Arc<dyn ToolPort>,
78 store: Option<Arc<dyn SessionStore>>,
79 tape: Option<Arc<dyn TapeStorePort>>,
80 events: Option<Arc<dyn EventSink>>,
81 system_prompt: Option<String>,
82}
83
84impl std::fmt::Debug for Agent {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 f.debug_struct("Agent")
87 .field("has_store", &self.store.is_some())
88 .field("has_tape", &self.tape.is_some())
89 .field("has_system_prompt", &self.system_prompt.is_some())
90 .finish_non_exhaustive()
91 }
92}
93
94impl Agent {
95 #[must_use]
100 pub fn from_runtime(runtime: Arc<dyn AgentRuntime>, tools: Arc<dyn ToolPort>) -> AgentBuilder {
101 AgentBuilder::new(runtime, tools)
102 }
103
104 #[must_use]
106 pub fn start_session(&self) -> Session {
107 Session::new(self.clone(), format!("session-{}", Uuid::new_v4()))
108 }
109
110 #[must_use]
112 pub fn start_session_with_id(&self, session_id: impl Into<String>) -> Session {
113 Session::new(self.clone(), session_id.into())
114 }
115
116 #[must_use]
118 pub fn runtime(&self) -> &Arc<dyn AgentRuntime> {
119 &self.runtime
120 }
121
122 #[must_use]
124 pub fn tools(&self) -> &Arc<dyn ToolPort> {
125 &self.tools
126 }
127}
128
129impl Clone for Agent {
130 fn clone(&self) -> Self {
131 Self {
132 runtime: self.runtime.clone(),
133 tools: self.tools.clone(),
134 store: self.store.clone(),
135 tape: self.tape.clone(),
136 events: self.events.clone(),
137 system_prompt: self.system_prompt.clone(),
138 }
139 }
140}
141
142pub struct AgentBuilder {
144 runtime: Arc<dyn AgentRuntime>,
145 tools: Arc<dyn ToolPort>,
146 store: Option<Arc<dyn SessionStore>>,
147 tape: Option<Arc<dyn TapeStorePort>>,
148 events: Option<Arc<dyn EventSink>>,
149 system_prompt: Option<String>,
150}
151
152impl std::fmt::Debug for AgentBuilder {
153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 f.debug_struct("AgentBuilder")
155 .field("has_store", &self.store.is_some())
156 .field("has_tape", &self.tape.is_some())
157 .field("has_system_prompt", &self.system_prompt.is_some())
158 .finish_non_exhaustive()
159 }
160}
161
162impl AgentBuilder {
163 #[must_use]
165 pub fn new(runtime: Arc<dyn AgentRuntime>, tools: Arc<dyn ToolPort>) -> Self {
166 Self { runtime, tools, store: None, tape: None, events: None, system_prompt: None }
167 }
168
169 #[must_use]
171 pub fn with_store(mut self, store: Arc<dyn SessionStore>) -> Self {
172 self.store = Some(store);
173 self
174 }
175
176 #[must_use]
178 pub fn with_tape(mut self, tape: Arc<dyn TapeStorePort>) -> Self {
179 self.tape = Some(tape);
180 self
181 }
182
183 #[must_use]
185 pub fn with_events(mut self, events: Arc<dyn EventSink>) -> Self {
186 self.events = Some(events);
187 self
188 }
189
190 #[must_use]
192 pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
193 self.system_prompt = Some(prompt.into());
194 self
195 }
196
197 #[must_use]
199 pub fn build(self) -> Agent {
200 Agent {
201 runtime: self.runtime,
202 tools: self.tools,
203 store: self.store,
204 tape: self.tape,
205 events: self.events,
206 system_prompt: self.system_prompt,
207 }
208 }
209}
210
211pub struct Session {
216 agent: Agent,
217 id: String,
218 agent_loop: AgentLoop,
219}
220
221impl std::fmt::Debug for Session {
222 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223 f.debug_struct("Session").field("id", &self.id).finish_non_exhaustive()
224 }
225}
226
227impl Session {
228 fn new(agent: Agent, id: String) -> Self {
230 let mut agent_loop = AgentLoop::new(agent.runtime.clone(), agent.tools.clone());
231
232 if let Some(ref store) = agent.store {
233 agent_loop = agent_loop.with_store(store.clone());
234 }
235 if let Some(ref tape) = agent.tape {
236 agent_loop = agent_loop.with_tape(tape.clone());
237 }
238 if let Some(ref events) = agent.events {
239 agent_loop = agent_loop.with_events(events.clone());
240 }
241 if let Some(ref prompt) = agent.system_prompt {
242 agent_loop = agent_loop.with_system_prompt(prompt.clone());
243 }
244
245 Self { agent, id, agent_loop }
246 }
247
248 pub async fn chat(&self, input: impl Into<String>) -> Result<AgentResponse, AgentError> {
257 let input = input.into();
258 self.chat_with_context(input, RequestContext::default()).await
259 }
260
261 pub async fn chat_with_context(
270 &self,
271 input: impl Into<String>,
272 context: RequestContext,
273 ) -> Result<AgentResponse, AgentError> {
274 let input = input.into();
275
276 match self.agent_loop.handle_input_with_context(&input, &self.id, context).await {
277 Ok(crate::agent_loop::AgentLoopOutput::Response(AgentRunResult::Finished(resp))) => {
278 Ok(AgentResponse::new(resp.content, resp.usage, resp.finish_reason))
279 }
280 Ok(crate::agent_loop::AgentLoopOutput::CommandOutput(output)) => {
281 Ok(AgentResponse::command_output(output))
282 }
283 Ok(crate::agent_loop::AgentLoopOutput::Quit) => Ok(AgentResponse::quit()),
284 Err(err) => Err(err),
285 }
286 }
287
288 #[must_use]
290 pub fn session_id(&self) -> &str {
291 &self.id
292 }
293
294 #[must_use]
296 pub fn agent(&self) -> &Agent {
297 &self.agent
298 }
299
300 pub async fn reset(&self) -> Result<(), AgentError> {
305 if let Some(ref store) = self.agent.store {
306 let retained_usage = store
307 .load(&self.id)
308 .await?
309 .map_or_else(TokenUsage::default, |state| state.total_usage);
310
311 store
312 .save(
313 &self.id,
314 &SessionState {
315 messages: Vec::new(),
316 total_usage: retained_usage,
317 ..Default::default()
318 },
319 )
320 .await?;
321 }
322 Ok(())
323 }
324
325 #[must_use]
327 pub fn new_session(&self) -> Self {
328 self.agent.start_session()
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use bob_core::{
335 error::{LlmError, ToolError},
336 types::{LlmRequest, LlmResponse, ToolCall, ToolDescriptor, ToolResult},
337 };
338
339 use super::*;
340 use crate::{AgentBootstrap, RuntimeBuilder};
341
342 struct StubLlm;
343
344 #[async_trait::async_trait]
345 impl bob_core::ports::LlmPort for StubLlm {
346 async fn complete(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
347 Ok(LlmResponse {
348 content: r#"{"type": "final", "content": "hello from session"}"#.into(),
349 usage: TokenUsage { prompt_tokens: 10, completion_tokens: 5 },
350 finish_reason: FinishReason::Stop,
351 tool_calls: Vec::new(),
352 })
353 }
354
355 async fn complete_stream(
356 &self,
357 _req: LlmRequest,
358 ) -> Result<bob_core::types::LlmStream, LlmError> {
359 Err(LlmError::Provider("not implemented".into()))
360 }
361 }
362
363 struct StubTools;
364
365 #[async_trait::async_trait]
366 impl ToolPort for StubTools {
367 async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError> {
368 Ok(vec![])
369 }
370
371 async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError> {
372 Ok(ToolResult { name: call.name, output: serde_json::json!(null), is_error: false })
373 }
374 }
375
376 struct StubStore;
377
378 #[async_trait::async_trait]
379 impl SessionStore for StubStore {
380 async fn load(
381 &self,
382 _id: &bob_core::types::SessionId,
383 ) -> Result<Option<SessionState>, bob_core::error::StoreError> {
384 Ok(None)
385 }
386
387 async fn save(
388 &self,
389 _id: &bob_core::types::SessionId,
390 _state: &SessionState,
391 ) -> Result<(), bob_core::error::StoreError> {
392 Ok(())
393 }
394 }
395
396 struct StubSink;
397
398 impl EventSink for StubSink {
399 fn emit(&self, _event: bob_core::types::AgentEvent) {}
400 }
401
402 #[tokio::test]
403 async fn session_chat_returns_flattened_response() {
404 let runtime = RuntimeBuilder::new()
405 .with_llm(Arc::new(StubLlm))
406 .with_tools(Arc::new(StubTools))
407 .with_store(Arc::new(StubStore))
408 .with_events(Arc::new(StubSink))
409 .with_default_model("test-model")
410 .build()
411 .expect("runtime should build");
412
413 let agent = Agent::from_runtime(runtime, Arc::new(StubTools))
414 .with_store(Arc::new(StubStore))
415 .build();
416
417 let session = agent.start_session();
418 let response = session.chat("hello").await.expect("chat should succeed");
419
420 assert_eq!(response.content, "hello from session");
421 assert_eq!(response.usage.prompt_tokens, 10);
422 assert_eq!(response.usage.completion_tokens, 5);
423 assert!(!response.is_quit);
424 }
425
426 #[tokio::test]
427 async fn session_has_unique_id() {
428 let runtime = RuntimeBuilder::new()
429 .with_llm(Arc::new(StubLlm))
430 .with_tools(Arc::new(StubTools))
431 .with_store(Arc::new(StubStore))
432 .with_events(Arc::new(StubSink))
433 .with_default_model("test-model")
434 .build()
435 .expect("runtime should build");
436
437 let agent = Agent::from_runtime(runtime, Arc::new(StubTools))
438 .with_store(Arc::new(StubStore))
439 .build();
440
441 let session1 = agent.start_session();
442 let session2 = agent.start_session();
443
444 assert_ne!(session1.session_id(), session2.session_id());
445 }
446}