Skip to main content

bob_runtime/
session.rs

1//! # Session Abstraction
2//!
3//! High-level session management that encapsulates conversation state,
4//! eliminating the need for manual session ID and context passing.
5//!
6//! ## Design Principles
7//!
8//! 1. **Hide mechanics**: Users shouldn't know about `Tape`, `Store`, `SkillsContext` unless they
9//!    want to customize (provide default abstraction layer).
10//! 2. **Ownership and state isolation**: `Agent` owns immutable config and tools, `Session` owns
11//!    time-varying history and context.
12//! 3. **Rust conventions**: Use `impl Into<String>`, builder patterns, friendly errors.
13
14use 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/// Flattened response type for public API.
26///
27/// This hides internal implementation details like `AgentRunResult::Finished`
28/// and provides a clean, simple interface.
29#[derive(Debug, Clone)]
30pub struct AgentResponse {
31    /// Final text content from the agent.
32    pub content: String,
33    /// Token usage for this turn.
34    pub usage: TokenUsage,
35    /// Why the turn ended.
36    pub finish_reason: FinishReason,
37    /// Whether this response indicates a quit command.
38    pub is_quit: bool,
39}
40
41impl AgentResponse {
42    /// Create a normal response.
43    #[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    /// Create a quit response.
49    #[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    /// Create a command output response.
60    #[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
71/// Agent configuration for building sessions.
72///
73/// This is the stateless component that holds configuration and tools.
74/// Use `Agent::builder()` or `Agent::from_runtime()` to create.
75pub 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    /// Create an Agent from pre-built runtime components.
96    ///
97    /// This is the most flexible constructor, allowing full control over
98    /// all components.
99    #[must_use]
100    pub fn from_runtime(runtime: Arc<dyn AgentRuntime>, tools: Arc<dyn ToolPort>) -> AgentBuilder {
101        AgentBuilder::new(runtime, tools)
102    }
103
104    /// Start a new session with auto-generated session ID.
105    #[must_use]
106    pub fn start_session(&self) -> Session {
107        Session::new(self.clone(), format!("session-{}", Uuid::new_v4()))
108    }
109
110    /// Start a new session with a specific session ID.
111    #[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    /// Get a reference to the underlying runtime.
117    #[must_use]
118    pub fn runtime(&self) -> &Arc<dyn AgentRuntime> {
119        &self.runtime
120    }
121
122    /// Get a reference to the tool port.
123    #[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
142/// Builder for constructing an [`Agent`] with optional components.
143pub 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    /// Create a new builder with required components.
164    #[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    /// Attach a session store for persistence.
170    #[must_use]
171    pub fn with_store(mut self, store: Arc<dyn SessionStore>) -> Self {
172        self.store = Some(store);
173        self
174    }
175
176    /// Attach a tape store for conversation recording.
177    #[must_use]
178    pub fn with_tape(mut self, tape: Arc<dyn TapeStorePort>) -> Self {
179        self.tape = Some(tape);
180        self
181    }
182
183    /// Attach an event sink for observability.
184    #[must_use]
185    pub fn with_events(mut self, events: Arc<dyn EventSink>) -> Self {
186        self.events = Some(events);
187        self
188    }
189
190    /// Set a system prompt override.
191    #[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    /// Build the Agent.
198    #[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
211/// A stateful conversation session.
212///
213/// Manages conversation history, session ID, and context automatically.
214/// Users only need to call `chat()` with their input.
215pub 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    /// Create a new session.
229    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    /// Send a message and get a response.
249    ///
250    /// This is the primary API for conversation. The session automatically
251    /// manages context, history, and tool execution.
252    ///
253    /// # Errors
254    ///
255    /// Returns an error if the LLM call fails or internal state is inconsistent.
256    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    /// Send a message with an explicit request context.
262    ///
263    /// Use this when you need to customize tool policies, add skills context,
264    /// or provide other per-request overrides.
265    ///
266    /// # Errors
267    ///
268    /// Returns an error if the LLM call fails or internal state is inconsistent.
269    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    /// Get the session ID.
289    #[must_use]
290    pub fn session_id(&self) -> &str {
291        &self.id
292    }
293
294    /// Get a reference to the underlying agent.
295    #[must_use]
296    pub fn agent(&self) -> &Agent {
297        &self.agent
298    }
299
300    /// Reset the session (clear history but keep the same session ID).
301    ///
302    /// This clears the conversation history in the session store while
303    /// preserving cumulative token usage.
304    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    /// Start a new session with the same agent configuration.
326    #[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}