agentbridge/adapters/
generic_stdio.rs1use 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#[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
44struct 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
79pub struct GenericStdioAdapter {
87 id: AgentId,
88 _child: Option<Mutex<Child>>,
90 io: Mutex<Io>,
91}
92
93impl GenericStdioAdapter {
94 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#[cfg(test)]
181mod tests {
182 use super::*;
183 use std::io::Cursor;
184
185 fn make_adapter(response_json: &str) -> GenericStdioAdapter {
186 let reader = Cursor::new(format!("{response_json}\n").into_bytes());
188 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 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 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 let result = GenericStdioAdapter::spawn("cat-adapter", "cat", &[]);
310 assert!(result.is_ok(), "spawn failed");
311 }
313}