Skip to main content

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;