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