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;