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}