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