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