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    // Resolve the agent target AND which laboratory targets its attachments
168    // are keyed on. Labs only apply in Tag/Instance mode (a direct Ref has no
169    // tag/AIH to key on). `run_multi_pass` does the actual DB resolution; here
170    // we only name the targets — the same 3 permutations as before: a grouped
171    // tag → the tag's labs; a resolved (Bound) tag → the tag's UNION the bound
172    // AIH's; an instance → the AIH's.
173    use crate::command::agents::locks::Family;
174    use crate::db::laboratory_attachments::Target;
175    let (mode, lab_targets, family): (Mode, Vec<Target>, Option<Family>) = match request.agent {
176        AgentSelector::Ref { agent } => (
177            Mode::Fresh {
178                agent: resolve_agent_ref(ctx, agent).await?,
179                tag: None,
180            },
181            Vec::new(),
182            None,
183        ),
184        AgentSelector::Tag { agent_tag } => {
185            match crate::db::tags::lookup(ctx.db_client().await?, &agent_tag).await? {
186                crate::db::tags::LookupState::Bound { agent_instance_hierarchy } => {
187                    let lab_targets = vec![
188                        Target::Tag(agent_tag.clone()),
189                        Target::Aih(agent_instance_hierarchy.clone()),
190                    ];
191                    (
192                        Mode::Historic {
193                            hierarchy: agent_instance_hierarchy.clone(),
194                        },
195                        lab_targets,
196                        Some(Family::Hierarchy(agent_instance_hierarchy)),
197                    )
198                }
199                crate::db::tags::LookupState::Grouped { agent_spec, tag_group_id, .. } => {
200                    let lab_targets = vec![Target::Tag(agent_tag.clone())];
201                    (
202                        Mode::Fresh {
203                            agent: agent_spec,
204                            tag: Some(agent_tag),
205                        },
206                        lab_targets,
207                        Some(Family::Group(tag_group_id)),
208                    )
209                }
210                crate::db::tags::LookupState::Absent => {
211                    return Err(Error::TagNotFound(agent_tag));
212                }
213            }
214        }
215        AgentSelector::Instance {
216            parent_agent_instance_hierarchy,
217            agent_instance,
218        } => {
219            let parent = parent_agent_instance_hierarchy
220                .as_deref()
221                .unwrap_or(&ctx.config.agent_instance_hierarchy);
222            let hierarchy = format!("{parent}/{agent_instance}");
223            let lab_targets = vec![Target::Aih(hierarchy.clone())];
224            (
225                Mode::Historic {
226                    hierarchy: hierarchy.clone(),
227                },
228                lab_targets,
229                Some(Family::Hierarchy(hierarchy)),
230            )
231        }
232    };
233
234    // Initial lock + params assembly. Acquire the agent's whole lock FAMILY
235    // (all-or-nothing, NON-BLOCKING) so that while it's live none of its tags
236    // can be relocated (`tags apply`) or have labs detached (`laboratories
237    // detach`): a GROUPED tag locks every tag in its group; a bound tag / AIH
238    // locks the AIH plus every tag bound to it. A held member means the agent
239    // (or another spawn of the tag) is already live → error. When the parent
240    // `agents message` transferred the family into this process, the lockfile
241    // adopts each claim lazily on this first acquire, so they re-acquire
242    // INSTANTLY rather than conflicting with the inherited handles. Mid-stream
243    // best-effort AIH claims in `run_multi_pass` are unaffected.
244    let state_dir = ctx.filesystem.state_dir();
245    let mut registry = AgentInstanceRegistry::new(state_dir.clone(), ctx.agent_locks_arc());
246    if let Some(family) = family {
247        let is_group = matches!(family, Family::Group(_));
248        match super::locks::try_acquire_family(
249            ctx.agent_locks(),
250            ctx.db_client().await?,
251            &state_dir,
252            family,
253        )
254        .await?
255        {
256            Some(fam) => {
257                if let Some((hierarchy, aih_lock)) = fam.aih {
258                    registry.preseed(hierarchy, aih_lock);
259                }
260                registry.hold_tag_claims(fam.tags);
261            }
262            None if is_group => {
263                // GROUPED: name the requested tag in the error.
264                let tag = match &mode {
265                    Mode::Fresh { tag: Some(tag), .. } => tag.clone(),
266                    _ => String::new(),
267                };
268                return Err(Error::AgentTagActive { tag });
269            }
270            None => {
271                let agent_instance_hierarchy = match &mode {
272                    Mode::Historic { hierarchy } => hierarchy.clone(),
273                    _ => String::new(),
274                };
275                return Err(Error::AgentInstanceActive {
276                    agent_instance_hierarchy,
277                });
278            }
279        }
280    }
281    let (agent, agent_tag, continuation) = match mode {
282        Mode::Fresh { agent, tag } => (agent, tag, None),
283        Mode::Historic { hierarchy } => {
284            let lookup = crate::db::logs::lookup_session(ctx.db_client().await?, &hierarchy)
285                .await?
286                .ok_or(Error::AgentNoPriorRequest {
287                    agent_instance_hierarchy: hierarchy,
288                })?;
289            (lookup.agent, None, lookup.continuation)
290        }
291    };
292
293    // Message-queue delivery to the live API happens through the
294    // conduit's `read_pending_and_upgrade_tag` call — the API
295    // pulls pending rows on demand as the stream runs and stamps
296    // their ids onto the first emitted assistant chunk's
297    // `request_message_ids`. No pre-spawn drain + prepend here.
298    //
299    // `run_multi_pass` builds the create-params and resolves the laboratory
300    // attachments (from `lab_targets`) internally.
301    let ctx_clone = ctx.clone();
302    Ok(Box::pin(run_multi_pass(
303        ctx_clone,
304        messages,
305        agent,
306        seed,
307        continuation,
308        agent_tag,
309        lab_targets,
310        registry,
311    )))
312}
313
314/// Drives one or more stream passes until no seen hierarchy has
315/// pending `message_queue` items. Each pass opens a fresh WS
316/// stream + log writer + MCP server + conduit; the
317/// [`AgentInstanceRegistry`] (carrying any initial AIH/tag claim)
318/// persists across passes so an agent's lock stays held for the
319/// whole spawn lifetime, not per-pass — and is released when the
320/// stream (and with it the registry) drops.
321/// Resolve the laboratory ids attached to `lab_targets` into the request's
322/// `laboratories` value. Lists every target CONCURRENTLY (one pool serves
323/// concurrent queries), then flattens + dedups (first-seen order). `None` when
324/// no targets / no attachments. Shared by `agents spawn` and `agents queue
325/// deliver` (both go through `run_multi_pass`). No liveness check — the conduit
326/// dials each laboratory on demand at MCP-initialize time.
327async fn resolve_laboratories(
328    ctx: &Context,
329    lab_targets: &[crate::db::laboratory_attachments::Target],
330) -> Result<Option<Vec<objectiveai_sdk::laboratories::Laboratory>>, Error> {
331    if lab_targets.is_empty() {
332        return Ok(None);
333    }
334    let pool = ctx.db_client().await?;
335    let lists = futures::future::try_join_all(
336        lab_targets
337            .iter()
338            .map(|target| crate::db::laboratory_attachments::list(pool, target)),
339    )
340    .await?;
341    let mut ids: Vec<String> = Vec::new();
342    for list in lists {
343        for id in list {
344            if !ids.contains(&id) {
345                ids.push(id);
346            }
347        }
348    }
349    if ids.is_empty() {
350        return Ok(None);
351    }
352    Ok(Some(
353        ids.into_iter()
354            .map(|id| {
355                objectiveai_sdk::laboratories::Laboratory::Client(
356                    objectiveai_sdk::laboratories::ClientLaboratory {
357                        r#type: objectiveai_sdk::laboratories::ClientLaboratoryType::Client,
358                        id,
359                    },
360                )
361            })
362            .collect(),
363    ))
364}
365
366#[allow(clippy::too_many_arguments)]
367pub(crate) fn run_multi_pass(
368    ctx: Context,
369    messages: Vec<Message>,
370    agent: InlineAgentBaseWithFallbacksOrRemoteCommitOptional,
371    seed: Option<i64>,
372    continuation: Option<String>,
373    agent_tag: Option<String>,
374    lab_targets: Vec<crate::db::laboratory_attachments::Target>,
375    mut registry: AgentInstanceRegistry,
376) -> impl Stream<Item = Result<ResponseItem, Error>> + Send {
377    async_stream::try_stream! {
378        // Resolve the agent's laboratory attachments (from the named targets)
379        // and assemble the create-params. `provider`/`response_format` are
380        // always defaulted and `stream` is always true for the in-process WS
381        // path; only `messages`/`continuation` change across restart passes.
382        let laboratories = resolve_laboratories(&ctx, &lab_targets).await?;
383        let mut params = AgentCompletionCreateParams {
384            messages,
385            provider: None,
386            agent,
387            response_format: None,
388            seed,
389            stream: Some(true),
390            continuation,
391            laboratories,
392        };
393        // A spawn has exactly one `(agent_instance_hierarchy,
394        // agent_full_id)` pair — set by the API on the very first
395        // chunk and never changes across restart passes. Capture
396        // once; reuse forever. `None` until the first chunk lands.
397        let mut identity: Option<(String, String)> = None;
398        // Has `ResponseItem::Id` been yielded yet? Persists across
399        // restart passes — the spawn-id handshake is a one-time
400        // event, gated on the LogWriter's `written_once` signal so
401        // the caller only sees the Id after at least one log row
402        // has been persisted.
403        let mut id_emitted = false;
404        // Resolve the MCP client tuning once for the whole spawn; every
405        // pass's conduit reuses these (cheap to pass per pass).
406        let mcp_timeout_ms = ctx.resolve_mcp_timeout_ms().await?;
407        let backoff_max_elapsed_time_ms =
408            ctx.resolve_backoff_max_elapsed_time_ms().await?;
409
410        loop {
411            // Per-pass resources. New WS connection, new log writer,
412            // new conduit + MCP server. The registry survives across
413            // passes (see above).
414            let mcp_server =
415                crate::websockets::mcp_server::spawn(ctx.clone());
416            let conduit =
417                crate::websockets::conduit::ConduitMcpHandler::new(
418                    mcp_server,
419                    ctx.clone(),
420                    agent_tag.clone(),
421                    mcp_timeout_ms,
422                    backoff_max_elapsed_time_ms,
423                );
424            // Spawn.rs doesn't need the primary-id ready signal —
425            // it yields `ResponseItem::Id` from
426            // `chunk.agent_instance_hierarchy` directly on the first
427            // chunk. Drop the receiver.
428            let (log_writer, _ready_rx) = crate::db::logs::write_agent_completion(
429                ctx.db_client().await?,
430                &params,
431                ctx.config.agent_instance_hierarchy.clone(),
432            )
433            .map_err(|e| Error::Instance(format!(
434                "failed to build agent-completion log writer: {e}"
435            )))?;
436
437            let (sdk_stream, notifier) =
438                objectiveai_sdk::agent::completions::create_agent_completion_streaming(
439                    ctx.api_client().await?,
440                    params.clone(),
441                    conduit.clone(),
442                )
443                .await
444                .map_err(|e| Error::Instance(format!(
445                    "failed to open agent-completion stream: {e}"
446                )))?;
447            conduit.install_notifier(notifier);
448
449            let mut sdk_stream = Box::pin(sdk_stream);
450            let mut last_continuation: Option<String> = None;
451            // Per-pass buffer of chunks held back until the
452            // LogWriter confirms it has persisted at least once.
453            // Only meaningful for pass 1 — pass 2+ already has
454            // `id_emitted = true` from a prior pass, so the buffer
455            // gate never triggers and chunks flow through directly.
456            let mut buffered: Vec<
457                objectiveai_sdk::agent::completions::response::streaming::AgentCompletionChunk,
458            > = Vec::new();
459            let mut stream_err: Option<String> = None;
460
461            while let Some(item) = sdk_stream.next().await {
462                let chunk = match item {
463                    Ok(c) => c,
464                    Err(e) => {
465                        stream_err = Some(format!("agent stream item error: {e}"));
466                        break;
467                    }
468                };
469
470                // First chunk EVER (first pass, first chunk):
471                // capture the spawn's identity + claim the lock
472                // file. Tag-group upgrade is owned by the conduit's
473                // `read_pending_and_upgrade_tag`, which the API
474                // fires before the very first chunk is produced —
475                // no upgrade fan-out is needed here. The
476                // `ResponseItem::Id` handshake itself fires later,
477                // gated on `log_writer.written_once()`.
478                if identity.is_none() {
479                    let hier = chunk.agent_instance_hierarchy.clone();
480                    let full_id = chunk.agent_full_id.clone();
481                    registry.observe(&hier).await;
482                    identity = Some((hier, full_id));
483                }
484
485                // Latest continuation seen on the wire — what we
486                // use to restart if pending messages turn up at
487                // EOF. Only the terminal chunk usually carries one.
488                if let Some(c) = chunk.continuation.as_deref() {
489                    last_continuation = Some(c.to_string());
490                }
491
492                // Upsert any `(AIH, continuation)` pairs the chunk
493                // carries into the `agent_continuations` registry
494                // (cumulative chunks always yield exactly one pair;
495                // the Vec is 0-or-1 long depending on whether
496                // `continuation` is `Some`). Awaited before the
497                // log-writer send + downstream yield so the registry
498                // row is visible by the time the chunk leaves this
499                // body.
500                let mut continuation_upserts: Vec<_> = Vec::new();
501                for (hier, continuation) in chunk.agent_instance_hierarchies() {
502                    if let Some(c) = continuation {
503                        continuation_upserts.push(
504                            crate::db::agent_continuations::upsert(ctx.db_client().await?, hier, c),
505                        );
506                    }
507                }
508                if let Err(e) =
509                    futures::future::try_join_all(continuation_upserts).await
510                {
511                    stream_err =
512                        Some(format!("agent_continuations upsert: {e}"));
513                    break;
514                }
515
516                // Log + forward. The write is a synchronous mpsc
517                // send into the LogWriter's listener task — DB IO
518                // happens off this critical path. Clone the chunk
519                // for the listener; the original yields downstream
520                // (or sits in the buffer until the Id gate opens).
521                if let Err(e) = log_writer.write(chunk.clone()) {
522                    stream_err = Some(format!("log writer error: {e}"));
523                    break;
524                }
525
526                // Id gate: once the LogWriter signals it has
527                // persisted at least one batch, yield the Id and
528                // drain any chunks buffered up to this point. The
529                // gate flips exactly once per spawn (across all
530                // passes) — `id_emitted` persists outside the
531                // restart loop.
532                if !id_emitted && log_writer.written_once() {
533                    let (hier, _) = identity
534                        .as_ref()
535                        .expect("identity set above on the first chunk");
536                    yield ResponseItem::Id(hier.clone());
537                    for c in buffered.drain(..) {
538                        yield ResponseItem::Chunk(c);
539                    }
540                    id_emitted = true;
541                }
542
543                if id_emitted {
544                    yield ResponseItem::Chunk(chunk);
545                } else {
546                    buffered.push(chunk);
547                }
548            }
549
550            // Post-stream: if the SDK closed before the LogWriter
551            // ever flipped `written_once` true (e.g. very fast EOF
552            // ahead of the listener's first batch), wait for the
553            // first persistence to land, then emit the Id + drain
554            // any held chunks. Only fires when we actually have
555            // chunks queued behind the gate.
556            if !id_emitted && !buffered.is_empty() {
557                if let Err(e) = log_writer.wait_written_once().await {
558                    stream_err.get_or_insert_with(|| format!("log writer wait: {e}"));
559                } else {
560                    let (hier, _) = identity
561                        .as_ref()
562                        .expect("identity set on the first chunk");
563                    yield ResponseItem::Id(hier.clone());
564                    for c in buffered.drain(..) {
565                        yield ResponseItem::Chunk(c);
566                    }
567                    id_emitted = true;
568                }
569            }
570
571            // Finalize the log writer (consumes it; drops the
572            // sender; awaits the listener task). By construction
573            // this returns only after the queue is empty AND no
574            // work is in flight.
575            if let Err(e) = log_writer.finalize().await {
576                stream_err.get_or_insert_with(|| format!("log writer finalize: {e}"));
577            }
578            drop(sdk_stream);
579            drop(conduit);
580
581            if let Some(e) = stream_err {
582                Err(Error::Instance(e))?;
583            }
584
585            // End-of-pass: a pure EXISTS check against the spawn's
586            // single hierarchy. The conduit already promoted every
587            // sibling tag in the group during its in-stream reads
588            // via `read_pending_and_upgrade_tag` — so this check
589            // sees the post-upgrade `tags` state and catches
590            // anything queued mid-stream against a now-BOUND
591            // sibling. On `false`, fall through to the implicit
592            // registry drop on function return (no explicit destroy
593            // needed — there's only one claim and we're done with it).
594            let Some((hier, _full_id)) = identity.as_ref() else {
595                // Empty stream — nothing was claimed, nothing to
596                // restart. Just exit.
597                break;
598            };
599            let pending = crate::db::message_queue::check_any_pending(
600                ctx.db_client().await?, hier,
601            )
602            .await
603            .unwrap_or(false);
604            if !pending {
605                break;
606            }
607
608            // Restart with the latest continuation only. No new
609            // messages — the API picks up state from the
610            // continuation token. Re-resolve the agent's laboratory
611            // attachments too: one may have been attached or detached
612            // while this pass ran, and each pass must dial whatever is
613            // attached NOW.
614            params.messages = Vec::new();
615            params.continuation = last_continuation;
616            params.laboratories = resolve_laboratories(&ctx, &lab_targets).await?;
617        }
618    }
619}
620
621/// Resolve an [`AgentRef`] into a typed agent. `Resolved` passes
622/// through; `File` / `PythonInline` / `PythonFile` run their IO /
623/// Python here via the shared 5-variant resolver (the `simple`
624/// slot is never populated for agent refs — `--agent <ref>`
625/// strings parse at the clap layer).
626pub(crate) async fn resolve_agent_ref(
627    ctx: &Context,
628    agent: AgentRef,
629) -> Result<InlineAgentBaseWithFallbacksOrRemoteCommitOptional, Error> {
630    let (file, python_inline, python_file) = match agent {
631        AgentRef::Resolved(resolved) => return Ok(resolved),
632        AgentRef::File(p) => (Some(p), None, None),
633        AgentRef::PythonInline(code) => (None, Some(code), None),
634        AgentRef::PythonFile(p) => (None, None, Some(p)),
635    };
636    crate::source_resolver::resolve_source(
637        ctx,
638        None,
639        None,
640        file,
641        python_inline,
642        python_file,
643        |_| unreachable!("agent refs have no plain-text variant"),
644    )
645    .await
646}
647
648pub mod request_schema {
649    use objectiveai_sdk::cli::command::agents::spawn as sdk;
650    use objectiveai_sdk::cli::command::agents::spawn::request_schema::{Request, Response};
651
652    use crate::context::Context;
653    use crate::error::Error;
654
655    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
656        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Request)))
657    }
658}
659
660pub mod response_schema {
661    use objectiveai_sdk::cli::command::agents::spawn as sdk;
662    use objectiveai_sdk::cli::command::agents::spawn::response_schema::{Request, Response};
663
664    use crate::context::Context;
665    use crate::error::Error;
666
667    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
668        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Response)))
669    }
670}