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