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).await,
93        Route::Locked {
94            dir,
95            key,
96            hierarchy,
97            tag,
98            child,
99        } => {
100            // Fast path: nobody holds the lock — exec the spawn child
101            // under the fresh claim.
102            if let Some(claim) = objectiveai_sdk::lockfile::try_acquire(&dir, &key, "").await {
103                return spawn_locked(child, content, seed, claim).await;
104            }
105
106            // Slow path: live owner. Park the message, then wait for
107            // whichever comes first — the row flipping inactive (the
108            // owner consumed it) or the lock freeing up (the owner
109            // died / finished without consuming; we take over).
110            let queue_id = crate::db::message_queue::enqueue_with_content(
111                ctx.db_client().await?,
112                hierarchy,
113                tag,
114                &ctx.config.agent_instance_hierarchy,
115                None,
116                content.clone(),
117            )
118            .await?;
119            let pool = ctx.db_client().await?.clone();
120            tokio::select! {
121                delivery = crate::db::message_queue::subscribe_delivered(&pool, queue_id) => {
122                    delivery?;
123                    Ok(Response::Delivered)
124                }
125                claim = objectiveai_sdk::lockfile::wait_acquire(&dir, &key, "") => {
126                    let claim = claim.map_err(|e| Error::Lockfile {
127                        key: key.clone(),
128                        source: e,
129                    })?;
130                    // We own the slot now. Reclaim our queue row so
131                    // the spawn child doesn't see it again — the
132                    // message rides inline instead.
133                    let _ = crate::db::message_queue::delete_by_id(
134                        ctx.db_client().await?,
135                        queue_id,
136                        &ctx.config.agent_instance_hierarchy,
137                    )
138                    .await;
139                    spawn_locked(child, content, seed, claim).await
140                }
141            }
142        }
143    }
144}
145
146/// Resolved delivery route for the non-enqueue flow.
147enum Route {
148    /// Plain agent ref — nothing to lock or enqueue against; always
149    /// spawns a fresh agent carrying the message.
150    Ref { child: AgentSelector },
151    /// Lockable target: the lock coordinates, the queue target
152    /// (exactly one of `hierarchy` / `tag` is `Some`), and the
153    /// selector the spawn child receives.
154    Locked {
155        dir: std::path::PathBuf,
156        key: String,
157        hierarchy: Option<String>,
158        tag: Option<String>,
159        child: AgentSelector,
160    },
161}
162
163/// Route for a fully resolved `agent_instance_hierarchy`: the AIH
164/// lock, queued against the hierarchy, child re-addressed as an
165/// explicit Instance (parent + leaf) so it doesn't depend on the
166/// child process's own identity.
167fn instance_route(state_dir: &std::path::Path, hierarchy: String) -> Route {
168    let (dir, key) = super::locks::agent_instance_lock(state_dir, &hierarchy);
169    let child = AgentSelector::Instance {
170        parent_agent_instance_hierarchy: Some(
171            crate::db::tags::parent_of(&hierarchy).to_string(),
172        ),
173        agent_instance: crate::db::tags::leaf_of(&hierarchy).to_string(),
174    };
175    Route::Locked {
176        dir,
177        key,
178        hierarchy: Some(hierarchy),
179        tag: None,
180        child,
181    }
182}
183
184/// Exec the spawn child for a freshly won lock. The claim — AIH or
185/// TAG — is TRANSFERRED into the child: the child adopts the inherited
186/// lock at startup and re-acquires it instantly, becoming the sole
187/// owner, and the lock lives exactly as long as the child. (Continuous
188/// hold through the child's pre-first-chunk window is preserved — the
189/// transfer hands off the same OS handles with no gap; a BOUND tag
190/// routes by AIH and never re-acquires the tag lock, so holding it for
191/// the child's lifetime is safe.)
192async fn spawn_locked(
193    agent: AgentSelector,
194    content: RichContent,
195    seed: Option<i64>,
196    claim: LockClaim,
197) -> Result<Response, Error> {
198    spawn_child(agent, content, seed, Some(claim)).await
199}
200
201/// Exec a detached `agents spawn` child (stream=true) and return its
202/// first item as the unary response. When `transfer` is `Some`, the lock
203/// claim is transferred into the child — the child adopts + re-acquires
204/// it instantly and becomes the sole owner; the lock lives until it
205/// exits. When `None` (a plain agent ref), the child acquires its own
206/// lock fresh. The child's first item is always its `Id` (chunks are
207/// gated behind it); the rest of the stream is dropped and the orphan
208/// keeps running.
209async fn spawn_child(
210    agent: AgentSelector,
211    content: RichContent,
212    seed: Option<i64>,
213    transfer: Option<LockClaim>,
214) -> Result<Response, Error> {
215    let child_request = spawn_sdk::Request {
216        path_type: spawn_sdk::Path::AgentsSpawn,
217        message: RequestMessage::Inline(content),
218        agent,
219        dangerous_advanced: Some(spawn_sdk::RequestDangerousAdvanced {
220            stream: Some(true),
221            seed,
222        }),
223        base: Default::default(),
224    };
225
226    let exe = std::env::current_exe()
227        .map_err(|e| Error::Spawn("current_exe".into(), e))?;
228    let mut executor = BinaryExecutor::from_path(exe).detach(true);
229    if let Some(claim) = transfer {
230        executor = executor.transfer_lock(claim);
231    }
232    let mut stream = executor
233        .execute::<spawn_sdk::Request, spawn_sdk::ResponseItem>(child_request, None)
234        .await
235        .map_err(|e| Error::Instance(format!("exec agents spawn child: {e}")))?;
236    let first = stream
237        .next()
238        .await
239        .ok_or(Error::EmptyStream)?
240        .map_err(|e| Error::Instance(format!("exec agents spawn child: {e}")))?;
241    match first {
242        spawn_sdk::ResponseItem::Id(agent_instance_hierarchy) => Ok(Response::Id {
243            agent_instance_hierarchy,
244        }),
245        spawn_sdk::ResponseItem::Chunk(_) => Err(Error::Instance(
246            "agents spawn child emitted a chunk before its id".to_string(),
247        )),
248    }
249}
250
251pub async fn resolve_message(
252    ctx: &Context,
253    message: RequestMessage,
254) -> Result<RichContent, Error> {
255    let (simple, inline, file, python_inline, python_file) = match message {
256        RequestMessage::Inline(rich) => return Ok(rich),
257        RequestMessage::Simple(s) => (Some(s), None, None, None, None),
258        RequestMessage::File(p) => (None, None, Some(p), None, None),
259        RequestMessage::PythonInline(code) => (None, None, None, Some(code), None),
260        RequestMessage::PythonFile(p) => (None, None, None, None, Some(p)),
261    };
262    crate::source_resolver::resolve_source(
263        ctx,
264        simple,
265        inline,
266        file,
267        python_inline,
268        python_file,
269        RichContent::Text,
270    )
271    .await
272}
273
274pub mod request_schema {
275    use objectiveai_sdk::cli::command::agents::message as sdk;
276    use objectiveai_sdk::cli::command::agents::message::request_schema::{Request, Response};
277
278    use crate::context::Context;
279    use crate::error::Error;
280
281    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
282        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Request)))
283    }
284}
285
286pub mod response_schema {
287    use objectiveai_sdk::cli::command::agents::message as sdk;
288    use objectiveai_sdk::cli::command::agents::message::response_schema::{Request, Response};
289
290    use crate::context::Context;
291    use crate::error::Error;
292
293    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
294        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Response)))
295    }
296}