runtime/tools/status.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The status tool: the model's view of its own situation, in the
7//! thirteen lines, in that order.
8//!
9//! The order is not cosmetic. A model reading its status reads the top
10//! first, so identity and mode come before the context reading, and that
11//! comes before the long tail of locks and children. Freezing the order
12//! means a Resident's habits transfer across versions instead of being
13//! relearned each time the field list grows - which is why the last
14//! field went on the end rather than beside the one it belongs with.
15//!
16//! **No ceiling is reported, because there is none**. The context
17//! reading reports what bounds a run: the tokens it has actually used
18//! against the window it was given.
19//!
20//! Nothing here samples. The snapshot is frozen at dispatch, and what
21//! moves while the run goes on - its children, its backlog, its context
22//! used, the time - is read from the one place that records it. The
23//! time is the driver's latest reading (`crates/runtime/spec/Clock.lean` §8-53): a tool that
24//! read a clock of its own would be a second sampling point in the run,
25//! and its "now" could name a moment no line of the ledger records.
26//! The worktree's size is the one reading taken at the call, from the
27//! disk rather than a clock: the tree is what the run is changing, so a
28//! size frozen at dispatch would be wrong after its first write.
29
30use kernel::{
31 Address, AxCode, AxError, ByteLen, CostTier, DelegateKind, Effect, Payload, RenderIntent,
32 Temporal, TimeMs, Tokens, Tool, ToolCall, ToolMeta, ToolName, ToolOutcome,
33};
34use serde_json::{Map, Value};
35
36use crate::backlog::{Backlog, Standing};
37use crate::clock::{ClockReading, iso};
38use crate::reminder::ContextReading;
39use kernel::RunPolicy;
40use std::path::{Path, PathBuf};
41
42/// How the gateway is currently able to serve. Degraded and LocalOnly
43/// are situations the model should plan around, so they are reported
44/// rather than hidden behind a retry.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ProviderMode {
47 Normal,
48 Degraded,
49 LocalOnly,
50}
51
52impl ProviderMode {
53 pub fn as_str(self) -> &'static str {
54 match self {
55 ProviderMode::Normal => "normal",
56 ProviderMode::Degraded => "degraded",
57 ProviderMode::LocalOnly => "local_only",
58 }
59 }
60}
61
62/// One piece of work this run handed down.
63///
64/// Two fields, because two facts exist. A child starts after the run
65/// that asked for it has frozen, so while that run is still reading its
66/// own status the child has no id, no phase and no context reading - and
67/// a field that can only ever hold zero says "zero" where the truth is
68/// "not yet".
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ChildStatus {
71 pub room: Address,
72 pub kind: DelegateKind,
73}
74
75/// The frozen fields. `backlog` is read live from the table, and the
76/// context used and the time from the run's readings, rather than
77/// frozen here, for the reason `children` is a closure: they change
78/// while the run goes on.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct StatusSnapshot {
81 pub who: String,
82 pub addr: Address,
83 /// The run policy this run was dispatched under; its mode heads
84 /// the line and the other three values follow it.
85 pub policy: RunPolicy,
86 pub ctx_limit: Tokens,
87 pub trust: String,
88 pub write_domain: String,
89 pub locks: Vec<String>,
90 /// The tree this run writes; its size is read at each call.
91 pub worktree: PathBuf,
92 pub signals_pending: u32,
93 pub provider_mode: ProviderMode,
94 /// How many residents this run can reach, itself excluded. A count
95 /// rather than a list: the list grows with the building's population
96 /// and this text does not, so the names live behind the `neighbours`
97 /// tool and what stands here is whether asking is worth a call.
98 ///
99 /// People, not places. An empty room takes messages nobody reads, so
100 /// counting one would make `neighbours: 1` mean the opposite of what
101 /// it says.
102 pub neighbours: u32,
103}
104
105pub struct StatusTool {
106 snapshot: StatusSnapshot,
107 children: Box<dyn Fn() -> Vec<ChildStatus> + Send + Sync>,
108 /// The table this run's background commands stand in. `None` in a
109 /// tool built without one, which reports `backlog: none` truthfully:
110 /// nothing was started through a table that does not exist.
111 backlog: Option<Backlog>,
112 /// The run's context reading. A tool built without one reports zero,
113 /// which is true of a run that has made no call.
114 context: ContextReading,
115 /// The driver's latest clock reading. A tool built without one
116 /// reports `not stamped`: nothing has told it what time it is.
117 clock: ClockReading,
118 meta: ToolMeta,
119}
120
121impl StatusTool {
122 /// The name this tool answers to, read by its registration and by
123 /// `mode::core_tools`.
124 pub const NAME: &'static str = "status";
125
126 /// A run that hands nothing down.
127 ///
128 /// # Errors
129 /// Propagates a malformed parameter schema.
130 pub fn new(snapshot: StatusSnapshot) -> Result<StatusTool, AxError> {
131 StatusTool::watching(snapshot, Box::new(Vec::new))
132 }
133
134 /// A run whose delegate desk is asked every time the model calls
135 /// `status`.
136 ///
137 /// A closure rather than a field, because delegation happens after
138 /// this tool is built: a snapshot taken before the run started is
139 /// empty for the whole run. A closure rather than a seam, because
140 /// the desk lives in `collab` and this crate may not depend on it -
141 /// the assembly layer owns both ends and hands one to the other.
142 ///
143 /// # Errors
144 /// Propagates a malformed parameter schema.
145 pub fn watching(
146 snapshot: StatusSnapshot,
147 children: Box<dyn Fn() -> Vec<ChildStatus> + Send + Sync>,
148 ) -> Result<StatusTool, AxError> {
149 let mut params = Map::new();
150 params.insert("type".to_owned(), Value::String("object".to_owned()));
151 params.insert("properties".to_owned(), Value::Object(Map::new()));
152 Ok(StatusTool {
153 snapshot,
154 children,
155 backlog: None,
156 context: ContextReading::default(),
157 clock: ClockReading::default(),
158 meta: ToolMeta {
159 name: ToolName::parse(Self::NAME)?,
160 disclosure:
161 "Report your current situation in thirteen lines: who you are, where you \
162 are, your mode and run policy, context used against the window, your trust, your write \
163 domain and its locks, the worktree and its size, the signals waiting, your \
164 children, the clock, whether the provider is serving, your neighbours, and \
165 your backlog. None of it is stated anywhere else."
166 .to_owned(),
167 params: Payload::new(params)?,
168 effect: Effect::Read,
169 cost_tier: CostTier::Free,
170 timeout: None,
171 render: RenderIntent::Generic,
172 temporal: Temporal::Timestamped,
173 },
174 })
175 }
176
177 /// The reading the run records the provider's count into, which the
178 /// `ctx` line reports (`crates/runtime/spec/Tools.lean` §8-14, `status`).
179 #[must_use]
180 pub fn metering(mut self, context: ContextReading) -> StatusTool {
181 self.context = context;
182 self
183 }
184
185 /// The reading the run's clock hook keeps, which the `now` line
186 /// reports (`crates/runtime/spec/Clock.lean` §8-53). It reports the time whatever the
187 /// run's stamp granularity: `now` is a field of this tool's answer,
188 /// not an envelope attachment, and a city that turned stamps off
189 /// has not asked its model to stop knowing the time.
190 #[must_use]
191 pub fn clocked(mut self, clock: ClockReading) -> StatusTool {
192 self.clock = clock;
193 self
194 }
195
196 /// The table whose members at this run's address the thirteenth
197 /// line reports (`crates/runtime/spec/Backlog.lean` §8-28-2).
198 #[must_use]
199 pub fn reporting(mut self, backlog: Backlog) -> StatusTool {
200 self.backlog = Some(backlog);
201 self
202 }
203
204 /// What stands in the backlog at this run's address. A table that
205 /// cannot be reached reads as empty: the tool reports the run's
206 /// situation, and a poisoned lock is a fact about the process that
207 /// a model can do nothing with.
208 fn standing(&self) -> Vec<Standing> {
209 self.backlog
210 .as_ref()
211 .and_then(|table| table.standing(&self.snapshot.addr).ok())
212 .unwrap_or_default()
213 }
214}
215
216impl StatusSnapshot {
217 /// The frozen order, one field per line.
218 ///
219 /// The result is text rather than a JSON object because a JSON
220 /// object has no order a reader can rely on — `serde_json`'s map
221 /// sorts its keys, so "the frozen order" would silently become
222 /// alphabetical. Order is a property of what the model reads, so it
223 /// is expressed where the model reads it.
224 fn render(&self, live: &Live<'_>) -> String {
225 let Live {
226 used,
227 now,
228 children,
229 standing,
230 disk,
231 } = live;
232 let locks = if self.locks.is_empty() {
233 "none".to_owned()
234 } else {
235 self.locks.join(", ")
236 };
237 let children = render_children(children);
238 let backlog = render_standing(standing);
239 let now = now.map_or_else(|| "not stamped".to_owned(), iso);
240 let disk = match disk {
241 Ok(bytes) => format!("{} bytes", bytes.get()),
242 Err(err) => format!("size unreadable: {err}"),
243 };
244 [
245 format!("who: {}", self.who),
246 format!("addr: {}", self.addr),
247 format!(
248 "mode: {} (write {}, admit {}, landing {})",
249 self.policy.mode.as_str(),
250 self.policy.write.as_str(),
251 self.policy.admit.as_str(),
252 self.policy.landing.as_str()
253 ),
254 format!("ctx: {}/{}", used.get(), self.ctx_limit.get()),
255 format!("trust: {}", self.trust),
256 format!("write_domain: {} (locks: {locks})", self.write_domain),
257 format!("worktree: {} ({disk})", self.worktree.display()),
258 format!("signals_pending: {}", self.signals_pending),
259 format!("children: {children}"),
260 format!("now: {now}"),
261 format!("provider_mode: {}", self.provider_mode.as_str()),
262 format!("neighbours: {}", self.neighbours),
263 format!("backlog: {backlog}"),
264 ]
265 .join("\n")
266 }
267}
268
269/// What moves while the run goes on, read at the call and rendered in
270/// the frozen order beside the snapshot.
271struct Live<'a> {
272 used: Tokens,
273 now: Option<TimeMs>,
274 children: &'a [ChildStatus],
275 standing: &'a [Standing],
276 disk: std::io::Result<ByteLen>,
277}
278
279/// Every regular file under `root`, summed. The city's own state under
280/// `.sprawling` is left out, because a run can read none of it, and a
281/// link is neither followed nor counted, so a link back up the tree
282/// cannot make the walk loop. `DirEntry::file_type` does not follow
283/// links on any platform, which is what keeps the second promise.
284fn tree_bytes(root: &Path) -> std::io::Result<ByteLen> {
285 let mut total: u64 = 0;
286 let mut pending = vec![root.to_path_buf()];
287 while let Some(dir) = pending.pop() {
288 for entry in std::fs::read_dir(&dir)? {
289 let entry = entry?;
290 let kind = entry.file_type()?;
291 if kind.is_dir() {
292 if entry.file_name() != kernel::RESERVED_PREFIX {
293 pending.push(entry.path());
294 }
295 } else if kind.is_file() {
296 total = total.saturating_add(entry.metadata()?.len());
297 }
298 }
299 }
300 Ok(ByteLen::new(total))
301}
302
303/// What is still running at this run's address, or the word for none.
304/// One line, for the reason `render_children` gives. No duration: this
305/// table samples no clock, and a status that sampled one to say "for
306/// forty seconds" would be a second sampling point.
307fn render_standing(standing: &[Standing]) -> String {
308 if standing.is_empty() {
309 return "none".to_owned();
310 }
311 standing
312 .iter()
313 .map(|member| format!("{} {} ({})", member.id, member.what, member.kind.as_str()))
314 .collect::<Vec<String>>()
315 .join("; ")
316}
317
318/// Where each piece of handed-down work went, or the word for none.
319/// One line, because `status` is read as lines and a list that wrapped
320/// would break the field order the whole tool exists to keep.
321fn render_children(children: &[ChildStatus]) -> String {
322 if children.is_empty() {
323 return "none".to_owned();
324 }
325 children
326 .iter()
327 .map(|child| format!("{} ({})", child.room, child.kind.as_str()))
328 .collect::<Vec<String>>()
329 .join("; ")
330}
331
332impl Tool for StatusTool {
333 fn meta(&self) -> &ToolMeta {
334 &self.meta
335 }
336
337 fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
338 if call.name != self.meta.name {
339 return Err(AxError::failure(
340 AxCode::InvalidArgs,
341 "read status",
342 format!("call routed to the wrong tool: {}", call.name.as_str()),
343 )
344 .with_recovery(format!(
345 "call `{}`, the name this tool answers to",
346 self.meta.name.as_str()
347 )));
348 }
349 let mut result = Map::new();
350 result.insert(
351 "text".to_owned(),
352 Value::String(self.snapshot.render(&Live {
353 used: self.context.tokens(),
354 now: self.clock.latest(),
355 children: &(self.children)(),
356 standing: &self.standing(),
357 disk: tree_bytes(&self.snapshot.worktree),
358 })),
359 );
360 Ok(ToolOutcome {
361 result: Payload::new(result)?,
362 attachments: Vec::new(),
363 })
364 }
365}
366
367#[cfg(test)]
368mod tests;