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/// The request type is named rather than linked because it lives behind the
99/// `control-socket` feature while this type does not, and an intra-doc link
100/// into a module that may not be compiled fails the docs build.
101///
102/// `lev ps` used to be a run id and a status word, which is why issue #184
103/// happened: `waiting` on its own says nothing about whether a person is needed,
104/// and there was no way to tell a run that had moved a second ago from one that
105/// had been stopped for an hour. Everything here is read straight off the live
106/// world, so it is the daemon's own view, not a re-read of `meta.json`.
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
108pub struct RunListEntry {
109    /// The run id (`lev ps`'s first column, and what `lev kill` takes).
110    pub run_id: String,
111    /// The generated one-line title, once there is one.
112    ///
113    /// Absent from this listing until now, which meant `lev ps` could only ever
114    /// print a run id and an agent name while the dashboard - reading the same
115    /// runs off disk - had a title for them.
116    #[serde(default)]
117    pub title: Option<String>,
118    /// The agent's live status.
119    pub status: AgentStatus,
120    /// Why the status is [`AgentStatus::Waiting`]; `None` for every other
121    /// status, and for a `Waiting` the host cannot attribute.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub wait_reason: Option<WaitReason>,
124    /// The stage the agent is in.
125    pub stage: String,
126    /// Zero-based index of that stage, when the agent tracks one.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub stage_index: Option<usize>,
129    /// How many stages the blueprint has.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub num_stages: Option<usize>,
132    /// Iterations completed in the current stage.
133    pub iteration: usize,
134    /// Cumulative tool calls across the run.
135    pub tool_calls: usize,
136    /// Unix seconds when this run last actually moved (see
137    /// [`PersistWatermark`](crate::pipeline::PersistWatermark)). Distinct from
138    /// `meta.json`'s `updated_at`, which also advances on a heartbeat and so
139    /// cannot be used to tell a working run from a wedged one.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub last_progress_at: Option<i64>,
142    /// Whether this run is unattended (`--yolo`). An unattended run should never
143    /// be sitting on a prompt; if it is, something dropped the flag.
144    #[serde(default)]
145    pub unattended: bool,
146    /// Whether this run finished having modified nothing, when its blueprint
147    /// gave it a way to. Only ever true for a run that has stopped.
148    ///
149    /// The flag itself is as old as issue #107, but nothing ever showed it: it
150    /// went into `meta.json` and was read back only on restart, so a run that
151    /// finished with no work to show for it looked exactly like one that
152    /// succeeded. Defaulted for the same reason as `unattended` - an older
153    /// daemon simply omits it.
154    #[serde(default)]
155    pub empty_output: bool,
156    /// How much of this run's `[read_paths]` its config granted at spawn.
157    /// `None` for a blueprint that declares none, which is nearly every agent.
158    ///
159    /// Worth a column of its own because an ungranted declaration is inert: the
160    /// run is up, looks healthy, and will be refused the reads its author
161    /// designed it around.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub read_paths: Option<leviath_core::run_meta::ReadPathGrantCounts>,
164    /// Whether the run has submitted a final output.
165    ///
166    /// The flag only, never the content: this row is sent over the control
167    /// socket on every `lev ps`, and an answer may be a quarter of a megabyte.
168    /// `lev result <run-id>` fetches it.
169    #[serde(default)]
170    pub has_final_output: bool,
171}
172
173/// Everything one [`ControlOp::List`] answers with: the live runs, the runs that
174/// finished recently enough to still be worth reporting, and the daemon's health.
175///
176/// A named struct rather than a tuple because the reply has now grown twice, and
177/// each time every caller had to be re-read positionally to find out which half
178/// was which.
179#[derive(Debug, Clone, Default, PartialEq)]
180pub struct RunListing {
181    /// One entry per run the daemon is hosting.
182    pub runs: Vec<RunListEntry>,
183    /// Runs the daemon has unloaded within its retention window, oldest first.
184    /// Kept apart from `runs` so a caller asking "what is running" still gets
185    /// only that.
186    pub finished: Vec<RunListEntry>,
187    /// How the daemon itself is doing.
188    pub health: DaemonHealth,
189}
190
191/// The daemon's own health, alongside the run listing.
192///
193/// A per-run view answers "what is this run doing"; this answers "is the daemon
194/// getting anywhere at all". They are different questions, and issue #191 was
195/// only visible in the second: every individual run looked fine, and the factory
196/// as a whole had not moved in hours.
197#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
198pub struct DaemonHealth {
199    /// Loaded agents by status.
200    pub agents: crate::world::AgentCounts,
201    /// Inference-pool occupancy, one entry per model actually used.
202    pub inference: Vec<crate::inference_pool::PoolOccupancy>,
203    /// Tool batches holding lane capacity and running.
204    pub tools_busy: usize,
205    /// Tool batches waiting for lane capacity.
206    pub tools_queued: usize,
207    /// Tool batches parked on an unbounded wait, holding no capacity.
208    pub tools_parked: usize,
209    /// The tool lane's concurrency cap, including any relief granted.
210    pub tools_workers: usize,
211    /// Consecutive safety re-drives that found a lane at capacity and no run
212    /// moving. Zero on a healthy daemon, and reset by any sign of progress.
213    pub dead_cycles: u32,
214    /// How many extra tool-lane permits the relief valve has handed out.
215    pub relief_granted: usize,
216    /// How often the daemon re-drives itself, so a client can turn
217    /// `dead_cycles` into wall-clock time.
218    pub redrive_secs: u64,
219    /// Providers currently out of service, and when each is probed again.
220    ///
221    /// Empty on a healthy daemon. `#[serde(default)]` so an older client still
222    /// parses a newer daemon's response (issue #201).
223    #[serde(default)]
224    pub providers_down: Vec<crate::pipeline::ProviderCircuitState>,
225}
226
227/// The daemon-installed function that turns [`SpawnArgs`] into a live agent:
228/// loads the blueprint, resolves stages/tools, spawns into the world, and
229/// returns the new entity (the host records the run-id mapping). Returns `Err`
230/// with a human-readable message on failure.
231pub type Spawner = Box<dyn FnMut(&mut PipelineWorld, &SpawnArgs) -> Result<Entity, String> + Send>;
232
233/// The daemon-installed function that pages a previously-unloaded run back into
234/// the world from its on-disk state: given a run id, it reloads the agent (its
235/// blueprint, tool state, context, stage) and returns the new entity, or `None`
236/// if there is no such resumable run on disk. Used for reload-on-demand - a
237/// control/sub-agent op targeting a run that isn't currently in memory pages it
238/// in first via the host's internal resolve-or-reload step. Installed with
239/// [`super::WorldHost::set_reloader`].
240pub type Reloader = Box<dyn FnMut(&mut PipelineWorld, &str) -> Option<AgentId> + Send>;
241
242/// The daemon-installed last resort for cancelling a run the world cannot hold:
243/// given a run id, it forces that run's **on-disk** state to a terminal status
244/// and reports whether a run directory existed to act on.
245///
246/// This is what makes a cancel unconditional. [`Reloader`] declines whenever a
247/// run can't be rebuilt - its blueprint was moved or deleted, its metadata is
248/// unreadable, it died mid-spawn before any agent existed - and before this seam
249/// a cancel in that state replied `false` and wrote nothing, so `meta.json` kept
250/// claiming `running`/`starting` forever and the run could never be got rid of.
251/// The runtime has no notion of the on-disk layout, so the daemon supplies the
252/// writer. Installed with [`super::WorldHost::set_force_terminator`]; without one, a
253/// cancel that misses in the world simply misses (the prior behavior).
254pub type ForceTerminator = Box<dyn FnMut(&str) -> bool + Send>;
255
256/// The daemon-installed hook run just before a terminal agent's entity is
257/// despawned (reaped). It receives the world and the entity while both are still
258/// valid, so the daemon can release per-agent resources the runtime doesn't know
259/// about - tearing down the agent's sandbox and dropping its tool state.
260/// Installed with [`super::WorldHost::set_reaper`]; a no-op when none is set.
261pub type Reaper = Box<dyn FnMut(&mut PipelineWorld, Entity) + Send>;
262
263/// An async hook the host awaits *before* servicing a top-level `Spawn` control
264/// op, so the daemon can do async preparation the sync spawner can't - e.g.
265/// lazily connecting the blueprint's MCP servers into the shared pool so
266/// they're warm by the time [`Spawner`] reads them. The returned future is
267/// `'static` (it must clone anything it needs from the `SpawnArgs`). Installed
268/// with [`super::WorldHost::set_spawn_preprocessor`]; when none is set, spawns proceed
269/// straight to the spawner.
270pub type SpawnPreprocessor = Box<
271    dyn Fn(&SpawnArgs) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> + Send,
272>;
273
274/// A world-access request from an agent's tool lane. The sub-agent tools
275/// (`spawn_agent`/`check_agent`/`send_to_agent`/`kill_agent`) need the world and
276/// the [`Spawner`], which only the host holds - the tool lane runs async, off the
277/// world. Each carries a oneshot reply, so the (sequential) tool lane blocks on
278/// the host applying it, mirroring the interaction hub.
279pub enum SubAgentOp {
280    /// Spawn a child agent from `args`, linked as a child of `parent_run_id`.
281    /// Rejected if the child would exceed `max_depth`. Reply is the child run id.
282    Spawn {
283        /// The child's spawn parameters (blueprint path, task, etc.). Boxed
284        /// because it is much larger than the other variants' payloads.
285        args: Box<SpawnArgs>,
286        /// The run id of the agent doing the spawning.
287        parent_run_id: String,
288        /// Maximum allowed sub-agent tree depth (root = 0).
289        max_depth: usize,
290        /// Reply: the child's run id, or an error message.
291        reply: oneshot::Sender<Result<String, String>>,
292    },
293    /// Report a run's current status and answer (`None` if the host has no such
294    /// live run).
295    Check {
296        /// The run to query.
297        run_id: String,
298        /// Reply: what the run is doing and what it has handed back.
299        reply: oneshot::Sender<Option<SubAgentReport>>,
300    },
301    /// Deliver a message into a running agent's inbox. Reply is whether a live
302    /// agent accepted it.
303    Send {
304        /// The target run.
305        run_id: String,
306        /// The run doing the sending. The target must be it or one of its
307        /// descendants - see `WorldHost::is_within_tree`.
308        caller_run_id: String,
309        /// The message body.
310        content: String,
311        /// Context region to deliver into (`None` = the "conversation"
312        /// default). The `send_to_agent` tool advertised this from the start
313        /// but the op had no field to carry it, so it was silently dropped.
314        target_region: Option<String>,
315        /// Reply: whether the message was accepted.
316        reply: oneshot::Sender<bool>,
317    },
318    /// Cancel a run and its whole sub-tree. Reply is whether any agent was found.
319    Kill {
320        /// The run to cancel (with its descendants).
321        run_id: String,
322        /// The run doing the cancelling. The target must be it or one of its
323        /// descendants - see `WorldHost::is_within_tree`.
324        caller_run_id: String,
325        /// Reply: whether anything was cancelled.
326        reply: oneshot::Sender<bool>,
327    },
328}
329
330/// What a parent learns when it checks on a child: what the child is doing, and
331/// what it has handed back.
332///
333/// The two used to be one thing - the status alone - which is why
334/// `wait_for_agent`, whose schema has always promised "return its final result",
335/// returned `"Sub-agent 'x' finished with status: Complete"` and nothing else. A
336/// parent had no way to receive a child's work except by agreeing on a file path
337/// out of band.
338#[derive(Debug, Clone, PartialEq)]
339pub struct SubAgentReport {
340    /// What the child is doing now.
341    pub status: AgentStatus,
342    /// What the child submitted, if anything. `None` for a child still working,
343    /// one whose blueprint never asks for an output, or one that finished
344    /// without giving it.
345    pub final_output: Option<leviath_core::output::FinalOutput>,
346}
347
348/// A control operation addressed to the host, each carrying a oneshot channel the
349/// host replies on. Agents are addressed by run id.
350pub enum ControlOp {
351    /// Spawn a new agent. Reply is the run id on success, or an error message.
352    Spawn {
353        /// The spawn request. Boxed because it is much larger than the other
354        /// variants' payloads.
355        args: Box<SpawnArgs>,
356        /// Reply channel.
357        reply: oneshot::Sender<Result<String, String>>,
358    },
359    /// The status of a run, or `None` if there is no such run.
360    Status {
361        /// The run to query.
362        run_id: String,
363        /// Reply channel.
364        reply: oneshot::Sender<Option<AgentStatus>>,
365    },
366    /// Report what a run handed back, if anything.
367    ///
368    /// The counterpart to [`Status`](Self::Status): that says whether a run is
369    /// done, this says what it concluded.
370    Result {
371        /// The run to query.
372        run_id: String,
373        /// Reply: the submitted answer, or `None` for a run that gave none (or
374        /// one the world no longer holds).
375        reply: oneshot::Sender<Option<leviath_core::output::FinalOutput>>,
376    },
377    /// Pause a run. Reply is `false` if there is no such (live) run.
378    Pause {
379        /// The run to pause.
380        run_id: String,
381        /// Reply channel.
382        reply: oneshot::Sender<bool>,
383    },
384    /// Resume a paused run. Reply is `false` if there is no such (live) run.
385    Resume {
386        /// The run to resume.
387        run_id: String,
388        /// Reply channel.
389        reply: oneshot::Sender<bool>,
390    },
391    /// Cancel a run. Reply is `false` if there is no such (live) run.
392    Cancel {
393        /// The run to cancel.
394        run_id: String,
395        /// Reply channel.
396        reply: oneshot::Sender<bool>,
397    },
398    /// List every known live run and its status, with the daemon's own health.
399    List {
400        /// Reply channel.
401        reply: oneshot::Sender<RunListing>,
402    },
403    /// Deliver a message to a running agent (by agent id). Reply is `false` if the
404    /// world's message channel is closed.
405    Message {
406        /// Target agent id.
407        agent_id: String,
408        /// Message body.
409        content: String,
410        /// Optional target region (defaults to the conversation region).
411        target_region: Option<String>,
412        /// Reply channel.
413        reply: oneshot::Sender<bool>,
414    },
415    /// List every open interaction awaiting an answer, as `(agent_id, request)`.
416    ListInteractions {
417        /// Reply channel.
418        reply: oneshot::Sender<Vec<(String, InteractionRequest)>>,
419    },
420    /// Answer an open interaction. Reply is `false` if no such request is open.
421    AnswerInteraction {
422        /// The answer (its `request_id` selects the interaction).
423        response: InteractionResponse,
424        /// Reply channel.
425        reply: oneshot::Sender<bool>,
426    },
427    /// Cancel an open interaction (its asker wakes with a neutral response).
428    /// Reply is `false` if no such request is open.
429    CancelInteraction {
430        /// The interaction id to cancel.
431        request_id: String,
432        /// Reply channel.
433        reply: oneshot::Sender<bool>,
434    },
435    /// Shut the daemon down: signal the world's shutdown so the serve loop
436    /// returns. Reply is sent (`true`) before the shutdown is triggered.
437    Shutdown {
438        /// Reply channel.
439        reply: oneshot::Sender<bool>,
440    },
441}