use std::collections::HashMap;
use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::{LazyLock, Mutex};
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use crate::agent_options::{ApproveToolFn, ApproveToolFuture};
use crate::schema::schema_for;
use crate::transfer::TransferSignal;
use crate::types::ContentBlock;
static SCHEMA_VALIDATOR_CACHE: LazyLock<Mutex<HashMap<String, Arc<jsonschema::Validator>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentToolResult {
pub content: Vec<ContentBlock>,
pub details: Value,
pub is_error: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transfer_signal: Option<TransferSignal>,
}
impl AgentToolResult {
pub fn text(text: impl Into<String>) -> Self {
Self {
content: vec![ContentBlock::Text { text: text.into() }],
details: Value::Null,
is_error: false,
transfer_signal: None,
}
}
pub fn error(message: impl Into<String>) -> Self {
Self {
content: vec![ContentBlock::Text {
text: message.into(),
}],
details: Value::Null,
is_error: true,
transfer_signal: None,
}
}
pub fn transfer(signal: TransferSignal) -> Self {
let text = format!("Transfer to {} initiated.", signal.target_agent());
Self {
content: vec![ContentBlock::Text { text }],
details: Value::Null,
is_error: false,
transfer_signal: Some(signal),
}
}
pub const fn is_transfer(&self) -> bool {
self.transfer_signal.is_some()
}
}
pub type ToolFuture<'a> = Pin<Box<dyn Future<Output = AgentToolResult> + Send + 'a>>;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ToolMetadata {
pub namespace: Option<String>,
pub version: Option<String>,
}
impl ToolMetadata {
#[must_use]
pub fn with_namespace(namespace: impl Into<String>) -> Self {
Self {
namespace: Some(namespace.into()),
version: None,
}
}
#[must_use]
pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
}
pub trait AgentTool: Send + Sync {
fn name(&self) -> &str;
fn label(&self) -> &str;
fn description(&self) -> &str;
fn parameters_schema(&self) -> &Value;
fn requires_approval(&self) -> bool {
false
}
fn metadata(&self) -> Option<ToolMetadata> {
None
}
fn approval_context(&self, _params: &Value) -> Option<Value> {
None
}
fn auth_config(&self) -> Option<crate::credential::AuthConfig> {
None
}
fn execute(
&self,
tool_call_id: &str,
params: Value,
cancellation_token: CancellationToken,
on_update: Option<Box<dyn Fn(AgentToolResult) + Send + Sync>>,
state: Arc<std::sync::RwLock<crate::SessionState>>,
credential: Option<crate::credential::ResolvedCredential>,
) -> ToolFuture<'_>;
}
pub trait IntoTool {
fn into_tool(self) -> Arc<dyn AgentTool>;
}
impl<T: AgentTool + 'static> IntoTool for T {
fn into_tool(self) -> Arc<dyn AgentTool> {
Arc::new(self)
}
}
pub fn validate_schema(schema: &Value) -> Result<(), String> {
compiled_validator(schema)?;
Ok(())
}
pub fn validate_tool_arguments(schema: &Value, arguments: &Value) -> Result<(), Vec<String>> {
let validator = compiled_validator(schema).map_err(|e| vec![e])?;
let errors: Vec<String> = validator
.iter_errors(arguments)
.map(|e| e.to_string())
.collect();
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
#[must_use]
pub(crate) fn permissive_object_schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": true
})
}
#[must_use]
pub(crate) fn debug_validated_schema(schema: Value) -> Value {
debug_assert!(validate_schema(&schema).is_ok());
schema
}
#[must_use]
pub(crate) fn validated_schema_for<T: schemars::JsonSchema>() -> Value {
debug_validated_schema(schema_for::<T>())
}
fn compiled_validator(schema: &Value) -> Result<Arc<jsonschema::Validator>, String> {
let cache_key = serde_json::to_string(schema).map_err(|e| e.to_string())?;
{
let cache = SCHEMA_VALIDATOR_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(validator) = cache.get(&cache_key) {
return Ok(Arc::clone(validator));
}
}
let compiled = Arc::new(jsonschema::validator_for(schema).map_err(|e| e.to_string())?);
let mut cache = SCHEMA_VALIDATOR_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Ok(Arc::clone(
cache
.entry(cache_key)
.or_insert_with(|| Arc::clone(&compiled)),
))
}
#[must_use]
pub fn unknown_tool_result(tool_name: &str) -> AgentToolResult {
AgentToolResult::error(format!("unknown tool: {tool_name}"))
}
#[must_use]
pub fn validation_error_result(errors: &[String]) -> AgentToolResult {
let message = errors.join("\n");
AgentToolResult::error(message)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolApproval {
Approved,
Rejected,
ApprovedWith(serde_json::Value),
}
#[derive(Clone)]
pub struct ToolApprovalRequest {
pub tool_call_id: String,
pub tool_name: String,
pub arguments: Value,
pub requires_approval: bool,
pub context: Option<Value>,
}
impl fmt::Debug for ToolApprovalRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let redacted_context = self.context.as_ref().map(redact_sensitive_values);
f.debug_struct("ToolApprovalRequest")
.field("tool_call_id", &self.tool_call_id)
.field("tool_name", &self.tool_name)
.field("arguments", &"[REDACTED]")
.field("requires_approval", &self.requires_approval)
.field("context", &redacted_context)
.finish()
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ApprovalMode {
Enabled,
#[default]
Smart,
Bypassed,
}
#[allow(clippy::type_complexity)]
pub fn selective_approve<F>(inner: F) -> Box<ApproveToolFn>
where
F: Fn(ToolApprovalRequest) -> ApproveToolFuture + Send + Sync + 'static,
{
Box::new(move |req: ToolApprovalRequest| {
if req.requires_approval {
inner(req)
} else {
Box::pin(async { ToolApproval::Approved })
}
})
}
const REDACTED: &str = "[REDACTED]";
const SENSITIVE_KEYS: &[&str] = &[
"password",
"secret",
"token",
"api_key",
"apikey",
"authorization",
];
#[must_use]
pub fn redact_sensitive_values(value: &Value) -> Value {
redact_value(value, None)
}
fn redact_value(value: &Value, parent_key: Option<&str>) -> Value {
if let Some(key) = parent_key
&& SENSITIVE_KEYS.iter().any(|&s| key.eq_ignore_ascii_case(s))
{
return Value::String(REDACTED.to_string());
}
match value {
Value::String(s) => {
if is_sensitive_string(s) {
Value::String(REDACTED.to_string())
} else {
value.clone()
}
}
Value::Array(arr) => Value::Array(arr.iter().map(|v| redact_value(v, None)).collect()),
Value::Object(map) => {
let redacted = map
.iter()
.map(|(k, v)| (k.clone(), redact_value(v, Some(k))))
.collect();
Value::Object(redacted)
}
_ => value.clone(),
}
}
fn is_sensitive_string(s: &str) -> bool {
if s.starts_with("sk-")
|| s.starts_with("key-")
|| s.starts_with("token-")
|| s.to_ascii_lowercase().starts_with("bearer ")
|| s.to_ascii_lowercase().starts_with("basic ")
{
return true;
}
thread_local! {
static ENV_VAR_RE: Regex =
Regex::new(r"^\$\{?[A-Z_][A-Z0-9_]*\}?$").expect("valid regex");
}
ENV_VAR_RE.with(|re| re.is_match(s))
}
pub trait ToolParameters {
fn json_schema() -> Value;
}
const _: () = {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<AgentToolResult>();
assert_send_sync::<ToolApproval>();
assert_send_sync::<ToolApprovalRequest>();
assert_send_sync::<ApprovalMode>();
};
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::FnTool;
fn stub_tool(name: &str) -> FnTool {
FnTool::new(name, name, "A test tool.")
}
#[test]
fn approval_request_debug_redacts_arguments_and_context() {
let req = ToolApprovalRequest {
tool_call_id: "call_1".into(),
tool_name: "bash".into(),
arguments: json!({"command": "echo secret"}),
requires_approval: true,
context: Some(json!({
"Authorization": "Bearer top-secret",
"path": "/tmp/output.txt",
})),
};
let debug = format!("{req:?}");
assert!(debug.contains("tool_call_id: \"call_1\""));
assert!(debug.contains("tool_name: \"bash\""));
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("echo secret"));
assert!(!debug.contains("top-secret"));
assert!(debug.contains("/tmp/output.txt"));
}
#[test]
fn redacts_sk_prefix() {
let val = json!({"key": "sk-abc123"});
let redacted = redact_sensitive_values(&val);
assert_eq!(redacted["key"], json!("[REDACTED]"));
}
#[test]
fn redacts_key_prefix() {
let val = json!({"data": "key-live-xyz"});
let redacted = redact_sensitive_values(&val);
assert_eq!(redacted["data"], json!("[REDACTED]"));
}
#[test]
fn redacts_token_prefix() {
let val = json!({"tok": "token-abcdef"});
let redacted = redact_sensitive_values(&val);
assert_eq!(redacted["tok"], json!("[REDACTED]"));
}
#[test]
fn redacts_bearer_prefix_case_insensitive() {
let val = json!({"auth": "Bearer eyJhbGciOi..."});
let redacted = redact_sensitive_values(&val);
assert_eq!(redacted["auth"], json!("[REDACTED]"));
let val2 = json!({"auth": "bearer xyz"});
let redacted2 = redact_sensitive_values(&val2);
assert_eq!(redacted2["auth"], json!("[REDACTED]"));
}
#[test]
fn redacts_basic_prefix_case_insensitive() {
let val = json!({"auth": "Basic dXNlcjpwYXNz"});
let redacted = redact_sensitive_values(&val);
assert_eq!(redacted["auth"], json!("[REDACTED]"));
let val2 = json!({"auth": "basic abc"});
let redacted2 = redact_sensitive_values(&val2);
assert_eq!(redacted2["auth"], json!("[REDACTED]"));
}
#[test]
fn redacts_env_var_dollar_sign() {
let val = json!({"ref": "$SECRET"});
let redacted = redact_sensitive_values(&val);
assert_eq!(redacted["ref"], json!("[REDACTED]"));
}
#[test]
fn redacts_env_var_braced() {
let val = json!({"ref": "${API_KEY}"});
let redacted = redact_sensitive_values(&val);
assert_eq!(redacted["ref"], json!("[REDACTED]"));
}
#[test]
fn redacts_sensitive_key_password() {
let val = json!({"password": "hunter2"});
let redacted = redact_sensitive_values(&val);
assert_eq!(redacted["password"], json!("[REDACTED]"));
}
#[test]
fn redacts_sensitive_key_secret() {
let val = json!({"secret": "mysecret"});
let redacted = redact_sensitive_values(&val);
assert_eq!(redacted["secret"], json!("[REDACTED]"));
}
#[test]
fn redacts_sensitive_key_token() {
let val = json!({"Token": "abc"});
let redacted = redact_sensitive_values(&val);
assert_eq!(redacted["Token"], json!("[REDACTED]"));
}
#[test]
fn redacts_sensitive_key_api_key() {
let val = json!({"api_key": "abc"});
let redacted = redact_sensitive_values(&val);
assert_eq!(redacted["api_key"], json!("[REDACTED]"));
}
#[test]
fn redacts_sensitive_key_apikey() {
let val = json!({"apiKey": "abc"});
let redacted = redact_sensitive_values(&val);
assert_eq!(redacted["apiKey"], json!("[REDACTED]"));
}
#[test]
fn redacts_sensitive_key_authorization() {
let val = json!({"Authorization": "something"});
let redacted = redact_sensitive_values(&val);
assert_eq!(redacted["Authorization"], json!("[REDACTED]"));
}
#[test]
fn passes_through_non_sensitive_values() {
let val = json!({
"command": "echo hello",
"path": "/tmp/file.txt",
"count": 42,
"verbose": true,
"items": ["one", "two"]
});
let redacted = redact_sensitive_values(&val);
assert_eq!(redacted, val);
}
#[test]
fn redacts_nested_objects() {
let val = json!({
"config": {
"password": "secret123",
"host": "localhost"
}
});
let redacted = redact_sensitive_values(&val);
assert_eq!(redacted["config"]["password"], json!("[REDACTED]"));
assert_eq!(redacted["config"]["host"], json!("localhost"));
}
#[test]
fn redacts_values_in_arrays() {
let val = json!(["normal", "sk-secret", "also normal"]);
let redacted = redact_sensitive_values(&val);
assert_eq!(redacted, json!(["normal", "[REDACTED]", "also normal"]));
}
#[test]
fn handles_null_and_numbers() {
let val = json!({"a": null, "b": 42, "c": 2.72});
let redacted = redact_sensitive_values(&val);
assert_eq!(redacted, val);
}
#[test]
fn valid_schema_passes() {
let schema = json!({
"type": "object",
"properties": {
"name": { "type": "string" }
},
"required": ["name"]
});
assert!(validate_schema(&schema).is_ok());
}
#[test]
fn invalid_schema_returns_error() {
let schema = json!({
"type": "not_a_real_type"
});
assert!(validate_schema(&schema).is_err());
}
#[test]
fn empty_object_schema_is_valid() {
let schema = json!({
"type": "object",
"properties": {}
});
assert!(validate_schema(&schema).is_ok());
}
#[test]
fn approval_mode_default_is_smart() {
assert_eq!(ApprovalMode::default(), ApprovalMode::Smart);
}
#[test]
fn approval_mode_variants_are_distinct() {
assert_ne!(ApprovalMode::Enabled, ApprovalMode::Smart);
assert_ne!(ApprovalMode::Smart, ApprovalMode::Bypassed);
assert_ne!(ApprovalMode::Enabled, ApprovalMode::Bypassed);
}
#[test]
fn tool_metadata_default_is_empty() {
let meta = ToolMetadata::default();
assert_eq!(meta.namespace, None);
assert_eq!(meta.version, None);
}
#[test]
fn tool_metadata_builder() {
let meta = ToolMetadata::with_namespace("filesystem").with_version("1.2.0");
assert_eq!(meta.namespace.as_deref(), Some("filesystem"));
assert_eq!(meta.version.as_deref(), Some("1.2.0"));
}
#[test]
fn agent_tool_metadata_defaults_to_none() {
let tool = stub_tool("minimal");
assert!(tool.metadata().is_none());
}
#[test]
fn agent_tool_auth_config_defaults_to_none() {
let tool = stub_tool("no-auth");
assert!(tool.auth_config().is_none());
}
#[test]
fn approval_context_default_none() {
let tool = stub_tool("plain");
assert!(tool.approval_context(&json!({})).is_none());
}
#[test]
fn approval_context_returns_value() {
use crate::FnTool;
let tool = FnTool::new("ctx", "Ctx", "With context").with_approval_context(|params| {
Some(json!({"preview": format!("Will process: {}", params)}))
});
let ctx = tool.approval_context(&json!({"file": "test.txt"}));
assert!(ctx.is_some());
assert!(
ctx.unwrap()["preview"]
.as_str()
.unwrap()
.contains("test.txt")
);
}
#[test]
fn approval_context_panic_caught() {
use crate::FnTool;
let tool = FnTool::new("panicker", "Panicker", "Panics in context").with_approval_context(
|_params| {
panic!("oops");
},
);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
tool.approval_context(&json!({}))
}));
assert!(result.is_err());
}
#[test]
fn approval_request_includes_context() {
let ctx = json!({"diff": "+new line"});
let req = ToolApprovalRequest {
tool_call_id: "call_1".into(),
tool_name: "write_file".into(),
arguments: json!({"path": "/tmp/test"}),
requires_approval: true,
context: Some(ctx.clone()),
};
assert_eq!(req.context, Some(ctx));
}
#[test]
fn transfer_constructor_sets_signal_and_text() {
use crate::transfer::TransferSignal;
let signal = TransferSignal::new("billing", "billing issue");
let result = AgentToolResult::transfer(signal);
assert!(result.is_transfer());
assert!(!result.is_error);
let text = match &result.content[0] {
ContentBlock::Text { text } => text.as_str(),
_ => panic!("expected text block"),
};
assert_eq!(text, "Transfer to billing initiated.");
assert!(result.transfer_signal.is_some());
let sig = result.transfer_signal.as_ref().unwrap();
assert_eq!(sig.target_agent(), "billing");
assert_eq!(sig.reason(), "billing issue");
}
#[test]
fn text_constructor_has_no_transfer_signal() {
let result = AgentToolResult::text("hello");
assert!(!result.is_transfer());
assert!(result.transfer_signal.is_none());
}
#[test]
fn error_constructor_has_no_transfer_signal() {
let result = AgentToolResult::error("something failed");
assert!(!result.is_transfer());
assert!(result.transfer_signal.is_none());
}
#[test]
fn deserialize_without_transfer_signal_defaults_to_none() {
let json = r#"{
"content": [{"type": "text", "text": "hello"}],
"details": null,
"is_error": false
}"#;
let result: AgentToolResult = serde_json::from_str(json).unwrap();
assert!(!result.is_transfer());
assert!(result.transfer_signal.is_none());
}
#[test]
fn transfer_signal_not_serialized_when_none() {
let result = AgentToolResult::text("hello");
let json = serde_json::to_value(&result).unwrap();
assert!(!json.as_object().unwrap().contains_key("transfer_signal"));
}
}