Skip to main content

agentbridge/
adapter.rs

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