pub mod bash;
mod edit;
mod edit_diff;
pub use edit_diff::{Replacement, preview_edits};
pub mod external;
mod find;
mod grep;
mod ls;
pub mod mutation;
pub mod path;
mod read;
pub mod registry;
pub mod truncate;
mod write;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use futures_util::future::BoxFuture;
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use yapi_agent::{Tool, UpdateSink};
use yapi_types::event::ToolResult;
use yapi_types::message::{ContentBlock, ThinkingLevel, ToolDeclaration};
use yapi_types::model::Model;
pub use bash::Bash;
pub use edit::Edit;
pub use find::Find;
pub use grep::Grep;
pub use ls::Ls;
pub use read::{Read, image_mime_type};
pub use write::Write;
pub const DEFAULT_TOOLS: [&str; 4] = ["read", "bash", "edit", "write"];
pub const BUILTIN_TOOLS: [&str; 7] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
#[derive(Clone, Debug)]
pub struct Runtime {
pub model: Option<Model>,
pub thinking_level: Option<ThinkingLevel>,
pub session_id: Option<String>,
pub session_file: Option<PathBuf>,
pub auto_resize_images: bool,
pub shell_path: Option<String>,
pub shell_command_prefix: Option<String>,
}
impl Default for Runtime {
fn default() -> Runtime {
Runtime {
model: None,
thinking_level: None,
session_id: None,
session_file: None,
auto_resize_images: true,
shell_path: None,
shell_command_prefix: None,
}
}
}
#[derive(Clone, Debug)]
pub struct ToolEnv {
pub cwd: PathBuf,
pub runtime: Arc<RwLock<Runtime>>,
pub bin_dir: PathBuf,
}
impl ToolEnv {
pub fn runtime(&self) -> Runtime {
yapi_types::sync::read(&self.runtime).clone()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Exposure {
Direct,
ModelOnly,
Codemode,
Deferred,
Hidden,
}
impl Exposure {
pub fn declarable(self) -> bool {
matches!(self, Exposure::Direct | Exposure::ModelOnly)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Namespace {
pub name: String,
pub description: Option<String>,
pub instructions: Option<String>,
}
pub struct Described {
tool: Arc<dyn Tool>,
declaration: ToolDeclaration,
}
impl Described {
pub fn new(tool: Arc<dyn Tool>, description: String) -> Described {
let declaration = ToolDeclaration {
description,
..tool.declaration().clone()
};
Described { tool, declaration }
}
}
impl Tool for Described {
fn declaration(&self) -> &ToolDeclaration {
&self.declaration
}
fn label(&self) -> &str {
self.tool.label()
}
fn execution_mode(&self) -> yapi_agent::ExecutionMode {
self.tool.execution_mode()
}
fn output_schema(&self) -> Option<&Value> {
self.tool.output_schema()
}
fn prepare_arguments(
&self,
arguments: serde_json::Map<String, Value>,
) -> serde_json::Map<String, Value> {
self.tool.prepare_arguments(arguments)
}
fn execute(
&self,
call_id: String,
args: Value,
cancel: CancellationToken,
updates: UpdateSink,
) -> BoxFuture<'_, Result<ToolResult, String>> {
self.tool.execute(call_id, args, cancel, updates)
}
}
#[derive(Clone)]
pub struct RegisteredTool {
pub tool: Arc<dyn Tool>,
pub snippet: Option<String>,
pub guidelines: Vec<String>,
pub exposure: Exposure,
pub namespace: Option<Namespace>,
pub default_active: bool,
pub annotations: Option<Value>,
pub source: Option<yapi_types::rpc::SourceInfo>,
}
impl RegisteredTool {
pub fn direct(tool: Arc<dyn Tool>, snippet: Option<String>, guidelines: Vec<String>) -> Self {
RegisteredTool {
tool,
snippet,
guidelines,
exposure: Exposure::Direct,
namespace: None,
default_active: true,
annotations: None,
source: None,
}
}
pub fn name(&self) -> &str {
&self.tool.declaration().name
}
pub fn info(&self) -> crate::extensions::ToolInfo {
let declaration = self.tool.declaration();
crate::extensions::ToolInfo {
name: declaration.name.clone(),
description: declaration.description.clone(),
parameters: declaration.parameters.clone(),
exposure: self.exposure,
namespace: self.namespace.clone(),
}
}
}
pub fn builtin(name: &str, env: &ToolEnv) -> Option<RegisteredTool> {
let (tool, snippet, guidelines): (Arc<dyn Tool>, &str, &[&str]) = match name {
"read" => (
Arc::new(Read::new(env.clone())),
"Read file contents",
&["Use read to examine files instead of cat or sed."],
),
"bash" => (
Arc::new(Bash::new(env.clone())),
"Execute bash commands (ls, grep, find, etc.)",
&["You can inspect PI_* environment variables for current model and session details."],
),
"edit" => (
Arc::new(Edit::new(env.clone())),
"Make precise file edits with exact text replacement, including multiple disjoint edits in one call",
&[
"Use edit for precise changes (edits[].oldText must match exactly)",
"When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",
"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.",
"Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
],
),
"write" => (
Arc::new(Write::new(env.clone())),
"Create or overwrite files",
&["Use write only for new files or complete rewrites."],
),
"grep" => (
Arc::new(Grep::new(env.clone())),
"Search file contents for patterns (respects .gitignore)",
&[],
),
"find" => (
Arc::new(Find::new(env.clone())),
"Find files by glob pattern (respects .gitignore)",
&[],
),
"ls" => (
Arc::new(Ls::new(env.clone())),
"List directory contents",
&[],
),
_ => return None,
};
Some(RegisteredTool::direct(
tool,
Some(snippet.to_owned()),
guidelines.iter().map(|g| (*g).to_owned()).collect(),
))
}
fn declaration(name: &str, description: String, parameters: Value) -> ToolDeclaration {
ToolDeclaration {
constrained_sampling: Some(serde_json::json!({"type": "json_schema", "strict": "prefer"})),
..plain_declaration(name, description, parameters)
}
}
fn plain_declaration(name: &str, description: String, parameters: Value) -> ToolDeclaration {
ToolDeclaration {
name: name.to_owned(),
description,
parameters,
constrained_sampling: None,
}
}
fn check_abort(cancel: &CancellationToken) -> Result<(), String> {
if cancel.is_cancelled() {
Err("Operation aborted".to_owned())
} else {
Ok(())
}
}
pub(crate) async fn sleep_until_opt(deadline: Option<std::time::Instant>) {
match deadline {
Some(deadline) => tokio::time::sleep_until(deadline.into()).await,
None => std::future::pending().await,
}
}
fn string_arg<'a>(args: &'a Value, name: &str) -> Result<&'a str, String> {
args[name]
.as_str()
.ok_or_else(|| format!("The \"{name}\" argument must be of type string."))
}
fn text_result(content: impl Into<String>, details: Option<Value>) -> ToolResult {
ToolResult {
content: vec![ContentBlock::text(content)],
details,
..ToolResult::default()
}
}
pub use yapi_types::js::node_error;
pub(crate) fn error_code(err: &std::io::Error) -> String {
match yapi_types::js::errno(err.kind()) {
Some((code, _)) => format!("Error code: {code}"),
None => err.to_string(),
}
}
pub fn js_number(value: f64) -> String {
yapi_types::json::to_string(&value).unwrap_or_default()
}
fn capped_output(
raw: &str,
before: Vec<String>,
after: Vec<String>,
mut details: serde_json::Map<String, Value>,
) -> ToolResult {
let mut notices = before;
let truncation = truncate::truncate_head(raw, JS_MAX_SAFE_INTEGER, truncate::DEFAULT_MAX_BYTES);
let mut output = truncation.content.clone();
if truncation.truncated {
notices.push(format!(
"{} limit reached",
truncate::format_size(truncate::DEFAULT_MAX_BYTES)
));
details.insert(
"truncation".into(),
serde_json::to_value(&truncation).unwrap_or_default(),
);
}
notices.extend(after);
if !notices.is_empty() {
output += &format!("\n\n[{}]", notices.join(". "));
}
text_result(
output,
(!details.is_empty()).then_some(Value::Object(details)),
)
}
const JS_MAX_SAFE_INTEGER: usize = 9_007_199_254_740_991;