use choreo_ai_protocols::ChatToolCall;
pub(crate) use choreo_ai_protocols::openai::AllowedCaller;
use choreo_ai_protocols::openai::ChatToolDefinition;
use choreo_keystore::ServiceCredential;
use choreo_proto::ImageReference;
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::path::Path;
use std::sync::Arc;
use crate::tools::ios_bridge::IosToolBridge;
use std::sync::OnceLock;
use std::sync::mpsc;
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};
mod sanitize;
pub(crate) use sanitize::*;
mod schema;
pub(crate) use schema::*;
mod text_stream;
pub(crate) use text_stream::*;
#[cfg(feature = "blockchain")]
impl From<choreo_blockchain::BlockchainError> for ToolExecError {
fn from(e: choreo_blockchain::BlockchainError) -> Self {
ToolExecError(e.to_string())
}
}
#[cfg(feature = "content")]
impl From<choreo_content::ContentError> for ToolExecError {
fn from(e: choreo_content::ContentError) -> Self {
ToolExecError(e.to_string())
}
}
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;
#[cfg(feature = "content")]
pub(crate) mod content;
pub(crate) mod db;
pub(crate) mod exec;
#[cfg(feature = "blockchain")]
pub(crate) mod evm;
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;
pub mod image;
pub mod image_gen;
pub mod ios;
pub mod ios_bridge;
pub(crate) mod nu;
#[cfg(feature = "blockchain")]
pub(crate) mod subxt;
#[cfg(feature = "pdf")]
pub(crate) mod pdf;
#[cfg_attr(not(target_os = "windows"), expect(dead_code))]
pub(crate) mod powershell;
pub(crate) mod random;
pub(crate) mod read_file;
pub(crate) mod read_file_range;
pub(crate) mod read_image;
pub(crate) mod retrieve_webpage;
pub(crate) mod series;
pub(crate) mod session_inspect;
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 image_ref: Option<ImageReference>,
pub result_json: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
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>,
}
impl PreparedImage {
pub fn mime_type(&self) -> &str {
&self.mime_type
}
pub fn data(&self) -> &[u8] {
&self.data
}
pub fn dimensions(&self) -> (u32, u32) {
(self.width, self.height)
}
pub fn alt_text(&self) -> Option<&str> {
self.alt.as_deref()
}
}
#[derive(Debug, Clone)]
pub struct ToolGroup {
pub name: String,
pub description: String,
}
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 extract_image_ref(&self, _ret: &Self::Return) -> Option<ImageReference> {
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);
}
let image_ref = self.extract_image_ref(&ret);
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,
image_ref,
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);
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);
}
let image_ref = self.extract_image_ref(&ret);
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,
image_ref,
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(|| {
#[allow(unused_mut)]
let mut groups = 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, powershell, exec)".into(),
},
ToolGroup {
name: "x".into(),
description: "X/Twitter API (post, search, user lookup)".into(),
},
ToolGroup {
name: "image".into(),
description: "Image generation (generate_image)".into(),
},
ToolGroup {
name: "vm".into(),
description: "RISC-V sandboxed code execution".into(),
},
ToolGroup {
name: "debug".into(),
description: "Read-only diagnostics and request dry-runs (session_inspect)".into(),
},
];
#[cfg(feature = "blockchain")]
groups.push(ToolGroup {
name: "blockchain".into(),
description: "EVM and Substrate/Polkadot blockchain queries (alloy/subxt)".into(),
});
#[cfg(feature = "content")]
groups.push(ToolGroup {
name: "content".into(),
description: "Choreographr Coordination Platform (blockchain content registry + IPFS + indexer)".into(),
});
groups
})
}
pub struct ToolRegistry {
tools: HashMap<String, Box<dyn ToolDyn>>,
dynamic_groups: Vec<(String, String)>,
protected_groups: HashSet<String>,
}
impl Default for ToolRegistry {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ToolPolicy {
#[default]
Full,
Mobile,
}
impl ToolRegistry {
pub fn new() -> Self {
Self::new_for_policy(ToolPolicy::Full)
}
pub fn new_for_policy(policy: ToolPolicy) -> Self {
let mut reg = Self {
tools: HashMap::new(),
dynamic_groups: Vec::new(),
protected_groups: HashSet::from(["core".to_string()]),
};
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(image_gen::GenerateImage::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);
if policy == ToolPolicy::Full {
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);
#[cfg(target_os = "windows")]
if shell_util::binary_exists("powershell") || shell_util::binary_exists("pwsh") {
reg.register(powershell::PowerShell);
}
}
reg.register(grep::Grep);
reg.register(find::Find);
#[cfg(feature = "pdf")]
{
reg.register(pdf::PdfClassify);
reg.register(pdf::PdfToMarkdown);
}
reg.register(read_image::ReadImage::new());
#[cfg(feature = "blockchain")]
{
reg.register(evm::EvmChain);
reg.register(evm::EvmBalance);
reg.register(evm::EvmTokenBalance);
reg.register(evm::EvmBlock);
reg.register(evm::EvmTransaction);
reg.register(evm::EvmCall);
reg.register(evm::EvmGas);
reg.register(evm::EvmLogs);
reg.register(evm::EvmNonce);
reg.register(evm::EvmResolve);
reg.register(subxt::SubxtChain);
reg.register(subxt::SubxtBalance);
reg.register(subxt::SubxtQuery);
reg.register(subxt::SubxtBlock);
}
reg.register(random::Random);
#[cfg(feature = "content")]
{
reg.register(content::CoordItem);
reg.register(content::CoordRevisions);
reg.register(content::CoordEvents);
reg.register(content::CoordAccountItems);
reg.register(content::CoordProfile);
reg.register(content::CoordDecodeContent);
reg.register(content::CoordImage);
reg.register(content::CoordStatus);
reg.register(content::CoordPublishItem);
reg.register(content::CoordPublishRevision);
reg.register(content::CoordLifecycle);
reg.register(content::CoordAccountLink);
reg.register(content::CoordSetProfile);
}
reg.register(time::GetCurrentTime);
reg.register(retrieve_webpage::RetrieveWebpage::default());
reg.register(session_inspect::SessionInspect);
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 register_platform_tools(&mut self, bridge: Arc<dyn IosToolBridge>) {
self.register(ios::clipboard::ClipboardWrite::new(Arc::clone(&bridge)));
self.register(ios::clipboard::ClipboardRead::new(Arc::clone(&bridge)));
self.register(ios::open_url::OpenUrl::new(Arc::clone(&bridge)));
self.register(ios::notify::Notify::new(bridge));
self.protected_groups.insert(ios::IOS_GROUP.to_string());
tracing::info!(
group = ios::IOS_GROUP,
"registered iOS platform tools (protected group)"
);
}
pub fn protected_groups(&self) -> &HashSet<String> {
&self.protected_groups
}
pub fn build(self) -> Arc<Self> {
self.build_for_policy(ToolPolicy::Full)
}
pub fn build_for_policy(self, policy: ToolPolicy) -> Arc<Self> {
Arc::new_cyclic(|weak| {
let mut reg = self;
if policy == ToolPolicy::Full {
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();
if self.protected_groups.contains(ios::IOS_GROUP) {
groups.push(ToolGroup {
name: ios::IOS_GROUP.into(),
description:
"Device-native tools (clipboard, open_url, notify) — always active on iOS"
.into(),
});
}
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| !self.protected_groups.contains(&g.name))
.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.extend(self.protected_groups.iter().cloned());
s
}
pub fn available_definitions(&self, active: &HashSet<String>) -> Vec<ChatToolDefinition> {
self.tools
.values()
.filter(|t| active.contains(t.group()) || self.protected_groups.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()) || self.protected_groups.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.get(1..).unwrap_or(path))
}
}
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)
}
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)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn image_group_registers_generate_image() {
let registry = ToolRegistry::new().build();
let groups: Vec<String> = registry.groups().into_iter().map(|g| g.name).collect();
assert!(groups.iter().any(|g| g == "image"), "groups: {groups:?}");
assert!(registry.group_names().iter().any(|g| g == "image"));
let active: HashSet<String> = ["image".into()].into_iter().collect();
let defs = registry.available_definitions(&active);
let names: Vec<&str> = defs.iter().map(|d| d.function.name.as_str()).collect();
assert!(names.contains(&"generate_image"), "names: {names:?}");
assert_ne!("image", Tool::group(&image::DisplayImage::new()));
}
#[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:?}"
);
}
}
#[cfg(feature = "blockchain")]
#[test]
fn blockchain_group_registers_all_tools() {
let registry = ToolRegistry::new().build();
let active: HashSet<String> = ["blockchain".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 [
"evm_chain",
"evm_balance",
"evm_token_balance",
"evm_block",
"evm_transaction",
"evm_call",
"evm_gas",
"evm_logs",
"evm_nonce",
"evm_resolve",
"subxt_chain",
"subxt_balance",
"subxt_query",
"subxt_block",
] {
assert!(
names.contains(&tool),
"missing {tool} in blockchain definitions: {names:?}"
);
}
let groups: Vec<String> = registry.groups().into_iter().map(|g| g.name).collect();
assert!(
groups.iter().any(|g| g == "blockchain"),
"blockchain group missing: {groups:?}"
);
}
#[cfg(feature = "content")]
#[test]
fn content_group_registers_all_tools() {
let registry = ToolRegistry::new().build();
let active: HashSet<String> = ["content".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 [
"coord_item",
"coord_revisions",
"coord_events",
"coord_account_items",
"coord_profile",
"coord_decode_content",
"coord_image",
"coord_status",
"coord_publish_item",
"coord_publish_revision",
"coord_lifecycle",
"coord_account_link",
"coord_set_profile",
] {
assert!(
names.contains(&tool),
"missing {tool} in content definitions: {names:?}"
);
}
let groups: Vec<String> = registry.groups().into_iter().map(|g| g.name).collect();
assert!(
groups.iter().any(|g| g == "content"),
"content group missing: {groups:?}"
);
let known = registry.known_group_names();
assert!(known.contains("content"), "content must be a known group");
assert!(
!known.contains("coord"),
"the pre-rename group name \"coord\" must not be known"
);
}
#[cfg(not(feature = "content"))]
#[test]
fn content_group_absent_without_feature() {
let registry = ToolRegistry::new().build();
let known = registry.known_group_names();
assert!(!known.contains("content"));
assert!(!known.contains("coord"));
let active: HashSet<String> = ["content".into(), "coord".into()].into_iter().collect();
let defs = registry.available_definitions(&active);
let names: Vec<&str> = defs.iter().map(|d| d.function.name.as_str()).collect();
assert!(
!names.iter().any(|n| n.starts_with("coord_")),
"content/coord groups must contribute no tools: {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]);
}
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 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");
}
#[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");
}
}