Skip to main content

runtime/tools/
search.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 search tool: one substring, one bounded subtree, and the line
7//! number `read` continues from.
8//!
9//! **A substring, never a regular expression.** This follows the ruling
10//! already recorded in the `compaction` module header, for its reason
11//! rather than by analogy: a pattern engine on a path a model drives
12//! puts backtracking between that model and its next turn, and here the
13//! pattern would be written by the model itself, so the pathological
14//! input is not a rare accident but an ordinary Tuesday.
15//!
16//! **What may be searched is what may be read.** The prefix goes
17//! through `chosen_path::admit` and `chosen_path::land`, every candidate
18//! file through `Address::is_reserved`, every link through `land`, and
19//! every building the walk enters from the city root through the read
20//! bound, so a run cannot reach its own governance or a confidential
21//! building sideways through a search after `read` closed the front door.
22
23use std::path::{Path, PathBuf};
24
25use kernel::{
26    AxCode, AxError, CostTier, Effect, Payload, RenderIntent, Temporal, Tool, ToolCall, ToolMeta,
27    ToolName, ToolOutcome,
28};
29use serde_json::{Map, Value};
30
31mod descent;
32
33use super::chosen_path::{self, ReadBound, Walked};
34use crate::elision::{self, Elided};
35use descent::{Entry, Step, admissible};
36
37/// How many hits one call may bring back. A search that filled the
38/// window would be a search nobody can afford to run twice.
39const MATCH_CAP: usize = 64;
40
41/// How large a file may be before the walk passes over it. Reading a
42/// large object into memory to look for a substring is a stall, and the
43/// material this tool is for is source and prose. A file past it is
44/// reported, because `read` can still take it an interval at a time.
45const FILE_BYTE_CAP: u64 = 1 << 20;
46
47/// How many of the places the walk could not look through are named:
48/// the count covers all of them, and a model acts on a few names.
49const UNREAD_SHOWN: usize = 16;
50
51/// How many lines of context each side may be asked for.
52const CONTEXT_CAP: u8 = 4;
53
54/// What one call is looking for, settled once from the arguments so the
55/// walk carries a value rather than four parameters.
56struct Predicate {
57    needle: String,
58    context: usize,
59}
60
61/// What the walk has found so far, and what it could not open.
62struct Findings {
63    hits: Vec<Value>,
64    /// Bytes of hit text handed back so far, against
65    /// `INTERVAL_CAP_BYTES`. Counted rather than measured at the end,
66    /// because the answer has to stop before it is over budget.
67    spent: usize,
68    unreadable: u64,
69    /// The first [`UNREAD_SHOWN`] of those, each with its reason.
70    unread: Vec<Value>,
71    truncated: bool,
72}
73
74impl Findings {
75    /// Counts a place the walk could not look through, and names it
76    /// while there is room.
77    fn could_not_look(&mut self, rel: &str, why: String) {
78        self.unreadable = self.unreadable.saturating_add(1);
79        if self.unread.len() < UNREAD_SHOWN {
80            self.unread
81                .push(serde_json::json!({ "path": rel, "why": why }));
82        }
83    }
84}
85
86/// What a search answers with, and what it refuses.
87pub struct SearchTool {
88    city_root: PathBuf,
89    /// What this run's building may read: the prefix is admitted by it,
90    /// and a walk from the city root enters only the buildings it opens.
91    bound: ReadBound,
92    meta: ToolMeta,
93}
94
95impl SearchTool {
96    /// The name this tool answers to, read by its registration and by
97    /// `mode::core_tools`.
98    pub const NAME: &'static str = "search";
99
100    /// # Errors
101    /// Propagates a malformed parameter schema, which is a build-time
102    /// defect rather than a runtime one.
103    pub fn new(city_root: &Path, bound: ReadBound) -> Result<SearchTool, AxError> {
104        Ok(SearchTool {
105            city_root: city_root.to_path_buf(),
106            bound,
107            meta: ToolMeta {
108                name: ToolName::parse(Self::NAME)?,
109                disclosure: "Find a substring in the files under one path. Literal text, no \
110                             regular expressions; the line number it reports is the offset \
111                             `read` continues from. A `grep` through `exec` returns the same \
112                             text without the version and without the offset."
113                    .to_owned(),
114                params: Payload::of(&serde_json::json!({
115                    "type": "object",
116                    "properties": {
117                        "text": { "type": "string", "description": "the substring to look for, \
118                            matched literally: this tool has no pattern syntax" },
119                        "path": { "type": "string", "description": "a city-relative directory \
120                            or file to search under; everything you may read when omitted" },
121                        "context": { "type": "integer", "description": "how many lines to \
122                            show each side of a hit, 0 to 4, default 0" },
123                    },
124                    "required": ["text"],
125                }))?,
126                effect: Effect::Read,
127                cost_tier: CostTier::Light,
128                timeout: None,
129                render: RenderIntent::Generic,
130                temporal: Temporal::Timeless,
131            },
132        })
133    }
134
135    /// What this call is looking for, or why it is not a question.
136    fn predicate(call: &ToolCall) -> Result<Predicate, AxError> {
137        let args = call.args.as_map();
138        let needle = args
139            .get("text")
140            .and_then(Value::as_str)
141            .filter(|text| !text.is_empty())
142            .ok_or_else(|| {
143                AxError::failure(
144                    AxCode::InvalidArgs,
145                    "search",
146                    "missing non-empty string argument `text`",
147                )
148                .with_recovery(
149                    "pass the literal text to look for; an empty predicate would match every \
150                     line in the city",
151                )
152            })?;
153        let context = match args.get("context") {
154            None => 0,
155            Some(value) => {
156                let asked = value.as_u64().ok_or_else(|| {
157                    AxError::failure(
158                        AxCode::InvalidArgs,
159                        "search",
160                        "`context` is not a whole number of lines",
161                    )
162                    .with_recovery("pass `context` as an integer from 0 to 4, or leave it out")
163                })?;
164                // Above the cap is served at the cap, and so is a number
165                // no `u8` holds.
166                usize::from(u8::try_from(asked).map_or(CONTEXT_CAP, |fits| fits.min(CONTEXT_CAP)))
167            }
168        };
169        Ok(Predicate {
170            needle: needle.to_owned(),
171            context,
172        })
173    }
174
175    /// Where the walk starts, as an absolute path and as the city
176    /// address that prefixes every hit it reports.
177    fn start(&self, call: &ToolCall) -> Result<(PathBuf, String), AxError> {
178        match call.args.as_map().get("path").and_then(Value::as_str) {
179            None => Ok((self.city_root.clone(), String::new())),
180            Some(asked) => {
181                let spelled = chosen_path::within_city(&self.city_root, asked, "search")?;
182                if spelled.is_empty() {
183                    return Ok((self.city_root.clone(), String::new()));
184                }
185                let addr = chosen_path::admit(&spelled, "search", &*self.bound)?;
186                let located = chosen_path::land(&self.city_root, &addr, "search", &*self.bound)?;
187                let chosen_path::Located::Present(path) = located else {
188                    return Err(AxError::failure(
189                        AxCode::InvalidArgs,
190                        "search",
191                        format!("{asked} is not a place in this city"),
192                    )
193                    .with_recovery("name a path that exists, or leave `path` out to search all"));
194                };
195                Ok((path, addr.as_str().to_owned()))
196            }
197        }
198    }
199
200    /// Walks the subtree in name order, so the same city and the same
201    /// question answer the same way twice.
202    fn walk(&self, from: (PathBuf, String), looking: &Predicate) -> Findings {
203        let mut found = Findings {
204            hits: Vec::new(),
205            spent: 0,
206            unreadable: 0,
207            unread: Vec::new(),
208            truncated: false,
209        };
210        let mut pending = vec![Step::Visit(from.0, from.1)];
211        while let Some(step) = pending.pop() {
212            if found.truncated {
213                break;
214            }
215            let (path, rel) = match step {
216                Step::Visit(path, rel) => (path, rel),
217                Step::Unread { child, why } => {
218                    found.could_not_look(&child, why);
219                    continue;
220                }
221            };
222            let path = match chosen_path::walked(&self.city_root, path, &rel, &*self.bound) {
223                Ok(Walked::Directory(path)) => path,
224                Ok(Walked::File(path)) => {
225                    self.scan_file(&path, &rel, looking, &mut found);
226                    continue;
227                }
228                Ok(Walked::Passed) => continue,
229                Err(unresolved) => {
230                    found.could_not_look(&rel, unresolved.to_string());
231                    continue;
232                }
233            };
234            match sorted_entries(&path) {
235                Ok(entries) => {
236                    // Reversed, because the stack hands back what was
237                    // pushed last and the answer is in name order.
238                    for name in entries.into_iter().rev() {
239                        match admissible(&rel, &name, &*self.bound) {
240                            Entry::Descend(child) => {
241                                pending.push(Step::Visit(path.join(&name), child));
242                            }
243                            Entry::Unread { child, why } => {
244                                pending.push(Step::Unread { child, why })
245                            }
246                            Entry::Passed => {}
247                        }
248                    }
249                }
250                Err(err) => found.could_not_look(&rel, format!("would not open: {err}")),
251            }
252        }
253        found
254    }
255
256    /// Looks through one file, adding what it finds until the cap.
257    fn scan_file(&self, path: &Path, rel: &str, looking: &Predicate, found: &mut Findings) {
258        let opened = std::fs::metadata(path).and_then(|meta| {
259            if meta.len() > FILE_BYTE_CAP {
260                Ok(None)
261            } else {
262                std::fs::read(path).map(Some)
263            }
264        });
265        let bytes = match opened {
266            Ok(Some(bytes)) => bytes,
267            Ok(None) => {
268                let why = format!(
269                    "larger than the {FILE_BYTE_CAP} bytes one search reads; `read` it an \
270                     interval at a time"
271                );
272                return found.could_not_look(rel, why);
273            }
274            Err(err) => return found.could_not_look(rel, format!("would not open: {err}")),
275        };
276        // A file that is not text has no readable line, so it is passed
277        // over rather than counted as a failure: binary content in a
278        // city is ordinary, not broken.
279        let Ok(body) = String::from_utf8(bytes) else {
280            return;
281        };
282        let lines: Vec<&str> = body.lines().collect();
283        for (at, line) in lines.iter().enumerate() {
284            if !line.contains(&looking.needle) {
285                continue;
286            }
287            if found.hits.len() >= MATCH_CAP {
288                found.truncated = true;
289                return;
290            }
291            // Sixty-four hits is a bound on how many answers travel, not
292            // on how large they are: nine context lines out of a
293            // generated file are nine lines of whatever that file puts
294            // on a line. The byte budget is the same ceiling `read`
295            // keeps, and for the same reason - one result may not spend
296            // the window a caller still has to think in.
297            let block = context_block(&lines, at, looking.context);
298            let budget = kernel::consts_policy::INTERVAL_CAP_BYTES;
299            let fits = found.spent.saturating_add(block.len()) <= budget;
300            if !fits && !found.hits.is_empty() {
301                found.truncated = true;
302                return;
303            }
304            // The first hit is held to the budget as well, cut at its
305            // tail and marked: its line number is where `read` takes up
306            // the rest, so nothing is lost by cutting it.
307            let text = if fits {
308                block
309            } else {
310                found.truncated = true;
311                let keep = budget.saturating_sub(elision::marker_room(block.len()));
312                elision::splice(&block, keep, block.len(), Elided::Tail).text
313            };
314            found.spent = found.spent.saturating_add(text.len());
315            found
316                .hits
317                .push(serde_json::json!({ "path": rel, "line": at, "text": text }));
318            if found.truncated {
319                return;
320            }
321        }
322    }
323}
324
325/// The lines around a hit, joined as they were read.
326fn context_block(lines: &[&str], at: usize, context: usize) -> String {
327    let first = at.saturating_sub(context);
328    let last = at
329        .saturating_add(context)
330        .min(lines.len().saturating_sub(1));
331    lines.get(first..=last).unwrap_or_default().join("\n")
332}
333
334/// One directory's entries by name, or why it will not open.
335fn sorted_entries(path: &Path) -> std::io::Result<Vec<String>> {
336    let mut names: Vec<String> = std::fs::read_dir(path)?
337        .filter_map(|entry| entry.ok())
338        .map(|entry| entry.file_name().to_string_lossy().into_owned())
339        .collect();
340    names.sort();
341    Ok(names)
342}
343
344impl Tool for SearchTool {
345    fn meta(&self) -> &ToolMeta {
346        &self.meta
347    }
348
349    fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
350        if call.name != self.meta.name {
351            return Err(AxError::failure(
352                AxCode::InvalidArgs,
353                "search",
354                format!("call routed to the wrong tool: {}", call.name.as_str()),
355            )
356            .with_recovery(format!(
357                "call `{}`, the name this tool answers to",
358                self.meta.name.as_str()
359            )));
360        }
361        let looking = SearchTool::predicate(call)?;
362        let found = self.walk(self.start(call)?, &looking);
363        let mut out = Map::new();
364        out.insert("count".to_owned(), Value::Number(found.hits.len().into()));
365        out.insert("truncated".to_owned(), Value::Bool(found.truncated));
366        // What the walk could not open, rather than silence about it: a
367        // search that reports nothing is a different answer from a
368        // search that could not look.
369        out.insert(
370            "unreadable".to_owned(),
371            Value::Number(found.unreadable.into()),
372        );
373        out.insert("unread".to_owned(), Value::Array(found.unread));
374        out.insert("matches".to_owned(), Value::Array(found.hits));
375        Ok(ToolOutcome {
376            result: Payload::new(out)?,
377            attachments: Vec::new(),
378        })
379    }
380}
381
382#[cfg(test)]
383#[allow(
384    clippy::unwrap_used,
385    clippy::expect_used,
386    clippy::panic,
387    clippy::indexing_slicing,
388    reason = "test code"
389)]
390mod tests;