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