objectiveai_sdk/cli/command/command_executor/plugin.rs
1use std::pin::Pin;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4
5use dashmap::DashMap;
6use futures::{Stream, StreamExt};
7use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
8use tokio::sync::{Mutex, mpsc};
9
10use crate::cli::command::{
11 AgentArguments, CommandExecutor, CommandRequest,
12 CommandResponse as CommandResponseTrait,
13};
14use crate::cli::plugins::{Command, CommandType, Output};
15
16/// Demultiplex many in-flight `CommandRequest` calls over a plugin's
17/// stdin/stdout. Each `execute` mints a fresh id, emits a
18/// `Output::Command(Command { id, command })` line on the plugin's stdout,
19/// and returns a stream that yields whatever the overlord writes back
20/// to the plugin's stdin under the same id.
21///
22/// Only one instance per process — the constructor consumes the global
23/// `tokio::io::stdin()` / `stdout()` handles. The struct is [`Clone`]
24/// so callers that need a second handle can share without an outer
25/// `Arc`: every field is already behind `Arc`, including `counter`, so
26/// clones share the id sequence and pending map.
27#[derive(Clone)]
28pub struct PluginExecutor {
29 stdout: Arc<Mutex<tokio::io::Stdout>>,
30 counter: Arc<AtomicU64>,
31 pending: Arc<DashMap<String, mpsc::UnboundedSender<serde_json::Value>>>,
32 /// `true` while the listener task is still reading stdin. Flipped
33 /// to `false` immediately before the listener drops its pending
34 /// senders, so `execute()` can re-check after registering its own
35 /// sender and bail with `Error::Closed` instead of installing a
36 /// channel nothing will ever drain.
37 listener_alive: Arc<AtomicBool>,
38}
39
40impl Default for PluginExecutor {
41 fn default() -> Self {
42 Self::new()
43 }
44}
45
46impl PluginExecutor {
47 /// Capture the plugin's stdin/stdout and spawn the demuxer task.
48 pub fn new() -> Self {
49 let pending: Arc<DashMap<String, mpsc::UnboundedSender<serde_json::Value>>> =
50 Arc::new(DashMap::new());
51 let listener_alive = Arc::new(AtomicBool::new(true));
52 Self::spawn_listener(
53 tokio::io::stdin(),
54 pending.clone(),
55 listener_alive.clone(),
56 );
57 Self {
58 stdout: Arc::new(Mutex::new(tokio::io::stdout())),
59 counter: Arc::new(AtomicU64::new(0)),
60 pending,
61 listener_alive,
62 }
63 }
64
65 fn spawn_listener(
66 stdin: tokio::io::Stdin,
67 pending: Arc<DashMap<String, mpsc::UnboundedSender<serde_json::Value>>>,
68 listener_alive: Arc<AtomicBool>,
69 ) {
70 tokio::spawn(async move {
71 let mut lines = BufReader::new(stdin).lines();
72 while let Ok(Some(line)) = lines.next_line().await {
73 let env = match serde_json::from_str::<CommandResponse>(&line) {
74 Ok(e) => e,
75 Err(_) => continue,
76 };
77 match env {
78 CommandResponse::Value { id, value } => {
79 // Host-form stream terminator:
80 // `{id, value:{type:"command_complete",…}}`. Swallow
81 // it — end this id's stream, never forward it on.
82 if value.get("type").and_then(serde_json::Value::as_str)
83 == Some("command_complete")
84 {
85 pending.remove(&id);
86 } else if let Some(sender) = pending.get(&id) {
87 if sender.send(value).is_err() {
88 drop(sender);
89 pending.remove(&id);
90 }
91 }
92 }
93 CommandResponse::Done { id, .. } => {
94 pending.remove(&id);
95 }
96 }
97 }
98 // stdin EOF or read error. Flip the liveness flag BEFORE
99 // dropping any senders — `execute()` does an insert-then-
100 // re-check, and this ordering guarantees a concurrent
101 // `execute()` either sees the flag and removes its own
102 // entry, or completes its insert before `clear()` runs and
103 // gets drained by it.
104 listener_alive.store(false, Ordering::Release);
105 pending.clear();
106 });
107 }
108}
109
110/// One line the overlord writes to a plugin's stdin in response to a
111/// previously-emitted `Output::Command`.
112///
113/// Wire shape:
114/// - Value: `{"id":"42","value":<JSON>}`
115/// - Done: `{"id":"42","done":true}`
116///
117/// Two end-of-stream markers are recognized for an id, after which the
118/// request's stream ends: the `Done` form above, and the host's
119/// `command_complete` form — a `Value` whose JSON is
120/// `{"type":"command_complete","exit_code":N}` (handled in the listener,
121/// swallowed, never surfaced to the consumer).
122#[derive(serde::Deserialize, Debug, Clone)]
123#[serde(untagged)]
124enum CommandResponse {
125 /// Listed first so the untagged decoder tries it before `Value` —
126 /// the `done` discriminator field is what tells the variants apart.
127 Done {
128 id: String,
129 #[allow(dead_code)]
130 done: bool,
131 },
132 Value {
133 id: String,
134 value: serde_json::Value,
135 },
136}
137
138#[derive(Debug, thiserror::Error)]
139pub enum Error {
140 /// Stdin closed (clean EOF or read error). The listener task has
141 /// exited and no new requests can be served.
142 #[error("plugin executor stdin closed")]
143 Closed,
144 #[error("plugin executor io: {0}")]
145 Io(std::io::Error),
146 #[error("plugin executor decode line: {0}")]
147 Json(serde_json::Error),
148 #[error("{0}")]
149 Cli(crate::cli::Error),
150 /// `execute_one` was called but the stream produced no items.
151 #[error("plugin executor stream produced no items")]
152 Empty,
153}
154
155/// Per-value untagged decode. `Err` first so `cli::Error`'s `type:"error"`
156/// constant short-circuits non-error wire shapes; `Ok(T)` is the
157/// fallthrough. Mirrors the helper in `binary.rs`.
158#[derive(serde::Deserialize)]
159#[serde(untagged)]
160enum Line<T> {
161 Err(crate::cli::Error),
162 Ok(T),
163}
164
165impl<T> From<Line<T>> for Result<T, Error> {
166 fn from(line: Line<T>) -> Self {
167 match line {
168 Line::Err(e) => Err(Error::Cli(e)),
169 Line::Ok(t) => Ok(t),
170 }
171 }
172}
173
174impl CommandExecutor for PluginExecutor {
175 type Error = Error;
176 type Stream<T>
177 = Pin<Box<dyn Stream<Item = Result<T, Error>> + Send>>
178 where
179 T: Send + 'static;
180
181 async fn execute<R, T>(
182 &self,
183 request: R,
184 // Plugin runs in-process — no subprocess to stamp env on. The
185 // bag is accepted for trait-signature symmetry with the
186 // binary executor and intentionally ignored.
187 _agent_arguments: Option<&AgentArguments>,
188 ) -> Result<Self::Stream<T>, Error>
189 where
190 R: CommandRequest + Send + serde::Serialize,
191 T: CommandResponseTrait + serde::Serialize + serde::de::DeserializeOwned + Send + 'static,
192 {
193 let id = self.counter.fetch_add(1, Ordering::Relaxed).to_string();
194 let (tx, rx) = mpsc::unbounded_channel::<serde_json::Value>();
195 self.pending.insert(id.clone(), tx);
196
197 // Re-check liveness AFTER insert. The listener stores `false`
198 // before it calls `pending.clear()`, so any of these happens:
199 // - We see `true`: listener is still running; if it dies
200 // later, its `clear()` will drop our sender and the stream
201 // will end naturally.
202 // - We see `false` and our entry got cleared: remove() is a
203 // no-op, sender is already dropped.
204 // - We see `false` and our entry survived (we inserted after
205 // `clear()` ran): remove() drops the sender ourselves.
206 // In every `false` path we bail with `Closed`.
207 if !self.listener_alive.load(Ordering::Acquire) {
208 self.pending.remove(&id);
209 return Err(Error::Closed);
210 }
211
212 // Carry the typed request as JSON via the cli's `--request` flag;
213 // the host re-enters `run` with this argv. (request -> argv lowering
214 // no longer exists.)
215 let argv = vec![
216 "--request".to_string(),
217 serde_json::to_string(&request).map_err(Error::Json)?,
218 ];
219 let envelope = Output::Command(Command {
220 r#type: CommandType::Command,
221 id: id.clone(),
222 // Carry argv structured — joining into a single string
223 // would lose argument boundaries for any value containing
224 // whitespace (e.g. `--simple "a b c"`), which the host
225 // could not recover.
226 command: argv,
227 });
228 let line = serde_json::to_string(&envelope).expect("Output serializes");
229
230 {
231 let mut stdout = self.stdout.lock().await;
232 if let Err(e) = stdout.write_all(line.as_bytes()).await {
233 self.pending.remove(&id);
234 return Err(Error::Io(e));
235 }
236 if let Err(e) = stdout.write_all(b"\n").await {
237 self.pending.remove(&id);
238 return Err(Error::Io(e));
239 }
240 if let Err(e) = stdout.flush().await {
241 self.pending.remove(&id);
242 return Err(Error::Io(e));
243 }
244 }
245
246 let pending = self.pending.clone();
247 let stream = futures::stream::unfold(
248 (rx, id, pending),
249 |(mut rx, id, pending)| async move {
250 match rx.recv().await {
251 Some(value) => {
252 let item = match serde_json::from_value::<Line<T>>(value) {
253 Ok(line) => line.into(),
254 Err(e) => Err(Error::Json(e)),
255 };
256 Some((item, (rx, id, pending)))
257 }
258 None => {
259 pending.remove(&id);
260 None
261 }
262 }
263 },
264 );
265
266 Ok(Box::pin(stream))
267 }
268
269 async fn execute_one<R, T>(
270 &self,
271 request: R,
272 agent_arguments: Option<&AgentArguments>,
273 ) -> Result<T, Error>
274 where
275 R: CommandRequest + Send + serde::Serialize,
276 T: CommandResponseTrait + serde::Serialize + serde::de::DeserializeOwned + Send + 'static,
277 {
278 let mut stream = self.execute::<R, T>(request, agent_arguments).await?;
279 stream.next().await.ok_or(Error::Empty)?
280 }
281}