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 ¶ms,
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}