Skip to main content

agentbridge/adapters/
generic_stdio.rs

1use serde::{Deserialize, Serialize};
2use shadi_mas::AgentId;
3use std::{
4    io::{BufRead, BufReader, Read, Write},
5    process::{Child, Command, Stdio},
6    sync::Mutex,
7};
8
9use crate::{
10    adapter::{CliAdapter, CliAdapterError},
11    context::ContextPacket,
12};
13
14// --- Wire protocol ----------------------------------------------------------
15//
16// Newline-delimited JSON on stdin/stdout.
17//
18// Requests (agentbridge → subprocess stdin):
19//   {"cmd":"snapshot"}
20//   {"cmd":"inject","context":{...}}
21//   {"cmd":"execute","prompt":"..."}
22//
23// Responses (subprocess stdout → agentbridge):
24//   {"ok":true,"data":<value>}
25//   {"ok":false,"error":"<message>"}
26
27#[derive(Serialize)]
28#[serde(tag = "cmd", rename_all = "snake_case")]
29enum Request<'a> {
30    Snapshot,
31    Inject { context: &'a ContextPacket },
32    Execute { prompt: &'a str },
33}
34
35#[derive(Deserialize)]
36struct Response {
37    ok: bool,
38    #[serde(default)]
39    data: serde_json::Value,
40    #[serde(default)]
41    error: Option<String>,
42}
43
44// --- I/O abstraction --------------------------------------------------------
45
46struct Io {
47    writer: Box<dyn Write + Send>,
48    reader: BufReader<Box<dyn Read + Send>>,
49}
50
51impl Io {
52    fn from_process(child: &mut Child) -> Result<Self, CliAdapterError> {
53        let stdin = child
54            .stdin
55            .take()
56            .ok_or_else(|| CliAdapterError::Subprocess("no stdin on child process".to_string()))?;
57        let stdout = child
58            .stdout
59            .take()
60            .ok_or_else(|| CliAdapterError::Subprocess("no stdout on child process".to_string()))?;
61        Ok(Self {
62            writer: Box::new(stdin),
63            reader: BufReader::new(Box::new(stdout)),
64        })
65    }
66
67    #[cfg(test)]
68    fn from_buffers(
69        writer: impl Write + Send + 'static,
70        reader: impl Read + Send + 'static,
71    ) -> Self {
72        Self {
73            writer: Box::new(writer),
74            reader: BufReader::new(Box::new(reader)),
75        }
76    }
77}
78
79// --- Adapter ----------------------------------------------------------------
80
81/// Generic CLI adapter that communicates with a subprocess via the
82/// newline-delimited JSON protocol defined in this module.
83///
84/// Any tool that implements the three-command protocol can be driven by this
85/// adapter. Argv-driven coding CLIs use `ProfileAdapter` and a JSON profile.
86pub struct GenericStdioAdapter {
87    id: AgentId,
88    /// Held to keep the subprocess alive; not accessed after spawning.
89    _child: Option<Mutex<Child>>,
90    io: Mutex<Io>,
91}
92
93impl GenericStdioAdapter {
94    /// Spawn `command` (with optional `args`) and return an adapter bound to
95    /// that subprocess.
96    pub fn spawn(
97        id: impl Into<String>,
98        command: &str,
99        args: &[&str],
100    ) -> Result<Self, CliAdapterError> {
101        let mut child = Command::new(command)
102            .args(args)
103            .stdin(Stdio::piped())
104            .stdout(Stdio::piped())
105            .stderr(Stdio::inherit())
106            .spawn()?;
107
108        let io = Io::from_process(&mut child)?;
109
110        Ok(Self {
111            id: AgentId(id.into()),
112            _child: Some(Mutex::new(child)),
113            io: Mutex::new(io),
114        })
115    }
116
117    fn send_request(&self, req: &Request<'_>) -> Result<Response, CliAdapterError> {
118        let mut io = self
119            .io
120            .lock()
121            .map_err(|_| CliAdapterError::Subprocess("io lock poisoned".to_string()))?;
122
123        let line = serde_json::to_string(req)?;
124        writeln!(io.writer, "{line}").map_err(|e| CliAdapterError::Subprocess(e.to_string()))?;
125        io.writer
126            .flush()
127            .map_err(|e| CliAdapterError::Subprocess(e.to_string()))?;
128
129        let mut buf = String::new();
130        io.reader
131            .read_line(&mut buf)
132            .map_err(|e| CliAdapterError::Subprocess(e.to_string()))?;
133
134        let resp: Response = serde_json::from_str(buf.trim())?;
135        Ok(resp)
136    }
137}
138
139impl CliAdapter for GenericStdioAdapter {
140    fn agent_id(&self) -> &AgentId {
141        &self.id
142    }
143
144    fn snapshot_context(&self) -> Result<ContextPacket, CliAdapterError> {
145        let resp = self.send_request(&Request::Snapshot)?;
146        if !resp.ok {
147            return Err(CliAdapterError::Protocol(
148                resp.error.unwrap_or_else(|| "snapshot failed".to_string()),
149            ));
150        }
151        Ok(serde_json::from_value(resp.data)?)
152    }
153
154    fn inject_context(&self, ctx: &ContextPacket) -> Result<(), CliAdapterError> {
155        let resp = self.send_request(&Request::Inject { context: ctx })?;
156        if !resp.ok {
157            return Err(CliAdapterError::Protocol(
158                resp.error.unwrap_or_else(|| "inject failed".to_string()),
159            ));
160        }
161        Ok(())
162    }
163
164    fn execute_prompt(&self, prompt: &str) -> Result<String, CliAdapterError> {
165        let resp = self.send_request(&Request::Execute { prompt })?;
166        if !resp.ok {
167            return Err(CliAdapterError::Protocol(
168                resp.error.unwrap_or_else(|| "execute failed".to_string()),
169            ));
170        }
171        Ok(match resp.data {
172            serde_json::Value::String(s) => s,
173            other => other.to_string(),
174        })
175    }
176}
177
178// --- Tests ------------------------------------------------------------------
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use std::io::Cursor;
184
185    fn make_adapter(response_json: &str) -> GenericStdioAdapter {
186        // Pre-load a Cursor with the server's canned response.
187        let reader = Cursor::new(format!("{response_json}\n").into_bytes());
188        // Writer goes to a Vec we don't inspect (request serialization is
189        // tested separately via the Request serde).
190        let writer = Vec::<u8>::new();
191
192        GenericStdioAdapter {
193            id: AgentId("test".to_string()),
194            _child: None,
195            io: Mutex::new(Io::from_buffers(writer, reader)),
196        }
197    }
198
199    #[test]
200    fn execute_prompt_parses_ok_string_response() {
201        let pkt = ContextPacket::new("test");
202        let data = serde_json::to_string(&pkt).unwrap();
203        let resp = format!(r#"{{"ok":true,"data":{data}}}"#);
204        let adapter = make_adapter(&resp);
205        // snapshot_context should parse the pre-loaded ContextPacket.
206        let result = adapter.snapshot_context();
207        assert!(result.is_ok(), "{result:?}");
208        assert_eq!(result.unwrap().source_agent, "test");
209    }
210
211    #[test]
212    fn execute_prompt_returns_error_on_not_ok() {
213        let adapter = make_adapter(r#"{"ok":false,"error":"boom"}"#);
214        let result = adapter.execute_prompt("hello");
215        assert!(matches!(result, Err(CliAdapterError::Protocol(msg)) if msg == "boom"));
216    }
217
218    #[test]
219    fn request_snapshot_serializes_correctly() {
220        let json = serde_json::to_string(&Request::Snapshot).unwrap();
221        assert_eq!(json, r#"{"cmd":"snapshot"}"#);
222    }
223
224    #[test]
225    fn request_execute_serializes_correctly() {
226        let json = serde_json::to_string(&Request::Execute { prompt: "hello" }).unwrap();
227        assert_eq!(json, r#"{"cmd":"execute","prompt":"hello"}"#);
228    }
229
230    #[test]
231    fn request_inject_serializes_correctly() {
232        let pkt = ContextPacket::new("src");
233        let json = serde_json::to_string(&Request::Inject { context: &pkt }).unwrap();
234        assert!(json.contains(r#""cmd":"inject""#));
235        assert!(json.contains("src"));
236    }
237
238    #[test]
239    fn snapshot_context_returns_error_on_not_ok() {
240        let adapter = make_adapter(r#"{"ok":false,"error":"snap-fail"}"#);
241        let result = adapter.snapshot_context();
242        assert!(matches!(result, Err(CliAdapterError::Protocol(msg)) if msg == "snap-fail"));
243    }
244
245    #[test]
246    fn snapshot_context_uses_default_error_when_no_error_field() {
247        let adapter = make_adapter(r#"{"ok":false}"#);
248        let result = adapter.snapshot_context();
249        assert!(matches!(result, Err(CliAdapterError::Protocol(msg)) if msg == "snapshot failed"));
250    }
251
252    #[test]
253    fn inject_context_ok_returns_unit() {
254        let adapter = make_adapter(r#"{"ok":true}"#);
255        let pkt = ContextPacket::new("src");
256        assert!(adapter.inject_context(&pkt).is_ok());
257    }
258
259    #[test]
260    fn inject_context_returns_error_on_not_ok() {
261        let adapter = make_adapter(r#"{"ok":false,"error":"inject-fail"}"#);
262        let pkt = ContextPacket::new("src");
263        let result = adapter.inject_context(&pkt);
264        assert!(matches!(result, Err(CliAdapterError::Protocol(msg)) if msg == "inject-fail"));
265    }
266
267    #[test]
268    fn inject_context_uses_default_error_when_no_error_field() {
269        let adapter = make_adapter(r#"{"ok":false}"#);
270        let pkt = ContextPacket::new("src");
271        let result = adapter.inject_context(&pkt);
272        assert!(matches!(result, Err(CliAdapterError::Protocol(msg)) if msg == "inject failed"));
273    }
274
275    #[test]
276    fn execute_prompt_returns_string_response() {
277        let adapter = make_adapter(r#"{"ok":true,"data":"fn answer() {}"}"#);
278        let result = adapter.execute_prompt("write a function");
279        assert_eq!(result.unwrap(), "fn answer() {}");
280    }
281
282    #[test]
283    fn execute_prompt_stringifies_non_string_json_data() {
284        // When data is a JSON object (not a plain string), it is serialized to string.
285        let adapter = make_adapter(r#"{"ok":true,"data":{"key":"value"}}"#);
286        let result = adapter.execute_prompt("any");
287        let text = result.unwrap();
288        assert!(text.contains("key"));
289        assert!(text.contains("value"));
290    }
291
292    #[test]
293    fn execute_prompt_uses_default_error_when_no_error_field() {
294        let adapter = make_adapter(r#"{"ok":false}"#);
295        let result = adapter.execute_prompt("prompt");
296        assert!(matches!(result, Err(CliAdapterError::Protocol(msg)) if msg == "execute failed"));
297    }
298
299    #[test]
300    fn agent_id_returns_configured_id() {
301        let adapter = make_adapter(r#"{"ok":true}"#);
302        assert_eq!(adapter.agent_id().0, "test");
303    }
304
305    #[test]
306    fn spawn_with_real_process_succeeds() {
307        // Spawn a real subprocess (cat) to exercise the production spawn path.
308        // We don't send any commands — just verify the adapter was constructed.
309        let result = GenericStdioAdapter::spawn("cat-adapter", "cat", &[]);
310        assert!(result.is_ok(), "spawn failed");
311        // Drop the adapter to close stdin and let cat exit.
312    }
313}