Skip to main content

yapi_core/tools/
mod.rs

1//! Built-in tools: `read`, `bash`, `edit`, `write`, and the optional `grep`,
2//! `find` and `ls`.
3//!
4//! Ports of `packages/coding-agent/src/core/tools` in pi `v1.0.0`. Declarations
5//! (names, descriptions, schemas) match pi byte for byte, because they are part of
6//! every request.
7
8pub mod bash;
9mod edit;
10mod edit_diff;
11
12pub use edit_diff::{Replacement, preview_edits};
13pub mod external;
14mod find;
15mod grep;
16mod ls;
17pub mod mutation;
18pub mod path;
19mod read;
20pub mod registry;
21pub mod truncate;
22mod write;
23
24use std::path::PathBuf;
25use std::sync::{Arc, RwLock};
26
27use futures_util::future::BoxFuture;
28use serde_json::Value;
29use tokio_util::sync::CancellationToken;
30use yapi_agent::{Tool, UpdateSink};
31use yapi_types::event::ToolResult;
32use yapi_types::message::{ContentBlock, ThinkingLevel, ToolDeclaration};
33use yapi_types::model::Model;
34
35pub use bash::Bash;
36pub use edit::Edit;
37pub use find::Find;
38pub use grep::Grep;
39pub use ls::Ls;
40pub use read::{Read, image_mime_type};
41pub use write::Write;
42
43/// Names of the tools active by default.
44pub const DEFAULT_TOOLS: [&str; 4] = ["read", "bash", "edit", "write"];
45
46/// Every built-in tool, in pi's registration order.
47pub const BUILTIN_TOOLS: [&str; 7] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
48
49/// Session state tools read while running.
50#[derive(Clone, Debug)]
51pub struct Runtime {
52    /// The current model.
53    pub model: Option<Model>,
54    /// The current thinking level.
55    pub thinking_level: Option<ThinkingLevel>,
56    /// The session id.
57    pub session_id: Option<String>,
58    /// The session file, when persisted.
59    pub session_file: Option<PathBuf>,
60    /// The `images.autoResize` setting.
61    pub auto_resize_images: bool,
62    /// The `shellPath` setting, for `bash`.
63    pub shell_path: Option<String>,
64    /// The `shellCommandPrefix` setting, for `bash`.
65    pub shell_command_prefix: Option<String>,
66}
67
68impl Default for Runtime {
69    /// No session state. Images resize, as in pi's tool factories.
70    fn default() -> Runtime {
71        Runtime {
72            model: None,
73            thinking_level: None,
74            session_id: None,
75            session_file: None,
76            auto_resize_images: true,
77            shell_path: None,
78            shell_command_prefix: None,
79        }
80    }
81}
82
83/// What built-in tools share: the working directory, the session state and
84/// where helper binaries live.
85#[derive(Clone, Debug)]
86pub struct ToolEnv {
87    /// Working directory for relative paths and commands.
88    pub cwd: PathBuf,
89    /// Session state, updated by the session.
90    pub runtime: Arc<RwLock<Runtime>>,
91    /// The agent's `bin` directory: prepended to `PATH` for commands, and where
92    /// `rg` and `fd` are installed when missing.
93    pub bin_dir: PathBuf,
94}
95
96impl ToolEnv {
97    /// A snapshot of the session state.
98    pub fn runtime(&self) -> Runtime {
99        yapi_types::sync::read(&self.runtime).clone()
100    }
101}
102
103/// pi's `ToolExposure`: how a tool reaches the model.
104#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
105#[serde(rename_all = "kebab-case")]
106pub enum Exposure {
107    /// Declared to the model while active, and callable from other tools.
108    Direct,
109    /// Declared while active, but not callable from other tools.
110    ModelOnly,
111    /// Callable from codemode scripts, and never declared.
112    Codemode,
113    /// Declared once `tool_search` loads it.
114    Deferred,
115    /// Registered but unreachable.
116    Hidden,
117}
118
119impl Exposure {
120    /// Whether activating the tool declares it to the model.
121    pub fn declarable(self) -> bool {
122        matches!(self, Exposure::Direct | Exposure::ModelOnly)
123    }
124}
125
126/// A group of tools, such as one MCP server's.
127#[derive(Clone, Debug, Default, PartialEq, Eq)]
128pub struct Namespace {
129    /// Name, such as `mcp__docs`.
130    pub name: String,
131    /// What the group offers.
132    pub description: Option<String>,
133    /// How to use the group's tools.
134    pub instructions: Option<String>,
135}
136
137/// A tool declared with another description. Everything else is the tool's.
138pub struct Described {
139    tool: Arc<dyn Tool>,
140    declaration: ToolDeclaration,
141}
142
143impl Described {
144    /// `tool`, declared with `description`.
145    pub fn new(tool: Arc<dyn Tool>, description: String) -> Described {
146        let declaration = ToolDeclaration {
147            description,
148            ..tool.declaration().clone()
149        };
150        Described { tool, declaration }
151    }
152}
153
154impl Tool for Described {
155    fn declaration(&self) -> &ToolDeclaration {
156        &self.declaration
157    }
158
159    fn label(&self) -> &str {
160        self.tool.label()
161    }
162
163    fn execution_mode(&self) -> yapi_agent::ExecutionMode {
164        self.tool.execution_mode()
165    }
166
167    fn output_schema(&self) -> Option<&Value> {
168        self.tool.output_schema()
169    }
170
171    fn prepare_arguments(
172        &self,
173        arguments: serde_json::Map<String, Value>,
174    ) -> serde_json::Map<String, Value> {
175        self.tool.prepare_arguments(arguments)
176    }
177
178    fn execute(
179        &self,
180        call_id: String,
181        args: Value,
182        cancel: CancellationToken,
183        updates: UpdateSink,
184    ) -> BoxFuture<'_, Result<ToolResult, String>> {
185        self.tool.execute(call_id, args, cancel, updates)
186    }
187}
188
189/// A tool as registered with a session: the tool, the text it adds to the
190/// system prompt, and how it reaches the model.
191#[derive(Clone)]
192pub struct RegisteredTool {
193    /// The tool.
194    pub tool: Arc<dyn Tool>,
195    /// One-line summary for the prompt's tool list, which leaves out tools
196    /// without one.
197    pub snippet: Option<String>,
198    /// Bullets added to the prompt's rules.
199    pub guidelines: Vec<String>,
200    /// How it reaches the model.
201    pub exposure: Exposure,
202    /// Its group.
203    pub namespace: Option<Namespace>,
204    /// Whether registering it activates it, when its exposure is declarable.
205    pub default_active: bool,
206    /// pi's `ToolAnnotations`, such as `readOnlyHint`.
207    pub annotations: Option<Value>,
208    /// The extension that registered it, or `None` for a built-in tool.
209    pub source: Option<yapi_types::rpc::SourceInfo>,
210}
211
212impl RegisteredTool {
213    /// A direct built-in tool, active on registration.
214    pub fn direct(tool: Arc<dyn Tool>, snippet: Option<String>, guidelines: Vec<String>) -> Self {
215        RegisteredTool {
216            tool,
217            snippet,
218            guidelines,
219            exposure: Exposure::Direct,
220            namespace: None,
221            default_active: true,
222            annotations: None,
223            source: None,
224        }
225    }
226
227    /// The tool's name.
228    pub fn name(&self) -> &str {
229        &self.tool.declaration().name
230    }
231
232    /// The tool as extensions see it.
233    pub fn info(&self) -> crate::extensions::ToolInfo {
234        let declaration = self.tool.declaration();
235        crate::extensions::ToolInfo {
236            name: declaration.name.clone(),
237            description: declaration.description.clone(),
238            parameters: declaration.parameters.clone(),
239            exposure: self.exposure,
240            namespace: self.namespace.clone(),
241        }
242    }
243}
244
245/// Creates the named built-in tool.
246pub fn builtin(name: &str, env: &ToolEnv) -> Option<RegisteredTool> {
247    let (tool, snippet, guidelines): (Arc<dyn Tool>, &str, &[&str]) = match name {
248        "read" => (
249            Arc::new(Read::new(env.clone())),
250            "Read file contents",
251            &["Use read to examine files instead of cat or sed."],
252        ),
253        "bash" => (
254            Arc::new(Bash::new(env.clone())),
255            "Execute bash commands (ls, grep, find, etc.)",
256            &["You can inspect PI_* environment variables for current model and session details."],
257        ),
258        "edit" => (
259            Arc::new(Edit::new(env.clone())),
260            "Make precise file edits with exact text replacement, including multiple disjoint edits in one call",
261            &[
262                "Use edit for precise changes (edits[].oldText must match exactly)",
263                "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",
264                "Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.",
265                "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
266            ],
267        ),
268        "write" => (
269            Arc::new(Write::new(env.clone())),
270            "Create or overwrite files",
271            &["Use write only for new files or complete rewrites."],
272        ),
273        "grep" => (
274            Arc::new(Grep::new(env.clone())),
275            "Search file contents for patterns (respects .gitignore)",
276            &[],
277        ),
278        "find" => (
279            Arc::new(Find::new(env.clone())),
280            "Find files by glob pattern (respects .gitignore)",
281            &[],
282        ),
283        "ls" => (
284            Arc::new(Ls::new(env.clone())),
285            "List directory contents",
286            &[],
287        ),
288        _ => return None,
289    };
290    Some(RegisteredTool::direct(
291        tool,
292        Some(snippet.to_owned()),
293        guidelines.iter().map(|g| (*g).to_owned()).collect(),
294    ))
295}
296
297/// A declaration with pi's `constrainedSampling: {type: json_schema, strict: prefer}`,
298/// as the core tools declare.
299fn declaration(name: &str, description: String, parameters: Value) -> ToolDeclaration {
300    ToolDeclaration {
301        constrained_sampling: Some(serde_json::json!({"type": "json_schema", "strict": "prefer"})),
302        ..plain_declaration(name, description, parameters)
303    }
304}
305
306/// A declaration without constrained sampling, as the search tools declare.
307fn plain_declaration(name: &str, description: String, parameters: Value) -> ToolDeclaration {
308    ToolDeclaration {
309        name: name.to_owned(),
310        description,
311        parameters,
312        constrained_sampling: None,
313    }
314}
315
316/// pi's `Operation aborted` once `cancel` fired.
317fn check_abort(cancel: &CancellationToken) -> Result<(), String> {
318    if cancel.is_cancelled() {
319        Err("Operation aborted".to_owned())
320    } else {
321        Ok(())
322    }
323}
324
325/// Sleeps until `deadline`, or forever without one.
326pub(crate) async fn sleep_until_opt(deadline: Option<std::time::Instant>) {
327    match deadline {
328        Some(deadline) => tokio::time::sleep_until(deadline.into()).await,
329        None => std::future::pending().await,
330    }
331}
332
333/// `args[name]` as a string, or an error in the style of Node's
334/// `ERR_INVALID_ARG_TYPE`: pi's file tools throw, touching nothing, when an
335/// extension's `tool_call` handler replaced a validated argument.
336fn string_arg<'a>(args: &'a Value, name: &str) -> Result<&'a str, String> {
337    args[name]
338        .as_str()
339        .ok_or_else(|| format!("The \"{name}\" argument must be of type string."))
340}
341
342fn text_result(content: impl Into<String>, details: Option<Value>) -> ToolResult {
343    ToolResult {
344        content: vec![ContentBlock::text(content)],
345        details,
346        ..ToolResult::default()
347    }
348}
349
350pub use yapi_types::js::node_error;
351
352/// `Error code: ENOENT` style text for an I/O error, as Node reports it.
353pub(crate) fn error_code(err: &std::io::Error) -> String {
354    match yapi_types::js::errno(err.kind()) {
355        Some((code, _)) => format!("Error code: {code}"),
356        None => err.to_string(),
357    }
358}
359
360/// A number as JavaScript prints it.
361pub fn js_number(value: f64) -> String {
362    yapi_types::json::to_string(&value).unwrap_or_default()
363}
364
365/// The result of output capped at the default byte limit with no line limit,
366/// then pi's bracketed notices: `before`, the byte limit if reached, `after`.
367/// The truncation joins `details` when it applied. Empty details are left out.
368fn capped_output(
369    raw: &str,
370    before: Vec<String>,
371    after: Vec<String>,
372    mut details: serde_json::Map<String, Value>,
373) -> ToolResult {
374    let mut notices = before;
375    let truncation = truncate::truncate_head(raw, JS_MAX_SAFE_INTEGER, truncate::DEFAULT_MAX_BYTES);
376    let mut output = truncation.content.clone();
377    if truncation.truncated {
378        notices.push(format!(
379            "{} limit reached",
380            truncate::format_size(truncate::DEFAULT_MAX_BYTES)
381        ));
382        details.insert(
383            "truncation".into(),
384            serde_json::to_value(&truncation).unwrap_or_default(),
385        );
386    }
387    notices.extend(after);
388    if !notices.is_empty() {
389        output += &format!("\n\n[{}]", notices.join(". "));
390    }
391    text_result(
392        output,
393        (!details.is_empty()).then_some(Value::Object(details)),
394    )
395}
396
397/// `Number.MAX_SAFE_INTEGER`, pi's "no line limit".
398const JS_MAX_SAFE_INTEGER: usize = 9_007_199_254_740_991;