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    types::{
58        AgentEvent, ApprovalContext, ApprovalDecision, ArtifactRecord, LlmCapabilities, LlmRequest,
59        LlmResponse, LlmStream, SessionId, SessionState, TokenUsage, ToolCall, ToolDescriptor,
60        ToolResult, TurnCheckpoint,
61    },
62};
63
64// ── LLM Port ─────────────────────────────────────────────────────────
65
66/// Port for LLM inference (complete and stream).
67#[async_trait::async_trait]
68pub trait LlmPort: Send + Sync {
69    /// Runtime capability declaration for dispatch decisions.
70    #[must_use]
71    fn capabilities(&self) -> LlmCapabilities {
72        LlmCapabilities::default()
73    }
74
75    /// Run a non-streaming inference call.
76    async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError>;
77
78    /// Run a streaming inference call.
79    async fn complete_stream(&self, req: LlmRequest) -> Result<LlmStream, LlmError>;
80}
81
82// ── Tool Port ────────────────────────────────────────────────────────
83
84/// Port for tool discovery.
85#[async_trait::async_trait]
86pub trait ToolCatalogPort: Send + Sync {
87    /// List all available tools.
88    async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError>;
89}
90
91/// Port for tool execution.
92#[async_trait::async_trait]
93pub trait ToolExecutorPort: Send + Sync {
94    /// Execute a tool call and return its result.
95    async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError>;
96}
97
98/// Backward-compatible composite tool port.
99///
100/// New code should prefer depending on [`ToolCatalogPort`] and [`ToolExecutorPort`] separately.
101#[async_trait::async_trait]
102pub trait ToolPort: Send + Sync {
103    /// List all available tools.
104    async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError>;
105
106    /// Execute a tool call and return its result.
107    async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError>;
108}
109
110#[async_trait::async_trait]
111impl<T> ToolCatalogPort for T
112where
113    T: ToolPort + ?Sized,
114{
115    async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError> {
116        ToolPort::list_tools(self).await
117    }
118}
119
120#[async_trait::async_trait]
121impl<T> ToolExecutorPort for T
122where
123    T: ToolPort + ?Sized,
124{
125    async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError> {
126        ToolPort::call_tool(self, call).await
127    }
128}
129
130/// Tool policy decision boundary.
131pub trait ToolPolicyPort: Send + Sync {
132    /// Returns `true` when a tool is allowed for the effective request policy.
133    fn is_tool_allowed(
134        &self,
135        tool: &str,
136        deny_tools: &[String],
137        allow_tools: Option<&[String]>,
138    ) -> bool;
139}
140
141/// Tool call approval boundary for interactive/sensitive operations.
142#[async_trait::async_trait]
143pub trait ApprovalPort: Send + Sync {
144    /// Decide whether the tool call may proceed.
145    async fn approve_tool_call(
146        &self,
147        call: &ToolCall,
148        context: &ApprovalContext,
149    ) -> Result<ApprovalDecision, ToolError>;
150}
151
152/// Port for persisting turn checkpoints.
153#[async_trait::async_trait]
154pub trait TurnCheckpointStorePort: Send + Sync {
155    async fn save_checkpoint(&self, checkpoint: &TurnCheckpoint) -> Result<(), StoreError>;
156    async fn load_latest(
157        &self,
158        session_id: &SessionId,
159    ) -> Result<Option<TurnCheckpoint>, StoreError>;
160}
161
162/// Port for storing per-turn artifacts.
163#[async_trait::async_trait]
164pub trait ArtifactStorePort: Send + Sync {
165    async fn put(&self, artifact: ArtifactRecord) -> Result<(), StoreError>;
166    async fn list_by_session(
167        &self,
168        session_id: &SessionId,
169    ) -> Result<Vec<ArtifactRecord>, StoreError>;
170}
171
172/// Port for cost metering and budget checks.
173#[async_trait::async_trait]
174pub trait CostMeterPort: Send + Sync {
175    async fn check_budget(&self, session_id: &SessionId) -> Result<(), CostError>;
176    async fn record_llm_usage(
177        &self,
178        session_id: &SessionId,
179        model: &str,
180        usage: &TokenUsage,
181    ) -> Result<(), CostError>;
182    async fn record_tool_result(
183        &self,
184        session_id: &SessionId,
185        tool_result: &ToolResult,
186    ) -> Result<(), CostError>;
187}
188
189// ── Session Store ────────────────────────────────────────────────────
190
191/// Port for session state persistence.
192#[async_trait::async_trait]
193pub trait SessionStore: Send + Sync {
194    /// Load a session by ID. Returns `None` if not found.
195    async fn load(&self, id: &SessionId) -> Result<Option<SessionState>, StoreError>;
196
197    /// Persist a session by ID.
198    async fn save(&self, id: &SessionId, state: &SessionState) -> Result<(), StoreError>;
199}
200
201// ── Event Sink ───────────────────────────────────────────────────────
202
203/// Port for emitting observability events (fire-and-forget).
204pub trait EventSink: Send + Sync {
205    /// Emit an agent event. Must not block.
206    fn emit(&self, event: AgentEvent);
207}
208
209// ── Tests ────────────────────────────────────────────────────────────
210
211#[cfg(test)]
212mod tests {
213    use std::sync::{Arc, Mutex};
214
215    use super::*;
216
217    // ── Mock LLM ─────────────────────────────────────────────────
218
219    struct MockLlm {
220        response: LlmResponse,
221    }
222
223    #[async_trait::async_trait]
224    impl LlmPort for MockLlm {
225        async fn complete(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
226            Ok(self.response.clone())
227        }
228
229        async fn complete_stream(&self, _req: LlmRequest) -> Result<LlmStream, LlmError> {
230            Err(LlmError::Provider("streaming not implemented in mock".into()))
231        }
232    }
233
234    // ── Mock Tool Port ───────────────────────────────────────────
235
236    struct MockToolPort {
237        tools: Vec<ToolDescriptor>,
238    }
239
240    #[async_trait::async_trait]
241    impl ToolPort for MockToolPort {
242        async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError> {
243            Ok(self.tools.clone())
244        }
245
246        async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError> {
247            Ok(ToolResult {
248                name: call.name,
249                output: serde_json::json!({"result": "mock"}),
250                is_error: false,
251            })
252        }
253    }
254
255    // ── Mock Session Store ───────────────────────────────────────
256
257    struct MockSessionStore {
258        inner: Mutex<std::collections::HashMap<SessionId, SessionState>>,
259    }
260
261    impl MockSessionStore {
262        fn new() -> Self {
263            Self { inner: Mutex::new(std::collections::HashMap::new()) }
264        }
265    }
266
267    #[async_trait::async_trait]
268    impl SessionStore for MockSessionStore {
269        async fn load(&self, id: &SessionId) -> Result<Option<SessionState>, StoreError> {
270            let map = self.inner.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
271            Ok(map.get(id).cloned())
272        }
273
274        async fn save(&self, id: &SessionId, state: &SessionState) -> Result<(), StoreError> {
275            let mut map = self.inner.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
276            map.insert(id.clone(), state.clone());
277            Ok(())
278        }
279    }
280
281    // ── Mock Event Sink ──────────────────────────────────────────
282
283    struct MockEventSink {
284        events: Mutex<Vec<AgentEvent>>,
285    }
286
287    impl MockEventSink {
288        fn new() -> Self {
289            Self { events: Mutex::new(Vec::new()) }
290        }
291    }
292
293    impl EventSink for MockEventSink {
294        fn emit(&self, event: AgentEvent) {
295            let mut events = self.events.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
296            events.push(event);
297        }
298    }
299
300    // ── Tests ────────────────────────────────────────────────────
301
302    #[tokio::test]
303    async fn llm_port_complete() {
304        let llm: Arc<dyn LlmPort> = Arc::new(MockLlm {
305            response: LlmResponse {
306                content: "Hello!".into(),
307                usage: crate::types::TokenUsage { prompt_tokens: 10, completion_tokens: 5 },
308                finish_reason: crate::types::FinishReason::Stop,
309                tool_calls: Vec::new(),
310            },
311        });
312
313        let req = LlmRequest { model: "test-model".into(), messages: vec![], tools: vec![] };
314
315        let resp = llm.complete(req).await;
316        assert!(resp.is_ok(), "complete should succeed");
317        if let Ok(resp) = resp {
318            assert_eq!(resp.content, "Hello!");
319            assert_eq!(resp.usage.total(), 15);
320        }
321    }
322
323    #[tokio::test]
324    async fn tool_port_list_and_call() {
325        let tools: Arc<dyn ToolPort> = Arc::new(MockToolPort {
326            tools: vec![ToolDescriptor {
327                id: "test/echo".into(),
328                description: "Echo tool".into(),
329                input_schema: serde_json::json!({}),
330                source: crate::types::ToolSource::Local,
331            }],
332        });
333
334        let listed = tools.list_tools().await;
335        assert!(listed.is_ok(), "list_tools should succeed");
336        if let Ok(listed) = listed {
337            assert_eq!(listed.len(), 1);
338            assert_eq!(listed[0].id, "test/echo");
339        }
340
341        let result = tools
342            .call_tool(ToolCall {
343                name: "test/echo".into(),
344                arguments: serde_json::json!({"msg": "hi"}),
345            })
346            .await;
347        assert!(result.is_ok(), "call_tool should succeed");
348        if let Ok(result) = result {
349            assert!(!result.is_error);
350        }
351    }
352
353    #[tokio::test]
354    async fn session_store_roundtrip() {
355        let store: Arc<dyn SessionStore> = Arc::new(MockSessionStore::new());
356
357        let id = "session-1".to_string();
358        let loaded_before = store.load(&id).await;
359        assert!(matches!(loaded_before, Ok(None)));
360
361        let state = SessionState {
362            messages: vec![crate::types::Message {
363                role: crate::types::Role::User,
364                content: "hello".into(),
365            }],
366            ..Default::default()
367        };
368
369        let saved = store.save(&id, &state).await;
370        assert!(saved.is_ok(), "save should succeed");
371
372        let loaded = store.load(&id).await;
373        assert!(matches!(loaded, Ok(Some(_))), "load should return stored state");
374        if let Ok(Some(loaded)) = loaded {
375            assert_eq!(loaded.messages.len(), 1);
376            assert_eq!(loaded.messages[0].content, "hello");
377        }
378    }
379
380    #[test]
381    fn event_sink_collect() {
382        let sink = MockEventSink::new();
383        sink.emit(AgentEvent::TurnStarted { session_id: "s1".into() });
384        sink.emit(AgentEvent::Error { error: "boom".into() });
385
386        let events = sink.events.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
387        assert_eq!(events.len(), 2);
388    }
389
390    #[test]
391    fn turn_policy_defaults() {
392        let policy = crate::types::TurnPolicy::default();
393        assert_eq!(policy.max_steps, 12);
394        assert_eq!(policy.max_tool_calls, 8);
395        assert_eq!(policy.max_consecutive_errors, 2);
396        assert_eq!(policy.turn_timeout_ms, 90_000);
397        assert_eq!(policy.tool_timeout_ms, 15_000);
398    }
399}