Skip to main content

objectiveai_cli/command/agents/
message.rs

1//! `agents message` — unary delivery primitive.
2//!
3//! The agent input is the shared [`AgentSelector`] — the same shape
4//! `agents spawn` takes, so an unspawned agent can be messaged:
5//!
6//! - **ref**: nothing to lock — exec a detached `agents spawn`
7//!   child (stream=true) carrying the resolved agent + message
8//!   inline, return its first item as `Id`.
9//! - **instance / BOUND tag** (the AIH lock) and **GROUPED tag**
10//!   (the tag lock): try_acquire. Won → exec the spawn child with
11//!   `skip_lock=true`; an AIH claim is TRANSFERRED into the child
12//!   (the lock then lives exactly as long as the child), a TAG claim
13//!   is held by THIS process instead — only AIH locks ever transfer
14//!   — and persists until this process exits → `Id`. Lost → enqueue
15//!   the message, then race `subscribe_delivered` (the row flipping
16//!   inactive means a live agent consumed it → `Delivered`) against
17//!   `wait_acquire` (the slot freed up first → reclaim our queue
18//!   row, exec the spawn child with the fresh claim → `Id`).
19//!
20//! The message payload is resolved ONCE in this process (file IO /
21//! Python run here); spawn children always receive the resolved
22//! `RichContent` via `--inline`. Fire-and-forget parking without
23//! the race lives in `agents enqueue`.
24
25use futures::StreamExt;
26use objectiveai_sdk::agent::completions::message::RichContent;
27use objectiveai_sdk::cli::command::agents::message::{Request, RequestMessage, Response};
28use objectiveai_sdk::cli::command::agents::selector::{AgentRef, AgentSelector};
29use objectiveai_sdk::cli::command::agents::spawn as spawn_sdk;
30use objectiveai_sdk::cli::command::{BinaryExecutor, CommandExecutor};
31
32use crate::context::Context;
33use crate::error::Error;
34
35pub async fn execute(ctx: &Context, request: Request) -> Result<Response, Error> {
36    let Request {
37        agent,
38        message,
39        dangerous_advanced,
40        ..
41    } = request;
42    let seed = dangerous_advanced.as_ref().and_then(|a| a.seed);
43
44    // Resolve the payload once, in this process.
45    let content = resolve_message(ctx, message).await?;
46
47    let state_dir = ctx.filesystem.state_dir();
48    let route = match agent {
49        AgentSelector::Ref { agent } => {
50            // Resolve file/python refs HERE too — the child gets the
51            // typed agent inline and never re-runs the Python.
52            let resolved = super::spawn::resolve_agent_ref(ctx, agent).await?;
53            Route::Ref {
54                child: AgentSelector::Ref {
55                    agent: AgentRef::Resolved(resolved),
56                },
57            }
58        }
59        AgentSelector::Instance {
60            parent_agent_instance_hierarchy,
61            agent_instance,
62        } => {
63            let parent = parent_agent_instance_hierarchy
64                .as_deref()
65                .unwrap_or(&ctx.config.agent_instance_hierarchy);
66            instance_route(&state_dir, format!("{parent}/{agent_instance}"))
67        }
68        AgentSelector::Tag { agent_tag } => {
69            match crate::db::tags::lookup(ctx.db_client().await?, &agent_tag).await? {
70                crate::db::tags::LookupState::Bound {
71                    agent_instance_hierarchy,
72                } => instance_route(&state_dir, agent_instance_hierarchy),
73                crate::db::tags::LookupState::Grouped { tag_group_id, .. } => {
74                    let (dir, key) = super::locks::agent_tag_lock(&state_dir, &agent_tag);
75                    Route::Locked {
76                        dir,
77                        key,
78                        family: super::locks::Family::Group(tag_group_id),
79                        hierarchy: None,
80                        tag: Some(agent_tag.clone()),
81                        child: AgentSelector::Tag { agent_tag },
82                    }
83                }
84                crate::db::tags::LookupState::Absent => {
85                    return Err(Error::TagNotFound(agent_tag));
86                }
87            }
88        }
89    };
90
91    match route {
92        Route::Ref { child } => spawn_child(child, content, seed, Vec::new()).await,
93        Route::Locked {
94            dir,
95            key,
96            family,
97            hierarchy,
98            tag,
99            child,
100        } => {
101            // Fast path: FETCH the current family + LOCK it (all-or-nothing) in
102            // one call. If every member is free, exec the spawn child carrying
103            // the family — transferred, so the child adopts it with no gap.
104            if let Some(fam) = super::locks::try_acquire_family(
105                ctx.agent_locks(),
106                ctx.db_client().await?,
107                &state_dir,
108                family.clone(),
109            )
110            .await?
111            {
112                return spawn_locked(child, content, seed, fam.into_locks()).await;
113            }
114
115            // Slow path: a live owner holds part of the family. Park the
116            // message, then race "the owner consumed it" vs "the owner exited
117            // and we take over". On exit we re-FETCH the (possibly changed)
118            // family and LOCK it in one call — the membership can shift during
119            // the long wait, so it must be resolved at lock time, not before.
120            let queue_id = crate::db::message_queue::enqueue_with_content(
121                ctx.db_client().await?,
122                hierarchy,
123                tag,
124                &ctx.config.agent_instance_hierarchy,
125                None,
126                content.clone(),
127            )
128            .await?;
129            let pool = ctx.db_client().await?.clone();
130            let primary = (dir, key);
131            let mut child = Some(child);
132            let mut content = Some(content);
133            loop {
134                tokio::select! {
135                    delivery = crate::db::message_queue::subscribe_delivered(&pool, queue_id) => {
136                        delivery?;
137                        return Ok(Response::Delivered);
138                    }
139                    // Wait for the owner to EXIT (release, don't acquire), so the
140                    // fetch+lock below sees a free family.
141                    released = objectiveai_sdk::lockfile::wait_released(&primary.0, &primary.1) => {
142                        released.map_err(|e| Error::Lockfile {
143                            key: primary.1.clone(),
144                            source: e,
145                        })?;
146                        match super::locks::try_acquire_family(
147                            ctx.agent_locks(),
148                            ctx.db_client().await?,
149                            &state_dir,
150                            family.clone(),
151                        )
152                        .await?
153                        {
154                            Some(fam) => {
155                                // We own the whole family. Reclaim our queue row
156                                // so the child doesn't re-see it — the message
157                                // rides inline instead.
158                                let _ = crate::db::message_queue::delete_by_id(
159                                    ctx.db_client().await?,
160                                    queue_id,
161                                    &ctx.config.agent_instance_hierarchy,
162                                )
163                                .await;
164                                return spawn_locked(
165                                    child.take().expect("child consumed once"),
166                                    content.take().expect("content consumed once"),
167                                    seed,
168                                    fam.into_locks(),
169                                )
170                                .await;
171                            }
172                            // Someone grabbed it first — re-arm; `wait_released`
173                            // blocks again on the now-held lock.
174                            None => continue,
175                        }
176                    }
177                }
178            }
179        }
180    }
181}
182
183/// Resolved delivery route for the non-enqueue flow.
184enum Route {
185    /// Plain agent ref — nothing to lock or enqueue against; always
186    /// spawns a fresh agent carrying the message.
187    Ref { child: AgentSelector },
188    /// Lockable target: the PRIMARY lock coordinates, the agent's whole
189    /// lock `family` (acquired together so none of its tags/labs can be
190    /// relocated/detached while live), the queue target (exactly one of
191    /// `hierarchy` / `tag` is `Some`), and the selector the spawn child receives.
192    Locked {
193        dir: std::path::PathBuf,
194        key: String,
195        family: super::locks::Family,
196        hierarchy: Option<String>,
197        tag: Option<String>,
198        child: AgentSelector,
199    },
200}
201
202/// Route for a fully resolved `agent_instance_hierarchy`: the AIH
203/// lock, queued against the hierarchy, child re-addressed as an
204/// explicit Instance (parent + leaf) so it doesn't depend on the
205/// child process's own identity.
206fn instance_route(state_dir: &std::path::Path, hierarchy: String) -> Route {
207    let (dir, key) = super::locks::agent_instance_lock(state_dir, &hierarchy);
208    let child = AgentSelector::Instance {
209        parent_agent_instance_hierarchy: Some(
210            crate::db::tags::parent_of(&hierarchy).to_string(),
211        ),
212        agent_instance: crate::db::tags::leaf_of(&hierarchy).to_string(),
213    };
214    Route::Locked {
215        dir,
216        key,
217        family: super::locks::Family::Hierarchy(hierarchy.clone()),
218        hierarchy: Some(hierarchy),
219        tag: None,
220        child,
221    }
222}
223
224/// Exec the spawn child for a freshly won lock. The claim — AIH or
225/// TAG — is TRANSFERRED into the child: the child adopts the inherited
226/// lock at startup and re-acquires it instantly, becoming the sole
227/// owner, and the lock lives exactly as long as the child. (Continuous
228/// hold through the child's pre-first-chunk window is preserved — the
229/// transfer hands off the same OS handles with no gap; a BOUND tag
230/// routes by AIH and never re-acquires the tag lock, so holding it for
231/// the child's lifetime is safe.)
232async fn spawn_locked(
233    agent: AgentSelector,
234    content: RichContent,
235    seed: Option<i64>,
236    family: Vec<super::locks::AgentLock>,
237) -> Result<Response, Error> {
238    spawn_child(agent, content, seed, family).await
239}
240
241/// Exec a detached `agents spawn` child (stream=true) and return its
242/// first item as the unary response. When `transfer` is `Some`, the lock
243/// claim is transferred into the child — the child adopts + re-acquires
244/// it instantly and becomes the sole owner; the lock lives until it
245/// exits. When `None` (a plain agent ref), the child acquires its own
246/// lock fresh. The child's first item is always its `Id` (chunks are
247/// gated behind it); the rest of the stream is dropped and the orphan
248/// keeps running.
249async fn spawn_child(
250    agent: AgentSelector,
251    content: RichContent,
252    seed: Option<i64>,
253    transfer: Vec<super::locks::AgentLock>,
254) -> Result<Response, Error> {
255    let child_request = spawn_sdk::Request {
256        path_type: spawn_sdk::Path::AgentsSpawn,
257        message: RequestMessage::Inline(content),
258        agent,
259        dangerous_advanced: Some(spawn_sdk::RequestDangerousAdvanced {
260            stream: Some(true),
261            seed,
262        }),
263        base: Default::default(),
264    };
265
266    let exe = std::env::current_exe()
267        .map_err(|e| Error::Spawn("current_exe".into(), e))?;
268    let mut executor = BinaryExecutor::from_path(exe).detach(true);
269    // Hold the in-process guards across the synchronous prepare→spawn→transfer
270    // inside `execute` (each cross-process claim is handed to the child). The
271    // `AgentLock`s — now guard-only after `take_claim` — drop at the end of this
272    // fn, freeing the per-key in-process mutexes; by then the child owns the
273    // cross-process locks, so a later in-process acquirer passes the mutex but
274    // correctly fails the lockfile.
275    let mut transfer = transfer;
276    let claims: Vec<_> = transfer
277        .iter_mut()
278        .filter_map(|lock| lock.take_claim())
279        .collect();
280    if !claims.is_empty() {
281        executor = executor.transfer_locks(claims);
282    }
283    let mut stream = executor
284        .execute::<spawn_sdk::Request, spawn_sdk::ResponseItem>(child_request, None)
285        .await
286        .map_err(|e| Error::Instance(format!("exec agents spawn child: {e}")))?;
287    let first = stream
288        .next()
289        .await
290        .ok_or(Error::EmptyStream)?
291        .map_err(|e| Error::Instance(format!("exec agents spawn child: {e}")))?;
292    match first {
293        spawn_sdk::ResponseItem::Id(agent_instance_hierarchy) => Ok(Response::Id {
294            agent_instance_hierarchy,
295        }),
296        spawn_sdk::ResponseItem::Chunk(_) => Err(Error::Instance(
297            "agents spawn child emitted a chunk before its id".to_string(),
298        )),
299    }
300}
301
302pub async fn resolve_message(
303    ctx: &Context,
304    message: RequestMessage,
305) -> Result<RichContent, Error> {
306    let (simple, inline, file, python_inline, python_file) = match message {
307        RequestMessage::Inline(rich) => return Ok(rich),
308        RequestMessage::Simple(s) => (Some(s), None, None, None, None),
309        RequestMessage::File(p) => (None, None, Some(p), None, None),
310        RequestMessage::PythonInline(code) => (None, None, None, Some(code), None),
311        RequestMessage::PythonFile(p) => (None, None, None, None, Some(p)),
312    };
313    crate::source_resolver::resolve_source(
314        ctx,
315        simple,
316        inline,
317        file,
318        python_inline,
319        python_file,
320        RichContent::Text,
321    )
322    .await
323}
324
325pub mod request_schema {
326    use objectiveai_sdk::cli::command::agents::message as sdk;
327    use objectiveai_sdk::cli::command::agents::message::request_schema::{Request, Response};
328
329    use crate::context::Context;
330    use crate::error::Error;
331
332    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
333        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Request)))
334    }
335}
336
337pub mod response_schema {
338    use objectiveai_sdk::cli::command::agents::message as sdk;
339    use objectiveai_sdk::cli::command::agents::message::response_schema::{Request, Response};
340
341    use crate::context::Context;
342    use crate::error::Error;
343
344    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
345        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Response)))
346    }
347}