1use 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#[async_trait::async_trait]
69pub trait LlmPort: Send + Sync {
70 #[must_use]
72 fn capabilities(&self) -> LlmCapabilities {
73 LlmCapabilities::default()
74 }
75
76 async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError>;
78
79 async fn complete_stream(&self, req: LlmRequest) -> Result<LlmStream, LlmError>;
81}
82
83#[async_trait::async_trait]
87pub trait ToolCatalogPort: Send + Sync {
88 async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError>;
90}
91
92#[async_trait::async_trait]
94pub trait ToolExecutorPort: Send + Sync {
95 async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError>;
97}
98
99#[async_trait::async_trait]
103pub trait ToolPort: Send + Sync {
104 async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError>;
106
107 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
131pub trait ToolPolicyPort: Send + Sync {
133 fn is_tool_allowed(
135 &self,
136 tool: &str,
137 deny_tools: &[String],
138 allow_tools: Option<&[String]>,
139 ) -> bool;
140}
141
142#[async_trait::async_trait]
144pub trait ApprovalPort: Send + Sync {
145 async fn approve_tool_call(
147 &self,
148 call: &ToolCall,
149 context: &ApprovalContext,
150 ) -> Result<ApprovalDecision, ToolError>;
151}
152
153#[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#[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#[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#[async_trait::async_trait]
198pub trait TapeStorePort: Send + Sync {
199 async fn append(
201 &self,
202 session_id: &SessionId,
203 kind: TapeEntryKind,
204 ) -> Result<TapeEntry, StoreError>;
205
206 async fn entries_since_last_handoff(
210 &self,
211 session_id: &SessionId,
212 ) -> Result<Vec<TapeEntry>, StoreError>;
213
214 async fn search(
216 &self,
217 session_id: &SessionId,
218 query: &str,
219 ) -> Result<Vec<TapeSearchResult>, StoreError>;
220
221 async fn all_entries(&self, session_id: &SessionId) -> Result<Vec<TapeEntry>, StoreError>;
223
224 async fn anchors(&self, session_id: &SessionId) -> Result<Vec<TapeEntry>, StoreError>;
226}
227
228#[async_trait::async_trait]
232pub trait SessionStore: Send + Sync {
233 async fn load(&self, id: &SessionId) -> Result<Option<SessionState>, StoreError>;
235
236 async fn save(&self, id: &SessionId, state: &SessionState) -> Result<(), StoreError>;
238}
239
240pub trait EventSink: Send + Sync {
244 fn emit(&self, event: AgentEvent);
246}
247
248#[cfg(test)]
251mod tests {
252 use std::sync::{Arc, Mutex};
253
254 use super::*;
255
256 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 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 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 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 #[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}