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};
31use objectiveai_sdk::lockfile::LockClaim;
32
33use crate::context::Context;
34use crate::error::Error;
35
36pub async fn execute(ctx: &Context, request: Request) -> Result<Response, Error> {
37    let Request {
38        agent,
39        message,
40        dangerous_advanced,
41        ..
42    } = request;
43    let seed = dangerous_advanced.as_ref().and_then(|a| a.seed);
44
45    // Resolve the payload once, in this process.
46    let content = resolve_message(ctx, message).await?;
47
48    let state_dir = ctx.filesystem.state_dir();
49    let route = match agent {
50        AgentSelector::Ref { agent } => {
51            // Resolve file/python refs HERE too — the child gets the
52            // typed agent inline and never re-runs the Python.
53            let resolved = super::spawn::resolve_agent_ref(ctx, agent).await?;
54            Route::Ref {
55                child: AgentSelector::Ref {
56                    agent: AgentRef::Resolved(resolved),
57                },
58            }
59        }
60        AgentSelector::Instance {
61            parent_agent_instance_hierarchy,
62            agent_instance,
63        } => {
64            let parent = parent_agent_instance_hierarchy
65                .as_deref()
66                .unwrap_or(&ctx.config.agent_instance_hierarchy);
67            instance_route(&state_dir, format!("{parent}/{agent_instance}"))
68        }
69        AgentSelector::Tag { agent_tag } => {
70            match crate::db::tags::lookup(ctx.db_client().await?, &agent_tag).await? {
71                crate::db::tags::LookupState::Bound {
72                    agent_instance_hierarchy,
73                } => instance_route(&state_dir, agent_instance_hierarchy),
74                crate::db::tags::LookupState::Grouped { .. } => {
75                    let (dir, key) = super::locks::agent_tag_lock(&state_dir, &agent_tag);
76                    Route::Locked {
77                        dir,
78                        key,
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, None, false).await,
93        Route::Locked {
94            dir,
95            key,
96            hierarchy,
97            tag,
98            child,
99        } => {
100            let is_tag = tag.is_some();
101            // Fast path: nobody holds the lock — exec the spawn child
102            // under the fresh claim.
103            if let Some(claim) = objectiveai_sdk::lockfile::try_acquire(&dir, &key, "").await {
104                return spawn_locked(child, content, seed, claim, is_tag).await;
105            }
106
107            // Slow path: live owner. Park the message, then wait for
108            // whichever comes first — the row flipping inactive (the
109            // owner consumed it) or the lock freeing up (the owner
110            // died / finished without consuming; we take over).
111            let queue_id = crate::db::message_queue::enqueue_with_content(
112                ctx.db_client().await?,
113                hierarchy,
114                tag,
115                &ctx.config.agent_instance_hierarchy,
116                None,
117                content.clone(),
118            )
119            .await?;
120            let pool = ctx.db_client().await?.clone();
121            tokio::select! {
122                delivery = crate::db::message_queue::subscribe_delivered(&pool, queue_id) => {
123                    delivery?;
124                    Ok(Response::Delivered)
125                }
126                claim = objectiveai_sdk::lockfile::wait_acquire(&dir, &key, "") => {
127                    let claim = claim.map_err(|e| Error::Lockfile {
128                        key: key.clone(),
129                        source: e,
130                    })?;
131                    // We own the slot now. Reclaim our queue row so
132                    // the spawn child doesn't see it again — the
133                    // message rides inline instead.
134                    let _ = crate::db::message_queue::delete_by_id(
135                        ctx.db_client().await?,
136                        queue_id,
137                        &ctx.config.agent_instance_hierarchy,
138                    )
139                    .await;
140                    spawn_locked(child, content, seed, claim, is_tag).await
141                }
142            }
143        }
144    }
145}
146
147/// Resolved delivery route for the non-enqueue flow.
148enum Route {
149    /// Plain agent ref — nothing to lock or enqueue against; always
150    /// spawns a fresh agent carrying the message.
151    Ref { child: AgentSelector },
152    /// Lockable target: the lock coordinates, the queue target
153    /// (exactly one of `hierarchy` / `tag` is `Some`), and the
154    /// selector the spawn child receives.
155    Locked {
156        dir: std::path::PathBuf,
157        key: String,
158        hierarchy: Option<String>,
159        tag: Option<String>,
160        child: AgentSelector,
161    },
162}
163
164/// Route for a fully resolved `agent_instance_hierarchy`: the AIH
165/// lock, queued against the hierarchy, child re-addressed as an
166/// explicit Instance (parent + leaf) so it doesn't depend on the
167/// child process's own identity.
168fn instance_route(state_dir: &std::path::Path, hierarchy: String) -> Route {
169    let (dir, key) = super::locks::agent_instance_lock(state_dir, &hierarchy);
170    let child = AgentSelector::Instance {
171        parent_agent_instance_hierarchy: Some(
172            crate::db::tags::parent_of(&hierarchy).to_string(),
173        ),
174        agent_instance: crate::db::tags::leaf_of(&hierarchy).to_string(),
175    };
176    Route::Locked {
177        dir,
178        key,
179        hierarchy: Some(hierarchy),
180        tag: None,
181        child,
182    }
183}
184
185/// Exec the spawn child for a freshly won lock. AIH claims TRANSFER
186/// into the child (the lock then lives exactly as long as the
187/// child); TAG claims never transfer — this process keeps holding
188/// the claim while the child runs with `skip_lock`, and since
189/// dropping a claim does not release it, the tag lock persists until
190/// this process exits. That's deliberate: nothing else may
191/// materialize the tag while the child is still pre-first-chunk; by
192/// the time the child's first item (`Id`) arrives it already holds
193/// its minted AIH lock, and the GROUPED→BOUND upgrade has landed.
194async fn spawn_locked(
195    agent: AgentSelector,
196    content: RichContent,
197    seed: Option<i64>,
198    claim: LockClaim,
199    is_tag: bool,
200) -> Result<Response, Error> {
201    if is_tag {
202        let _tag_claim = claim;
203        spawn_child(agent, content, seed, None, true).await
204    } else {
205        spawn_child(agent, content, seed, Some(claim), true).await
206    }
207}
208
209/// Exec a detached `agents spawn` child (stream=true) and return its
210/// first item as the unary response. When `transfer` is `Some`, the
211/// lock is transferred into the child — the child becomes the sole
212/// owner and the lock lives until it exits. `skip_lock` tells the
213/// child to skip its own initial acquisition (set whenever a lock is
214/// held for it, transferred or not). The child's first item is
215/// always its `Id` (chunks are gated behind it); the rest of the
216/// stream is dropped and the orphan keeps running.
217async fn spawn_child(
218    agent: AgentSelector,
219    content: RichContent,
220    seed: Option<i64>,
221    transfer: Option<LockClaim>,
222    skip_lock: bool,
223) -> Result<Response, Error> {
224    let skip_lock = skip_lock.then_some(true);
225    let child_request = spawn_sdk::Request {
226        path_type: spawn_sdk::Path::AgentsSpawn,
227        message: RequestMessage::Inline(content),
228        agent,
229        dangerous_advanced: Some(spawn_sdk::RequestDangerousAdvanced {
230            stream: Some(true),
231            seed,
232            skip_lock,
233        }),
234        base: Default::default(),
235    };
236
237    let exe = std::env::current_exe()
238        .map_err(|e| Error::Spawn("current_exe".into(), e))?;
239    let mut executor = BinaryExecutor::from_path(exe).detach(true);
240    if let Some(claim) = transfer {
241        executor = executor.transfer_lock(claim);
242    }
243    let mut stream = executor
244        .execute::<spawn_sdk::Request, spawn_sdk::ResponseItem>(child_request, None)
245        .await
246        .map_err(|e| Error::Instance(format!("exec agents spawn child: {e}")))?;
247    let first = stream
248        .next()
249        .await
250        .ok_or(Error::EmptyStream)?
251        .map_err(|e| Error::Instance(format!("exec agents spawn child: {e}")))?;
252    match first {
253        spawn_sdk::ResponseItem::Id(agent_instance_hierarchy) => Ok(Response::Id {
254            agent_instance_hierarchy,
255        }),
256        spawn_sdk::ResponseItem::Chunk(_) => Err(Error::Instance(
257            "agents spawn child emitted a chunk before its id".to_string(),
258        )),
259    }
260}
261
262pub async fn resolve_message(
263    ctx: &Context,
264    message: RequestMessage,
265) -> Result<RichContent, Error> {
266    let (simple, inline, file, python_inline, python_file) = match message {
267        RequestMessage::Inline(rich) => return Ok(rich),
268        RequestMessage::Simple(s) => (Some(s), None, None, None, None),
269        RequestMessage::File(p) => (None, None, Some(p), None, None),
270        RequestMessage::PythonInline(code) => (None, None, None, Some(code), None),
271        RequestMessage::PythonFile(p) => (None, None, None, None, Some(p)),
272    };
273    crate::source_resolver::resolve_source(
274        ctx,
275        simple,
276        inline,
277        file,
278        python_inline,
279        python_file,
280        RichContent::Text,
281    )
282    .await
283}
284
285pub mod request_schema {
286    use objectiveai_sdk::cli::command::agents::message as sdk;
287    use objectiveai_sdk::cli::command::agents::message::request_schema::{Request, Response};
288
289    use crate::context::Context;
290    use crate::error::Error;
291
292    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
293        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Request)))
294    }
295}
296
297pub mod response_schema {
298    use objectiveai_sdk::cli::command::agents::message as sdk;
299    use objectiveai_sdk::cli::command::agents::message::response_schema::{Request, Response};
300
301    use crate::context::Context;
302    use crate::error::Error;
303
304    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
305        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Response)))
306    }
307}