Skip to main content

objectiveai_cli/command/agents/
spawn.rs

1//! `agents spawn` — in-process chunk-or-id streaming handler.
2//!
3//! The agent input is the shared [`AgentSelector`] — a direct ref
4//! (inline / file / python / remote), a tag, or an existing
5//! instance. Tags resolve first: BOUND → the live hierarchy
6//! (historic case), GROUPED → the group's stored spec plus the tag
7//! threaded into the conduit for the BOUND upgrade, ABSENT → error.
8//!
9//! Stream-true (`dangerous_advanced.stream = Some(true)`): resolve
10//! + lock + drive the SDK streaming WS connection inside this cli
11//! process. The INITIAL lock (try_acquire, failure = error; skipped
12//! when `dangerous_advanced.skip_lock` says a parent already
13//! transferred the claim into this process): historic case → the
14//! AIH lock, un-upgraded tag case → the tag lock, plain ref → no
15//! initial lock. Historic spawns load their agent params +
16//! continuation from the stored session. Mid-stream, every newly
17//! revealed hierarchy gets a best-effort AIH claim
18//! ([`AgentInstanceRegistry::observe`]); the first success releases
19//! the tag claim. End-of-stream: if the hierarchy has undelivered
20//! `message_queue` rows, restart with the latest continuation —
21//! restart passes flow into the same output stream.
22//!
23//! Stream-false (the default): re-invoke `objectiveai-cli agents
24//! spawn ...` as a **detached subprocess** with the same arguments
25//! plus `stream = true` (so the resolution + locking above runs in
26//! the child), read the first `ResponseItem::Id` line off the
27//! child's stdout, yield it, and return. The subprocess runs
28//! orphaned to completion (Unix: kernel re-parents to init;
29//! Windows: `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` keeps
30//! it alive past parent exit).
31//!
32//! `params.stream` on the wire is always `Some(true)`; the
33//! `dangerous_advanced.stream` setting only controls cli-side
34//! output.
35
36use std::pin::Pin;
37
38use futures::Stream;
39use futures::StreamExt;
40use objectiveai_sdk::agent::InlineAgentBaseWithFallbacksOrRemoteCommitOptional;
41use objectiveai_sdk::agent::completions::message::{Message, UserMessage};
42use objectiveai_sdk::agent::completions::request::AgentCompletionCreateParams;
43use objectiveai_sdk::cli::command::agents::selector::{AgentRef, AgentSelector};
44use objectiveai_sdk::cli::command::agents::spawn::{
45    Request, RequestDangerousAdvanced, ResponseItem,
46};
47use objectiveai_sdk::cli::command::{BinaryExecutor, CommandExecutor};
48
49use crate::context::Context;
50use crate::error::Error;
51use crate::websockets::agent_hierarchies::ChunkAgentHierarchies;
52use crate::websockets::agent_registry::AgentInstanceRegistry;
53
54type ItemStream = Pin<Box<dyn Stream<Item = Result<ResponseItem, Error>> + Send>>;
55
56pub async fn execute(
57    ctx: &Context,
58    request: Request,
59) -> Result<ItemStream, Error> {
60    let want_stream = request
61        .dangerous_advanced
62        .as_ref()
63        .and_then(|a| a.stream)
64        .unwrap_or(false);
65    if want_stream {
66        execute_streaming(ctx, request).await
67    } else {
68        execute_detached(request).await
69    }
70}
71
72/// Stream-false: re-invoke `objectiveai-cli agents spawn`
73/// as a detached subprocess with `stream = true`, capture the
74/// first `ResponseItem::Id` off the child's stdout, yield it, and
75/// return. The subprocess outlives this call — its
76/// `tokio::process::Child` handle is dropped without kill (the
77/// SDK's `BinaryExecutor` default + Windows `DETACHED_PROCESS`
78/// flag).
79async fn execute_detached(request: Request) -> Result<ItemStream, Error> {
80    // Re-invoke with stream=true so the child runs the real
81    // streaming path. Same argv otherwise — `BinaryExecutor` will
82    // ask `Request::into_command()` for it.
83    let mut child_request = request;
84    match child_request.dangerous_advanced.as_mut() {
85        Some(adv) => adv.stream = Some(true),
86        None => {
87            child_request.dangerous_advanced = Some(RequestDangerousAdvanced {
88                stream: Some(true),
89                ..Default::default()
90            })
91        }
92    }
93    // The child is a re-exec of this CLI — it must not inherit the
94    // parent's transform / token budget (timeout survives).
95    crate::command::reexec::strip_inherited(&mut child_request.base);
96
97    // Self-respawn: point the executor at *this* binary (whichever
98    // path the OS recorded for the current process), then arm
99    // Windows-detach so the child survives parent exit. Unix gets
100    // re-parent-to-init for free via the default kill_on_drop=false.
101    let exe = std::env::current_exe()
102        .map_err(|e| Error::Spawn("current_exe".into(), e))?;
103    let executor = BinaryExecutor::from_path(exe).detach(true);
104
105    let mut stream = executor
106        .execute::<Request, ResponseItem>(child_request, None)
107        .await
108        .map_err(|e| Error::Instance(format!(
109            "self-respawn for agents spawn: {e}"
110        )))?;
111
112    // Take exactly the first ResponseItem (the LogStreamReady Id),
113    // yield it, return. Drop the rest of the stream + the Child
114    // handle without kill. On Windows the detach flags keep the
115    // child running; on Unix the kernel re-parents to init.
116    let first = stream
117        .next()
118        .await
119        .ok_or(Error::EmptyStream)?
120        .map_err(|e| Error::Instance(format!(
121            "self-respawn for agents spawn: {e}"
122        )))?;
123    Ok(Box::pin(
124        objectiveai_sdk::cli::command::StreamOnce::new(Ok(first)),
125    ))
126}
127
128/// Spawn modes after selector resolution: a fresh agent (direct
129/// ref, or a GROUPED tag carrying the tag name for the conduit
130/// upgrade) or an existing hierarchy resumed via its stored
131/// session + continuation.
132enum Mode {
133    Fresh {
134        agent: InlineAgentBaseWithFallbacksOrRemoteCommitOptional,
135        tag: Option<String>,
136    },
137    Historic {
138        hierarchy: String,
139    },
140}
141
142async fn execute_streaming(
143    ctx: &Context,
144    request: Request,
145) -> Result<ItemStream, Error> {
146    // Required user-message slot — gets wrapped into a single
147    // `Message::User` at the head of the API call's `messages`
148    // array. Reuses `agents message`'s `resolve_message`
149    // so the five wire variants (`Simple` / `Inline(RichContent)`
150    // / `File` / `PythonInline` / `PythonFile`) round-trip
151    // identically. EMPTY resolved content (`--simple ""`, an empty
152    // Inline text, empty parts) means a wake-up/resume turn: send an
153    // EMPTY `messages` array — never a user message with an empty
154    // string — and let the API drive from the continuation + the
155    // conduit's queue drain.
156    let content = super::message::resolve_message(ctx, request.message).await?;
157    let messages = if content.is_empty() {
158        Vec::new()
159    } else {
160        vec![Message::User(UserMessage {
161            content,
162        })]
163    };
164    let seed = request.dangerous_advanced.as_ref().and_then(|a| a.seed);
165    let skip_lock = request
166        .dangerous_advanced
167        .as_ref()
168        .and_then(|a| a.skip_lock)
169        .unwrap_or(false);
170
171    let mode = match request.agent {
172        AgentSelector::Ref { agent } => Mode::Fresh {
173            agent: resolve_agent_ref(ctx, agent).await?,
174            tag: None,
175        },
176        AgentSelector::Tag { agent_tag } => {
177            match crate::db::tags::lookup(ctx.db_client().await?, &agent_tag).await? {
178                crate::db::tags::LookupState::Bound { agent_instance_hierarchy } => {
179                    Mode::Historic {
180                        hierarchy: agent_instance_hierarchy,
181                    }
182                }
183                crate::db::tags::LookupState::Grouped { agent_spec, .. } => {
184                    let agent = agent_spec;
185                    Mode::Fresh {
186                        agent,
187                        tag: Some(agent_tag),
188                    }
189                }
190                crate::db::tags::LookupState::Absent => {
191                    return Err(Error::TagNotFound(agent_tag));
192                }
193            }
194        }
195        AgentSelector::Instance {
196            parent_agent_instance_hierarchy,
197            agent_instance,
198        } => {
199            let parent = parent_agent_instance_hierarchy
200                .as_deref()
201                .unwrap_or(&ctx.config.agent_instance_hierarchy);
202            Mode::Historic {
203                hierarchy: format!("{parent}/{agent_instance}"),
204            }
205        }
206    };
207
208    // Initial lock + params assembly. try_acquire only — a held lock
209    // means the agent (or another spawn of the tag) is already live,
210    // and this spawn errors out. `skip_lock` skips exactly this step:
211    // the parent `agents message` already holds the lock through the
212    // handles it transferred into this process (re-acquiring would
213    // fail against ourselves). Mid-stream best-effort AIH claims in
214    // `run_multi_pass` are unaffected.
215    let state_dir = ctx.filesystem.state_dir();
216    let mut registry = AgentInstanceRegistry::new(state_dir.clone());
217    let (agent, agent_tag, continuation) = match mode {
218        Mode::Fresh { agent, tag } => {
219            if !skip_lock {
220                if let Some(tag) = &tag {
221                    let (dir, key) = super::locks::agent_tag_lock(&state_dir, tag);
222                    match objectiveai_sdk::lockfile::try_acquire(&dir, &key, "").await {
223                        Some(claim) => registry.hold_tag_claim(claim),
224                        None => return Err(Error::AgentTagActive { tag: tag.clone() }),
225                    }
226                }
227            }
228            (agent, tag, None)
229        }
230        Mode::Historic { hierarchy } => {
231            if !skip_lock {
232                let (dir, key) = super::locks::agent_instance_lock(&state_dir, &hierarchy);
233                match objectiveai_sdk::lockfile::try_acquire(&dir, &key, "").await {
234                    Some(claim) => registry.preseed(hierarchy.clone(), claim),
235                    None => {
236                        return Err(Error::AgentInstanceActive {
237                            agent_instance_hierarchy: hierarchy,
238                        });
239                    }
240                }
241            }
242            let lookup = crate::db::logs::lookup_session(ctx.db_client().await?, &hierarchy)
243                .await?
244                .ok_or(Error::AgentNoPriorRequest {
245                    agent_instance_hierarchy: hierarchy,
246                })?;
247            (lookup.agent, None, lookup.continuation)
248        }
249    };
250
251    let params = AgentCompletionCreateParams {
252        messages,
253        provider: None,
254        agent,
255        response_format: None,
256        seed,
257        stream: Some(true),
258        continuation,
259    };
260
261    // Message-queue delivery to the live API happens through the
262    // conduit's `read_pending_and_upgrade_tag` call — the API
263    // pulls pending rows on demand as the stream runs and stamps
264    // their ids onto the first emitted assistant chunk's
265    // `request_message_ids`. No pre-spawn drain + prepend here.
266    let ctx_clone = ctx.clone();
267    Ok(Box::pin(run_multi_pass(ctx_clone, params, agent_tag, registry)))
268}
269
270/// Drives one or more stream passes until no seen hierarchy has
271/// pending `message_queue` items. Each pass opens a fresh WS
272/// stream + log writer + MCP server + conduit; the
273/// [`AgentInstanceRegistry`] (carrying any initial AIH/tag claim)
274/// persists across passes so an agent's lock stays held for the
275/// whole spawn lifetime, not per-pass — and is released when the
276/// stream (and with it the registry) drops.
277pub(crate) fn run_multi_pass(
278    ctx: Context,
279    initial_params: AgentCompletionCreateParams,
280    agent_tag: Option<String>,
281    mut registry: AgentInstanceRegistry,
282) -> impl Stream<Item = Result<ResponseItem, Error>> + Send {
283    async_stream::try_stream! {
284        let mut params = initial_params;
285        // A spawn has exactly one `(agent_instance_hierarchy,
286        // agent_full_id)` pair — set by the API on the very first
287        // chunk and never changes across restart passes. Capture
288        // once; reuse forever. `None` until the first chunk lands.
289        let mut identity: Option<(String, String)> = None;
290        // Has `ResponseItem::Id` been yielded yet? Persists across
291        // restart passes — the spawn-id handshake is a one-time
292        // event, gated on the LogWriter's `written_once` signal so
293        // the caller only sees the Id after at least one log row
294        // has been persisted.
295        let mut id_emitted = false;
296        // Resolve the MCP client tuning once for the whole spawn; every
297        // pass's conduit reuses these (cheap to pass per pass).
298        let mcp_timeout_ms = ctx.resolve_mcp_timeout_ms().await?;
299        let backoff_max_elapsed_time_ms =
300            ctx.resolve_backoff_max_elapsed_time_ms().await?;
301
302        loop {
303            // Per-pass resources. New WS connection, new log writer,
304            // new conduit + MCP server. The registry survives across
305            // passes (see above).
306            let mcp_server =
307                crate::websockets::mcp_server::spawn(ctx.clone());
308            let conduit =
309                crate::websockets::conduit::ConduitMcpHandler::new(
310                    mcp_server,
311                    ctx.clone(),
312                    agent_tag.clone(),
313                    mcp_timeout_ms,
314                    backoff_max_elapsed_time_ms,
315                );
316            // Spawn.rs doesn't need the primary-id ready signal —
317            // it yields `ResponseItem::Id` from
318            // `chunk.agent_instance_hierarchy` directly on the first
319            // chunk. Drop the receiver.
320            let (log_writer, _ready_rx) = crate::db::logs::write_agent_completion(
321                ctx.db_client().await?,
322                &params,
323                ctx.config.agent_instance_hierarchy.clone(),
324            )
325            .map_err(|e| Error::Instance(format!(
326                "failed to build agent-completion log writer: {e}"
327            )))?;
328
329            let (sdk_stream, notifier) =
330                objectiveai_sdk::agent::completions::create_agent_completion_streaming(
331                    ctx.api_client().await?,
332                    params.clone(),
333                    conduit.clone(),
334                )
335                .await
336                .map_err(|e| Error::Instance(format!(
337                    "failed to open agent-completion stream: {e}"
338                )))?;
339            conduit.install_notifier(notifier);
340
341            let mut sdk_stream = Box::pin(sdk_stream);
342            let mut last_continuation: Option<String> = None;
343            // Per-pass buffer of chunks held back until the
344            // LogWriter confirms it has persisted at least once.
345            // Only meaningful for pass 1 — pass 2+ already has
346            // `id_emitted = true` from a prior pass, so the buffer
347            // gate never triggers and chunks flow through directly.
348            let mut buffered: Vec<
349                objectiveai_sdk::agent::completions::response::streaming::AgentCompletionChunk,
350            > = Vec::new();
351            let mut stream_err: Option<String> = None;
352
353            while let Some(item) = sdk_stream.next().await {
354                let chunk = match item {
355                    Ok(c) => c,
356                    Err(e) => {
357                        stream_err = Some(format!("agent stream item error: {e}"));
358                        break;
359                    }
360                };
361
362                // First chunk EVER (first pass, first chunk):
363                // capture the spawn's identity + claim the lock
364                // file. Tag-group upgrade is owned by the conduit's
365                // `read_pending_and_upgrade_tag`, which the API
366                // fires before the very first chunk is produced —
367                // no upgrade fan-out is needed here. The
368                // `ResponseItem::Id` handshake itself fires later,
369                // gated on `log_writer.written_once()`.
370                if identity.is_none() {
371                    let hier = chunk.agent_instance_hierarchy.clone();
372                    let full_id = chunk.agent_full_id.clone();
373                    registry.observe(&hier).await;
374                    identity = Some((hier, full_id));
375                }
376
377                // Latest continuation seen on the wire — what we
378                // use to restart if pending messages turn up at
379                // EOF. Only the terminal chunk usually carries one.
380                if let Some(c) = chunk.continuation.as_deref() {
381                    last_continuation = Some(c.to_string());
382                }
383
384                // Upsert any `(AIH, continuation)` pairs the chunk
385                // carries into the `agent_continuations` registry
386                // (cumulative chunks always yield exactly one pair;
387                // the Vec is 0-or-1 long depending on whether
388                // `continuation` is `Some`). Awaited before the
389                // log-writer send + downstream yield so the registry
390                // row is visible by the time the chunk leaves this
391                // body.
392                let mut continuation_upserts: Vec<_> = Vec::new();
393                for (hier, continuation) in chunk.agent_instance_hierarchies() {
394                    if let Some(c) = continuation {
395                        continuation_upserts.push(
396                            crate::db::agent_continuations::upsert(ctx.db_client().await?, hier, c),
397                        );
398                    }
399                }
400                if let Err(e) =
401                    futures::future::try_join_all(continuation_upserts).await
402                {
403                    stream_err =
404                        Some(format!("agent_continuations upsert: {e}"));
405                    break;
406                }
407
408                // Log + forward. The write is a synchronous mpsc
409                // send into the LogWriter's listener task — DB IO
410                // happens off this critical path. Clone the chunk
411                // for the listener; the original yields downstream
412                // (or sits in the buffer until the Id gate opens).
413                if let Err(e) = log_writer.write(chunk.clone()) {
414                    stream_err = Some(format!("log writer error: {e}"));
415                    break;
416                }
417
418                // Id gate: once the LogWriter signals it has
419                // persisted at least one batch, yield the Id and
420                // drain any chunks buffered up to this point. The
421                // gate flips exactly once per spawn (across all
422                // passes) — `id_emitted` persists outside the
423                // restart loop.
424                if !id_emitted && log_writer.written_once() {
425                    let (hier, _) = identity
426                        .as_ref()
427                        .expect("identity set above on the first chunk");
428                    yield ResponseItem::Id(hier.clone());
429                    for c in buffered.drain(..) {
430                        yield ResponseItem::Chunk(c);
431                    }
432                    id_emitted = true;
433                }
434
435                if id_emitted {
436                    yield ResponseItem::Chunk(chunk);
437                } else {
438                    buffered.push(chunk);
439                }
440            }
441
442            // Post-stream: if the SDK closed before the LogWriter
443            // ever flipped `written_once` true (e.g. very fast EOF
444            // ahead of the listener's first batch), wait for the
445            // first persistence to land, then emit the Id + drain
446            // any held chunks. Only fires when we actually have
447            // chunks queued behind the gate.
448            if !id_emitted && !buffered.is_empty() {
449                if let Err(e) = log_writer.wait_written_once().await {
450                    stream_err.get_or_insert_with(|| format!("log writer wait: {e}"));
451                } else {
452                    let (hier, _) = identity
453                        .as_ref()
454                        .expect("identity set on the first chunk");
455                    yield ResponseItem::Id(hier.clone());
456                    for c in buffered.drain(..) {
457                        yield ResponseItem::Chunk(c);
458                    }
459                    id_emitted = true;
460                }
461            }
462
463            // Finalize the log writer (consumes it; drops the
464            // sender; awaits the listener task). By construction
465            // this returns only after the queue is empty AND no
466            // work is in flight.
467            if let Err(e) = log_writer.finalize().await {
468                stream_err.get_or_insert_with(|| format!("log writer finalize: {e}"));
469            }
470            drop(sdk_stream);
471            drop(conduit);
472
473            if let Some(e) = stream_err {
474                Err(Error::Instance(e))?;
475            }
476
477            // End-of-pass: a pure EXISTS check against the spawn's
478            // single hierarchy. The conduit already promoted every
479            // sibling tag in the group during its in-stream reads
480            // via `read_pending_and_upgrade_tag` — so this check
481            // sees the post-upgrade `tags` state and catches
482            // anything queued mid-stream against a now-BOUND
483            // sibling. On `false`, fall through to the implicit
484            // registry drop on function return (no explicit destroy
485            // needed — there's only one claim and we're done with it).
486            let Some((hier, _full_id)) = identity.as_ref() else {
487                // Empty stream — nothing was claimed, nothing to
488                // restart. Just exit.
489                break;
490            };
491            let pending = crate::db::message_queue::check_any_pending(
492                ctx.db_client().await?, hier,
493            )
494            .await
495            .unwrap_or(false);
496            if !pending {
497                break;
498            }
499
500            // Restart with the latest continuation only. No new
501            // messages — the API picks up state from the
502            // continuation token.
503            params.messages = Vec::new();
504            params.continuation = last_continuation;
505        }
506    }
507}
508
509/// Resolve an [`AgentRef`] into a typed agent. `Resolved` passes
510/// through; `File` / `PythonInline` / `PythonFile` run their IO /
511/// Python here via the shared 5-variant resolver (the `simple`
512/// slot is never populated for agent refs — `--agent <ref>`
513/// strings parse at the clap layer).
514pub(crate) async fn resolve_agent_ref(
515    ctx: &Context,
516    agent: AgentRef,
517) -> Result<InlineAgentBaseWithFallbacksOrRemoteCommitOptional, Error> {
518    let (file, python_inline, python_file) = match agent {
519        AgentRef::Resolved(resolved) => return Ok(resolved),
520        AgentRef::File(p) => (Some(p), None, None),
521        AgentRef::PythonInline(code) => (None, Some(code), None),
522        AgentRef::PythonFile(p) => (None, None, Some(p)),
523    };
524    crate::source_resolver::resolve_source(
525        ctx,
526        None,
527        None,
528        file,
529        python_inline,
530        python_file,
531        |_| unreachable!("agent refs have no plain-text variant"),
532    )
533    .await
534}
535
536pub mod request_schema {
537    use objectiveai_sdk::cli::command::agents::spawn as sdk;
538    use objectiveai_sdk::cli::command::agents::spawn::request_schema::{Request, Response};
539
540    use crate::context::Context;
541    use crate::error::Error;
542
543    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
544        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Request)))
545    }
546}
547
548pub mod response_schema {
549    use objectiveai_sdk::cli::command::agents::spawn as sdk;
550    use objectiveai_sdk::cli::command::agents::spawn::response_schema::{Request, Response};
551
552    use crate::context::Context;
553    use crate::error::Error;
554
555    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
556        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Response)))
557    }
558}