Skip to main content

objectiveai_cli/
executor.rs

1//! In-process [`CommandExecutor`] implementor backed by the CLI's own
2//! leaf handlers. Lets programmatic consumers drive the SDK's per-leaf
3//! `execute<E>` entry points against the CLI's local logic without
4//! spawning a subprocess.
5//!
6//! `run.rs` also delegates here for non-`instance` argv — the
7//! `objectiveai_sdk::cli::command::execute(&executor, request)` call
8//! flows back through this executor down to each leaf.
9
10use std::any::{Any, TypeId};
11use std::future::Future;
12use std::marker::PhantomData;
13use std::pin::Pin;
14use std::sync::Arc;
15use std::task::{Context as TaskContext, Poll};
16
17use futures::{Stream, StreamExt, TryStreamExt};
18use objectiveai_sdk::cli::command::{
19    AgentArguments, CommandExecutor, CommandRequest, CommandResponse, ResponseItem, Transform,
20    parse_request,
21};
22use serde_json::Value;
23
24use crate::context::Context;
25use crate::error::Error;
26
27/// In-process executor. Owns a [`Context`] and dispatches each
28/// `CommandRequest` through the CLI's local root dispatcher.
29pub struct CliCommandExecutor {
30    ctx: Context,
31}
32
33impl CliCommandExecutor {
34    pub fn new(ctx: Context) -> Self {
35        Self { ctx }
36    }
37}
38
39/// Walk down externally-tagged enum wrappers (`{"<Variant>": <inner>}`)
40/// until `from_value::<T>` succeeds, or no more single-key-object layers
41/// remain. The aggregator's variant keys are PascalCase and leaf fields
42/// are snake_case across the SDK, so the first level whose shape matches
43/// `T` is the leaf.
44fn extract_leaf<T: serde::de::DeserializeOwned>(value: Value) -> Result<T, serde_json::Error> {
45    let mut current = value;
46    loop {
47        match serde_json::from_value::<T>(current.clone()) {
48            Ok(t) => return Ok(t),
49            Err(_) => match current {
50                Value::Object(map) if map.len() == 1 => {
51                    current = map.into_iter().next().unwrap().1;
52                }
53                other => return serde_json::from_value::<T>(other),
54            },
55        }
56    }
57}
58
59impl CliCommandExecutor {
60    /// Build the per-call [`Context`] for this execute. When
61    /// `agent_arguments` is `Some`, clone the base ctx and overwrite
62    /// the seven per-request identity fields on its `Config`:
63    /// `agent_id`, `agent_full_id`, `agent_remote`, `response_id`,
64    /// `response_ids`, `mcp_session_id` are set verbatim (including
65    /// `None`, which clears the slot), and `agent_instance_hierarchy`
66    /// falls back to `"UNKNOWN"` when missing because it's a non-
67    /// nullable String on the cli's `Config`. The clone's API-client
68    /// cell is detached afterwards so the memoized `HttpClient`
69    /// rebuilds with the overridden identity headers. When
70    /// `agent_arguments` is `None`, the base ctx is borrowed
71    /// unchanged.
72    fn resolve_ctx<'a>(
73        &'a self,
74        agent_arguments: Option<&AgentArguments>,
75    ) -> std::borrow::Cow<'a, Context> {
76        match agent_arguments {
77            None => std::borrow::Cow::Borrowed(&self.ctx),
78            Some(args) => {
79                let mut ctx = self.ctx.clone();
80                ctx.config.agent_instance_hierarchy = args
81                    .agent_instance_hierarchy
82                    .clone()
83                    .unwrap_or_else(|| "UNKNOWN".to_string());
84                ctx.config.agent_id = args.agent_id.clone();
85                ctx.config.agent_full_id = args.agent_full_id.clone();
86                ctx.config.agent_remote = args.agent_remote.clone();
87                ctx.config.response_id = args.response_id.clone();
88                ctx.config.response_ids = args.response_ids.clone();
89                ctx.config.mcp_session_id = args.mcp_session_id.clone();
90                ctx.reset_api_client();
91                std::borrow::Cow::Owned(ctx)
92            }
93        }
94    }
95}
96
97impl CommandExecutor for CliCommandExecutor {
98    type Error = Error;
99    type Stream<T>
100        = Pin<Box<dyn Stream<Item = Result<T, Self::Error>> + Send>>
101    where
102        T: Send + 'static;
103
104    async fn execute<R, T>(
105        &self,
106        request: R,
107        agent_arguments: Option<&AgentArguments>,
108    ) -> Result<Self::Stream<T>, Self::Error>
109    where
110        R: CommandRequest + Send,
111        T: CommandResponse + serde::Serialize + serde::de::DeserializeOwned + Send + 'static,
112    {
113        // Pull the envelope controls off the request up front, before
114        // the stream is built — upcoming conditional transforms consume
115        // these: the output transform (owned: jq / python, python wins),
116        // the timeout cap, and the max-tokens cap.
117        let base = request.request_base();
118        let transform = base.transform();
119        let timeout = base.timeout_seconds;
120        let max_tokens = base.max_tokens;
121
122        let argv = request.into_command();
123        let sdk_request = parse_request(&argv).map_err(|e| match e {
124            objectiveai_sdk::cli::command::ParseError::Clap(e) => Error::ClapParse(e),
125            objectiveai_sdk::cli::command::ParseError::FromArgs(e) => Error::FromArgs(e),
126        })?;
127        // Own the ctx so the deferred dispatch future can hold it —
128        // the returned stream is `'static` and outlives this call.
129        let ctx = self.resolve_ctx(agent_arguments).into_owned();
130        // The Python transform adapter needs its own ctx handle (for
131        // the python runtime); clone one before `ctx` is moved into the
132        // dispatch future, but only for a Python transform (jq needs no
133        // ctx).
134        let transform_ctx =
135            matches!(transform, Some(Transform::Python(_))).then(|| ctx.clone());
136
137        // Base stream ("the first one"): don't await the dispatcher
138        // here. Wrap the `Future<Result<Stream<Result<ResponseItem>>>>`
139        // as a stream that, on first poll, drives the future and
140        // flattens into its inner stream — so `execute` returns
141        // instantly and a dispatch setup error surfaces as the stream's
142        // first item rather than an eager `Err`. Box it so the wrappers
143        // below can hold it `Unpin`.
144        let source = futures::stream::once(async move {
145            crate::command::command::execute(&ctx, sdk_request).await
146        })
147        .try_flatten();
148        let source: Pin<Box<dyn Stream<Item = Result<ResponseItem, Error>> + Send>> =
149            Box::pin(source);
150
151        // Layer the wrapper adapters on top of the base, conditionally.
152        //
153        // Always: convert root `ResponseItem` items to the caller's `T`
154        // ([`ConvertStream`] — identity fast-path when `T ==
155        // ResponseItem`, else a serde round-trip).
156        let mut stream: Self::Stream<T> = Box::pin(ConvertStream::new(source));
157
158        // Conditional: an output transform — each item is run through
159        // the transform and its (non-null) output replaces it; a null
160        // output skips the item. Comes BEFORE the token budget so the
161        // budget counts the transformed output. python overrides jq
162        // (resolved upstream by `RequestBase::transform`).
163        if let Some(transform) = transform {
164            match transform {
165                Transform::Python(code) => {
166                    let ctx = transform_ctx.expect("cloned whenever a python transform is present");
167                    stream = Box::pin(PythonTransformStream::new(stream, ctx, code));
168                }
169                Transform::Jq(filter) => {
170                    stream = Box::pin(JqTransformStream::new(stream, filter));
171                }
172            }
173        }
174
175        // Conditional: a running token budget over the serialized output
176        // ([`TokenCountStream`]). Sits BEFORE the timeout so the deadline
177        // still bounds it.
178        if let Some(max_tokens) = max_tokens {
179            stream = Box::pin(TokenCountStream::new(stream, max_tokens));
180        }
181
182        // Conditional: a single whole-stream deadline ([`TimeoutStream`]
183        // — anchored at first poll; the stream never outlives it). Last,
184        // so it's the outermost cap over every other adapter.
185        if let Some(timeout_seconds) = timeout {
186            stream = Box::pin(TimeoutStream::new(stream, timeout_seconds));
187        }
188
189        Ok(stream)
190    }
191
192    async fn execute_one<R, T>(
193        &self,
194        request: R,
195        agent_arguments: Option<&AgentArguments>,
196    ) -> Result<T, Self::Error>
197    where
198        R: CommandRequest + Send,
199        T: CommandResponse + serde::Serialize + serde::de::DeserializeOwned + Send + 'static,
200    {
201        let mut stream: Self::Stream<T> =
202            self.execute::<R, T>(request, agent_arguments).await?;
203        match stream.next().await {
204            Some(item) => item,
205            None => Err(Error::EmptyStream),
206        }
207    }
208}
209
210/// Stream adapter: convert the dispatcher's root [`ResponseItem`] items
211/// into the caller's `T`.
212///
213/// Mode is decided once at construction by a `TypeId` compare:
214/// - `T == ResponseItem`: identity — move each item straight through
215///   with no serialization (the alloc-free `Option` downcast).
216/// - otherwise: serialize to `serde_json::Value` and walk the
217///   externally-tagged wrappers down to `T` via [`extract_leaf`].
218struct ConvertStream<S, T> {
219    inner: S,
220    /// `true` ⇔ `T` is the root `ResponseItem` (take the identity path).
221    identity: bool,
222    _marker: PhantomData<fn() -> T>,
223}
224
225impl<S, T: 'static> ConvertStream<S, T> {
226    fn new(inner: S) -> Self {
227        Self {
228            inner,
229            identity: TypeId::of::<T>() == TypeId::of::<ResponseItem>(),
230            _marker: PhantomData,
231        }
232    }
233}
234
235impl<S, T> Stream for ConvertStream<S, T>
236where
237    S: Stream<Item = Result<ResponseItem, Error>> + Unpin,
238    T: serde::de::DeserializeOwned + 'static,
239{
240    type Item = Result<T, Error>;
241
242    fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
243        let this = self.get_mut();
244        match Pin::new(&mut this.inner).poll_next(cx) {
245            Poll::Ready(Some(Ok(item))) => Poll::Ready(Some(convert_item::<T>(item, this.identity))),
246            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
247            Poll::Ready(None) => Poll::Ready(None),
248            Poll::Pending => Poll::Pending,
249        }
250    }
251}
252
253/// Convert one root item to `T`, per the mode [`ConvertStream`] chose.
254fn convert_item<T: serde::de::DeserializeOwned + 'static>(
255    item: ResponseItem,
256    identity: bool,
257) -> Result<T, Error> {
258    if identity {
259        let mut slot = Some(item);
260        Ok((&mut slot as &mut dyn Any)
261            .downcast_mut::<Option<T>>()
262            .and_then(Option::take)
263            .expect("identity flag guarantees T == ResponseItem"))
264    } else {
265        let value = serde_json::to_value(item).map_err(Error::InlineJson)?;
266        extract_leaf::<T>(value).map_err(Error::InlineJson)
267    }
268}
269
270/// Stream adapter: a Python output transform with null-skip.
271///
272/// Each upstream item is fed to the script as the global `input`; the
273/// script's output is taken back. NO output (a null result / nothing
274/// printed) SKIPS the item (nothing is yielded); any other output is
275/// deserialized back into `T` and yielded. Upstream errors pass
276/// through. The runtime call is async, so the in-flight future is held
277/// across polls (one item transformed at a time).
278struct PythonTransformStream<S, T> {
279    inner: S,
280    ctx: Context,
281    code: Arc<str>,
282    pending: Option<Pin<Box<dyn Future<Output = Result<Option<serde_json::Value>, Error>> + Send>>>,
283    _marker: PhantomData<fn() -> T>,
284}
285
286impl<S, T> PythonTransformStream<S, T> {
287    fn new(inner: S, ctx: Context, code: String) -> Self {
288        Self {
289            inner,
290            ctx,
291            code: Arc::from(code),
292            pending: None,
293            _marker: PhantomData,
294        }
295    }
296}
297
298impl<S, T> Stream for PythonTransformStream<S, T>
299where
300    S: Stream<Item = Result<T, Error>> + Unpin,
301    T: serde::Serialize + serde::de::DeserializeOwned + Send + 'static,
302{
303    type Item = Result<T, Error>;
304
305    fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
306        let this = self.get_mut();
307        loop {
308            // Drive an in-flight transform to completion first. The
309            // script's output arrives as a raw `Value`: a null result
310            // (or no output) skips the item; only a non-null `Value` is
311            // deserialized into `T` and yielded.
312            if let Some(fut) = this.pending.as_mut() {
313                match fut.as_mut().poll(cx) {
314                    Poll::Ready(Ok(Some(value))) => {
315                        this.pending = None;
316                        if !value.is_null() {
317                            return Poll::Ready(Some(
318                                serde_json::from_value::<T>(value).map_err(Error::InlineJson),
319                            ));
320                        }
321                        // null output → skip; fall through to the next item.
322                    }
323                    // no output → skip; fall through to pull the next item.
324                    Poll::Ready(Ok(None)) => this.pending = None,
325                    Poll::Ready(Err(e)) => {
326                        this.pending = None;
327                        return Poll::Ready(Some(Err(e)));
328                    }
329                    Poll::Pending => return Poll::Pending,
330                }
331            }
332            // No in-flight transform — pull the next upstream item.
333            match Pin::new(&mut this.inner).poll_next(cx) {
334                Poll::Ready(Some(Ok(item))) => {
335                    let ctx = this.ctx.clone();
336                    let code = Arc::clone(&this.code);
337                    this.pending = Some(Box::pin(async move {
338                        transform_item::<T>(&ctx, &code, item).await
339                    }));
340                    // loop to poll the freshly-built future
341                }
342                Poll::Ready(Some(Err(e))) => return Poll::Ready(Some(Err(e))),
343                Poll::Ready(None) => return Poll::Ready(None),
344                Poll::Pending => return Poll::Pending,
345            }
346        }
347    }
348}
349
350/// Run the Python transform for one item: feed it as `input`, take the
351/// output as a raw `serde_json::Value` (NOT yet `T`). `None` ⇔ the
352/// script produced no output. The caller decides skip-vs-yield: a null
353/// `Value` (or `None`) skips, and only a non-null `Value` is
354/// deserialized into `T` — so a non-nullable `T` never errors on a
355/// "skip" result.
356async fn transform_item<I: serde::Serialize>(
357    ctx: &Context,
358    code: &str,
359    item: I,
360) -> Result<Option<serde_json::Value>, Error> {
361    ctx.python().await?.exec_code(code, Some(item)).await
362}
363
364/// Stream adapter: a jq output transform with null-skip.
365///
366/// Each upstream item is run through the jq `filter` (jaq, via
367/// [`crate::filesystem::run_jq`]); the multi-result vector is collapsed
368/// the historical way — 0 results → null, exactly 1 → that result
369/// AS-IS, >1 → an array. A null collapsed result SKIPS the item (so a
370/// jq `select(...)` that yields nothing filters it out); any other
371/// result is deserialized into `T` and yielded. Upstream errors pass
372/// through. jaq is a synchronous CPU call, so the transform runs inline
373/// in `poll_next` — no held future.
374struct JqTransformStream<S, T> {
375    inner: S,
376    filter: Arc<str>,
377    _marker: PhantomData<fn() -> T>,
378}
379
380impl<S, T> JqTransformStream<S, T> {
381    fn new(inner: S, filter: String) -> Self {
382        Self {
383            inner,
384            filter: Arc::from(filter),
385            _marker: PhantomData,
386        }
387    }
388}
389
390impl<S, T> Stream for JqTransformStream<S, T>
391where
392    S: Stream<Item = Result<T, Error>> + Unpin,
393    T: serde::Serialize + serde::de::DeserializeOwned,
394{
395    type Item = Result<T, Error>;
396
397    fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
398        let this = self.get_mut();
399        loop {
400            match Pin::new(&mut this.inner).poll_next(cx) {
401                Poll::Ready(Some(Ok(item))) => match jq_apply::<T>(&item, &this.filter) {
402                    Ok(Some(t)) => return Poll::Ready(Some(Ok(t))),
403                    // null collapsed result → skip; loop to the next item.
404                    Ok(None) => {}
405                    Err(e) => return Poll::Ready(Some(Err(e))),
406                },
407                Poll::Ready(Some(Err(e))) => return Poll::Ready(Some(Err(e))),
408                Poll::Ready(None) => return Poll::Ready(None),
409                Poll::Pending => return Poll::Pending,
410            }
411        }
412    }
413}
414
415/// Run the jq filter for one item and collapse its results the
416/// historical way (length-1 → as-is). `None` ⇔ the collapsed result is
417/// null (skip the item); otherwise the result deserialized into `T`.
418fn jq_apply<T: serde::Serialize + serde::de::DeserializeOwned>(
419    item: &T,
420    filter: &str,
421) -> Result<Option<T>, Error> {
422    let mut results = crate::filesystem::run_jq(item, filter).map_err(Error::Filesystem)?;
423    let value = match results.len() {
424        0 => serde_json::Value::Null,
425        1 => results.remove(0),
426        _ => serde_json::Value::Array(results),
427    };
428    if value.is_null() {
429        Ok(None)
430    } else {
431        serde_json::from_value::<T>(value)
432            .map(Some)
433            .map_err(Error::InlineJson)
434    }
435}
436
437/// Stream adapter: a running token budget over the serialized output.
438///
439/// Each item is `serde_json::to_string`'d and tokenized (tiktoken
440/// `o200k_base`, the same encoding the old `db query` budget used); the
441/// token count is added to a running total. The moment the total
442/// exceeds `max_tokens`, the adapter yields one final
443/// `Err(Error::TokenBudgetExceeded)` IN PLACE OF the item that pushed
444/// it over, and then ends. The encoder is built once at construction.
445struct TokenCountStream<S> {
446    inner: S,
447    max_tokens: u64,
448    total: u64,
449    encoder: tiktoken_rs::CoreBPE,
450    /// Set once the budget was blown or the inner stream ended.
451    finished: bool,
452}
453
454impl<S> TokenCountStream<S> {
455    fn new(inner: S, max_tokens: u64) -> Self {
456        Self {
457            inner,
458            max_tokens,
459            total: 0,
460            // Embedded BPE data — loading it is effectively infallible.
461            encoder: tiktoken_rs::o200k_base()
462                .expect("o200k_base BPE data is embedded and always loads"),
463            finished: false,
464        }
465    }
466}
467
468impl<S, I> Stream for TokenCountStream<S>
469where
470    S: Stream<Item = Result<I, Error>> + Unpin,
471    I: serde::Serialize,
472{
473    type Item = Result<I, Error>;
474
475    fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
476        let this = self.get_mut();
477        if this.finished {
478            return Poll::Ready(None);
479        }
480        match Pin::new(&mut this.inner).poll_next(cx) {
481            Poll::Ready(Some(Ok(item))) => {
482                // Tally before yielding: serialize, tokenize, accumulate.
483                let json = match serde_json::to_string(&item) {
484                    Ok(s) => s,
485                    Err(e) => {
486                        this.finished = true;
487                        return Poll::Ready(Some(Err(Error::InlineJson(e))));
488                    }
489                };
490                this.total += this.encoder.encode_with_special_tokens(&json).len() as u64;
491                if this.total > this.max_tokens {
492                    this.finished = true;
493                    return Poll::Ready(Some(Err(Error::TokenBudgetExceeded {
494                        limit: this.max_tokens,
495                        actual: this.total,
496                    })));
497                }
498                Poll::Ready(Some(Ok(item)))
499            }
500            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
501            Poll::Ready(None) => {
502                this.finished = true;
503                Poll::Ready(None)
504            }
505            Poll::Pending => Poll::Pending,
506        }
507    }
508}
509
510/// Stream adapter: a single whole-stream deadline.
511///
512/// The `tokio::time::sleep` is created on the FIRST `poll_next`, so the
513/// deadline anchors at first poll, not at construction. Every poll
514/// races the deadline against the inner stream; once it elapses — even
515/// mid-wait on a slow item — the adapter yields one final
516/// `Err(Error::Timeout)` and then ends. The stream can never outlive
517/// `timeout_seconds`.
518struct TimeoutStream<S> {
519    inner: S,
520    timeout_seconds: u64,
521    /// Created lazily on first poll so the deadline anchors there.
522    deadline: Option<Pin<Box<tokio::time::Sleep>>>,
523    /// Set once the deadline fired or the inner stream ended.
524    finished: bool,
525}
526
527impl<S> TimeoutStream<S> {
528    fn new(inner: S, timeout_seconds: u64) -> Self {
529        Self {
530            inner,
531            timeout_seconds,
532            deadline: None,
533            finished: false,
534        }
535    }
536}
537
538impl<S, I> Stream for TimeoutStream<S>
539where
540    S: Stream<Item = Result<I, Error>> + Unpin,
541{
542    type Item = Result<I, Error>;
543
544    fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
545        let this = self.get_mut();
546        if this.finished {
547            return Poll::Ready(None);
548        }
549        let secs = this.timeout_seconds;
550        let deadline = this.deadline.get_or_insert_with(|| {
551            Box::pin(tokio::time::sleep(std::time::Duration::from_secs(secs)))
552        });
553        // Deadline first: it's a hard cap, so it wins ties.
554        if deadline.as_mut().poll(cx).is_ready() {
555            this.finished = true;
556            return Poll::Ready(Some(Err(Error::Timeout {
557                timeout_seconds: secs,
558            })));
559        }
560        match Pin::new(&mut this.inner).poll_next(cx) {
561            Poll::Ready(Some(item)) => Poll::Ready(Some(item)),
562            Poll::Ready(None) => {
563                this.finished = true;
564                Poll::Ready(None)
565            }
566            Poll::Pending => Poll::Pending,
567        }
568    }
569}