Skip to main content

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;