Skip to main content

agentbridge/
adapter.rs

1use shadi_mas::{
2    AgentId, Epoch, ToolAdapter, ToolCall, ToolProvider, ToolResult,
3};
4use std::sync::Arc;
5use thiserror::Error;
6
7use crate::context::ContextPacket;
8
9#[derive(Debug, Error)]
10pub enum CliAdapterError {
11    #[error("subprocess error: {0}")]
12    Subprocess(String),
13    #[error("protocol error: {0}")]
14    Protocol(String),
15    #[error("i/o error: {0}")]
16    Io(#[from] std::io::Error),
17    #[error("serialization error: {0}")]
18    Serde(#[from] serde_json::Error),
19}
20
21/// Abstracts a single coding CLI tool (Claude Code, Copilot, Codex, etc.)
22/// as a first-class agentbridge participant.
23///
24/// Implementations are responsible for spawning and communicating with the
25/// underlying CLI process. The blanket [`CliToolAdapter`] wrapper bridges
26/// any `CliAdapter` into the `shadi_mas::ToolAdapter` trait so it can
27/// participate in `MasRuntime<DevelopmentEngine>` coordination rounds.
28pub trait CliAdapter: Send + Sync {
29    /// Stable identifier for this adapter, e.g. `"claude-code"`.
30    fn agent_id(&self) -> &AgentId;
31
32    /// Capture the current session state from the CLI tool.
33    fn snapshot_context(&self) -> Result<ContextPacket, CliAdapterError>;
34
35    /// Inject a context snapshot into the CLI tool, starting or continuing
36    /// a session with the given history and code state.
37    fn inject_context(&self, ctx: &ContextPacket) -> Result<(), CliAdapterError>;
38
39    /// Send a free-form prompt to the CLI tool and return its text response.
40    /// Used by `CliToolAdapter` to drive the development coordination loop.
41    fn execute_prompt(&self, prompt: &str) -> Result<String, CliAdapterError>;
42}
43
44/// Wraps any [`CliAdapter`] as a `shadi_mas::ToolAdapter` so it can be
45/// plugged directly into `MasRuntime<DevelopmentEngine>`.
46///
47/// Tool call semantics:
48/// - `tool_name` is ignored; `arguments` is decoded as a UTF-8 prompt.
49/// - The CLI's text response is returned as `payload` bytes.
50pub struct CliToolAdapter<A: CliAdapter> {
51    inner: Arc<A>,
52}
53
54impl<A: CliAdapter> CliToolAdapter<A> {
55    pub fn new(adapter: Arc<A>) -> Self {
56        Self { inner: adapter }
57    }
58}
59
60impl<A: CliAdapter> ToolAdapter for CliToolAdapter<A> {
61    fn provider(&self) -> ToolProvider {
62        ToolProvider::AgentSkills
63    }
64
65    fn call(&self, request: ToolCall) -> Result<ToolResult, String> {
66        let prompt =
67            String::from_utf8(request.arguments).map_err(|e| e.to_string())?;
68
69        let response = self
70            .inner
71            .execute_prompt(&prompt)
72            .map_err(|e| e.to_string())?;
73
74        Ok(ToolResult {
75            provider: ToolProvider::AgentSkills,
76            tool_name: request.tool_name,
77            payload: response.into_bytes(),
78            target: request.target,
79            correlation_id: request.correlation_id,
80            epoch: request.epoch,
81        })
82    }
83}
84
85/// Helper: build a `ToolCall` carrying a text prompt for a given epoch.
86pub fn prompt_tool_call(prompt: impl Into<String>, epoch: Epoch) -> ToolCall {
87    ToolCall {
88        provider: ToolProvider::AgentSkills,
89        tool_name: "execute_prompt".to_string(),
90        arguments: prompt.into().into_bytes(),
91        target: None,
92        correlation_id: None,
93        epoch,
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::context::ContextPacket;
101    use std::sync::Arc;
102
103    // --- Mock adapter -------------------------------------------------------
104
105    struct MockAdapter {
106        id: AgentId,
107        response: String,
108    }
109
110    impl CliAdapter for MockAdapter {
111        fn agent_id(&self) -> &AgentId {
112            &self.id
113        }
114        fn snapshot_context(&self) -> Result<ContextPacket, CliAdapterError> {
115            Ok(ContextPacket::new(self.id.0.clone()))
116        }
117        fn inject_context(&self, _: &ContextPacket) -> Result<(), CliAdapterError> {
118            Ok(())
119        }
120        fn execute_prompt(&self, _: &str) -> Result<String, CliAdapterError> {
121            Ok(self.response.clone())
122        }
123    }
124
125    struct FailingAdapter {
126        id: AgentId,
127    }
128
129    impl CliAdapter for FailingAdapter {
130        fn agent_id(&self) -> &AgentId {
131            &self.id
132        }
133        fn snapshot_context(&self) -> Result<ContextPacket, CliAdapterError> {
134            Err(CliAdapterError::Subprocess("fail".to_string()))
135        }
136        fn inject_context(&self, _: &ContextPacket) -> Result<(), CliAdapterError> {
137            Err(CliAdapterError::Subprocess("fail".to_string()))
138        }
139        fn execute_prompt(&self, _: &str) -> Result<String, CliAdapterError> {
140            Err(CliAdapterError::Subprocess("fail".to_string()))
141        }
142    }
143
144    fn make_call(args: Vec<u8>) -> ToolCall {
145        ToolCall {
146            provider: ToolProvider::AgentSkills,
147            tool_name: "test_tool".to_string(),
148            arguments: args,
149            target: None,
150            correlation_id: None,
151            epoch: Epoch(0),
152        }
153    }
154
155    // --- Tests --------------------------------------------------------------
156
157    #[test]
158    fn mock_adapter_all_methods_callable() {
159        let adapter = MockAdapter {
160            id: AgentId("test-id".to_string()),
161            response: "hello".to_string(),
162        };
163        assert_eq!(adapter.agent_id().0, "test-id");
164        let ctx = adapter.snapshot_context().unwrap();
165        assert_eq!(ctx.source_agent, "test-id");
166        assert!(adapter.inject_context(&ctx).is_ok());
167    }
168
169    #[test]
170    fn failing_adapter_all_methods_return_error() {
171        let adapter = FailingAdapter {
172            id: AgentId("fail-id".to_string()),
173        };
174        assert_eq!(adapter.agent_id().0, "fail-id");
175        assert!(adapter.snapshot_context().is_err());
176        let ctx = ContextPacket::new("src");
177        assert!(adapter.inject_context(&ctx).is_err());
178    }
179
180    #[test]
181    fn new_wraps_adapter_and_provider_is_agent_skills() {
182        let inner = Arc::new(MockAdapter {
183            id: AgentId("mock".to_string()),
184            response: String::new(),
185        });
186        let ta = CliToolAdapter::new(Arc::clone(&inner));
187        assert_eq!(ta.provider(), ToolProvider::AgentSkills);
188    }
189
190    #[test]
191    fn call_success_returns_response_as_payload() {
192        let inner = Arc::new(MockAdapter {
193            id: AgentId("mock".to_string()),
194            response: "fn answer() {}".to_string(),
195        });
196        let ta = CliToolAdapter::new(inner);
197        let result = ta.call(make_call(b"write a function".to_vec()));
198        assert!(result.is_ok());
199        let tr = result.unwrap();
200        assert_eq!(tr.payload, b"fn answer() {}");
201        assert_eq!(tr.provider, ToolProvider::AgentSkills);
202        assert_eq!(tr.tool_name, "test_tool");
203        assert_eq!(tr.epoch, Epoch(0));
204        assert!(tr.target.is_none());
205        assert!(tr.correlation_id.is_none());
206    }
207
208    #[test]
209    fn call_with_invalid_utf8_returns_error() {
210        let inner = Arc::new(MockAdapter {
211            id: AgentId("mock".to_string()),
212            response: String::new(),
213        });
214        let ta = CliToolAdapter::new(inner);
215        let result = ta.call(make_call(vec![0xFF, 0xFE])); // invalid UTF-8
216        assert!(result.is_err());
217    }
218
219    #[test]
220    fn call_propagates_adapter_error() {
221        let inner = Arc::new(FailingAdapter {
222            id: AgentId("fail".to_string()),
223        });
224        let ta = CliToolAdapter::new(inner);
225        let result = ta.call(make_call(b"any prompt".to_vec()));
226        assert!(result.is_err());
227        assert!(result.unwrap_err().contains("fail"));
228    }
229
230    #[test]
231    fn prompt_tool_call_sets_all_fields_correctly() {
232        let call = prompt_tool_call("implement a parser", Epoch(7));
233        assert_eq!(call.provider, ToolProvider::AgentSkills);
234        assert_eq!(call.tool_name, "execute_prompt");
235        assert_eq!(call.arguments, b"implement a parser");
236        assert_eq!(call.epoch, Epoch(7));
237        assert!(call.target.is_none());
238        assert!(call.correlation_id.is_none());
239    }
240}