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. A file comes back with its `version`, which `edit` \
259 takes as `base_version` and `plan finish` as evidence. Reading a directory or a missing file is refused."
260 .to_owned(),
261 params: Payload::new(params)?,
262 effect: Effect::Read,
263 cost_tier: CostTier::Light,
264 timeout: None,
265 render: RenderIntent::Generic,
266 temporal: Temporal::Timeless,
267 },
268 })
269 }
270
271 /// Where one argument points, or why it points nowhere.
272 ///
273 /// Catalog entries are tried first: a building that admits a skill
274 /// called `review` has said what that word means here, and a file
275 /// that happens to share the name must not be able to shadow it.
276 fn resolve(&self, asked: &str) -> Result<Found, AxError> {
277 if let Some(read) = locator::open_locator(asked, &self.reader, "read") {
278 let (_, bytes) = read?;
279 return String::from_utf8(bytes).map(Found::Text).map_err(|_| {
280 AxError::failure(AxCode::InvalidArgs, "read", format!("{asked} is not text"))
281 .with_recovery("read hands back text; these bytes are not UTF-8")
282 });
283 }
284 let catalog = self.catalog.lock().map_err(|_| {
285 AxError::failure(
286 AxCode::StorageFatal,
287 "read",
288 "the catalog was left locked by a thread that died",
289 )
290 .with_recovery(
291 "end this run and resume it: the catalog cannot be trusted again inside a \
292 process where a thread died holding it",
293 )
294 })?;
295 if let Some(expansion) = catalog.expand(asked) {
296 return match expansion {
297 Expansion::Skill { addr, .. } => {
298 let addr = kernel::Address::parse(&addr).map_err(|err| {
299 AxError::failure(
300 AxCode::ConfigInvalid,
301 "read",
302 format!("{asked} is shelved at {}", err.subject()),
303 )
304 .with_recovery(
305 "this building's reading room names a skill the city cannot address; \
306 the User has to fix the shelf",
307 )
308 })?;
309 package::open_document(&self.reader.city_root, &addr, asked)
310 }
311 // The catalog's own second level. The prompt carries one
312 // line per entry, and this is what that line stood for,
313 // so it is handed over rather than refused.
314 Expansion::Said { text } => Ok(Found::Text(text)),
315 };
316 }
317 if let Some(inside) = package::open_in_package(&catalog, &self.reader.city_root, asked) {
318 return inside;
319 }
320 drop(catalog);
321 // The judgement every model-chosen path gets, in the one place
322 // it is written. `search` asks the same function the same
323 // question, so what is reserved and what is closed have one
324 // answer each.
325 let city_root = &self.reader.city_root;
326 let spelled = super::chosen_path::within_city(city_root, asked, "read")?;
327 let addr = super::chosen_path::admit(&spelled, "read", &*self.reader.bound)?;
328 Ok(Found::File {
329 at: super::chosen_path::land(city_root, &addr, "read", &*self.reader.bound)?,
330 floor: Floor::of_address(city_root, &addr),
331 })
332 }
333}
334
335impl Tool for ReadTool {
336 fn meta(&self) -> &ToolMeta {
337 &self.meta
338 }
339
340 fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
341 if call.name != self.meta.name {
342 return Err(AxError::failure(
343 AxCode::InvalidArgs,
344 "read",
345 format!("call routed to the wrong tool: {}", call.name.as_str()),
346 )
347 .with_recovery(format!(
348 "call `{}`, the name this tool answers to",
349 self.meta.name.as_str()
350 )));
351 }
352 let asked = call
353 .args
354 .as_map()
355 .get("path")
356 .and_then(Value::as_str)
357 .ok_or_else(|| {
358 AxError::failure(
359 AxCode::InvalidArgs,
360 "read",
361 "missing string argument `path`",
362 )
363 .with_recovery("pass one string: a city-relative path, or a catalog name")
364 })?;
365 let mut out = Map::new();
366 out.insert("path".to_owned(), Value::String(asked.to_owned()));
367 let text = match self.resolve(asked)? {
368 Found::Text(text) => text,
369 Found::File { at, floor } => {
370 let text = miss::text_at(asked, at, &floor)?;
371 // The whole file's version, whatever interval is cut below:
372 // `edit` guards and `plan finish` cites the file, not the lines.
373 out.insert(
374 "version".to_owned(),
375 Value::String(super::version_of(text.as_bytes())),
376 );
377 text
378 }
379 };
380 let interval = Interval::asked_for(call)?;
381 let taken = interval.cut(&text);
382 if let Some(total) = taken.total_lines {
383 out.insert("total_lines".to_owned(), Value::Number(total.into()));
384 }
385 if let Some(next) = taken.next_offset {
386 out.insert("next_offset".to_owned(), Value::Number(next.into()));
387 }
388 // The count the model needs to decide whether it has the whole
389 // thing: a result the pipeline shortened says so in its own
390 // envelope, and this is what it was shortened from.
391 out.insert("bytes".to_owned(), Value::Number(taken.text.len().into()));
392 out.insert("text".to_owned(), Value::String(taken.text));
393 Ok(ToolOutcome {
394 result: Payload::new(out)?,
395 attachments: Vec::new(),
396 })
397 }
398}
399
400#[cfg(test)]
401#[allow(
402 clippy::unwrap_used,
403 clippy::expect_used,
404 clippy::panic,
405 clippy::indexing_slicing,
406 reason = "test code"
407)]
408mod tests;