Skip to main content

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}