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. 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;