Skip to main content

bob_core/
ports.rs

1//! # Port Traits
2//!
3//! Hexagonal port traits for the Bob Agent Framework.
4//!
5//! These are the 4 v1 boundaries that adapters must implement.
6//! All async traits use `async_trait` for dyn-compatibility.
7//!
8//! ## Architecture
9//!
10//! ```text
11//! ┌─────────────────────────────────────────┐
12//! │          Runtime / Application          │
13//! └─────────────────────────────────────────┘
14//!                  ↓ uses ports
15//! ┌─────────────────────────────────────────┐
16//! │            Port Traits (this module)    │
17//! │  ┌─────────┐ ┌─────────┐ ┌──────────┐  │
18//! │  │LlmPort  │ │ToolPort │ │Store ... │  │
19//! │  └─────────┘ └─────────┘ └──────────┘  │
20//! └─────────────────────────────────────────┘
21//!                  ↓ implemented by
22//! ┌─────────────────────────────────────────┐
23//! │            Adapters (bob-adapters)      │
24//! └─────────────────────────────────────────┘
25//! ```
26//!
27//! ## Implementing a Port
28//!
29//! To create a custom adapter:
30//!
31//! ```rust,ignore
32//! use bob_core::{
33//!     ports::LlmPort,
34//!     types::{LlmRequest, LlmResponse, LlmStream},
35//!     error::LlmError,
36//! };
37//! use async_trait::async_trait;
38//!
39//! pub struct MyCustomLlm {
40//!     // Your fields here
41//! }
42//!
43//! #[async_trait]
44//! impl LlmPort for MyCustomLlm {
45//!     async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
46//!         // Your implementation
47//!     }
48//!
49//!     async fn complete_stream(&self, req: LlmRequest) -> Result<LlmStream, LlmError> {
50//!         // Your implementation
51//!     }
52//! }
53//! ```
54
55use crate::{
56    error::{CostError, LlmError, StoreError, ToolError},
57    tape::{TapeEntry, TapeEntryKind, TapeSearchResult},
58    types::{
59        AgentEvent, ApprovalContext, ApprovalDecision, ArtifactRecord, LlmCapabilities, LlmRequest,
60        LlmResponse, LlmStream, SessionId, SessionState, TokenUsage, ToolCall, ToolDescriptor,
61        ToolResult, TurnCheckpoint,
62    },
63};
64
65// ── LLM Port ─────────────────────────────────────────────────────────
66
67/// Port for LLM inference (complete and stream).
68#[async_trait::async_trait]
69pub trait LlmPort: Send + Sync {
70    /// Runtime capability declaration for dispatch decisions.
71    #[must_use]
72    fn capabilities(&self) -> LlmCapabilities {
73        LlmCapabilities::default()
74    }
75
76    /// Run a non-streaming inference call.
77    async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError>;
78
79    /// Run a streaming inference call.
80    async fn complete_stream(&self, req: LlmRequest) -> Result<LlmStream, LlmError>;
81}
82
83// ── Tool Port ────────────────────────────────────────────────────────
84
85/// Port for tool discovery.
86#[async_trait::async_trait]
87pub trait ToolCatalogPort: Send + Sync {
88    /// List all available tools.
89    async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError>;
90}
91
92/// Port for tool execution.
93#[async_trait::async_trait]
94pub trait ToolExecutorPort: Send + Sync {
95    /// Execute a tool call and return its result.
96    async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError>;
97}
98
99/// Backward-compatible composite tool port.
100///
101/// New code should prefer depending on [`ToolCatalogPort`] and [`ToolExecutorPort`] separately.
102#[async_trait::async_trait]
103pub trait ToolPort: Send + Sync {
104    /// List all available tools.
105    async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError>;
106
107    /// Execute a tool call and return its result.
108    async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError>;
109}
110
111#[async_trait::async_trait]
112impl<T> ToolCatalogPort for T
113where
114    T: ToolPort + ?Sized,
115{
116    async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError> {
117        ToolPort::list_tools(self).await
118    }
119}
120
121#[async_trait::async_trait]
122impl<T> ToolExecutorPort for T
123where
124    T: ToolPort + ?Sized,
125{
126    async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError> {
127        ToolPort::call_tool(self, call).await
128    }
129}
130
131/// Tool policy decision boundary.
132pub trait ToolPolicyPort: Send + Sync {
133    /// Returns `true` when a tool is allowed for the effective request policy.
134    fn is_tool_allowed(
135        &self,
136        tool: &str,
137        deny_tools: &[String],
138        allow_tools: Option<&[String]>,
139    ) -> bool;
140}
141
142/// Tool call approval boundary for interactive/sensitive operations.
143#[async_trait::async_trait]
144pub trait ApprovalPort: Send + Sync {
145    /// Decide whether the tool call may proceed.
146    async fn approve_tool_call(
147        &self,
148        call: &ToolCall,
149        context: &ApprovalContext,
150    ) -> Result<ApprovalDecision, ToolError>;
151}
152
153/// Port for persisting turn checkpoints.
154#[async_trait::async_trait]
155pub trait TurnCheckpointStorePort: Send + Sync {
156    async fn save_checkpoint(&self, checkpoint: &TurnCheckpoint) -> Result<(), StoreError>;
157    async fn load_latest(
158        &self,
159        session_id: &SessionId,
160    ) -> Result<Option<TurnCheckpoint>, StoreError>;
161}
162
163/// Port for storing per-turn artifacts.
164#[async_trait::async_trait]
165pub trait ArtifactStorePort: Send + Sync {
166    async fn put(&self, artifact: ArtifactRecord) -> Result<(), StoreError>;
167    async fn list_by_session(
168        &self,
169        session_id: &SessionId,
170    ) -> Result<Vec<ArtifactRecord>, StoreError>;
171}
172
173/// Port for cost metering and budget checks.
174#[async_trait::async_trait]
175pub trait CostMeterPort: Send + Sync {
176    async fn check_budget(&self, session_id: &SessionId) -> Result<(), CostError>;
177    async fn record_llm_usage(
178        &self,
179        session_id: &SessionId,
180        model: &str,
181        usage: &TokenUsage,
182    ) -> Result<(), CostError>;
183    async fn record_tool_result(
184        &self,
185        session_id: &SessionId,
186        tool_result: &ToolResult,
187    ) -> Result<(), CostError>;
188}
189
190// ── Tape Store ───────────────────────────────────────────────────────
191
192/// Port for the append-only conversation tape.
193///
194/// The tape records all messages, events, anchors, and handoffs. It is
195/// separate from the session store: the session store tracks LLM context,
196/// while the tape provides a searchable audit log.
197#[async_trait::async_trait]
198pub trait TapeStorePort: Send + Sync {
199    /// Append a new entry to the session's tape.
200    async fn append(
201        &self,
202        session_id: &SessionId,
203        kind: TapeEntryKind,
204    ) -> Result<TapeEntry, StoreError>;
205
206    /// Return entries recorded since the most recent handoff.
207    ///
208    /// If no handoff exists, returns all entries.
209    async fn entries_since_last_handoff(
210        &self,
211        session_id: &SessionId,
212    ) -> Result<Vec<TapeEntry>, StoreError>;
213
214    /// Search the tape for entries matching a query string.
215    async fn search(
216        &self,
217        session_id: &SessionId,
218        query: &str,
219    ) -> Result<Vec<TapeSearchResult>, StoreError>;
220
221    /// Return all entries for a session.
222    async fn all_entries(&self, session_id: &SessionId) -> Result<Vec<TapeEntry>, StoreError>;
223
224    /// Return only anchor entries for a session.
225    async fn anchors(&self, session_id: &SessionId) -> Result<Vec<TapeEntry>, StoreError>;
226}
227
228// ── Session Store ────────────────────────────────────────────────────
229
230/// Port for session state persistence.
231#[async_trait::async_trait]
232pub trait SessionStore: Send + Sync {
233    /// Load a session by ID. Returns `None` if not found.
234    async fn load(&self, id: &SessionId) -> Result<Option<SessionState>, StoreError>;
235
236    /// Persist a session by ID.
237    async fn save(&self, id: &SessionId, state: &SessionState) -> Result<(), StoreError>;
238}
239
240// ── Event Sink ───────────────────────────────────────────────────────
241
242/// Port for emitting observability events (fire-and-forget).
243pub trait EventSink: Send + Sync {
244    /// Emit an agent event. Must not block.
245    fn emit(&self, event: AgentEvent);
246}
247
248// ── Tests ────────────────────────────────────────────────────────────
249
250#[cfg(test)]
251mod tests {
252    use std::sync::{Arc, Mutex};
253
254    use super::*;
255
256    // ── Mock LLM ─────────────────────────────────────────────────
257
258    struct MockLlm {
259        response: LlmResponse,
260    }
261
262    #[async_trait::async_trait]
263    impl LlmPort for MockLlm {
264        async fn complete(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
265            Ok(self.response.clone())
266        }
267
268        async fn complete_stream(&self, _req: LlmRequest) -> Result<LlmStream, LlmError> {
269            Err(LlmError::Provider("streaming not implemented in mock".into()))
270        }
271    }
272
273    // ── Mock Tool Port ───────────────────────────────────────────
274
275    struct MockToolPort {
276        tools: Vec<ToolDescriptor>,
277    }
278
279    #[async_trait::async_trait]
280    impl ToolPort for MockToolPort {
281        async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError> {
282            Ok(self.tools.clone())
283        }
284
285        async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError> {
286            Ok(ToolResult {
287                name: call.name,
288                output: serde_json::json!({"result": "mock"}),
289                is_error: false,
290            })
291        }
292    }
293
294    // ── Mock Session Store ───────────────────────────────────────
295
296    struct MockSessionStore {
297        inner: Mutex<std::collections::HashMap<SessionId, SessionState>>,
298    }
299
300    impl MockSessionStore {
301        fn new() -> Self {
302            Self { inner: Mutex::new(std::collections::HashMap::new()) }
303        }
304    }
305
306    #[async_trait::async_trait]
307    impl SessionStore for MockSessionStore {
308        async fn load(&self, id: &SessionId) -> Result<Option<SessionState>, StoreError> {
309            let map = self.inner.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
310            Ok(map.get(id).cloned())
311        }
312
313        async fn save(&self, id: &SessionId, state: &SessionState) -> Result<(), StoreError> {
314            let mut map = self.inner.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
315            map.insert(id.clone(), state.clone());
316            Ok(())
317        }
318    }
319
320    // ── Mock Event Sink ──────────────────────────────────────────
321
322    struct MockEventSink {
323        events: Mutex<Vec<AgentEvent>>,
324    }
325
326    impl MockEventSink {
327        fn new() -> Self {
328            Self { events: Mutex::new(Vec::new()) }
329        }
330    }
331
332    impl EventSink for MockEventSink {
333        fn emit(&self, event: AgentEvent) {
334            let mut events = self.events.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
335            events.push(event);
336        }
337    }
338
339    // ── Tests ────────────────────────────────────────────────────
340
341    #[tokio::test]
342    async fn llm_port_complete() {
343        let llm: Arc<dyn LlmPort> = Arc::new(MockLlm {
344            response: LlmResponse {
345                content: "Hello!".into(),
346                usage: crate::types::TokenUsage { prompt_tokens: 10, completion_tokens: 5 },
347                finish_reason: crate::types::FinishReason::Stop,
348                tool_calls: Vec::new(),
349            },
350        });
351
352        let req = LlmRequest { model: "test-model".into(), messages: vec![], tools: vec![] };
353
354        let resp = llm.complete(req).await;
355        assert!(resp.is_ok(), "complete should succeed");
356        if let Ok(resp) = resp {
357            assert_eq!(resp.content, "Hello!");
358            assert_eq!(resp.usage.total(), 15);
359        }
360    }
361
362    #[tokio::test]
363    async fn tool_port_list_and_call() {
364        let tools: Arc<dyn ToolPort> = Arc::new(MockToolPort {
365            tools: vec![ToolDescriptor {
366                id: "test/echo".into(),
367                description: "Echo tool".into(),
368                input_schema: serde_json::json!({}),
369                source: crate::types::ToolSource::Local,
370            }],
371        });
372
373        let listed = tools.list_tools().await;
374        assert!(listed.is_ok(), "list_tools should succeed");
375        if let Ok(listed) = listed {
376            assert_eq!(listed.len(), 1);
377            assert_eq!(listed[0].id, "test/echo");
378        }
379
380        let result = tools
381            .call_tool(ToolCall {
382                name: "test/echo".into(),
383                arguments: serde_json::json!({"msg": "hi"}),
384            })
385            .await;
386        assert!(result.is_ok(), "call_tool should succeed");
387        if let Ok(result) = result {
388            assert!(!result.is_error);
389        }
390    }
391
392    #[tokio::test]
393    async fn session_store_roundtrip() {
394        let store: Arc<dyn SessionStore> = Arc::new(MockSessionStore::new());
395
396        let id = "session-1".to_string();
397        let loaded_before = store.load(&id).await;
398        assert!(matches!(loaded_before, Ok(None)));
399
400        let state = SessionState {
401            messages: vec![crate::types::Message {
402                role: crate::types::Role::User,
403                content: "hello".into(),
404            }],
405            ..Default::default()
406        };
407
408        let saved = store.save(&id, &state).await;
409        assert!(saved.is_ok(), "save should succeed");
410
411        let loaded = store.load(&id).await;
412        assert!(matches!(loaded, Ok(Some(_))), "load should return stored state");
413        if let Ok(Some(loaded)) = loaded {
414            assert_eq!(loaded.messages.len(), 1);
415            assert_eq!(loaded.messages[0].content, "hello");
416        }
417    }
418
419    #[test]
420    fn event_sink_collect() {
421        let sink = MockEventSink::new();
422        sink.emit(AgentEvent::TurnStarted { session_id: "s1".into() });
423        sink.emit(AgentEvent::Error { error: "boom".into() });
424
425        let events = sink.events.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
426        assert_eq!(events.len(), 2);
427    }
428
429    #[test]
430    fn turn_policy_defaults() {
431        let policy = crate::types::TurnPolicy::default();
432        assert_eq!(policy.max_steps, 12);
433        assert_eq!(policy.max_tool_calls, 8);
434        assert_eq!(policy.max_consecutive_errors, 2);
435        assert_eq!(policy.turn_timeout_ms, 90_000);
436        assert_eq!(policy.tool_timeout_ms, 15_000);
437    }
438}