use choreo_ai_protocols::ChatToolCall;
pub(crate) use choreo_ai_protocols::openai::AllowedCaller;
use choreo_ai_protocols::openai::ChatToolDefinition;
use choreo_keystore::ServiceCredential;
use crossbeam_channel;
use humfmt::{BytesOptions, bytes_with};
use schemars::JsonSchema;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::{self, BufRead, BufReader};
use std::path::Path;
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::mpsc;
use unicode_general_category::{GeneralCategory, get_general_category};
pub(crate) fn encode_outer<R: Serialize, E: Serialize>(
result: Result<Result<R, E>, ToolError>,
) -> Vec<u8> {
postcard::to_allocvec(&result).unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to postcard-encode tool result");
Vec::new()
})
}
macro_rules! define_tool {
($struct:ident, $name:literal, $desc:literal, $args_ty:ty,
$exec_fn:path, $group:literal, $invoke_fn:path) => {
impl $crate::tools::Tool for $struct {
type Args = $args_ty;
type Return = String;
type Error = $crate::tools::ToolExecError;
fn name(&self) -> &'static str {
$name
}
fn group(&self) -> &'static str {
$group
}
fn description(&self) -> &'static str {
$desc
}
fn execute(
&self,
args: Self::Args,
_x_credentials: Option<&$crate::tools::ServiceCredential>,
working_dir: Option<&std::path::Path>,
_ctx: Option<&$crate::tools::context::ToolContext>,
) -> Result<Self::Return, Self::Error> {
$exec_fn(&args, working_dir).map_err(Into::into)
}
fn return_string(ret: &Self::Return) -> String {
ret.clone()
}
fn describe_invocation(&self, args: &Self::Args) -> String {
$invoke_fn(args)
}
}
};
}
pub(crate) mod admin;
mod error;
pub(crate) mod load_tools;
pub(crate) mod set_session_title;
pub(crate) mod set_working_dir;
pub(crate) mod unload_tools;
pub use error::ToolError;
pub use error::ToolExecError;
pub(crate) use error::{tool_err, tool_ok};
pub(crate) const STREAMING_CHANNEL_CAPACITY: usize = 64;
#[derive(Debug, Clone, Serialize)]
pub struct EmptyArgs {}
impl JsonSchema for EmptyArgs {
fn schema_name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("EmptyArgs")
}
fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "object",
"properties": {},
"additionalProperties": false
})
}
}
impl<'de> Deserialize<'de> for EmptyArgs {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::Error;
match serde_json::Value::deserialize(d)? {
serde_json::Value::Null => Ok(EmptyArgs {}),
serde_json::Value::Object(m) if m.is_empty() => Ok(EmptyArgs {}),
other => Err(D::Error::custom(format!(
"expected null or empty object, got {other}"
))),
}
}
}
pub mod context;
pub(crate) mod db;
pub(crate) mod exec;
pub(crate) mod find;
pub(crate) mod fish;
pub(crate) mod fs;
pub(crate) mod git;
pub(crate) mod glob_util;
pub(crate) mod grep;
pub mod http;
mod image;
pub(crate) mod nu;
#[cfg(feature = "pdf")]
pub(crate) mod pdf;
pub(crate) mod random;
pub(crate) mod read_file;
pub(crate) mod read_file_range;
pub(crate) mod series;
pub(crate) mod sh;
pub mod shell_util;
pub mod subsession;
pub(crate) mod time;
pub(crate) mod vm;
pub(crate) mod x;
#[derive(Debug, Clone, Copy)]
pub enum ToolOutputFormat {
Text,
Json,
}
#[derive(Debug, Clone, Default)]
pub struct ToolOutput {
pub content: String,
pub is_error: bool,
pub invocation_description: String,
pub result_json: Option<serde_json::Value>,
}
#[derive(Debug)]
pub struct PreparedImage {
pub(crate) mime_type: String,
pub(crate) data: Vec<u8>,
pub(crate) width: u32,
pub(crate) height: u32,
pub(crate) alt: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ToolGroup {
pub name: String,
pub description: String,
}
fn sanitize_schema(
mut schema: serde_json::Value,
add_additional_properties: bool,
) -> serde_json::Value {
let defs = schema.as_object_mut().and_then(|obj| {
obj.remove("$schema");
obj.remove("title");
obj.remove("$defs")
});
if let Some(serde_json::Value::Object(defs_map)) = defs {
resolve_refs(&mut schema, &defs_map);
}
if add_additional_properties && let Some(obj) = schema.as_object_mut() {
obj.insert("additionalProperties".into(), false.into());
}
schema
}
fn sanitize_params_schema(schema: serde_json::Value) -> serde_json::Value {
let mut s = sanitize_schema(schema, true);
if s.get("type") == Some(&serde_json::Value::String("null".into())) {
s = serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false
});
}
s
}
fn sanitize_output_schema(schema: serde_json::Value) -> serde_json::Value {
sanitize_schema(schema, false)
}
fn resolve_refs(value: &mut serde_json::Value, defs: &serde_json::Map<String, serde_json::Value>) {
match value {
serde_json::Value::Object(map) => {
if let Some(ref_path) = map.get("$ref").and_then(|v| v.as_str())
&& let Some(def_key) = ref_path.strip_prefix("#/$defs/")
&& let Some(resolved) = defs.get(def_key)
{
let mut resolved = resolved.clone();
if let Some(desc) = map.remove("description")
&& let Some(resolved_obj) = resolved.as_object_mut()
{
resolved_obj.insert("description".into(), desc);
}
*value = resolved;
return;
}
for v in map.values_mut() {
resolve_refs(v, defs);
}
}
serde_json::Value::Array(arr) => {
for v in arr.iter_mut() {
resolve_refs(v, defs);
}
}
_ => {}
}
}
pub trait Tool: Send + Sync {
type Args: DeserializeOwned + JsonSchema + 'static;
type Return: Serialize + JsonSchema + 'static;
type Error: std::error::Error + Send + Sync + Serialize + DeserializeOwned + 'static;
fn name(&self) -> &'static str;
fn group(&self) -> &'static str {
"core"
}
fn description(&self) -> &'static str;
fn schema(&self) -> serde_json::Value {
sanitize_params_schema(
serde_json::to_value(schemars::schema_for!(Self::Args)).unwrap_or_default(),
)
}
fn output_schema(&self) -> Option<serde_json::Value> {
Some(sanitize_output_schema(
serde_json::to_value(schemars::schema_for!(Self::Return)).unwrap_or_default(),
))
}
fn allowed_callers(&self) -> Vec<AllowedCaller> {
vec![AllowedCaller::Direct, AllowedCaller::Programmatic]
}
fn describe_invocation(&self, args: &Self::Args) -> String;
fn execute(
&self,
args: Self::Args,
x_credentials: Option<&ServiceCredential>,
working_dir: Option<&std::path::Path>,
ctx: Option<&context::ToolContext>,
) -> Result<Self::Return, Self::Error>;
fn execute_streaming(
&self,
args: Self::Args,
x_credentials: Option<&ServiceCredential>,
working_dir: Option<&std::path::Path>,
_output_tx: crossbeam_channel::Sender<Vec<u8>>,
ctx: Option<&context::ToolContext>,
) -> Result<Self::Return, Self::Error> {
tracing::trace!("non-streaming tool called via execute_streaming, delegating to execute");
self.execute(args, x_credentials, working_dir, ctx)
}
fn extract_image(&self, _ret: &Self::Return) -> Option<PreparedImage> {
None
}
fn supports_streaming_output() -> bool {
false
}
fn return_string(ret: &Self::Return) -> String;
}
pub trait ToolDyn: Send + Sync {
fn name(&self) -> &str;
fn group(&self) -> &str;
fn description(&self) -> &str;
fn schema(&self) -> serde_json::Value;
fn output_schema(&self) -> Option<serde_json::Value>;
fn allowed_callers(&self) -> Vec<AllowedCaller>;
fn describe_invocation_json(&self, args_json: &str) -> String;
fn supports_streaming_output(&self) -> bool;
fn execute_json(
&self,
args_json: &str,
format: ToolOutputFormat,
x_credentials: Option<&ServiceCredential>,
working_dir: Option<&std::path::Path>,
ctx: Option<&context::ToolContext>,
image_tx: Option<mpsc::Sender<PreparedImage>>,
) -> Result<ToolOutput, ToolError>;
#[expect(clippy::too_many_arguments)]
fn execute_streaming_json(
&self,
args_json: &str,
format: ToolOutputFormat,
x_credentials: Option<&ServiceCredential>,
working_dir: Option<&std::path::Path>,
output_tx: crossbeam_channel::Sender<Vec<u8>>,
ctx: Option<&context::ToolContext>,
image_tx: Option<mpsc::Sender<PreparedImage>>,
) -> Result<ToolOutput, ToolError>;
fn execute_postcard(
&self,
args_bytes: &[u8],
x_credentials: Option<&ServiceCredential>,
working_dir: Option<&std::path::Path>,
ctx: Option<&context::ToolContext>,
) -> Vec<u8>;
}
impl<T: Tool + 'static> ToolDyn for T {
fn name(&self) -> &'static str {
Tool::name(self)
}
fn group(&self) -> &'static str {
Tool::group(self)
}
fn description(&self) -> &'static str {
Tool::description(self)
}
fn schema(&self) -> serde_json::Value {
Tool::schema(self)
}
fn output_schema(&self) -> Option<serde_json::Value> {
Tool::output_schema(self)
}
fn allowed_callers(&self) -> Vec<AllowedCaller> {
Tool::allowed_callers(self)
}
fn describe_invocation_json(&self, args_json: &str) -> String {
match serde_json::from_str::<T::Args>(args_json) {
Ok(args) => T::describe_invocation(self, &args),
Err(_) => Tool::description(self).to_string(),
}
}
fn supports_streaming_output(&self) -> bool {
T::supports_streaming_output()
}
fn execute_json(
&self,
args_json: &str,
format: ToolOutputFormat,
x_credentials: Option<&ServiceCredential>,
working_dir: Option<&std::path::Path>,
ctx: Option<&context::ToolContext>,
image_tx: Option<mpsc::Sender<PreparedImage>>,
) -> Result<ToolOutput, ToolError> {
let args = serde_json::from_str::<T::Args>(args_json)?;
let desc = T::describe_invocation(self, &args);
let ret = match self.execute(args, x_credentials, working_dir, ctx) {
Ok(r) => r,
Err(e) => {
return Ok(ToolOutput {
content: e.to_string(),
is_error: true,
invocation_description: desc,
..Default::default()
});
}
};
if let Some(tx) = image_tx
&& let Some(image) = self.extract_image(&ret)
{
let _ = tx.send(image);
}
Ok(ToolOutput {
content: match format {
ToolOutputFormat::Text => T::return_string(&ret),
ToolOutputFormat::Json => serde_json::to_string(&ret).unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to JSON-encode tool return");
String::new()
}),
},
is_error: false,
invocation_description: desc,
result_json: serde_json::to_value(&ret).ok(),
})
}
fn execute_postcard(
&self,
args_bytes: &[u8],
x_credentials: Option<&ServiceCredential>,
working_dir: Option<&std::path::Path>,
ctx: Option<&context::ToolContext>,
) -> Vec<u8> {
let args = match postcard::from_bytes::<T::Args>(args_bytes) {
Ok(a) => a,
Err(e) => {
return encode_outer::<T::Return, T::Error>(Err(ToolError::Postcard(
e.to_string(),
)));
}
};
let result: Result<T::Return, T::Error> =
self.execute(args, x_credentials, working_dir, ctx);
encode_outer::<T::Return, T::Error>(Ok(result))
}
fn execute_streaming_json(
&self,
args_json: &str,
format: ToolOutputFormat,
x_credentials: Option<&ServiceCredential>,
working_dir: Option<&std::path::Path>,
output_tx: crossbeam_channel::Sender<Vec<u8>>,
ctx: Option<&context::ToolContext>,
image_tx: Option<mpsc::Sender<PreparedImage>>,
) -> Result<ToolOutput, ToolError> {
let args = serde_json::from_str::<T::Args>(args_json)?;
let desc = T::describe_invocation(self, &args);
if T::supports_streaming_output() {
let _ = output_tx.send(desc.as_bytes().to_vec());
}
let ret = match self.execute_streaming(args, x_credentials, working_dir, output_tx, ctx) {
Ok(r) => r,
Err(e) => {
return Ok(ToolOutput {
content: e.to_string(),
is_error: true,
invocation_description: desc,
..Default::default()
});
}
};
if let Some(tx) = image_tx
&& let Some(image) = self.extract_image(&ret)
{
let _ = tx.send(image);
}
Ok(ToolOutput {
content: match format {
ToolOutputFormat::Text => T::return_string(&ret),
ToolOutputFormat::Json => serde_json::to_string(&ret).unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to JSON-encode tool return");
String::new()
}),
},
is_error: false,
invocation_description: desc,
result_json: serde_json::to_value(&ret).ok(),
})
}
}
pub fn static_groups() -> &'static [ToolGroup] {
static GROUPS: OnceLock<Vec<ToolGroup>> = OnceLock::new();
GROUPS.get_or_init(|| {
vec![
ToolGroup {
name: "core".into(),
description: "File system operations, HTTP requests, image display, PDF classification and Markdown extraction, file search, random values, time queries, and series execution".into(),
},
ToolGroup {
name: "db".into(),
description: "Session-scoped key-value database (redb)".into(),
},
ToolGroup {
name: "git".into(),
description: "Local Git repository operations (status, diff, log, add, commit, push, show)".into(),
},
ToolGroup {
name: "shell".into(),
description: "Shell command execution (bash, nushell, fish, exec)".into(),
},
ToolGroup {
name: "x".into(),
description: "X/Twitter API (post, search, user lookup)".into(),
},
ToolGroup {
name: "vm".into(),
description: "RISC-V sandboxed code execution".into(),
},
]
})
}
pub struct ToolRegistry {
tools: HashMap<String, Box<dyn ToolDyn>>,
dynamic_groups: Vec<(String, String)>,
}
impl Default for ToolRegistry {
fn default() -> Self {
Self::new()
}
}
impl ToolRegistry {
pub fn new() -> Self {
let mut reg = Self {
tools: HashMap::new(),
dynamic_groups: Vec::new(),
};
reg.register(read_file::ReadFile);
reg.register(read_file_range::ReadFileRange);
reg.register(fs::ListFiles);
reg.register(fs::DeleteFiles);
reg.register(fs::LineCount);
reg.register(http::HttpRequest);
reg.register(fs::WriteFile);
reg.register(fs::EditFile);
reg.register(image::DisplayImage::new());
reg.register(git::GitStatus);
reg.register(git::GitDiff);
reg.register(git::GitLog);
reg.register(git::GitAdd);
reg.register(git::GitCommit);
reg.register(git::GitPush);
reg.register(git::GitShow);
reg.register(sh::Sh);
if shell_util::binary_exists("nu") {
reg.register(nu::NuShell);
}
if shell_util::binary_exists("fish") {
reg.register(fish::FishShell);
}
reg.register(exec::Exec);
reg.register(grep::Grep);
reg.register(find::Find);
#[cfg(feature = "pdf")]
reg.register(pdf::PdfClassify);
#[cfg(feature = "pdf")]
reg.register(pdf::PdfToMarkdown);
reg.register(random::Random);
reg.register(time::GetCurrentTime);
reg.register(x::XPost);
reg.register(x::XSearchRecent);
reg.register(x::XUserLookup);
reg.register(db::DbSet);
reg.register(db::DbGet);
reg.register(db::DbDelete);
reg.register(db::DbDeleteRange);
reg.register(db::DbGetRange);
reg.register(db::DbList);
reg.register(db::DbCount);
reg.register(admin::ListSessions);
reg.register(admin::GetSession);
reg.register(admin::LoadSkill);
reg.register(set_session_title::SetSessionTitle);
reg.register(set_working_dir::SetWorkingDir);
reg.register(subsession::SpawnSubsession);
reg
}
pub fn build(self) -> Arc<Self> {
Arc::new_cyclic(|weak| {
let mut reg = self;
reg.register(vm::RunRiscV::new(weak.clone()));
reg.register(series::RunSeries::new(weak.clone()));
reg.register(load_tools::LoadTools::new(weak.clone()));
reg.register(unload_tools::UnloadTools::new(weak.clone()));
reg
})
}
pub(crate) fn register(&mut self, tool: impl Tool + 'static) {
let name = tool.name().to_string();
self.tools.insert(name, Box::new(tool));
}
pub fn execute_json(
&self,
tool_call: &ChatToolCall,
format: ToolOutputFormat,
x_credentials: Option<&ServiceCredential>,
working_dir: Option<&std::path::Path>,
ctx: Option<&context::ToolContext>,
image_tx: Option<mpsc::Sender<PreparedImage>>,
) -> Result<ToolOutput, ToolError> {
match self.tools.get(tool_call.name.as_str()) {
Some(tool) => tool.execute_json(
&tool_call.arguments_json,
format,
x_credentials,
working_dir,
ctx,
image_tx,
),
None => Err(ToolError::Other(format!(
"unknown tool: {}",
tool_call.name
))),
}
}
#[expect(clippy::too_many_arguments)]
pub fn execute_streaming_json(
&self,
tool_call: &ChatToolCall,
format: ToolOutputFormat,
output_tx: crossbeam_channel::Sender<Vec<u8>>,
x_credentials: Option<&ServiceCredential>,
working_dir: Option<&std::path::Path>,
ctx: Option<&context::ToolContext>,
image_tx: Option<mpsc::Sender<PreparedImage>>,
) -> Result<ToolOutput, ToolError> {
match self.tools.get(tool_call.name.as_str()) {
Some(tool) => tool.execute_streaming_json(
&tool_call.arguments_json,
format,
x_credentials,
working_dir,
output_tx,
ctx,
image_tx,
),
None => Err(ToolError::Other(format!(
"unknown tool: {}",
tool_call.name
))),
}
}
pub fn describe_invocation(&self, tool_call: &ChatToolCall) -> String {
match self.tools.get(tool_call.name.as_str()) {
Some(tool) => tool.describe_invocation_json(&tool_call.arguments_json),
None => tool_call.name.clone(),
}
}
pub fn describe_invocation_for(&self, name: &str, args_json: &str) -> Option<String> {
self.tools
.get(name)
.map(|t| t.describe_invocation_json(args_json))
}
pub fn execute_postcard(
&self,
name: &str,
args_bytes: &[u8],
x_credentials: Option<&ServiceCredential>,
working_dir: Option<&std::path::Path>,
ctx: Option<&context::ToolContext>,
) -> Vec<u8> {
match self.tools.get(name) {
Some(tool) => tool.execute_postcard(args_bytes, x_credentials, working_dir, ctx),
None => encode_outer::<(), ()>(Err(ToolError::Other(format!("unknown tool: {name}")))),
}
}
pub fn register_dynamic(&mut self, name: String, group: String, tool: Box<dyn ToolDyn>) {
tracing::debug!(tool = %name, group = %group, "registered dynamic tool");
self.tools.insert(name, tool);
}
pub fn register_dynamic_group(&mut self, name: String, description: String) {
self.dynamic_groups.push((name, description));
}
pub fn unregister_group(&mut self, group: &str) -> Vec<String> {
let mut removed = Vec::new();
self.tools.retain(|name, tool| {
if tool.group() == group {
removed.push(name.clone());
false
} else {
true
}
});
self.dynamic_groups.retain(|(g, _)| g != group);
if !removed.is_empty() {
tracing::debug!(group = %group, count = removed.len(), "unregistered dynamic group");
}
removed
}
pub fn groups(&self) -> Vec<ToolGroup> {
let mut groups: Vec<ToolGroup> = static_groups().to_vec();
for (name, desc) in &self.dynamic_groups {
groups.push(ToolGroup {
name: name.clone(),
description: desc.clone(),
});
}
groups
}
pub fn group_names(&self) -> Vec<String> {
self.groups()
.into_iter()
.filter(|g| g.name != "core")
.map(|g| g.name)
.collect()
}
pub(crate) fn known_group_names(&self) -> HashSet<String> {
let mut s: HashSet<String> = self.group_names().into_iter().collect();
s.insert("core".into());
s
}
pub fn available_definitions(&self, active: &HashSet<String>) -> Vec<ChatToolDefinition> {
self.tools
.values()
.filter(|t| active.contains(t.group()))
.map(|t| ChatToolDefinition::function(t.name(), t.description(), t.schema()))
.collect()
}
pub fn available_definitions_for_responses(
&self,
active: &HashSet<String>,
) -> Vec<ChatToolDefinition> {
self.tools
.values()
.filter(|t| active.contains(t.group()))
.map(|t| {
let callers = t.allowed_callers();
ChatToolDefinition::function_with_options(
t.name(),
t.description(),
t.schema(),
t.output_schema(),
if callers.is_empty() {
None
} else {
Some(callers)
},
)
})
.collect()
}
}
pub(crate) fn unknown_group_names(
groups: &[String],
known: &HashSet<String>,
) -> Option<Vec<String>> {
let unknown: Vec<String> = groups
.iter()
.filter(|g| !known.contains(*g))
.cloned()
.collect();
if unknown.is_empty() {
None
} else {
Some(unknown)
}
}
pub(crate) fn groups_enum_schema(names: Vec<String>, description: &str) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"groups": {
"type": "array",
"items": {
"type": "string",
"enum": names
},
"description": description
}
},
"required": ["groups"]
})
}
pub(crate) fn expand_tilde(path: &str) -> String {
if path == "~" || path.starts_with("~/") {
match dirs::home_dir() {
Some(home) => {
let home_str = home.to_string_lossy();
if path == "~" {
home_str.into_owned()
} else {
format!("{home_str}{}", &path[1..])
}
}
None => {
tracing::warn!(
"expand_tilde: no home directory found, leaving '{}' unchanged",
path
);
path.to_string()
}
}
} else {
path.to_string()
}
}
pub(crate) fn resolve_path(
path: &str,
working_dir: Option<&std::path::Path>,
) -> std::path::PathBuf {
let expanded = expand_tilde(path);
let p = std::path::Path::new(&expanded);
if p.is_absolute() {
return p.to_path_buf();
}
if let Some(working_dir) = working_dir {
if path == "." || path == "./" {
working_dir.to_path_buf()
} else {
working_dir.join(p)
}
} else {
p.to_path_buf()
}
}
pub(crate) fn sha256_hex(content: &str) -> String {
let digest = Sha256::digest(content.as_bytes());
hex::encode(digest)
}
pub(crate) const MAX_TOOL_OUTPUT_BYTES: usize = 128 * 1024;
pub(crate) const BINARY_SNIFF_BYTES: usize = 8 * 1024;
pub(crate) const MAX_LINE_DISPLAY_BYTES: usize = 64 * 1024;
pub(crate) fn truncate_tool_output(content: &str) -> String {
if content.len() <= MAX_TOOL_OUTPUT_BYTES {
return content.to_string();
}
let split = content.floor_char_boundary(MAX_TOOL_OUTPUT_BYTES);
let mut truncated = content[..split].to_string();
truncated.push_str("\n...[truncated]");
truncated
}
fn is_plain_ascii(text: &str, keep_tabs: bool) -> bool {
text.bytes()
.all(|b| (b == b'\t' && keep_tabs) || (0x20..=0x7e).contains(&b))
}
pub(crate) fn sanitize_text(text: &str, keep_tabs: bool) -> String {
if is_plain_ascii(text, keep_tabs) {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if sanitize_keeps(c, keep_tabs) {
out.push(c);
} else {
out.extend(c.escape_default());
}
}
out
}
fn sanitize_keeps(c: char, keep_tabs: bool) -> bool {
if c.is_ascii() {
return (c == '\t' && keep_tabs) || (' '..='~').contains(&c);
}
!c.is_control() && !is_unsafe_unicode(c)
}
pub(crate) fn sanitize_text_len(text: &str, keep_tabs: bool) -> usize {
if is_plain_ascii(text, keep_tabs) {
return text.len();
}
text.chars()
.map(|c| {
if sanitize_keeps(c, keep_tabs) {
c.len_utf8()
} else {
c.escape_default().count()
}
})
.sum()
}
fn is_unsafe_unicode(c: char) -> bool {
matches!(c, '\u{2028}' | '\u{2029}')
|| (get_general_category(c) == GeneralCategory::Format
&& !matches!(c, '\u{200c}' | '\u{200d}'))
}
pub(crate) fn sanitize_name(name: &str) -> String {
sanitize_text(name, false)
}
pub(crate) fn sanitize_content(content: &str) -> String {
sanitize_text(content, true)
}
const BYTE_OPTIONS: BytesOptions = BytesOptions::new().binary().space(true);
pub(crate) fn human_size(bytes: u64) -> String {
bytes_with(bytes, BYTE_OPTIONS).to_string()
}
pub(crate) fn symlink_target_label(path: &Path) -> String {
let target = match std::fs::read_link(path) {
Ok(target) => target.to_string_lossy().into_owned(),
Err(err) => {
tracing::warn!(
error = %err,
path = %path.display(),
"failed to resolve symlink target"
);
return "<unreadable target>".to_string();
}
};
let label = match std::fs::metadata(path) {
Ok(meta) if meta.is_dir() => format!("{target}/"),
_ => target,
};
sanitize_name(&label)
}
pub(crate) fn finish_tool_output(body: &str, marker: Option<String>) -> String {
let mut out = truncate_tool_output(body);
if let Some(marker) = marker {
out.push_str(&format!("\n{marker}"));
}
out
}
pub(crate) fn truncation_marker(truncated: bool, cap: usize, noun: &str) -> Option<String> {
truncated.then(|| format!("...[truncated at {cap} {noun}]"))
}
pub(crate) fn open_text_reader(path: &std::path::Path) -> Result<BufReader<File>, ToolExecError> {
let file = File::open(path)?;
let mut reader = BufReader::with_capacity(BINARY_SNIFF_BYTES, file);
let head = reader.fill_buf()?;
if let Some(pos) = head.iter().position(|&b| b == 0) {
return Err(ToolExecError(format!(
"'{}' appears to be a binary file (NUL byte at offset {pos}); \
read_file/read_file_range are for UTF-8 text files",
path.display()
)));
}
if let Err(e) = std::str::from_utf8(head)
&& e.error_len().is_some()
{
return Err(ToolExecError(format!(
"'{}' is not valid UTF-8 text (invalid byte sequence at offset {})",
path.display(),
e.valid_up_to()
)));
}
Ok(reader)
}
pub(crate) fn read_line_capped<R: BufRead>(
reader: &mut R,
buf: &mut Vec<u8>,
cap: usize,
) -> io::Result<bool> {
buf.clear();
loop {
let (consumed, done) = {
let available = reader.fill_buf()?;
if available.is_empty() {
return Ok(true);
}
let remaining = cap.saturating_sub(buf.len());
if remaining == 0 {
return Ok(false);
}
let take = available.len().min(remaining);
match available[..take].iter().position(|&b| b == b'\n') {
Some(idx) => {
buf.extend_from_slice(&available[..=idx]);
(idx + 1, true)
}
None => {
buf.extend_from_slice(&available[..take]);
(take, false)
}
}
};
reader.consume(consumed);
if done {
return Ok(true);
}
}
}
pub(crate) fn drain_rest_of_line<R: BufRead>(reader: &mut R) -> io::Result<u64> {
let mut drained: u64 = 0;
loop {
let (consumed, done) = {
let available = reader.fill_buf()?;
if available.is_empty() {
return Ok(drained);
}
match available.iter().position(|&b| b == b'\n') {
Some(idx) => (idx + 1, true),
None => (available.len(), false),
}
};
drained += consumed as u64;
reader.consume(consumed);
if done {
return Ok(drained);
}
}
}
pub(crate) struct StreamedLine {
pub line_number: u64,
pub content: Vec<u8>,
pub complete: bool,
pub start_offset: u64,
}
pub(crate) struct TextStream<R: BufRead> {
reader: R,
line_buf: Vec<u8>,
lines_read: u64,
total_bytes: u64,
finished: bool,
}
impl<R: BufRead> TextStream<R> {
pub(crate) fn new(reader: R) -> Self {
Self {
reader,
line_buf: Vec::with_capacity(MAX_LINE_DISPLAY_BYTES),
lines_read: 0,
total_bytes: 0,
finished: false,
}
}
pub(crate) fn total_lines(&self) -> u64 {
self.lines_read
}
pub(crate) fn total_bytes(&self) -> u64 {
self.total_bytes
}
}
impl<R: BufRead> Iterator for TextStream<R> {
type Item = io::Result<StreamedLine>;
fn next(&mut self) -> Option<Self::Item> {
if self.finished {
return None;
}
let complete =
match read_line_capped(&mut self.reader, &mut self.line_buf, MAX_LINE_DISPLAY_BYTES) {
Ok(complete) => complete,
Err(e) => {
self.finished = true;
return Some(Err(e));
}
};
if self.line_buf.is_empty() {
self.finished = true;
return None;
}
let start_offset = self.total_bytes;
let line_total = if complete {
self.line_buf.len() as u64
} else {
match drain_rest_of_line(&mut self.reader) {
Ok(drained) => self.line_buf.len() as u64 + drained,
Err(e) => {
self.finished = true;
return Some(Err(e));
}
}
};
self.total_bytes += line_total;
self.lines_read += 1;
Some(Ok(StreamedLine {
line_number: self.lines_read,
content: self.line_buf.clone(),
complete,
start_offset,
}))
}
}
pub(crate) struct OutputBudget {
max_bytes: usize,
shown_bytes: usize,
truncated: bool,
}
impl OutputBudget {
pub(crate) fn new(max_bytes: usize) -> Self {
Self {
max_bytes,
shown_bytes: 0,
truncated: false,
}
}
pub(crate) fn shown_bytes(&self) -> usize {
self.shown_bytes
}
pub(crate) fn is_truncated(&self) -> bool {
self.truncated
}
pub(crate) fn push_line(&mut self, out: &mut String, line: &str) -> bool {
let display_len = line.len() + 1;
if self.truncated || self.shown_bytes + display_len > self.max_bytes {
self.truncated = true;
return false;
}
out.push_str(line);
out.push('\n');
self.shown_bytes += display_len;
true
}
}
pub(crate) fn render_streamed_line(
line: &StreamedLine,
path: &std::path::Path,
numbered: bool,
) -> Result<String, ToolExecError> {
if let Some(pos) = line.content.iter().position(|&b| b == 0) {
return Err(ToolExecError(format!(
"'{}' appears to be a binary file (NUL byte at offset {})",
path.display(),
line.start_offset + pos as u64
)));
}
let line_str = match std::str::from_utf8(&line.content) {
Ok(s) => s,
Err(e) if !line.complete && e.error_len().is_none() => {
std::str::from_utf8(&line.content[..e.valid_up_to()]).unwrap_or_default()
}
Err(e) => {
return Err(ToolExecError(format!(
"'{}' is not valid UTF-8 text (invalid byte sequence at offset {})",
path.display(),
line.start_offset + e.valid_up_to() as u64
)));
}
};
let mut display = line_str;
if let Some(stripped) = display.strip_suffix('\n') {
display = stripped;
}
if let Some(stripped) = display.strip_suffix('\r') {
display = stripped;
}
let mut display_line = String::new();
if numbered {
display_line.push_str(&format!("{} | {display}", line.line_number));
} else {
display_line.push_str(display);
}
if !line.complete {
display_line.push_str("\n...[line truncated: exceeds 64 KiB]");
}
Ok(display_line)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn available_definitions_includes_session_config_tools() {
let registry = ToolRegistry::new().build();
let active: HashSet<String> = ["core".into()].into_iter().collect();
let defs = registry.available_definitions(&active);
let names: Vec<&str> = defs.iter().map(|d| d.function.name.as_str()).collect();
for tool in [
"set_working_dir",
"load_tools",
"unload_tools",
"set_session_title",
] {
assert!(
names.contains(&tool),
"missing {tool} in core definitions: {names:?}"
);
}
}
#[test]
fn available_definitions_responses_restricts_session_config_tools() {
let registry = ToolRegistry::new().build();
let active: HashSet<String> = ["core".into()].into_iter().collect();
let defs = registry.available_definitions_for_responses(&active);
let set_wd = defs
.iter()
.find(|d| d.function.name == "set_working_dir")
.expect("set_working_dir should be defined");
assert_eq!(
set_wd.function.allowed_callers.as_deref(),
Some(&[AllowedCaller::Direct][..])
);
let read_file = defs
.iter()
.find(|d| d.function.name == "read_file")
.expect("read_file should be defined");
assert_eq!(
read_file.function.allowed_callers.as_deref(),
Some(&[AllowedCaller::Direct, AllowedCaller::Programmatic][..])
);
}
#[test]
fn expand_tilde_plain_path_unchanged() {
assert_eq!(expand_tilde("/absolute/path"), "/absolute/path");
assert_eq!(expand_tilde("relative/path"), "relative/path");
assert_eq!(expand_tilde("./dots"), "./dots");
assert_eq!(expand_tilde(""), "");
}
#[test]
fn expand_tilde_expands_to_home_dir() {
let expanded = expand_tilde("~");
let home = dirs::home_dir().expect("home dir should exist in test env");
assert_eq!(expanded, home.to_string_lossy());
}
#[test]
fn expand_tilde_expands_with_slash() {
let expanded = expand_tilde("~/choreographr");
let home = dirs::home_dir().expect("home dir should exist in test env");
let expected = format!("{}/choreographr", home.to_string_lossy());
assert_eq!(expanded, expected);
}
#[test]
fn expand_tilde_expands_nested() {
let expanded = expand_tilde("~/projects/foo/bar");
let home = dirs::home_dir().expect("home dir should exist in test env");
let expected = format!("{}/projects/foo/bar", home.to_string_lossy());
assert_eq!(expanded, expected);
}
#[test]
fn expand_tilde_user_form_left_alone() {
assert_eq!(expand_tilde("~other/project"), "~other/project");
assert_eq!(expand_tilde("~other"), "~other");
}
#[test]
fn expand_tilde_mid_path_left_alone() {
assert_eq!(expand_tilde("/path/~foo"), "/path/~foo");
}
struct DefaultTool;
impl Tool for DefaultTool {
type Args = ();
type Return = String;
type Error = ToolExecError;
fn name(&self) -> &'static str {
"default_tool"
}
fn group(&self) -> &'static str {
"test"
}
fn description(&self) -> &'static str {
"A tool with default settings"
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object", "properties": {}})
}
fn execute(
&self,
_args: Self::Args,
_x_credentials: Option<&ServiceCredential>,
_working_dir: Option<&std::path::Path>,
_ctx: Option<&crate::tools::context::ToolContext>,
) -> Result<Self::Return, Self::Error> {
Ok("ok".to_string())
}
fn return_string(ret: &Self::Return) -> String {
ret.clone()
}
fn describe_invocation(&self, _args: &Self::Args) -> String {
format!("{}.", Tool::description(self))
}
}
#[test]
fn default_output_schema_is_string() {
let tool = DefaultTool;
let schema = Tool::output_schema(&tool).expect("schema");
assert_eq!(schema["type"], "string");
}
#[test]
fn default_allowed_callers_includes_both() {
let tool = DefaultTool;
let callers = Tool::allowed_callers(&tool);
assert_eq!(callers.len(), 2);
assert!(callers.contains(&AllowedCaller::Direct));
assert!(callers.contains(&AllowedCaller::Programmatic));
}
#[test]
fn default_tool_name_description_schema() {
let tool = DefaultTool;
assert_eq!(Tool::name(&tool), "default_tool");
assert_eq!(Tool::group(&tool), "test");
assert_eq!(Tool::description(&tool), "A tool with default settings");
}
#[test]
fn tooldyn_delegates_output_schema() {
let tool: Box<dyn ToolDyn> = Box::new(DefaultTool);
let schema = tool.output_schema().expect("schema");
assert_eq!(schema["type"], "string");
}
#[test]
fn tooldyn_delegates_allowed_callers() {
let tool: Box<dyn ToolDyn> = Box::new(DefaultTool);
let callers = tool.allowed_callers();
assert!(callers.contains(&AllowedCaller::Direct));
assert!(callers.contains(&AllowedCaller::Programmatic));
}
#[test]
fn tooldyn_delegates_group() {
let tool: Box<dyn ToolDyn> = Box::new(DefaultTool);
assert_eq!(tool.group(), "test");
}
struct RestrictedTool;
impl Tool for RestrictedTool {
type Args = ();
type Return = u64;
type Error = ToolExecError;
fn name(&self) -> &'static str {
"restricted_tool"
}
fn group(&self) -> &'static str {
"test"
}
fn description(&self) -> &'static str {
"A tool with restricted callers"
}
fn return_string(ret: &Self::Return) -> String {
ret.to_string()
}
fn describe_invocation(&self, _args: &Self::Args) -> String {
format!("{}.", Tool::description(self))
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object", "properties": {}})
}
fn output_schema(&self) -> Option<serde_json::Value> {
Some(serde_json::json!({"type": "integer"}))
}
fn allowed_callers(&self) -> Vec<AllowedCaller> {
vec![AllowedCaller::Direct]
}
fn execute(
&self,
_args: Self::Args,
_x_credentials: Option<&ServiceCredential>,
_working_dir: Option<&std::path::Path>,
_ctx: Option<&crate::tools::context::ToolContext>,
) -> Result<Self::Return, Self::Error> {
Ok(42)
}
}
#[test]
fn restricted_tool_uses_overridden_output_schema() {
let tool = RestrictedTool;
assert_eq!(
Tool::output_schema(&tool),
Some(serde_json::json!({"type": "integer"}))
);
}
#[test]
fn restricted_tool_uses_overridden_allowed_callers() {
let tool = RestrictedTool;
assert_eq!(Tool::allowed_callers(&tool), vec![AllowedCaller::Direct]);
assert!(!Tool::allowed_callers(&tool).contains(&AllowedCaller::Programmatic));
}
#[test]
fn tooldyn_delegates_restricted_output_schema() {
let tool: Box<dyn ToolDyn> = Box::new(RestrictedTool);
assert_eq!(
tool.output_schema(),
Some(serde_json::json!({"type": "integer"}))
);
}
#[test]
fn tooldyn_delegates_restricted_allowed_callers() {
let tool: Box<dyn ToolDyn> = Box::new(RestrictedTool);
assert_eq!(tool.allowed_callers(), vec![AllowedCaller::Direct]);
}
#[test]
fn sanitize_schema_strips_metadata() {
let input = serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "MySchema",
"$defs": { "Foo": { "type": "string" } },
"type": "object"
});
let result = super::sanitize_schema(input, false);
assert!(result.get("$schema").is_none(), "should strip $schema");
assert!(result.get("title").is_none(), "should strip title");
assert!(result.get("$defs").is_none(), "should strip $defs");
assert_eq!(result["type"], "object");
}
#[test]
fn sanitize_schema_inlines_refs() {
let input = serde_json::json!({
"$defs": { "Point": { "type": "object", "properties": { "x": {"type": "integer"} } } },
"type": "object",
"properties": {
"location": { "$ref": "#/$defs/Point" }
}
});
let result = super::sanitize_schema(input, false);
let location = &result["properties"]["location"];
assert!(location.get("$ref").is_none(), "$ref should be resolved");
assert_eq!(location["type"], "object");
assert_eq!(location["properties"]["x"]["type"], "integer");
}
#[test]
fn sanitize_schema_preserves_description_across_ref() {
let input = serde_json::json!({
"$defs": { "Str": { "type": "string" } },
"items": { "$ref": "#/$defs/Str", "description": "A string item" }
});
let result = super::sanitize_schema(input, false);
assert_eq!(result["items"]["type"], "string");
assert_eq!(result["items"]["description"], "A string item");
}
#[test]
fn sanitize_schema_adds_additional_properties() {
let input = serde_json::json!({ "type": "object", "properties": {} });
let result = super::sanitize_schema(input, true);
assert_eq!(result["additionalProperties"], false);
}
#[test]
fn sanitize_schema_skips_additional_properties_when_false() {
let input = serde_json::json!({ "type": "string" });
let result = super::sanitize_schema(input, false);
assert!(result.get("additionalProperties").is_none());
}
#[test]
fn sanitize_schema_passthrough_clean_schema() {
let input = serde_json::json!({ "type": "integer" });
let result = super::sanitize_schema(input.clone(), false);
assert_eq!(result, input);
}
#[test]
fn sanitize_schema_resolves_refs_in_arrays() {
let input = serde_json::json!({
"$defs": { "Tag": { "type": "string" } },
"type": "array",
"prefixItems": [
{ "$ref": "#/$defs/Tag" },
{ "type": "integer" }
]
});
let result = super::sanitize_schema(input, false);
assert!(result["prefixItems"][0].get("$ref").is_none());
assert_eq!(result["prefixItems"][0]["type"], "string");
assert_eq!(result["prefixItems"][1]["type"], "integer");
}
#[test]
fn sanitize_params_schema_converts_null_to_object() {
let input = serde_json::json!({ "type": "null" });
let result = super::sanitize_params_schema(input);
assert_eq!(result["type"], "object");
assert_eq!(result["properties"], serde_json::json!({}));
assert_eq!(result["additionalProperties"], false);
}
#[test]
fn sanitize_params_schema_preserves_normal_schema() {
let input = serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string" }
}
});
let result = super::sanitize_params_schema(input);
assert_eq!(result["type"], "object");
assert_eq!(result["properties"]["name"]["type"], "string");
assert_eq!(result["additionalProperties"], false);
}
#[test]
fn sanitize_params_schema_strips_schema_title_defs() {
let input = serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Args",
"$defs": { "X": { "type": "string" } },
"type": "object"
});
let result = super::sanitize_params_schema(input);
assert!(result.get("$schema").is_none());
assert!(result.get("title").is_none());
assert!(result.get("$defs").is_none());
}
#[test]
fn sanitize_output_schema_no_additional_properties() {
let input = serde_json::json!({ "type": "string" });
let result = super::sanitize_output_schema(input);
assert_eq!(result["type"], "string");
assert!(result.get("additionalProperties").is_none());
}
#[test]
fn sanitize_output_schema_strips_metadata() {
let input = serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Return",
"type": "integer"
});
let result = super::sanitize_output_schema(input);
assert!(result.get("$schema").is_none());
assert!(result.get("title").is_none());
}
#[test]
fn resolve_refs_basic() {
let mut value = serde_json::json!({ "$ref": "#/$defs/MyType" });
let defs = [("MyType".to_string(), serde_json::json!({"type": "string"}))]
.into_iter()
.collect();
super::resolve_refs(&mut value, &defs);
assert_eq!(value, serde_json::json!({"type": "string"}));
}
#[test]
fn resolve_refs_no_match_unchanged() {
let original = serde_json::json!({ "$ref": "#/$defs/Unknown" });
let mut value = original.clone();
let defs = serde_json::Map::new();
super::resolve_refs(&mut value, &defs);
assert_eq!(value, original);
}
#[test]
fn resolve_refs_no_ref_unchanged() {
let original = serde_json::json!({ "type": "object", "properties": {} });
let mut value = original.clone();
let defs = serde_json::Map::new();
super::resolve_refs(&mut value, &defs);
assert_eq!(value, original);
}
#[test]
fn resolve_refs_nested_skipped() {
let mut value = serde_json::json!({ "$ref": "#/$defs/B" });
let mut defs = serde_json::Map::new();
defs.insert("A".into(), serde_json::json!({"type": "string"}));
defs.insert("B".into(), serde_json::json!({"$ref": "#/$defs/A"}));
super::resolve_refs(&mut value, &defs);
assert_eq!(value, serde_json::json!({"$ref": "#/$defs/A"}));
}
struct UnitArgsTool;
impl Tool for UnitArgsTool {
type Args = ();
type Return = String;
type Error = ToolExecError;
fn name(&self) -> &'static str {
"unit_args_tool"
}
fn group(&self) -> &'static str {
"test"
}
fn description(&self) -> &'static str {
"Tool with unit args"
}
fn return_string(ret: &Self::Return) -> String {
ret.clone()
}
fn describe_invocation(&self, _args: &Self::Args) -> String {
format!("{}.", Tool::description(self))
}
fn execute(
&self,
_args: Self::Args,
_x_credentials: Option<&ServiceCredential>,
_working_dir: Option<&std::path::Path>,
_ctx: Option<&crate::tools::context::ToolContext>,
) -> Result<Self::Return, Self::Error> {
Ok("ok".to_string())
}
}
#[test]
fn unit_args_tool_schema_is_empty_object() {
let schema = Tool::schema(&UnitArgsTool);
assert_eq!(schema["type"], "object");
assert_eq!(schema["properties"], serde_json::json!({}));
assert_eq!(schema["additionalProperties"], false);
}
struct RawOutputTool;
impl Tool for RawOutputTool {
type Args = ();
type Return = String;
type Error = ToolExecError;
fn name(&self) -> &'static str {
"raw_output_tool"
}
fn group(&self) -> &'static str {
"test"
}
fn description(&self) -> &'static str {
"Tool with default return_string (Display)"
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object", "properties": {}})
}
fn execute(
&self,
_args: Self::Args,
_credentials: Option<&ServiceCredential>,
_working_dir: Option<&std::path::Path>,
_ctx: Option<&context::ToolContext>,
) -> Result<Self::Return, Self::Error> {
Ok("raw\noutput".to_string())
}
fn return_string(ret: &Self::Return) -> String {
ret.clone()
}
fn describe_invocation(&self, _args: &Self::Args) -> String {
format!("{}.", Tool::description(self))
}
}
#[test]
fn return_string_default_for_string_is_raw() {
let content = <DefaultTool as Tool>::return_string(&"hello".to_string());
assert_eq!(content, "hello");
}
#[test]
fn return_string_default_for_integer_is_plain_number() {
let content = <RestrictedTool as Tool>::return_string(&42u64);
assert_eq!(content, "42");
}
#[test]
fn return_string_through_execute_json_text_format() {
let tool = RawOutputTool;
let result = tool
.execute_json("null", ToolOutputFormat::Text, None, None, None, None)
.unwrap();
assert!(!result.is_error, "should succeed");
assert_eq!(result.content, "raw\noutput");
assert!(
result
.invocation_description
.contains("Tool with default return_string")
);
}
#[test]
fn return_string_through_execute_json_json_format() {
let tool = RawOutputTool;
let result = tool
.execute_json("null", ToolOutputFormat::Json, None, None, None, None)
.unwrap();
assert!(!result.is_error, "should succeed");
assert_eq!(result.content, r#""raw\noutput""#);
}
#[test]
fn encode_outer_ok_ok() {
let bytes = encode_outer::<String, ToolExecError>(Ok(Ok("hello".into())));
let decoded: Result<Result<String, ToolExecError>, ToolError> =
postcard::from_bytes(&bytes).unwrap();
assert!(matches!(decoded, Ok(Ok(v)) if v == "hello"));
}
#[test]
fn encode_outer_ok_err() {
let bytes = encode_outer::<String, ToolExecError>(Ok(Err(ToolExecError("fail".into()))));
let decoded: Result<Result<String, ToolExecError>, ToolError> =
postcard::from_bytes(&bytes).unwrap();
assert!(matches!(decoded, Ok(Err(e)) if e.to_string() == "fail"));
}
#[test]
fn encode_outer_err_infra() {
let bytes =
encode_outer::<String, ToolExecError>(Err(ToolError::Other("infra fail".into())));
let decoded: Result<Result<String, ToolExecError>, ToolError> =
postcard::from_bytes(&bytes).unwrap();
assert!(matches!(decoded, Err(e) if e.to_string() == "infra fail"));
}
#[test]
fn empty_args_from_null() {
let args: EmptyArgs = serde_json::from_str("null").unwrap();
let _ = args;
}
#[test]
fn empty_args_from_empty_object() {
let args: EmptyArgs = serde_json::from_str("{}").unwrap();
let _ = args;
}
#[test]
fn empty_args_rejects_nonempty_object() {
let result: Result<EmptyArgs, _> = serde_json::from_str(r#"{"key": "value"}"#);
assert!(result.is_err());
}
#[test]
fn empty_args_schema_is_empty_object() {
let schema = serde_json::to_value(schemars::schema_for!(EmptyArgs)).unwrap();
let schema = sanitize_params_schema(schema);
assert_eq!(schema["type"], "object");
assert_eq!(
schema["additionalProperties"],
serde_json::Value::Bool(false),
"should forbid extra properties"
);
}
#[test]
fn describe_invocation_json_uses_tool_description_fallback_on_bad_args() {
let tool = DefaultTool;
let wrapper: Box<dyn ToolDyn> = Box::new(tool);
let desc = wrapper.describe_invocation_json("\"this is a string\"");
assert_eq!(desc, "A tool with default settings");
}
#[test]
fn describe_invocation_json_returns_description_for_valid_args() {
let tool = DefaultTool;
let wrapper: Box<dyn ToolDyn> = Box::new(tool);
let desc = wrapper.describe_invocation_json("null");
assert_eq!(desc, "A tool with default settings.");
}
#[test]
fn describe_invocation_in_tool_output_is_populated_on_success() {
let tool = DefaultTool;
let wrapper: Box<dyn ToolDyn> = Box::new(tool);
let (output_tx, _output_rx) = crossbeam_channel::unbounded();
let result = wrapper
.execute_streaming_json(
"null",
ToolOutputFormat::Text,
None,
None,
output_tx,
None,
None,
)
.unwrap();
assert!(
!result.invocation_description.is_empty(),
"invocation_description should be populated: {:?}",
result.invocation_description,
);
}
#[test]
fn describe_invocation_in_tool_output_is_populated_on_execute_json() {
let tool = DefaultTool;
let wrapper: Box<dyn ToolDyn> = Box::new(tool);
let result = wrapper
.execute_json("null", ToolOutputFormat::Text, None, None, None, None)
.unwrap();
assert!(
!result.invocation_description.is_empty(),
"invocation_description should be populated: {:?}",
result.invocation_description,
);
}
#[test]
fn non_streaming_tool_sends_no_chunk() {
let tool = DefaultTool;
let wrapper: Box<dyn ToolDyn> = Box::new(tool);
let (output_tx, output_rx) = crossbeam_channel::unbounded();
let result = wrapper
.execute_streaming_json(
"null",
ToolOutputFormat::Text,
None,
None,
output_tx,
None,
None,
)
.unwrap();
assert!(
!result.invocation_description.is_empty(),
"invocation_description should be populated even for non-streaming tools: {:?}",
result.invocation_description,
);
assert!(!result.is_error, "tool should succeed: {}", result.content);
match output_rx.try_recv() {
Err(crossbeam_channel::TryRecvError::Empty)
| Err(crossbeam_channel::TryRecvError::Disconnected) => {
}
Ok(chunk) => {
panic!(
"non-streaming tool should NOT send streaming chunks, got: {:?}",
chunk
);
}
}
}
#[test]
fn text_stream_counts_lines_bytes_and_offsets() {
use std::io::Cursor;
let mut stream = TextStream::new(Cursor::new(b"a\nbb\nccc\n".to_vec()));
let lines: Vec<StreamedLine> = stream.by_ref().map(|l| l.unwrap()).collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].line_number, 1);
assert_eq!(lines[0].content, b"a\n");
assert_eq!(lines[0].start_offset, 0);
assert_eq!(lines[1].line_number, 2);
assert_eq!(lines[1].content, b"bb\n");
assert_eq!(lines[1].start_offset, 2);
assert_eq!(lines[2].line_number, 3);
assert_eq!(lines[2].content, b"ccc\n");
assert_eq!(lines[2].start_offset, 5);
assert_eq!(stream.total_lines(), 3);
assert_eq!(stream.total_bytes(), 9);
}
#[test]
fn text_stream_handles_over_cap_lines() {
use std::io::Cursor;
let content = vec![b'x'; 70 * 1024];
let mut stream = TextStream::new(Cursor::new(content.clone()));
let line = stream.next().unwrap().unwrap();
assert!(!line.complete);
assert_eq!(line.content.len(), MAX_LINE_DISPLAY_BYTES);
assert_eq!(stream.total_bytes(), content.len() as u64);
assert!(stream.next().is_none());
}
#[test]
fn output_budget_rejects_lines_past_cap() {
let mut out = String::new();
let mut budget = OutputBudget::new(10);
assert!(budget.push_line(&mut out, "abc")); assert!(budget.push_line(&mut out, "def")); assert!(!budget.push_line(&mut out, "ghi")); assert!(budget.is_truncated());
assert_eq!(budget.shown_bytes(), 8);
assert_eq!(out, "abc\ndef\n");
assert!(!budget.push_line(&mut out, "x"));
assert_eq!(out, "abc\ndef\n");
}
#[test]
fn render_streamed_line_rejects_binary_and_bad_utf8() {
let path = Path::new("f.txt");
let nul = StreamedLine {
line_number: 1,
content: b"ok\x00no".to_vec(),
complete: true,
start_offset: 0,
};
let err = render_streamed_line(&nul, path, false)
.unwrap_err()
.to_string();
assert!(err.contains("binary file"), "{err}");
let bad = StreamedLine {
line_number: 2,
content: b"ok\xff".to_vec(),
complete: true,
start_offset: 10,
};
let err = render_streamed_line(&bad, path, false)
.unwrap_err()
.to_string();
assert!(err.contains("not valid UTF-8"), "{err}");
assert!(err.contains("offset 12"), "{err}");
}
#[test]
fn render_streamed_line_normalizes_endings_and_numbers() {
let path = Path::new("f.txt");
let line = StreamedLine {
line_number: 3,
content: b"hi\r\n".to_vec(),
complete: true,
start_offset: 0,
};
assert_eq!(render_streamed_line(&line, path, true).unwrap(), "3 | hi");
assert_eq!(render_streamed_line(&line, path, false).unwrap(), "hi");
}
#[test]
fn render_streamed_line_handles_mid_char_cap_cut() {
let path = Path::new("f.txt");
let content = "€".repeat(21846); let line = StreamedLine {
line_number: 1,
content: content.into_bytes(),
complete: false,
start_offset: 0,
};
let out = render_streamed_line(&line, path, false).unwrap();
assert!(out.contains("...[line truncated: exceeds 64 KiB]"), "{out}");
std::str::from_utf8(out.as_bytes()).expect("output must be valid UTF-8");
}
#[test]
fn human_size_formats() {
assert_eq!(human_size(0), "0 B");
assert_eq!(human_size(512), "512 B");
assert_eq!(human_size(1024), "1 KiB");
assert_eq!(human_size(1500), "1.5 KiB");
assert_eq!(human_size(1024 * 1024), "1 MiB");
assert_eq!(human_size(5 * 1024 * 1024), "5 MiB");
assert_eq!(human_size(100 * 1024 * 1024), "100 MiB");
}
#[test]
fn sanitize_name_escapes_control_chars() {
assert_eq!(sanitize_name("plain.txt"), "plain.txt");
assert_eq!(sanitize_name("a\nb"), "a\\nb");
assert_eq!(sanitize_name("a\tb"), "a\\tb");
}
#[test]
fn sanitize_name_escapes_unicode_separators_and_format_chars() {
assert_eq!(sanitize_name("a\u{2028}b"), "a\\u{2028}b");
assert_eq!(sanitize_name("a\u{2029}b"), "a\\u{2029}b");
assert_eq!(sanitize_name("a\u{200e}b"), "a\\u{200e}b");
assert_eq!(sanitize_name("a\u{200f}b"), "a\\u{200f}b");
assert_eq!(sanitize_name("a\u{061c}b"), "a\\u{61c}b");
assert_eq!(sanitize_name("a\u{202e}b"), "a\\u{202e}b");
assert_eq!(sanitize_name("a\u{2066}b"), "a\\u{2066}b");
assert_eq!(sanitize_name("a\u{200b}b"), "a\\u{200b}b");
assert_eq!(sanitize_name("a\u{2060}b"), "a\\u{2060}b");
assert_eq!(sanitize_name("a\u{feff}b"), "a\\u{feff}b");
assert_eq!(sanitize_name("a\u{180e}b"), "a\\u{180e}b");
assert_eq!(sanitize_name("a\u{200c}b"), "a\u{200c}b");
assert_eq!(sanitize_name("a\u{200d}b"), "a\u{200d}b");
assert_eq!(sanitize_name("café"), "café");
}
#[test]
fn sanitize_content_keeps_tabs_but_escapes_separators_and_format_chars() {
assert_eq!(sanitize_content("a\tb"), "a\tb");
assert_eq!(sanitize_content("a\nb"), "a\\nb");
assert_eq!(sanitize_content("a\u{2028}b"), "a\\u{2028}b");
assert_eq!(sanitize_content("a\u{2029}b"), "a\\u{2029}b");
assert_eq!(sanitize_content("a\u{200f}b"), "a\\u{200f}b");
assert_eq!(sanitize_content("a\u{202e}b"), "a\\u{202e}b");
assert_eq!(sanitize_content("a\u{1b}b"), "a\\u{1b}b");
assert_eq!(sanitize_content("a\u{200b}b"), "a\\u{200b}b");
assert_eq!(sanitize_content("a\u{2060}b"), "a\\u{2060}b");
assert_eq!(sanitize_content("a\u{feff}b"), "a\\u{feff}b");
assert_eq!(sanitize_content("a\u{180e}b"), "a\\u{180e}b");
assert_eq!(sanitize_content("a\u{200c}b"), "a\u{200c}b");
}
#[test]
fn sanitize_text_len_matches_actual_sanitized_length() {
for s in [
"plain ascii",
"tab\there",
"new\nline",
"esc \u{1b}[31m",
"sep\u{2028}arator",
"bidi\u{202e}evil",
"mongolian\u{180e}vowel",
"café \u{200b} zwsp",
"",
] {
assert_eq!(
sanitize_text_len(s, true),
sanitize_content(s).len(),
"{s:?}"
);
assert_eq!(sanitize_text_len(s, false), sanitize_name(s).len(), "{s:?}");
}
}
#[test]
fn sanitize_keeps_matches_policy_for_all_chars() {
for c in '\u{0}'..=char::MAX {
let is_control = c.is_control();
let is_separator = matches!(c, '\u{2028}' | '\u{2029}');
let is_cf = get_general_category(c) == GeneralCategory::Format;
let is_joiner = matches!(c, '\u{200c}' | '\u{200d}');
assert_eq!(
sanitize_keeps(c, false),
!is_control && !is_separator && !(is_cf && !is_joiner),
"name-policy keep drift for U+{:04X}",
c as u32
);
assert_eq!(
sanitize_keeps(c, true),
(c == '\t') || (!is_control && !is_separator && !(is_cf && !is_joiner)),
"content-policy keep drift for U+{:04X}",
c as u32
);
}
}
#[cfg(unix)]
#[test]
fn symlink_target_label_sanitizes_control_chars() {
use std::os::unix::fs::symlink;
let dir = tempfile::TempDir::new().expect("temp dir");
let target_name = "evil\ntarget.txt";
std::fs::write(dir.path().join(target_name), "hi").expect("write target");
symlink(target_name, dir.path().join("link")).expect("symlink");
let label = symlink_target_label(&dir.path().join("link"));
assert_eq!(label, "evil\\ntarget.txt");
}
#[test]
fn finish_tool_output_keeps_marker_past_byte_cap() {
let big = "x".repeat(super::MAX_TOOL_OUTPUT_BYTES + 100);
let out = finish_tool_output(&big, Some("...[truncated at 5 results]".to_string()));
assert!(out.contains("...[truncated]"), "expected byte-cap marker");
assert!(
out.ends_with("...[truncated at 5 results]"),
"marker must survive the cap: …{}",
&out[out.len().saturating_sub(60)..]
);
}
#[test]
fn finish_tool_output_without_marker_is_plain_cap() {
let body = "a\nb";
assert_eq!(finish_tool_output(body, None), body);
}
#[test]
fn truncation_marker_only_when_capped() {
assert_eq!(truncation_marker(false, 50, "results"), None);
assert_eq!(
truncation_marker(true, 50, "results").as_deref(),
Some("...[truncated at 50 results]")
);
assert_eq!(
truncation_marker(true, 200, "matches").as_deref(),
Some("...[truncated at 200 matches]")
);
}
}