mod schema;
mod types;
use std::sync::Arc;
use serde_json::Value;
use crate::error::{Result, TinyAgentsError};
pub use schema::*;
pub use types::*;
impl ToolSchema {
pub fn new(name: impl Into<String>, description: impl Into<String>, parameters: Value) -> Self {
Self {
name: name.into(),
description: description.into(),
parameters,
format: ToolFormat::Json,
}
}
pub fn with_format(mut self, format: ToolFormat) -> Self {
self.format = format;
self
}
pub fn validate_call(&self, call: &ToolCall) -> Result<()> {
if call.name != self.name {
return Err(TinyAgentsError::Validation(format!(
"tool call `{}` does not match schema `{}`",
call.name, self.name
)));
}
validate_schema_value(
&self.parameters,
&call.arguments,
&format!("tool `{}` arguments", self.name),
)
}
}
impl ToolFormat {
pub fn is_json(&self) -> bool {
matches!(self, ToolFormat::Json)
}
}
impl ToolTimeout {
pub fn is_inherit(&self) -> bool {
matches!(self, ToolTimeout::Inherit)
}
}
impl ToolDisplay {
pub fn is_empty(&self) -> bool {
self.label.is_none() && self.detail.is_none()
}
pub fn new(label: Option<impl Into<String>>, detail: Option<impl Into<String>>) -> Self {
Self {
label: label.map(Into::into),
detail: detail.map(Into::into),
}
}
pub fn label(label: impl Into<String>) -> Self {
Self {
label: Some(label.into()),
detail: None,
}
}
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
}
impl ToolCall {
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: Value) -> Self {
Self {
id: id.into(),
name: name.into(),
arguments,
invalid: None,
}
}
pub fn invalid(
id: impl Into<String>,
name: impl Into<String>,
raw: impl Into<String>,
reason: impl Into<String>,
) -> Self {
Self {
id: id.into(),
name: name.into(),
arguments: Value::String(raw.into()),
invalid: Some(reason.into()),
}
}
pub fn is_invalid(&self) -> bool {
self.invalid.is_some()
}
}
impl ToolResult {
pub fn text(
call_id: impl Into<String>,
name: impl Into<String>,
content: impl Into<String>,
) -> Self {
Self {
call_id: call_id.into(),
name: name.into(),
content: content.into(),
raw: None,
error: None,
elapsed_ms: 0,
}
}
pub fn error(
call_id: impl Into<String>,
name: impl Into<String>,
message: impl Into<String>,
) -> Self {
let message = message.into();
Self {
call_id: call_id.into(),
name: name.into(),
content: message.clone(),
raw: None,
error: Some(message),
elapsed_ms: 0,
}
}
pub fn is_error(&self) -> bool {
self.error.is_some()
}
}
impl ToolPolicy {
pub fn read_only() -> Self {
Self {
classified: true,
side_effects: ToolSideEffects {
read_only: true,
..ToolSideEffects::default()
},
runtime: ToolRuntime {
idempotent: true,
cancelable: true,
..ToolRuntime::default()
},
access: ToolAccess {
background_safe: true,
..ToolAccess::default()
},
display: ToolDisplay::default(),
}
}
pub fn classified() -> Self {
Self {
classified: true,
..Self::default()
}
}
pub fn with_side_effects(mut self, side_effects: ToolSideEffects) -> Self {
self.classified = true;
self.side_effects = side_effects;
self
}
pub fn with_runtime(mut self, runtime: ToolRuntime) -> Self {
self.classified = true;
self.runtime = runtime;
self
}
pub fn with_access(mut self, access: ToolAccess) -> Self {
self.classified = true;
self.access = access;
self
}
pub fn with_display(mut self, display: ToolDisplay) -> Self {
self.display = display;
self
}
pub fn requiring_approval(mut self) -> Self {
self.classified = true;
self.access.approval_required = true;
self
}
pub fn has_side_effects(&self) -> bool {
let s = &self.side_effects;
s.writes_files
|| s.network
|| s.installs_dependencies
|| s.destructive
|| s.external_service
|| s.payment
}
}
pub fn humanize_tool_name(name: &str) -> String {
let trimmed = name
.strip_prefix("composio_")
.or_else(|| name.strip_prefix("mcp_"))
.unwrap_or(name);
let mut out = String::with_capacity(trimmed.len());
let mut capitalize = true;
for ch in trimmed.chars() {
if ch == '_' || ch == '-' {
if !out.is_empty() && !out.ends_with(' ') {
out.push(' ');
}
capitalize = true;
} else if capitalize {
out.extend(ch.to_uppercase());
capitalize = false;
} else {
out.push(ch);
}
}
let label = out.trim();
if label.is_empty() {
name.to_string()
} else {
label.to_string()
}
}
pub fn context_detail_from_args(args: &Value) -> Option<String> {
const CONTEXT_KEYS: &[&str] = &[
"to",
"recipient",
"recipient_email",
"to_email",
"email",
"query",
"q",
"search",
"search_query",
"url",
"file_path",
"path",
"command",
"cmd",
"subject",
"title",
"channel",
"channel_id",
"repo",
"repository",
"name",
"id",
];
let obj = args.as_object()?;
for key in CONTEXT_KEYS {
let Some(value) = obj.get(*key) else {
continue;
};
if let Some(rendered) = render_context_value(value) {
return Some(rendered);
}
}
None
}
fn render_context_value(value: &Value) -> Option<String> {
const MAX_DETAIL: usize = 80;
let raw = match value {
Value::String(s) => s.trim().to_string(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Array(items) => items
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(", "),
_ => String::new(),
};
let raw = raw.split_whitespace().collect::<Vec<_>>().join(" ");
if raw.is_empty() {
return None;
}
if raw.chars().count() > MAX_DETAIL {
let truncated: String = raw.chars().take(MAX_DETAIL.saturating_sub(3)).collect();
Some(format!("{truncated}..."))
} else {
Some(raw)
}
}
impl<State: Send + Sync> ToolRegistry<State> {
pub fn new() -> Self {
Self {
tools: std::collections::HashMap::new(),
}
}
pub fn register(&mut self, tool: Arc<dyn Tool<State>>) -> &mut Self {
self.tools.insert(tool.name().to_owned(), tool);
self
}
pub fn get(&self, name: &str) -> Option<Arc<dyn Tool<State>>> {
self.tools.get(name).cloned()
}
pub fn names(&self) -> Vec<String> {
let mut names: Vec<String> = self.tools.keys().cloned().collect();
names.sort();
names
}
pub fn schemas(&self) -> Vec<ToolSchema> {
let mut schemas: Vec<ToolSchema> = self.tools.values().map(|t| t.schema()).collect();
schemas.sort_by(|a, b| a.name.cmp(&b.name));
schemas
}
pub fn policies(&self) -> std::collections::HashMap<String, ToolPolicy> {
self.tools
.iter()
.map(|(name, tool)| (name.clone(), tool.policy()))
.collect()
}
}
impl<State: Send + Sync> Default for ToolRegistry<State> {
fn default() -> Self {
Self::new()
}
}
fn validate_schema_value(schema: &Value, value: &Value, path: &str) -> Result<()> {
if schema.is_null() || schema.as_object().is_some_and(|o| o.is_empty()) {
return Ok(());
}
if let Some(enum_values) = schema.get("enum").and_then(Value::as_array)
&& !enum_values.iter().any(|allowed| allowed == value)
{
return Err(TinyAgentsError::Validation(format!(
"{path} must be one of the declared enum values"
)));
}
if let Some(type_spec) = schema.get("type") {
validate_type_spec(type_spec, value, path)?;
}
if let Some(required) = schema.get("required").and_then(Value::as_array) {
let object = value.as_object().ok_or_else(|| {
TinyAgentsError::Validation(format!("{path} must be an object with declared fields"))
})?;
for field in required.iter().filter_map(Value::as_str) {
if !object.contains_key(field) {
return Err(TinyAgentsError::Validation(format!(
"{path}.{field} is required"
)));
}
}
}
if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
let object = value.as_object().ok_or_else(|| {
TinyAgentsError::Validation(format!("{path} must be an object with declared fields"))
})?;
if schema.get("additionalProperties").and_then(Value::as_bool) == Some(false) {
for field in object.keys() {
if !properties.contains_key(field) {
return Err(TinyAgentsError::Validation(format!(
"{path}.{field} is not allowed"
)));
}
}
}
for (field, field_schema) in properties {
if let Some(field_value) = object.get(field) {
validate_schema_value(field_schema, field_value, &format!("{path}.{field}"))?;
}
}
}
if let Some(items_schema) = schema.get("items")
&& let Some(items) = value.as_array()
{
for (index, item) in items.iter().enumerate() {
validate_schema_value(items_schema, item, &format!("{path}[{index}]"))?;
}
}
Ok(())
}
fn validate_type_spec(type_spec: &Value, value: &Value, path: &str) -> Result<()> {
if let Some(kind) = type_spec.as_str() {
if json_value_matches_type(value, kind) {
return Ok(());
}
return Err(TinyAgentsError::Validation(format!(
"{path} must be {kind}, got {}",
json_value_kind(value)
)));
}
if let Some(kinds) = type_spec.as_array() {
let allowed: Vec<&str> = kinds.iter().filter_map(Value::as_str).collect();
if allowed
.iter()
.any(|kind| json_value_matches_type(value, kind))
{
return Ok(());
}
return Err(TinyAgentsError::Validation(format!(
"{path} must be one of {}, got {}",
allowed.join(", "),
json_value_kind(value)
)));
}
Ok(())
}
fn json_value_matches_type(value: &Value, kind: &str) -> bool {
match kind {
"null" => value.is_null(),
"boolean" => value.is_boolean(),
"object" => value.is_object(),
"array" => value.is_array(),
"number" => value.is_number(),
"integer" => value.as_i64().is_some() || value.as_u64().is_some(),
"string" => value.is_string(),
_ => true,
}
}
fn json_value_kind(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(n) if n.as_i64().is_some() || n.as_u64().is_some() => "integer",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
#[cfg(test)]
mod schema_test;
#[cfg(test)]
mod test;