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