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