runtime/tools/read.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 read tool: one argument that is either a path this city lets a
7//! model choose, or a name its reading room already admitted.
8//!
9//! **Two ways in, and the difference is who chose.** A path is chosen by
10//! the model, so it is judged: it must parse as an address, it must not
11//! reach a reserved subtree — the accounting, the configuration, the
12//! rules and the history stay unreadable to the thing they govern — and
13//! the read bound must let this run's building read it. A
14//! catalog name was chosen by the person who wrote the building's
15//! reading room, and admission happened when they wrote it; the skill it
16//! resolves to may therefore sit in reserved space, because the answer to
17//! "may this run see it" was given before the run existed.
18//!
19//! Without this tool the catalog could name a skill and never hand it
20//! over, and every file the prompt asks an agent to consult had to be
21//! reached by writing Python inside `exec` — which a city with no
22//! sandbox and no shell cannot do at all.
23//!
24//! **An answer is an interval, and it says what it is part of.** Whole-
25//! file reading made every long document an all-or-nothing call: the
26//! kernel's own SPEC is fourteen hundred lines, and a read of it either
27//! spent the window or was cut somewhere by the pipeline — usually
28//! through the passage the caller wanted. So a call takes `offset` and
29//! `limit`, at most 512 lines, and a truncated answer carries the total
30//! and the offset to continue from, which is the number `search`
31//! reports for a hit.
32
33use std::path::Path;
34use std::sync::{Arc, Mutex};
35
36use kernel::{
37 AxCode, AxError, CostTier, Effect, Payload, RenderIntent, Temporal, Tool, ToolCall, ToolMeta,
38 ToolName, ToolOutcome,
39};
40use serde_json::{Map, Value};
41
42pub(super) mod locator;
43mod miss;
44mod package;
45
46use super::BoundReader;
47use super::chosen_path::{Located, ReadBound};
48use crate::catalog::{Catalog, Expansion};
49use miss::Floor;
50
51/// The most lines one call may bring back, and the default: a default
52/// below the cap would hide the cap from every caller that never sets
53/// `limit`. A `u16`, so widening it to `usize` cannot fail.
54const LINE_CAP: u16 = 512;
55
56/// Where the answer to one call comes from.
57enum Found {
58 /// A place on disk, and the highest directory a miss there may list.
59 File {
60 at: Located,
61 floor: Floor,
62 },
63 Text(String),
64}
65
66/// The interval of one answer: where it starts and how much of it there
67/// is at most. `offset` stays the number the caller sent until it meets
68/// the text it counts the lines of.
69struct Interval {
70 offset: u64,
71 limit: u16,
72}
73
74/// What one interval took out of a document, and what the taking left.
75///
76/// `total_lines` and `next_offset` are present only when they say
77/// something: a whole small file needs neither, and an offset past the
78/// end gets the total without a `next_offset`, because handing one back
79/// would invite a call that reads the same nothing again.
80struct Taken {
81 text: String,
82 total_lines: Option<usize>,
83 next_offset: Option<usize>,
84}
85
86impl Interval {
87 /// # Errors
88 /// `E_INVALID_ARGS` when `offset` or `limit` is not a whole number,
89 /// or when `limit` is zero. A `limit` above the cap is served at the
90 /// cap rather than refused: the answer carries the truth about what
91 /// it left behind, so a refusal would only cost the caller a turn.
92 fn asked_for(call: &ToolCall) -> Result<Interval, AxError> {
93 let args = call.args.as_map();
94 let whole = |field: &'static str| -> Result<Option<u64>, AxError> {
95 match args.get(field) {
96 None => Ok(None),
97 Some(value) => value.as_u64().map(Some).ok_or_else(|| {
98 AxError::failure(
99 AxCode::InvalidArgs,
100 "read",
101 format!("`{field}` is not a whole number of lines"),
102 )
103 .with_recovery(
104 "pass `offset` and `limit` as non-negative integers, or leave them out \
105 to read from the start",
106 )
107 }),
108 }
109 };
110 let offset = whole("offset")?.unwrap_or(0);
111 // Above the cap is served at the cap, and a number no `u16`
112 // holds is above it.
113 let limit = whole("limit")?.map_or(LINE_CAP, |asked| {
114 u16::try_from(asked).map_or(LINE_CAP, |fits| fits.min(LINE_CAP))
115 });
116 if limit == 0 {
117 return Err(AxError::failure(
118 AxCode::InvalidArgs,
119 "read",
120 "`limit` is zero, which asks for no lines at all",
121 )
122 .with_recovery("pass a `limit` from 1 to 512, or leave it out"));
123 }
124 Ok(Interval { offset, limit })
125 }
126
127 /// Cuts on line ends rather than on lines: every line travels with
128 /// its own terminator, so reading a whole short file returns the
129 /// bytes the file holds and `bytes` keeps the meaning it had.
130 ///
131 /// **Two ceilings, and the tighter one decides.** 512 lines bounds
132 /// how much structure an answer carries;
133 /// [`INTERVAL_CAP_BYTES`](kernel::consts_policy::INTERVAL_CAP_BYTES)
134 /// bounds how much of the window it spends, because a line is about
135 /// forty bytes in ordinary source and a whole minified bundle in a
136 /// generated one. The byte ceiling is applied here rather than
137 /// downstream for one reason: `next_offset` is how a caller
138 /// continues, so a cut made after this function had already reported
139 /// one would leave that number pointing past lines nobody delivered,
140 /// and the gap would be silent.
141 fn cut(&self, text: &str) -> Taken {
142 let lines: Vec<&str> = text.split_inclusive('\n').collect();
143 let total = lines.len();
144 // An offset no `usize` holds is past the end of every text this
145 // machine can hold, which is the answer the second arm gives.
146 let start = match usize::try_from(self.offset) {
147 Ok(start) if start < total => start,
148 Ok(_) | Err(_) => {
149 return Taken {
150 text: String::new(),
151 total_lines: Some(total),
152 next_offset: None,
153 };
154 }
155 };
156 let ceiling = start.saturating_add(usize::from(self.limit)).min(total);
157 let budget = kernel::consts_policy::INTERVAL_CAP_BYTES;
158 let mut end = start;
159 let mut spent = 0usize;
160 while end < ceiling {
161 let Some(line) = lines.get(end) else { break };
162 let after = spent.saturating_add(line.len());
163 // The first line travels however long it is. A call that
164 // returned nothing would hand back the offset it was given,
165 // and the caller would ask the same question forever.
166 if after > budget && end > start {
167 break;
168 }
169 spent = after;
170 end = end.saturating_add(1);
171 }
172 let taken = lines.get(start..end).unwrap_or_default().concat();
173 if end < total {
174 Taken {
175 text: taken,
176 total_lines: Some(total),
177 next_offset: Some(end),
178 }
179 } else {
180 Taken {
181 text: taken,
182 total_lines: None,
183 next_offset: None,
184 }
185 }
186 }
187}
188
189/// What a read answers with, and what it refuses.
190pub struct ReadTool {
191 /// The same catalog the model was shown, shared: a copy would be a
192 /// second authority on what this run may open, and copies drift.
193 catalog: Arc<Mutex<Catalog>>,
194 /// The tree this run reads in, what its building may read - asked
195 /// of every path the model chooses and of the building a Locator's
196 /// bytes belong to - and where this city keeps content blocks, which
197 /// a worktree does not hold. A Locator's bytes are read through it.
198 reader: BoundReader,
199 meta: ToolMeta,
200}
201
202impl ReadTool {
203 /// The name this tool answers to, read by its registration and by
204 /// `mode::core_tools`.
205 pub const NAME: &'static str = "read";
206
207 /// # Errors
208 /// Propagates a malformed parameter schema, which is a build-time
209 /// defect rather than a runtime one.
210 pub fn new(
211 city_root: &Path,
212 catalog: Arc<Mutex<Catalog>>,
213 bound: ReadBound,
214 block_store: &Path,
215 ) -> Result<ReadTool, AxError> {
216 let mut params = Map::new();
217 params.insert("type".to_owned(), Value::String("object".to_owned()));
218 let mut properties = Map::new();
219 let mut spec = Map::new();
220 spec.insert("type".to_owned(), Value::String("string".to_owned()));
221 spec.insert(
222 "description".to_owned(),
223 Value::String(
224 "a file path relative to the city root, or the name of a catalog entry".to_owned(),
225 ),
226 );
227 properties.insert("path".to_owned(), Value::Object(spec));
228 for (name, description) in [
229 (
230 "offset",
231 "the first line to return, counted from 0; default 0",
232 ),
233 (
234 "limit",
235 "how many lines to return, at most 512, which is also the default",
236 ),
237 ] {
238 let mut spec = Map::new();
239 spec.insert("type".to_owned(), Value::String("integer".to_owned()));
240 spec.insert(
241 "description".to_owned(),
242 Value::String(description.to_owned()),
243 );
244 properties.insert(name.to_owned(), Value::Object(spec));
245 }
246 params.insert("properties".to_owned(), Value::Object(properties));
247 params.insert(
248 "required".to_owned(),
249 Value::Array(vec![Value::String("path".to_owned())]),
250 );
251 Ok(ReadTool {
252 catalog,
253 reader: BoundReader::new(city_root, bound, block_store),
254 meta: ToolMeta {
255 name: ToolName::parse(Self::NAME)?,
256 disclosure: "Read a file by its path, or a skill by the name the catalog lists \
257 it under. A truncated answer states the total and the offset to \
258 continue from. Reading a directory or a missing file is refused."
259 .to_owned(),
260 params: Payload::new(params)?,
261 effect: Effect::Read,
262 cost_tier: CostTier::Light,
263 timeout: None,
264 render: RenderIntent::Generic,
265 temporal: Temporal::Timeless,
266 },
267 })
268 }
269
270 /// Where one argument points, or why it points nowhere.
271 ///
272 /// Catalog entries are tried first: a building that admits a skill
273 /// called `review` has said what that word means here, and a file
274 /// that happens to share the name must not be able to shadow it.
275 fn resolve(&self, asked: &str) -> Result<Found, AxError> {
276 if let Some(read) = locator::open_locator(asked, &self.reader, "read") {
277 let (_, bytes) = read?;
278 return String::from_utf8(bytes).map(Found::Text).map_err(|_| {
279 AxError::failure(AxCode::InvalidArgs, "read", format!("{asked} is not text"))
280 .with_recovery("read hands back text; these bytes are not UTF-8")
281 });
282 }
283 let catalog = self.catalog.lock().map_err(|_| {
284 AxError::failure(
285 AxCode::StorageFatal,
286 "read",
287 "the catalog was left locked by a thread that died",
288 )
289 .with_recovery(
290 "end this run and resume it: the catalog cannot be trusted again inside a \
291 process where a thread died holding it",
292 )
293 })?;
294 if let Some(expansion) = catalog.expand(asked) {
295 return match expansion {
296 Expansion::Skill { addr, .. } => {
297 let addr = kernel::Address::parse(&addr).map_err(|err| {
298 AxError::failure(
299 AxCode::ConfigInvalid,
300 "read",
301 format!("{asked} is shelved at {}", err.subject()),
302 )
303 .with_recovery(
304 "this building's reading room names a skill the city cannot address; \
305 a person has to fix the shelf",
306 )
307 })?;
308 package::open_document(&self.reader.city_root, &addr, asked)
309 }
310 // The catalog's own second level. The prompt carries one
311 // line per entry, and this is what that line stood for,
312 // so it is handed over rather than refused.
313 Expansion::Said { text } => Ok(Found::Text(text)),
314 };
315 }
316 if let Some(inside) = package::open_in_package(&catalog, &self.reader.city_root, asked) {
317 return inside;
318 }
319 drop(catalog);
320 // The judgement every model-chosen path gets, in the one place
321 // it is written. `search` asks the same function the same
322 // question, so what is reserved and what is closed have one
323 // answer each.
324 let city_root = &self.reader.city_root;
325 let spelled = super::chosen_path::within_city(city_root, asked, "read")?;
326 let addr = super::chosen_path::admit(&spelled, "read", &*self.reader.bound)?;
327 Ok(Found::File {
328 at: super::chosen_path::land(city_root, &addr, "read", &*self.reader.bound)?,
329 floor: Floor::of_address(city_root, &addr),
330 })
331 }
332}
333
334impl Tool for ReadTool {
335 fn meta(&self) -> &ToolMeta {
336 &self.meta
337 }
338
339 fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
340 if call.name != self.meta.name {
341 return Err(AxError::failure(
342 AxCode::InvalidArgs,
343 "read",
344 format!("call routed to the wrong tool: {}", call.name.as_str()),
345 )
346 .with_recovery(format!(
347 "call `{}`, the name this tool answers to",
348 self.meta.name.as_str()
349 )));
350 }
351 let asked = call
352 .args
353 .as_map()
354 .get("path")
355 .and_then(Value::as_str)
356 .ok_or_else(|| {
357 AxError::failure(
358 AxCode::InvalidArgs,
359 "read",
360 "missing string argument `path`",
361 )
362 .with_recovery("pass one string: a city-relative path, or a catalog name")
363 })?;
364 let text = match self.resolve(asked)? {
365 Found::Text(text) => text,
366 Found::File { at, floor } => miss::text_at(asked, at, &floor)?,
367 };
368 let mut out = Map::new();
369 out.insert("path".to_owned(), Value::String(asked.to_owned()));
370 let interval = Interval::asked_for(call)?;
371 let taken = interval.cut(&text);
372 if let Some(total) = taken.total_lines {
373 out.insert("total_lines".to_owned(), Value::Number(total.into()));
374 }
375 if let Some(next) = taken.next_offset {
376 out.insert("next_offset".to_owned(), Value::Number(next.into()));
377 }
378 // The count the model needs to decide whether it has the whole
379 // thing: a result the pipeline shortened says so in its own
380 // envelope, and this is what it was shortened from.
381 out.insert("bytes".to_owned(), Value::Number(taken.text.len().into()));
382 out.insert("text".to_owned(), Value::String(taken.text));
383 Ok(ToolOutcome {
384 result: Payload::new(out)?,
385 attachments: Vec::new(),
386 })
387 }
388}
389
390#[cfg(test)]
391#[allow(
392 clippy::unwrap_used,
393 clippy::expect_used,
394 clippy::panic,
395 clippy::indexing_slicing,
396 reason = "test code"
397)]
398mod tests;