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 + serde::Serialize,
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        // Round-trip the typed request through the cli's `--request` JSON
123        // entry — request -> argv lowering no longer exists. `parse_request`
124        // deserializes the JSON straight back into the aggregate `Request`.
125        let argv = vec![
126            "--request".to_string(),
127            serde_json::to_string(&request).map_err(Error::InlineJson)?,
128        ];
129        let sdk_request = parse_request(&argv).map_err(|e| match e {
130            objectiveai_sdk::cli::command::ParseError::Clap(e) => Error::ClapParse(e),
131            objectiveai_sdk::cli::command::ParseError::FromArgs(e) => Error::FromArgs(e),
132        })?;
133        // Own the ctx so the deferred dispatch future can hold it —
134        // the returned stream is `'static` and outlives this call.
135        let ctx = self.resolve_ctx(agent_arguments).into_owned();
136        // The Python transform adapter needs its own ctx handle (for
137        // the python runtime); clone one before `ctx` is moved into the
138        // dispatch future, but only for a Python transform (jq needs no
139        // ctx).
140        let transform_ctx =
141            matches!(transform, Some(Transform::Python(_))).then(|| ctx.clone());
142
143        // Base stream ("the first one"): don't await the dispatcher
144        // here. Wrap the `Future<Result<Stream<Result<ResponseItem>>>>`
145        // as a stream that, on first poll, drives the future and
146        // flattens into its inner stream — so `execute` returns
147        // instantly and a dispatch setup error surfaces as the stream's
148        // first item rather than an eager `Err`. Box it so the wrappers
149        // below can hold it `Unpin`.
150        let source = futures::stream::once(async move {
151            crate::command::command::execute(&ctx, sdk_request).await
152        })
153        .try_flatten();
154        let source: Pin<Box<dyn Stream<Item = Result<ResponseItem, Error>> + Send>> =
155            Box::pin(source);
156
157        // Layer the wrapper adapters on top of the base, conditionally.
158        //
159        // Always: convert root `ResponseItem` items to the caller's `T`
160        // ([`ConvertStream`] — identity fast-path when `T ==
161        // ResponseItem`, else a serde round-trip).
162        let mut stream: Self::Stream<T> = Box::pin(ConvertStream::new(source));
163
164        // Conditional: an output transform — each item is run through
165        // the transform and its (non-null) output replaces it; a null
166        // output skips the item. Comes BEFORE the token budget so the
167        // budget counts the transformed output. python overrides jq
168        // (resolved upstream by `RequestBase::transform`).
169        if let Some(transform) = transform {
170            match transform {
171                Transform::Python(code) => {
172                    let ctx = transform_ctx.expect("cloned whenever a python transform is present");
173                    stream = Box::pin(PythonTransformStream::new(stream, ctx, code));
174                }
175                Transform::Jq(filter) => {
176                    stream = Box::pin(JqTransformStream::new(stream, filter));
177                }
178            }
179        }
180
181        // Conditional: a running token budget over the serialized output
182        // ([`TokenCountStream`]). Sits BEFORE the timeout so the deadline
183        // still bounds it.
184        if let Some(max_tokens) = max_tokens {
185            stream = Box::pin(TokenCountStream::new(stream, max_tokens));
186        }
187
188        // Conditional: a single whole-stream deadline ([`TimeoutStream`]
189        // — anchored at first poll; the stream never outlives it). Last,
190        // so it's the outermost cap over every other adapter.
191        if let Some(timeout_seconds) = timeout {
192            stream = Box::pin(TimeoutStream::new(stream, timeout_seconds));
193        }
194
195        Ok(stream)
196    }
197
198    async fn execute_one<R, T>(
199        &self,
200        request: R,
201        agent_arguments: Option<&AgentArguments>,
202    ) -> Result<T, Self::Error>
203    where
204        R: CommandRequest + Send + serde::Serialize,
205        T: CommandResponse + serde::Serialize + serde::de::DeserializeOwned + Send + 'static,
206    {
207        let mut stream: Self::Stream<T> =
208            self.execute::<R, T>(request, agent_arguments).await?;
209        match stream.next().await {
210            Some(item) => item,
211            None => Err(Error::EmptyStream),
212        }
213    }
214}
215
216/// Stream adapter: convert the dispatcher's root [`ResponseItem`] items
217/// into the caller's `T`.
218///
219/// Mode is decided once at construction by a `TypeId` compare:
220/// - `T == ResponseItem`: identity — move each item straight through
221///   with no serialization (the alloc-free `Option` downcast).
222/// - otherwise: serialize to `serde_json::Value` and walk the
223///   externally-tagged wrappers down to `T` via [`extract_leaf`].
224struct ConvertStream<S, T> {
225    inner: S,
226    /// `true` ⇔ `T` is the root `ResponseItem` (take the identity path).
227    identity: bool,
228    _marker: PhantomData<fn() -> T>,
229}
230
231impl<S, T: 'static> ConvertStream<S, T> {
232    fn new(inner: S) -> Self {
233        Self {
234            inner,
235            identity: TypeId::of::<T>() == TypeId::of::<ResponseItem>(),
236            _marker: PhantomData,
237        }
238    }
239}
240
241impl<S, T> Stream for ConvertStream<S, T>
242where
243    S: Stream<Item = Result<ResponseItem, Error>> + Unpin,
244    T: serde::de::DeserializeOwned + 'static,
245{
246    type Item = Result<T, Error>;
247
248    fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
249        let this = self.get_mut();
250        match Pin::new(&mut this.inner).poll_next(cx) {
251            Poll::Ready(Some(Ok(item))) => Poll::Ready(Some(convert_item::<T>(item, this.identity))),
252            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
253            Poll::Ready(None) => Poll::Ready(None),
254            Poll::Pending => Poll::Pending,
255        }
256    }
257}
258
259/// Convert one root item to `T`, per the mode [`ConvertStream`] chose.
260fn convert_item<T: serde::de::DeserializeOwned + 'static>(
261    item: ResponseItem,
262    identity: bool,
263) -> Result<T, Error> {
264    if identity {
265        let mut slot = Some(item);
266        Ok((&mut slot as &mut dyn Any)
267            .downcast_mut::<Option<T>>()
268            .and_then(Option::take)
269            .expect("identity flag guarantees T == ResponseItem"))
270    } else {
271        let value = serde_json::to_value(item).map_err(Error::InlineJson)?;
272        extract_leaf::<T>(value).map_err(Error::InlineJson)
273    }
274}
275
276/// Stream adapter: a Python output transform with null-skip.
277///
278/// Each upstream item is fed to the script as the global `input`; the
279/// script's output is taken back. NO output (a null result / nothing
280/// printed) SKIPS the item (nothing is yielded); any other output is
281/// deserialized back into `T` and yielded. Upstream errors pass
282/// through. The runtime call is async, so the in-flight future is held
283/// across polls (one item transformed at a time).
284struct PythonTransformStream<S, T> {
285    inner: S,
286    ctx: Context,
287    code: Arc<str>,
288    pending: Option<Pin<Box<dyn Future<Output = Result<Option<serde_json::Value>, Error>> + Send>>>,
289    _marker: PhantomData<fn() -> T>,
290}
291
292impl<S, T> PythonTransformStream<S, T> {
293    fn new(inner: S, ctx: Context, code: String) -> Self {
294        Self {
295            inner,
296            ctx,
297            code: Arc::from(code),
298            pending: None,
299            _marker: PhantomData,
300        }
301    }
302}
303
304impl<S, T> Stream for PythonTransformStream<S, T>
305where
306    S: Stream<Item = Result<T, Error>> + Unpin,
307    T: serde::Serialize + serde::de::DeserializeOwned + Send + 'static,
308{
309    type Item = Result<T, Error>;
310
311    fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
312        let this = self.get_mut();
313        loop {
314            // Drive an in-flight transform to completion first. The
315            // script's output arrives as a raw `Value`: a null result
316            // (or no output) skips the item; only a non-null `Value` is
317            // deserialized into `T` and yielded.
318            if let Some(fut) = this.pending.as_mut() {
319                match fut.as_mut().poll(cx) {
320                    Poll::Ready(Ok(Some(value))) => {
321                        this.pending = None;
322                        if !value.is_null() {
323                            return Poll::Ready(Some(
324                                serde_json::from_value::<T>(value).map_err(Error::InlineJson),
325                            ));
326                        }
327                        // null output → skip; fall through to the next item.
328                    }
329                    // no output → skip; fall through to pull the next item.
330                    Poll::Ready(Ok(None)) => this.pending = None,
331                    Poll::Ready(Err(e)) => {
332                        this.pending = None;
333                        return Poll::Ready(Some(Err(e)));
334                    }
335                    Poll::Pending => return Poll::Pending,
336                }
337            }
338            // No in-flight transform — pull the next upstream item.
339            match Pin::new(&mut this.inner).poll_next(cx) {
340                Poll::Ready(Some(Ok(item))) => {
341                    let ctx = this.ctx.clone();
342                    let code = Arc::clone(&this.code);
343                    this.pending = Some(Box::pin(async move {
344                        transform_item::<T>(&ctx, &code, item).await
345                    }));
346                    // loop to poll the freshly-built future
347                }
348                Poll::Ready(Some(Err(e))) => return Poll::Ready(Some(Err(e))),
349                Poll::Ready(None) => return Poll::Ready(None),
350                Poll::Pending => return Poll::Pending,
351            }
352        }
353    }
354}
355
356/// Run the Python transform for one item: feed it as `input`, take the
357/// output as a raw `serde_json::Value` (NOT yet `T`). `None` ⇔ the
358/// script produced no output. The caller decides skip-vs-yield: a null
359/// `Value` (or `None`) skips, and only a non-null `Value` is
360/// deserialized into `T` — so a non-nullable `T` never errors on a
361/// "skip" result.
362async fn transform_item<I: serde::Serialize>(
363    ctx: &Context,
364    code: &str,
365    item: I,
366) -> Result<Option<serde_json::Value>, Error> {
367    ctx.python().await?.exec_code(ctx, code, Some(item)).await
368}
369
370/// Stream adapter: a jq output transform with null-skip.
371///
372/// Each upstream item is run through the jq `filter` (jaq, via
373/// [`crate::filesystem::run_jq`]); the multi-result vector is collapsed
374/// the historical way — 0 results → null, exactly 1 → that result
375/// AS-IS, >1 → an array. A null collapsed result SKIPS the item (so a
376/// jq `select(...)` that yields nothing filters it out); any other
377/// result is deserialized into `T` and yielded. Upstream errors pass
378/// through. jaq is a synchronous CPU call, so the transform runs inline
379/// in `poll_next` — no held future.
380struct JqTransformStream<S, T> {
381    inner: S,
382    filter: Arc<str>,
383    _marker: PhantomData<fn() -> T>,
384}
385
386impl<S, T> JqTransformStream<S, T> {
387    fn new(inner: S, filter: String) -> Self {
388        Self {
389            inner,
390            filter: Arc::from(filter),
391            _marker: PhantomData,
392        }
393    }
394}
395
396impl<S, T> Stream for JqTransformStream<S, T>
397where
398    S: Stream<Item = Result<T, Error>> + Unpin,
399    T: serde::Serialize + serde::de::DeserializeOwned,
400{
401    type Item = Result<T, Error>;
402
403    fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
404        let this = self.get_mut();
405        loop {
406            match Pin::new(&mut this.inner).poll_next(cx) {
407                Poll::Ready(Some(Ok(item))) => match jq_apply::<T>(&item, &this.filter) {
408                    Ok(Some(t)) => return Poll::Ready(Some(Ok(t))),
409                    // null collapsed result → skip; loop to the next item.
410                    Ok(None) => {}
411                    Err(e) => return Poll::Ready(Some(Err(e))),
412                },
413                Poll::Ready(Some(Err(e))) => return Poll::Ready(Some(Err(e))),
414                Poll::Ready(None) => return Poll::Ready(None),
415                Poll::Pending => return Poll::Pending,
416            }
417        }
418    }
419}
420
421/// Run the jq filter for one item and collapse its results the
422/// historical way (length-1 → as-is). `None` ⇔ the collapsed result is
423/// null (skip the item); otherwise the result deserialized into `T`.
424fn jq_apply<T: serde::Serialize + serde::de::DeserializeOwned>(
425    item: &T,
426    filter: &str,
427) -> Result<Option<T>, Error> {
428    let mut results = crate::filesystem::run_jq(item, filter).map_err(Error::Filesystem)?;
429    let value = match results.len() {
430        0 => serde_json::Value::Null,
431        1 => results.remove(0),
432        _ => serde_json::Value::Array(results),
433    };
434    if value.is_null() {
435        Ok(None)
436    } else {
437        serde_json::from_value::<T>(value)
438            .map(Some)
439            .map_err(Error::InlineJson)
440    }
441}
442
443/// Stream adapter: a running token budget over the serialized output.
444///
445/// Each item is `serde_json::to_string`'d and tokenized (tiktoken
446/// `o200k_base`, the same encoding the old `db query` budget used); the
447/// token count is added to a running total. The moment the total
448/// exceeds `max_tokens`, the adapter yields one final
449/// `Err(Error::TokenBudgetExceeded)` IN PLACE OF the item that pushed
450/// it over, and then ends. The encoder is built once at construction.
451struct TokenCountStream<S> {
452    inner: S,
453    max_tokens: u64,
454    total: u64,
455    encoder: tiktoken_rs::CoreBPE,
456    /// Set once the budget was blown or the inner stream ended.
457    finished: bool,
458}
459
460impl<S> TokenCountStream<S> {
461    fn new(inner: S, max_tokens: u64) -> Self {
462        Self {
463            inner,
464            max_tokens,
465            total: 0,
466            // Embedded BPE data — loading it is effectively infallible.
467            encoder: tiktoken_rs::o200k_base()
468                .expect("o200k_base BPE data is embedded and always loads"),
469            finished: false,
470        }
471    }
472}
473
474impl<S, I> Stream for TokenCountStream<S>
475where
476    S: Stream<Item = Result<I, Error>> + Unpin,
477    I: serde::Serialize,
478{
479    type Item = Result<I, Error>;
480
481    fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
482        let this = self.get_mut();
483        if this.finished {
484            return Poll::Ready(None);
485        }
486        match Pin::new(&mut this.inner).poll_next(cx) {
487            Poll::Ready(Some(Ok(item))) => {
488                // Tally before yielding: serialize, tokenize, accumulate.
489                let json = match serde_json::to_string(&item) {
490                    Ok(s) => s,
491                    Err(e) => {
492                        this.finished = true;
493                        return Poll::Ready(Some(Err(Error::InlineJson(e))));
494                    }
495                };
496                this.total += this.encoder.encode_with_special_tokens(&json).len() as u64;
497                if this.total > this.max_tokens {
498                    this.finished = true;
499                    return Poll::Ready(Some(Err(Error::TokenBudgetExceeded {
500                        limit: this.max_tokens,
501                        actual: this.total,
502                    })));
503                }
504                Poll::Ready(Some(Ok(item)))
505            }
506            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
507            Poll::Ready(None) => {
508                this.finished = true;
509                Poll::Ready(None)
510            }
511            Poll::Pending => Poll::Pending,
512        }
513    }
514}
515
516/// Stream adapter: a single whole-stream deadline.
517///
518/// The `tokio::time::sleep` is created on the FIRST `poll_next`, so the
519/// deadline anchors at first poll, not at construction. Every poll
520/// races the deadline against the inner stream; once it elapses — even
521/// mid-wait on a slow item — the adapter yields one final
522/// `Err(Error::Timeout)` and then ends. The stream can never outlive
523/// `timeout_seconds`.
524struct TimeoutStream<S> {
525    inner: S,
526    timeout_seconds: u64,
527    /// Created lazily on first poll so the deadline anchors there.
528    deadline: Option<Pin<Box<tokio::time::Sleep>>>,
529    /// Set once the deadline fired or the inner stream ended.
530    finished: bool,
531}
532
533impl<S> TimeoutStream<S> {
534    fn new(inner: S, timeout_seconds: u64) -> Self {
535        Self {
536            inner,
537            timeout_seconds,
538            deadline: None,
539            finished: false,
540        }
541    }
542}
543
544impl<S, I> Stream for TimeoutStream<S>
545where
546    S: Stream<Item = Result<I, Error>> + Unpin,
547{
548    type Item = Result<I, Error>;
549
550    fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
551        let this = self.get_mut();
552        if this.finished {
553            return Poll::Ready(None);
554        }
555        let secs = this.timeout_seconds;
556        let deadline = this.deadline.get_or_insert_with(|| {
557            Box::pin(tokio::time::sleep(std::time::Duration::from_secs(secs)))
558        });
559        // Deadline first: it's a hard cap, so it wins ties.
560        if deadline.as_mut().poll(cx).is_ready() {
561            this.finished = true;
562            return Poll::Ready(Some(Err(Error::Timeout {
563                timeout_seconds: secs,
564            })));
565        }
566        match Pin::new(&mut this.inner).poll_next(cx) {
567            Poll::Ready(Some(item)) => Poll::Ready(Some(item)),
568            Poll::Ready(None) => {
569                this.finished = true;
570                Poll::Ready(None)
571            }
572            Poll::Pending => Poll::Pending,
573        }
574    }
575}