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    // Spawn-by-SPEC detection for the agent_refs registry: only a
176    // direct `--agent` ref counts — spawns via tag or AIH resume or
177    // rebind an existing agent and record nothing.
178    let by_spec = matches!(request.agent, AgentSelector::Ref { .. });
179    let (mode, lab_targets, family): (Mode, Vec<Target>, Option<Family>) = match request.agent {
180        AgentSelector::Ref { agent } => (
181            Mode::Fresh {
182                agent: resolve_agent_ref(ctx, agent).await?,
183                tag: None,
184            },
185            Vec::new(),
186            None,
187        ),
188        AgentSelector::Tag { agent_tag } => {
189            match crate::db::tags::lookup(ctx.db_client().await?, &agent_tag).await? {
190                crate::db::tags::LookupState::Bound { agent_instance_hierarchy } => {
191                    let lab_targets = vec![
192                        Target::Tag(agent_tag.clone()),
193                        Target::Aih(agent_instance_hierarchy.clone()),
194                    ];
195                    (
196                        Mode::Historic {
197                            hierarchy: agent_instance_hierarchy.clone(),
198                        },
199                        lab_targets,
200                        Some(Family::Hierarchy(agent_instance_hierarchy)),
201                    )
202                }
203                crate::db::tags::LookupState::Grouped { agent_spec, tag_group_id, .. } => {
204                    let lab_targets = vec![Target::Tag(agent_tag.clone())];
205                    (
206                        Mode::Fresh {
207                            agent: agent_spec,
208                            tag: Some(agent_tag),
209                        },
210                        lab_targets,
211                        Some(Family::Group(tag_group_id)),
212                    )
213                }
214                crate::db::tags::LookupState::Absent => {
215                    return Err(Error::TagNotFound(agent_tag));
216                }
217            }
218        }
219        AgentSelector::Instance {
220            parent_agent_instance_hierarchy,
221            agent_instance,
222        } => {
223            let parent = parent_agent_instance_hierarchy
224                .as_deref()
225                .unwrap_or(&ctx.config.agent_instance_hierarchy);
226            let hierarchy = format!("{parent}/{agent_instance}");
227            let lab_targets = vec![Target::Aih(hierarchy.clone())];
228            (
229                Mode::Historic {
230                    hierarchy: hierarchy.clone(),
231                },
232                lab_targets,
233                Some(Family::Hierarchy(hierarchy)),
234            )
235        }
236    };
237
238    // Initial lock + params assembly. Acquire the agent's whole lock FAMILY
239    // (all-or-nothing, NON-BLOCKING) so that while it's live none of its tags
240    // can be relocated (`tags apply`) or have labs detached (`laboratories
241    // detach`): a GROUPED tag locks every tag in its group; a bound tag / AIH
242    // locks the AIH plus every tag bound to it. A held member means the agent
243    // (or another spawn of the tag) is already live → error. When the parent
244    // `agents message` transferred the family into this process, the lockfile
245    // adopts each claim lazily on this first acquire, so they re-acquire
246    // INSTANTLY rather than conflicting with the inherited handles. Mid-stream
247    // best-effort AIH claims in `run_multi_pass` are unaffected.
248    let state_dir = ctx.filesystem.state_dir();
249    let mut registry = AgentInstanceRegistry::new(state_dir.clone(), ctx.agent_locks_arc());
250    if let Some(family) = family {
251        let is_group = matches!(family, Family::Group(_));
252        match super::locks::try_acquire_family(
253            ctx.agent_locks(),
254            ctx.db_client().await?,
255            &state_dir,
256            family,
257        )
258        .await?
259        {
260            Some(fam) => {
261                if let Some((hierarchy, aih_lock)) = fam.aih {
262                    registry.preseed(hierarchy, aih_lock);
263                }
264                registry.hold_tag_claims(fam.tags);
265            }
266            None if is_group => {
267                // GROUPED: name the requested tag in the error.
268                let tag = match &mode {
269                    Mode::Fresh { tag: Some(tag), .. } => tag.clone(),
270                    _ => String::new(),
271                };
272                return Err(Error::AgentTagActive { tag });
273            }
274            None => {
275                let agent_instance_hierarchy = match &mode {
276                    Mode::Historic { hierarchy } => hierarchy.clone(),
277                    _ => String::new(),
278                };
279                return Err(Error::AgentInstanceActive {
280                    agent_instance_hierarchy,
281                });
282            }
283        }
284    }
285    let (agent, agent_tag, continuation) = match mode {
286        Mode::Fresh { agent, tag } => (agent, tag, None),
287        Mode::Historic { hierarchy } => {
288            // The AIH lock is HELD here (family acquired above) —
289            // failures are loggable, per the rule. Ad-hoc tee: the
290            // spawn-long tee is created inside `run_multi_pass`,
291            // which this error prevents from ever starting.
292            let lookup = async {
293                crate::db::logs::lookup_session(ctx.db_client().await?, &hierarchy)
294                    .await?
295                    .ok_or(Error::AgentNoPriorRequest {
296                        agent_instance_hierarchy: hierarchy.clone(),
297                    })
298            }
299            .await;
300            let lookup = match lookup {
301                Ok(lookup) => lookup,
302                Err(e) => {
303                    let tee =
304                        crate::db::logs::ConversationTee::spawn(ctx.filesystem.state_dir());
305                    note_error(ctx, &tee, Some(&hierarchy), None, &e).await;
306                    return Err(e);
307                }
308            };
309            (lookup.agent, None, lookup.continuation)
310        }
311    };
312
313    // The definition source to record at AIH-lock acquisition —
314    // spawn-by-SPEC only.
315    let agent_ref = if by_spec {
316        match &agent {
317            InlineAgentBaseWithFallbacksOrRemoteCommitOptional::Remote(remote) => {
318                crate::db::agent_refs::AgentRefValue::remote(remote)
319            }
320            InlineAgentBaseWithFallbacksOrRemoteCommitOptional::AgentBase(spec) => {
321                crate::db::agent_refs::AgentRefValue::inline(spec)
322            }
323        }
324    } else {
325        None
326    };
327
328    // Message-queue delivery to the live API happens through the
329    // conduit's `read_pending_and_upgrade_tag` call — the API
330    // pulls pending rows on demand as the stream runs and stamps
331    // their ids onto the first emitted assistant chunk's
332    // `request_message_ids`. No pre-spawn drain + prepend here.
333    //
334    // `run_multi_pass` builds the create-params and resolves the laboratory
335    // attachments (from `lab_targets`) internally.
336    let ctx_clone = ctx.clone();
337    Ok(Box::pin(run_multi_pass(
338        ctx_clone,
339        messages,
340        agent,
341        seed,
342        continuation,
343        agent_tag,
344        lab_targets,
345        agent_ref,
346        registry,
347    )))
348}
349
350/// Drives one or more stream passes until no seen hierarchy has
351/// pending `message_queue` items. Each pass opens a fresh WS
352/// stream + log writer + MCP server + conduit; the
353/// [`AgentInstanceRegistry`] (carrying any initial AIH/tag claim)
354/// persists across passes so an agent's lock stays held for the
355/// whole spawn lifetime, not per-pass — and is released when the
356/// stream (and with it the registry) drops.
357/// Resolve the laboratory ids attached to `lab_targets` into the request's
358/// `laboratories` value. Lists every target CONCURRENTLY (one pool serves
359/// concurrent queries), then flattens + dedups (first-seen order). `None` when
360/// no targets / no attachments. Shared by `agents spawn` and `agents queue
361/// deliver` (both go through `run_multi_pass`). No liveness check — the conduit
362/// dials each laboratory on demand at MCP-initialize time.
363async fn resolve_laboratories(
364    ctx: &Context,
365    lab_targets: &[crate::db::laboratory_attachments::Target],
366) -> Result<Option<Vec<objectiveai_sdk::laboratories::Laboratory>>, Error> {
367    if lab_targets.is_empty() {
368        return Ok(None);
369    }
370    let pool = ctx.db_client().await?;
371    let lists = futures::future::try_join_all(
372        lab_targets
373            .iter()
374            .map(|target| crate::db::laboratory_attachments::list(pool, target)),
375    )
376    .await?;
377    let mut ids: Vec<String> = Vec::new();
378    for list in lists {
379        for id in list {
380            if !ids.contains(&id) {
381                ids.push(id);
382            }
383        }
384    }
385    if ids.is_empty() {
386        return Ok(None);
387    }
388    Ok(Some(
389        ids.into_iter()
390            .map(|id| {
391                objectiveai_sdk::laboratories::Laboratory::Client(
392                    objectiveai_sdk::laboratories::ClientLaboratory {
393                        r#type: objectiveai_sdk::laboratories::ClientLaboratoryType::Client,
394                        id,
395                    },
396                )
397            })
398            .collect(),
399    ))
400}
401
402/// Persist + tee one spawn-path error. THE LOGGING RULE: an error is
403/// recorded iff the AIH is known at the moment it occurs — the AIH
404/// lock is held (Historic / Instance spawns hold it from acquisition;
405/// `observe` claims it on the first chunk) or identity has been
406/// minted. `aih == None` (a grouped-tag / ref spawn failing before its
407/// first chunk) is a silent no-op by design. Best-effort: its own
408/// failure is swallowed — there is nowhere left to report it.
409pub(crate) async fn note_error(
410    ctx: &Context,
411    tee: &crate::db::logs::ConversationTee,
412    aih: Option<&str>,
413    response_id: Option<&str>,
414    error: &Error,
415) {
416    let Some(aih) = aih else { return };
417    let Ok(pool) = ctx.db_client().await else {
418        return;
419    };
420    let value = error.output_message();
421    let timestamp = std::time::SystemTime::now()
422        .duration_since(std::time::UNIX_EPOCH)
423        .map(|d| d.as_secs() as i64)
424        .unwrap_or(0);
425    // Persist BEFORE returning the error to the caller; tee after (the
426    // tee is fire-and-forget).
427    if crate::db::logs::insert_error(pool, aih, response_id, &value, timestamp)
428        .await
429        .is_ok()
430    {
431        tee.send(crate::db::logs::error_frame(
432            aih.to_string(),
433            response_id.map(str::to_string),
434            value,
435            timestamp,
436        ));
437    }
438}
439
440#[allow(clippy::too_many_arguments)]
441pub(crate) fn run_multi_pass(
442    ctx: Context,
443    messages: Vec<Message>,
444    agent: InlineAgentBaseWithFallbacksOrRemoteCommitOptional,
445    seed: Option<i64>,
446    continuation: Option<String>,
447    agent_tag: Option<String>,
448    lab_targets: Vec<crate::db::laboratory_attachments::Target>,
449    agent_ref: Option<crate::db::agent_refs::AgentRefValue>,
450    mut registry: AgentInstanceRegistry,
451) -> impl Stream<Item = Result<ResponseItem, Error>> + Send {
452    async_stream::try_stream! {
453        let mut agent_ref = agent_ref;
454        // One live-conversation tee for the whole spawn (created FIRST
455        // so even pre-loop failures can ship their error frame): every
456        // pass's log writer shares the one daemon socket connection.
457        let conversation_tee =
458            crate::db::logs::ConversationTee::spawn(ctx.filesystem.state_dir());
459        // Resolve the agent's laboratory attachments (from the named targets)
460        // and assemble the create-params. `provider`/`response_format` are
461        // always defaulted and `stream` is always true for the in-process WS
462        // path; only `messages`/`continuation` change across restart passes.
463        let laboratories = match resolve_laboratories(&ctx, &lab_targets).await {
464            Ok(laboratories) => laboratories,
465            Err(e) => {
466                note_error(&ctx, &conversation_tee, registry.aih(), None, &e).await;
467                Err(e)?;
468                unreachable!("Err(e)? diverges");
469            }
470        };
471        let mut params = AgentCompletionCreateParams {
472            messages,
473            provider: None,
474            agent,
475            response_format: None,
476            seed,
477            stream: Some(true),
478            continuation,
479            laboratories,
480        };
481        // A spawn has exactly one `(agent_instance_hierarchy,
482        // agent_full_id)` pair — set by the API on the very first
483        // chunk and never changes across restart passes. Capture
484        // once; reuse forever. `None` until the first chunk lands.
485        let mut identity: Option<(String, String)> = None;
486        // Has `ResponseItem::Id` been yielded yet? Persists across
487        // restart passes — the spawn-id handshake is a one-time
488        // event, gated on the LogWriter's `written_once` signal so
489        // the caller only sees the Id after at least one log row
490        // has been persisted.
491        let mut id_emitted = false;
492        // Resolve the MCP client tuning once for the whole spawn; every
493        // pass's conduit reuses these (cheap to pass per pass).
494        let tuning = async {
495            Ok::<_, Error>((
496                ctx.resolve_mcp_timeout_ms().await?,
497                ctx.resolve_backoff_max_elapsed_time_ms().await?,
498            ))
499        }
500        .await;
501        let (mcp_timeout_ms, backoff_max_elapsed_time_ms) = match tuning {
502            Ok(tuning) => tuning,
503            Err(e) => {
504                note_error(&ctx, &conversation_tee, registry.aih(), None, &e).await;
505                Err(e)?;
506                unreachable!("Err(e)? diverges");
507            }
508        };
509        // Track the most recent response id seen on the wire — the id
510        // logged errors attach to once a stream has existed.
511        let mut last_response_id: Option<String> = None;
512
513        loop {
514            // Per-pass resources. New WS connection, new log writer,
515            // new conduit + MCP server. The registry survives across
516            // passes (see above).
517            let mcp_server =
518                crate::websockets::mcp_server::spawn(ctx.clone());
519            let conduit =
520                crate::websockets::conduit::ConduitMcpHandler::new(
521                    mcp_server,
522                    ctx.clone(),
523                    agent_tag.clone(),
524                    mcp_timeout_ms,
525                    backoff_max_elapsed_time_ms,
526                );
527            // Spawn.rs doesn't need the primary-id ready signal —
528            // it yields `ResponseItem::Id` from
529            // `chunk.agent_instance_hierarchy` directly on the first
530            // chunk. Drop the receiver.
531            let pool = match ctx.db_client().await {
532                Ok(pool) => pool,
533                Err(e) => {
534                    let e = Error::from(e);
535                    note_error(
536                        &ctx,
537                        &conversation_tee,
538                        identity.as_ref().map(|(h, _)| h.as_str()).or(registry.aih()),
539                        last_response_id.as_deref(),
540                        &e,
541                    )
542                    .await;
543                    Err(e)?;
544                    unreachable!("Err(e)? diverges");
545                }
546            };
547            let (log_writer, _ready_rx) = match crate::db::logs::write_agent_completion(
548                pool,
549                &params,
550                ctx.config.agent_instance_hierarchy.clone(),
551                Some(conversation_tee.clone()),
552            )
553            .map_err(|e| Error::Instance(format!(
554                "failed to build agent-completion log writer: {e}"
555            ))) {
556                Ok(writer) => writer,
557                Err(e) => {
558                    note_error(
559                        &ctx,
560                        &conversation_tee,
561                        identity.as_ref().map(|(h, _)| h.as_str()).or(registry.aih()),
562                        last_response_id.as_deref(),
563                        &e,
564                    )
565                    .await;
566                    Err(e)?;
567                    unreachable!("Err(e)? diverges");
568                }
569            };
570
571            let stream_open = async {
572                objectiveai_sdk::agent::completions::create_agent_completion_streaming(
573                    ctx.api_client().await?,
574                    params.clone(),
575                    conduit.clone(),
576                )
577                .await
578                .map_err(|e| Error::Instance(format!(
579                    "failed to open agent-completion stream: {e}"
580                )))
581            }
582            .await;
583            let (sdk_stream, notifier) = match stream_open {
584                Ok(opened) => opened,
585                Err(e) => {
586                    note_error(
587                        &ctx,
588                        &conversation_tee,
589                        identity.as_ref().map(|(h, _)| h.as_str()).or(registry.aih()),
590                        last_response_id.as_deref(),
591                        &e,
592                    )
593                    .await;
594                    Err(e)?;
595                    unreachable!("Err(e)? diverges");
596                }
597            };
598            conduit.install_notifier(notifier);
599
600            let mut sdk_stream = Box::pin(sdk_stream);
601            let mut last_continuation: Option<String> = None;
602            // Per-pass buffer of chunks held back until the
603            // LogWriter confirms it has persisted at least once.
604            // Only meaningful for pass 1 — pass 2+ already has
605            // `id_emitted = true` from a prior pass, so the buffer
606            // gate never triggers and chunks flow through directly.
607            let mut buffered: Vec<
608                objectiveai_sdk::agent::completions::response::streaming::AgentCompletionChunk,
609            > = Vec::new();
610            let mut stream_err: Option<String> = None;
611
612            while let Some(item) = sdk_stream.next().await {
613                let chunk = match item {
614                    Ok(c) => c,
615                    Err(e) => {
616                        stream_err = Some(format!("agent stream item error: {e}"));
617                        break;
618                    }
619                };
620
621                // The id every subsequently-logged error attaches to.
622                last_response_id = Some(chunk.id.clone());
623
624                // First chunk EVER (first pass, first chunk):
625                // capture the spawn's identity + claim the lock
626                // file. Tag-group upgrade is owned by the conduit's
627                // `read_pending_and_upgrade_tag`, which the API
628                // fires before the very first chunk is produced —
629                // no upgrade fan-out is needed here. The
630                // `ResponseItem::Id` handshake itself fires later,
631                // gated on `log_writer.written_once()`.
632                if identity.is_none() {
633                    let hier = chunk.agent_instance_hierarchy.clone();
634                    let full_id = chunk.agent_full_id.clone();
635                    registry.observe(&hier).await;
636                    // Spawn-by-spec: record the definition source the
637                    // moment the AIH lock is acquired.
638                    if let Some(value) = agent_ref.take() {
639                        let upsert = async {
640                            crate::db::agent_refs::upsert(
641                                ctx.db_client().await?,
642                                &hier,
643                                value,
644                            )
645                            .await?;
646                            Ok::<_, Error>(())
647                        }
648                        .await;
649                        if let Err(e) = upsert {
650                            // Fold into the consolidated raise: it
651                            // runs AFTER `log_writer.finalize()`, so
652                            // the error row lands after every queued
653                            // conversation row — never out of order.
654                            stream_err = Some(format!("agent_refs upsert: {e}"));
655                            break;
656                        }
657                    }
658                    identity = Some((hier, full_id));
659                }
660
661                // Latest continuation seen on the wire — what we
662                // use to restart if pending messages turn up at
663                // EOF. Only the terminal chunk usually carries one.
664                if let Some(c) = chunk.continuation.as_deref() {
665                    last_continuation = Some(c.to_string());
666                }
667
668                // Upsert any `(AIH, continuation)` pairs the chunk
669                // carries into the `agent_continuations` registry
670                // (cumulative chunks always yield exactly one pair;
671                // the Vec is 0-or-1 long depending on whether
672                // `continuation` is `Some`). Awaited before the
673                // log-writer send + downstream yield so the registry
674                // row is visible by the time the chunk leaves this
675                // body.
676                let mut continuation_upserts: Vec<_> = Vec::new();
677                for (hier, continuation) in chunk.agent_instance_hierarchies() {
678                    if let Some(c) = continuation {
679                        continuation_upserts.push(
680                            crate::db::agent_continuations::upsert(ctx.db_client().await?, hier, c),
681                        );
682                    }
683                }
684                if let Err(e) =
685                    futures::future::try_join_all(continuation_upserts).await
686                {
687                    stream_err =
688                        Some(format!("agent_continuations upsert: {e}"));
689                    break;
690                }
691
692                // Log + forward. The write is a synchronous mpsc
693                // send into the LogWriter's listener task — DB IO
694                // happens off this critical path. Clone the chunk
695                // for the listener; the original yields downstream
696                // (or sits in the buffer until the Id gate opens).
697                if let Err(e) = log_writer.write(chunk.clone()) {
698                    stream_err = Some(format!("log writer error: {e}"));
699                    break;
700                }
701
702                // Id gate: once the LogWriter signals it has
703                // persisted at least one batch, yield the Id and
704                // drain any chunks buffered up to this point. The
705                // gate flips exactly once per spawn (across all
706                // passes) — `id_emitted` persists outside the
707                // restart loop.
708                if !id_emitted && log_writer.written_once() {
709                    let (hier, _) = identity
710                        .as_ref()
711                        .expect("identity set above on the first chunk");
712                    yield ResponseItem::Id(hier.clone());
713                    for c in buffered.drain(..) {
714                        yield ResponseItem::Chunk(c);
715                    }
716                    id_emitted = true;
717                }
718
719                if id_emitted {
720                    yield ResponseItem::Chunk(chunk);
721                } else {
722                    buffered.push(chunk);
723                }
724            }
725
726            // Post-stream: if the SDK closed before the LogWriter
727            // ever flipped `written_once` true (e.g. very fast EOF
728            // ahead of the listener's first batch), wait for the
729            // first persistence to land, then emit the Id + drain
730            // any held chunks. Only fires when we actually have
731            // chunks queued behind the gate.
732            if !id_emitted && !buffered.is_empty() {
733                if let Err(e) = log_writer.wait_written_once().await {
734                    stream_err.get_or_insert_with(|| format!("log writer wait: {e}"));
735                } else {
736                    let (hier, _) = identity
737                        .as_ref()
738                        .expect("identity set on the first chunk");
739                    yield ResponseItem::Id(hier.clone());
740                    for c in buffered.drain(..) {
741                        yield ResponseItem::Chunk(c);
742                    }
743                    id_emitted = true;
744                }
745            }
746
747            // Finalize the log writer (consumes it; drops the
748            // sender; awaits the listener task). By construction
749            // this returns only after the queue is empty AND no
750            // work is in flight.
751            if let Err(e) = log_writer.finalize().await {
752                stream_err.get_or_insert_with(|| format!("log writer finalize: {e}"));
753            }
754            drop(sdk_stream);
755            drop(conduit);
756
757            if let Some(e) = stream_err {
758                let e = Error::Instance(e);
759                note_error(
760                    &ctx,
761                    &conversation_tee,
762                    identity.as_ref().map(|(h, _)| h.as_str()).or(registry.aih()),
763                    last_response_id.as_deref(),
764                    &e,
765                )
766                .await;
767                Err(e)?;
768            }
769
770            // End-of-pass: a pure EXISTS check against the spawn's
771            // single hierarchy. The conduit already promoted every
772            // sibling tag in the group during its in-stream reads
773            // via `read_pending_and_upgrade_tag` — so this check
774            // sees the post-upgrade `tags` state and catches
775            // anything queued mid-stream against a now-BOUND
776            // sibling. On `false`, fall through to the implicit
777            // registry drop on function return (no explicit destroy
778            // needed — there's only one claim and we're done with it).
779            let Some((hier, _full_id)) = identity.as_ref() else {
780                // Empty stream — nothing was claimed, nothing to
781                // restart. Just exit.
782                break;
783            };
784            let pending = crate::db::message_queue::check_any_pending(
785                ctx.db_client().await?, hier,
786            )
787            .await
788            .unwrap_or(false);
789            if !pending {
790                break;
791            }
792
793            // Restart with the latest continuation only. No new
794            // messages — the API picks up state from the
795            // continuation token. Re-resolve the agent's laboratory
796            // attachments too: one may have been attached or detached
797            // while this pass ran, and each pass must dial whatever is
798            // attached NOW.
799            params.messages = Vec::new();
800            params.continuation = last_continuation;
801            params.laboratories = match resolve_laboratories(&ctx, &lab_targets).await {
802                Ok(laboratories) => laboratories,
803                Err(e) => {
804                    note_error(
805                        &ctx,
806                        &conversation_tee,
807                        identity.as_ref().map(|(h, _)| h.as_str()).or(registry.aih()),
808                        last_response_id.as_deref(),
809                        &e,
810                    )
811                    .await;
812                    Err(e)?;
813                    unreachable!("Err(e)? diverges");
814                }
815            };
816        }
817    }
818}
819
820/// Resolve an [`AgentRef`] into a typed agent. `Resolved` passes
821/// through; `File` / `PythonInline` / `PythonFile` run their IO /
822/// Python here via the shared 5-variant resolver (the `simple`
823/// slot is never populated for agent refs — `--agent <ref>`
824/// strings parse at the clap layer).
825pub(crate) async fn resolve_agent_ref(
826    ctx: &Context,
827    agent: AgentRef,
828) -> Result<InlineAgentBaseWithFallbacksOrRemoteCommitOptional, Error> {
829    let (file, python_inline, python_file) = match agent {
830        AgentRef::Resolved(resolved) => return Ok(resolved),
831        AgentRef::File(p) => (Some(p), None, None),
832        AgentRef::PythonInline(code) => (None, Some(code), None),
833        AgentRef::PythonFile(p) => (None, None, Some(p)),
834    };
835    crate::source_resolver::resolve_source(
836        ctx,
837        None,
838        None,
839        file,
840        python_inline,
841        python_file,
842        |_| unreachable!("agent refs have no plain-text variant"),
843    )
844    .await
845}
846
847pub mod request_schema {
848    use objectiveai_sdk::cli::command::agents::spawn as sdk;
849    use objectiveai_sdk::cli::command::agents::spawn::request_schema::{Request, Response};
850
851    use crate::context::Context;
852    use crate::error::Error;
853
854    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
855        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Request)))
856    }
857}
858
859pub mod response_schema {
860    use objectiveai_sdk::cli::command::agents::spawn as sdk;
861    use objectiveai_sdk::cli::command::agents::spawn::response_schema::{Request, Response};
862
863    use crate::context::Context;
864    use crate::error::Error;
865
866    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
867        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Response)))
868    }
869}