Skip to main content

leviath_runtime/host/
types.rs

1//! The wire and callback types the host speaks: what a caller can ask for, and
2//! what it gets back.
3//!
4//! Split out of the host itself because these are the crate's public
5//! vocabulary. `ControlOp` is what every client sends and `RunListEntry` is what
6//! `lev ps` renders, while the host is the thing that happens to interpret them.
7//! They are re-exported from the parent, so every existing `host::ControlOp`
8//! path is unchanged.
9
10use std::collections::HashMap;
11
12use bevy_ecs::entity::Entity;
13
14use crate::world::AgentId;
15use serde::{Deserialize, Serialize};
16use tokio::sync::oneshot;
17
18use crate::components::{AgentStatus, WaitReason};
19use crate::world::PipelineWorld;
20use leviath_core::interaction::{InteractionRequest, InteractionResponse};
21
22/// The parameters for spawning an agent into the world. The runtime doesn't know
23/// how to load blueprints or resolve tools - that policy lives in the
24/// [`Spawner`] the daemon installs - so this just carries the raw request.
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
26pub struct SpawnArgs {
27    /// The run id to give the new agent (its directory / control key).
28    pub run_id: String,
29    /// Path to the agent manifest directory or bundle.
30    pub blueprint_path: String,
31    /// The task prompt. Seeded into the region keyed `task` (see
32    /// [`crate::context_setup::init_window_seeded`]); a matching `regions`
33    /// entry, if present, overrides it.
34    pub task: String,
35    /// Literal seed content for named caller-input regions, keyed by the
36    /// region's caller-input name. Merged over `task` at spawn. `#[serde(default)]`
37    /// keeps older requests (which never sent this) deserializing to an empty map.
38    #[serde(default)]
39    pub regions: HashMap<String, String>,
40    /// Optional model override (`provider/model` or `model`).
41    #[serde(default)]
42    pub model: Option<String>,
43    /// Working directory for tool execution.
44    pub workdir: String,
45    /// Custom key/value metadata from the request.
46    #[serde(default)]
47    pub metadata: HashMap<String, String>,
48    /// Webhook to POST on completion/error (surfaced in the run metadata).
49    #[serde(default)]
50    pub callback_url: Option<String>,
51    /// Optional shared secret for HMAC-SHA256 signing the webhook body.
52    #[serde(default)]
53    pub callback_secret: Option<String>,
54    /// Run this agent unattended (the `--yolo` launch override): approve every
55    /// tool call, waive the taint gate, and auto-answer the agent's own prompts
56    /// (`ask_user_*`, blueprint interaction points) rather than parking on the
57    /// interaction hub for a person who isn't there.
58    ///
59    /// An interaction point may opt out with `unattended = "ask"`, and then it
60    /// parks anyway: the point exists precisely because auto-approving it is
61    /// the wrong answer. Such a run waits for `[limits]
62    /// interaction_timeout_secs` (default 3600) rather than forever, but from
63    /// the outside an hour of `Waiting` is indistinguishable from a hang.
64    #[serde(default)]
65    pub yolo: bool,
66    /// Refuse this run's `seed = { command = ... }` regions (the
67    /// `--no-seed-commands` launch override). Command seeds execute at spawn,
68    /// before any approval prompt, so this is the per-run counterpart to the
69    /// `[security] allow_seed_commands` config switch.
70    #[serde(default)]
71    pub no_seed_commands: bool,
72    /// Tools to allow outright for this run (the `--allow` launch override).
73    #[serde(default)]
74    pub allow: Vec<String>,
75    /// Override the blueprint's max sub-agent tree depth.
76    #[serde(default)]
77    pub max_depth: Option<usize>,
78    /// The run id of this agent's parent, when it is a sub-agent / fan-out
79    /// worker. Persisted in the run metadata so observers (dashboard, `serve`
80    /// tree) can nest children under their parent. `None` for a top-level run.
81    #[serde(default)]
82    pub parent_run_id: Option<String>,
83    /// The shape this caller wants the run's final output in, overriding what
84    /// the blueprint declares.
85    ///
86    /// A request, not a contract: it changes what the model is asked to produce
87    /// and what gets recorded, and nothing converts between shapes. Naming a
88    /// format without also supplying a schema drops the blueprint's declared
89    /// schema, since a check written for one shape says nothing about another
90    /// (see [`leviath_core::resolve_output_spec`]).
91    #[serde(default)]
92    pub output: Option<leviath_core::output::OutputSpec>,
93}
94
95/// One row of a run listing ([`ControlRequest::List`]): a live run, its status,
96/// and enough context to judge whether that status is a problem.
97///
98/// [`ControlRequest::List`]: crate::control_socket::ControlRequest::List
99///
100/// `lev ps` used to be a run id and a status word, which is why issue #184
101/// happened: `waiting` on its own says nothing about whether a person is needed,
102/// and there was no way to tell a run that had moved a second ago from one that
103/// had been stopped for an hour. Everything here is read straight off the live
104/// world, so it is the daemon's own view, not a re-read of `meta.json`.
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
106pub struct RunListEntry {
107    /// The run id (`lev ps`'s first column, and what `lev kill` takes).
108    pub run_id: String,
109    /// The generated one-line title, once there is one.
110    ///
111    /// Absent from this listing until now, which meant `lev ps` could only ever
112    /// print a run id and an agent name while the dashboard - reading the same
113    /// runs off disk - had a title for them.
114    #[serde(default)]
115    pub title: Option<String>,
116    /// The agent's live status.
117    pub status: AgentStatus,
118    /// Why the status is [`AgentStatus::Waiting`]; `None` for every other
119    /// status, and for a `Waiting` the host cannot attribute.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub wait_reason: Option<WaitReason>,
122    /// The stage the agent is in.
123    pub stage: String,
124    /// Zero-based index of that stage, when the agent tracks one.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub stage_index: Option<usize>,
127    /// How many stages the blueprint has.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub num_stages: Option<usize>,
130    /// Iterations completed in the current stage.
131    pub iteration: usize,
132    /// Cumulative tool calls across the run.
133    pub tool_calls: usize,
134    /// Unix seconds when this run last actually moved (see
135    /// [`PersistWatermark`](crate::pipeline::PersistWatermark)). Distinct from
136    /// `meta.json`'s `updated_at`, which also advances on a heartbeat and so
137    /// cannot be used to tell a working run from a wedged one.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub last_progress_at: Option<i64>,
140    /// Whether this run is unattended (`--yolo`). An unattended run should never
141    /// be sitting on a prompt; if it is, something dropped the flag.
142    #[serde(default)]
143    pub unattended: bool,
144    /// Whether this run finished having modified nothing, when its blueprint
145    /// gave it a way to. Only ever true for a run that has stopped.
146    ///
147    /// The flag itself is as old as issue #107, but nothing ever showed it: it
148    /// went into `meta.json` and was read back only on restart, so a run that
149    /// finished with no work to show for it looked exactly like one that
150    /// succeeded. Defaulted for the same reason as `unattended` - an older
151    /// daemon simply omits it.
152    #[serde(default)]
153    pub empty_output: bool,
154    /// How much of this run's `[read_paths]` its config granted at spawn.
155    /// `None` for a blueprint that declares none, which is nearly every agent.
156    ///
157    /// Worth a column of its own because an ungranted declaration is inert: the
158    /// run is up, looks healthy, and will be refused the reads its author
159    /// designed it around.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub read_paths: Option<leviath_core::run_meta::ReadPathGrantCounts>,
162    /// Whether the run has submitted a final output.
163    ///
164    /// The flag only, never the content: this row is sent over the control
165    /// socket on every `lev ps`, and an answer may be a quarter of a megabyte.
166    /// `lev result <run-id>` fetches it.
167    #[serde(default)]
168    pub has_final_output: bool,
169}
170
171/// Everything one [`ControlOp::List`] answers with: the live runs, the runs that
172/// finished recently enough to still be worth reporting, and the daemon's health.
173///
174/// A named struct rather than a tuple because the reply has now grown twice, and
175/// each time every caller had to be re-read positionally to find out which half
176/// was which.
177#[derive(Debug, Clone, Default, PartialEq)]
178pub struct RunListing {
179    /// One entry per run the daemon is hosting.
180    pub runs: Vec<RunListEntry>,
181    /// Runs the daemon has unloaded within its retention window, oldest first.
182    /// Kept apart from `runs` so a caller asking "what is running" still gets
183    /// only that.
184    pub finished: Vec<RunListEntry>,
185    /// How the daemon itself is doing.
186    pub health: DaemonHealth,
187}
188
189/// The daemon's own health, alongside the run listing.
190///
191/// A per-run view answers "what is this run doing"; this answers "is the daemon
192/// getting anywhere at all". They are different questions, and issue #191 was
193/// only visible in the second: every individual run looked fine, and the factory
194/// as a whole had not moved in hours.
195#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
196pub struct DaemonHealth {
197    /// Loaded agents by status.
198    pub agents: crate::world::AgentCounts,
199    /// Inference-pool occupancy, one entry per model actually used.
200    pub inference: Vec<crate::inference_pool::PoolOccupancy>,
201    /// Tool batches holding lane capacity and running.
202    pub tools_busy: usize,
203    /// Tool batches waiting for lane capacity.
204    pub tools_queued: usize,
205    /// Tool batches parked on an unbounded wait, holding no capacity.
206    pub tools_parked: usize,
207    /// The tool lane's concurrency cap, including any relief granted.
208    pub tools_workers: usize,
209    /// Consecutive safety re-drives that found a lane at capacity and no run
210    /// moving. Zero on a healthy daemon, and reset by any sign of progress.
211    pub dead_cycles: u32,
212    /// How many extra tool-lane permits the relief valve has handed out.
213    pub relief_granted: usize,
214    /// How often the daemon re-drives itself, so a client can turn
215    /// `dead_cycles` into wall-clock time.
216    pub redrive_secs: u64,
217    /// Providers currently out of service, and when each is probed again.
218    ///
219    /// Empty on a healthy daemon. `#[serde(default)]` so an older client still
220    /// parses a newer daemon's response (issue #201).
221    #[serde(default)]
222    pub providers_down: Vec<crate::pipeline::ProviderCircuitState>,
223}
224
225/// The daemon-installed function that turns [`SpawnArgs`] into a live agent:
226/// loads the blueprint, resolves stages/tools, spawns into the world, and
227/// returns the new entity (the host records the run-id mapping). Returns `Err`
228/// with a human-readable message on failure.
229pub type Spawner = Box<dyn FnMut(&mut PipelineWorld, &SpawnArgs) -> Result<Entity, String> + Send>;
230
231/// The daemon-installed function that pages a previously-unloaded run back into
232/// the world from its on-disk state: given a run id, it reloads the agent (its
233/// blueprint, tool state, context, stage) and returns the new entity, or `None`
234/// if there is no such resumable run on disk. Used for reload-on-demand - a
235/// control/sub-agent op targeting a run that isn't currently in memory pages it
236/// in first via the host's internal resolve-or-reload step. Installed with
237/// [`super::WorldHost::set_reloader`].
238pub type Reloader = Box<dyn FnMut(&mut PipelineWorld, &str) -> Option<AgentId> + Send>;
239
240/// The daemon-installed last resort for cancelling a run the world cannot hold:
241/// given a run id, it forces that run's **on-disk** state to a terminal status
242/// and reports whether a run directory existed to act on.
243///
244/// This is what makes a cancel unconditional. [`Reloader`] declines whenever a
245/// run can't be rebuilt - its blueprint was moved or deleted, its metadata is
246/// unreadable, it died mid-spawn before any agent existed - and before this seam
247/// a cancel in that state replied `false` and wrote nothing, so `meta.json` kept
248/// claiming `running`/`starting` forever and the run could never be got rid of.
249/// The runtime has no notion of the on-disk layout, so the daemon supplies the
250/// writer. Installed with [`super::WorldHost::set_force_terminator`]; without one, a
251/// cancel that misses in the world simply misses (the prior behavior).
252pub type ForceTerminator = Box<dyn FnMut(&str) -> bool + Send>;
253
254/// The daemon-installed hook run just before a terminal agent's entity is
255/// despawned (reaped). It receives the world and the entity while both are still
256/// valid, so the daemon can release per-agent resources the runtime doesn't know
257/// about - tearing down the agent's sandbox and dropping its tool state.
258/// Installed with [`super::WorldHost::set_reaper`]; a no-op when none is set.
259pub type Reaper = Box<dyn FnMut(&mut PipelineWorld, Entity) + Send>;
260
261/// An async hook the host awaits *before* servicing a top-level `Spawn` control
262/// op, so the daemon can do async preparation the sync spawner can't - e.g.
263/// lazily connecting the blueprint's MCP servers into the shared pool so
264/// they're warm by the time [`Spawner`] reads them. The returned future is
265/// `'static` (it must clone anything it needs from the `SpawnArgs`). Installed
266/// with [`super::WorldHost::set_spawn_preprocessor`]; when none is set, spawns proceed
267/// straight to the spawner.
268pub type SpawnPreprocessor = Box<
269    dyn Fn(&SpawnArgs) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> + Send,
270>;
271
272/// A world-access request from an agent's tool lane. The sub-agent tools
273/// (`spawn_agent`/`check_agent`/`send_to_agent`/`kill_agent`) need the world and
274/// the [`Spawner`], which only the host holds - the tool lane runs async, off the
275/// world. Each carries a oneshot reply, so the (sequential) tool lane blocks on
276/// the host applying it, mirroring the interaction hub.
277pub enum SubAgentOp {
278    /// Spawn a child agent from `args`, linked as a child of `parent_run_id`.
279    /// Rejected if the child would exceed `max_depth`. Reply is the child run id.
280    Spawn {
281        /// The child's spawn parameters (blueprint path, task, etc.). Boxed
282        /// because it is much larger than the other variants' payloads.
283        args: Box<SpawnArgs>,
284        /// The run id of the agent doing the spawning.
285        parent_run_id: String,
286        /// Maximum allowed sub-agent tree depth (root = 0).
287        max_depth: usize,
288        /// Reply: the child's run id, or an error message.
289        reply: oneshot::Sender<Result<String, String>>,
290    },
291    /// Report a run's current status and answer (`None` if the host has no such
292    /// live run).
293    Check {
294        /// The run to query.
295        run_id: String,
296        /// Reply: what the run is doing and what it has handed back.
297        reply: oneshot::Sender<Option<SubAgentReport>>,
298    },
299    /// Deliver a message into a running agent's inbox. Reply is whether a live
300    /// agent accepted it.
301    Send {
302        /// The target run.
303        run_id: String,
304        /// The run doing the sending. The target must be it or one of its
305        /// descendants - see `WorldHost::is_within_tree`.
306        caller_run_id: String,
307        /// The message body.
308        content: String,
309        /// Context region to deliver into (`None` = the "conversation"
310        /// default). The `send_to_agent` tool advertised this from the start
311        /// but the op had no field to carry it, so it was silently dropped.
312        target_region: Option<String>,
313        /// Reply: whether the message was accepted.
314        reply: oneshot::Sender<bool>,
315    },
316    /// Cancel a run and its whole sub-tree. Reply is whether any agent was found.
317    Kill {
318        /// The run to cancel (with its descendants).
319        run_id: String,
320        /// The run doing the cancelling. The target must be it or one of its
321        /// descendants - see `WorldHost::is_within_tree`.
322        caller_run_id: String,
323        /// Reply: whether anything was cancelled.
324        reply: oneshot::Sender<bool>,
325    },
326}
327
328/// What a parent learns when it checks on a child: what the child is doing, and
329/// what it has handed back.
330///
331/// The two used to be one thing - the status alone - which is why
332/// `wait_for_agent`, whose schema has always promised "return its final result",
333/// returned `"Sub-agent 'x' finished with status: Complete"` and nothing else. A
334/// parent had no way to receive a child's work except by agreeing on a file path
335/// out of band.
336#[derive(Debug, Clone, PartialEq)]
337pub struct SubAgentReport {
338    /// What the child is doing now.
339    pub status: AgentStatus,
340    /// What the child submitted, if anything. `None` for a child still working,
341    /// one whose blueprint never asks for an output, or one that finished
342    /// without giving it.
343    pub final_output: Option<leviath_core::output::FinalOutput>,
344}
345
346/// A control operation addressed to the host, each carrying a oneshot channel the
347/// host replies on. Agents are addressed by run id.
348pub enum ControlOp {
349    /// Spawn a new agent. Reply is the run id on success, or an error message.
350    Spawn {
351        /// The spawn request. Boxed because it is much larger than the other
352        /// variants' payloads.
353        args: Box<SpawnArgs>,
354        /// Reply channel.
355        reply: oneshot::Sender<Result<String, String>>,
356    },
357    /// The status of a run, or `None` if there is no such run.
358    Status {
359        /// The run to query.
360        run_id: String,
361        /// Reply channel.
362        reply: oneshot::Sender<Option<AgentStatus>>,
363    },
364    /// Report what a run handed back, if anything.
365    ///
366    /// The counterpart to [`Status`](Self::Status): that says whether a run is
367    /// done, this says what it concluded.
368    Result {
369        /// The run to query.
370        run_id: String,
371        /// Reply: the submitted answer, or `None` for a run that gave none (or
372        /// one the world no longer holds).
373        reply: oneshot::Sender<Option<leviath_core::output::FinalOutput>>,
374    },
375    /// Pause a run. Reply is `false` if there is no such (live) run.
376    Pause {
377        /// The run to pause.
378        run_id: String,
379        /// Reply channel.
380        reply: oneshot::Sender<bool>,
381    },
382    /// Resume a paused run. Reply is `false` if there is no such (live) run.
383    Resume {
384        /// The run to resume.
385        run_id: String,
386        /// Reply channel.
387        reply: oneshot::Sender<bool>,
388    },
389    /// Cancel a run. Reply is `false` if there is no such (live) run.
390    Cancel {
391        /// The run to cancel.
392        run_id: String,
393        /// Reply channel.
394        reply: oneshot::Sender<bool>,
395    },
396    /// List every known live run and its status, with the daemon's own health.
397    List {
398        /// Reply channel.
399        reply: oneshot::Sender<RunListing>,
400    },
401    /// Deliver a message to a running agent (by agent id). Reply is `false` if the
402    /// world's message channel is closed.
403    Message {
404        /// Target agent id.
405        agent_id: String,
406        /// Message body.
407        content: String,
408        /// Optional target region (defaults to the conversation region).
409        target_region: Option<String>,
410        /// Reply channel.
411        reply: oneshot::Sender<bool>,
412    },
413    /// List every open interaction awaiting an answer, as `(agent_id, request)`.
414    ListInteractions {
415        /// Reply channel.
416        reply: oneshot::Sender<Vec<(String, InteractionRequest)>>,
417    },
418    /// Answer an open interaction. Reply is `false` if no such request is open.
419    AnswerInteraction {
420        /// The answer (its `request_id` selects the interaction).
421        response: InteractionResponse,
422        /// Reply channel.
423        reply: oneshot::Sender<bool>,
424    },
425    /// Cancel an open interaction (its asker wakes with a neutral response).
426    /// Reply is `false` if no such request is open.
427    CancelInteraction {
428        /// The interaction id to cancel.
429        request_id: String,
430        /// Reply channel.
431        reply: oneshot::Sender<bool>,
432    },
433    /// Shut the daemon down: signal the world's shutdown so the serve loop
434    /// returns. Reply is sent (`true`) before the shutdown is triggered.
435    Shutdown {
436        /// Reply channel.
437        reply: oneshot::Sender<bool>,
438    },
439}