Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions src/harness/tool/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,42 @@ impl ToolFormat {
}
}

impl ToolTimeout {
/// Returns `true` for the default inherited timeout behavior.
pub fn is_inherit(&self) -> bool {
matches!(self, ToolTimeout::Inherit)
}
}

impl ToolDisplay {
/// Returns `true` when no display metadata is set.
pub fn is_empty(&self) -> bool {
self.label.is_none() && self.detail.is_none()
}

/// Creates display metadata with optional label and detail fields.
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),
}
}

/// Creates display metadata with only a label.
pub fn label(label: impl Into<String>) -> Self {
Self {
label: Some(label.into()),
detail: None,
}
}

/// Sets the static display detail.
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
}

impl ToolCall {
/// Creates a tool call with the given id, name, and arguments.
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: Value) -> Self {
Expand Down Expand Up @@ -140,6 +176,7 @@ impl ToolPolicy {
background_safe: true,
..ToolAccess::default()
},
display: ToolDisplay::default(),
}
}

Expand Down Expand Up @@ -173,6 +210,12 @@ impl ToolPolicy {
self
}

/// Sets human-facing presentation metadata for timeline/audit use.
pub fn with_display(mut self, display: ToolDisplay) -> Self {
self.display = display;
self
}

/// Marks the tool as requiring explicit human approval before each call.
pub fn requiring_approval(mut self) -> Self {
self.classified = true;
Expand All @@ -192,6 +235,110 @@ impl ToolPolicy {
}
}

/// Derives a title-cased human-readable label from a raw tool name.
///
/// Common machine prefixes are stripped, and `snake_case` / `kebab-case` names
/// become spaced labels. Degenerate names fall back to the original input so
/// callers never receive an empty label unless the input itself was empty.
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()
}
}

/// Extracts a compact human-facing detail from common tool argument keys.
///
/// The first recognized scalar value wins, using keys that usually identify the
/// resource being acted on (`path`, `query`, `to`, `url`, and similar). Returns
/// `None` for non-object args, empty values, and complex values.
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> {
/// Creates an empty registry.
pub fn new() -> Self {
Expand Down
131 changes: 130 additions & 1 deletion src/harness/tool/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use super::*;
use async_trait::async_trait;
use serde_json::json;
use serde_json::{Value, json};

struct EchoTool;

Expand Down Expand Up @@ -38,6 +38,37 @@ impl Tool<()> for EchoTool {
}
}

struct PolicyTool {
policy: ToolPolicy,
}

#[async_trait]
impl Tool<()> for PolicyTool {
fn name(&self) -> &str {
"mcp_file-read"
}

fn description(&self) -> &str {
"reads files"
}

fn schema(&self) -> ToolSchema {
ToolSchema::new(
self.name(),
self.description(),
json!({"type": "object", "properties": {"path": {"type": "string"}}}),
)
}

fn policy(&self) -> ToolPolicy {
self.policy.clone()
}

async fn call(&self, _state: &(), call: ToolCall) -> crate::Result<ToolResult> {
Ok(ToolResult::text(call.id, self.name(), "ok"))
}
}

#[test]
fn registry_register_get_names_schemas() {
let mut registry: ToolRegistry<()> = ToolRegistry::new();
Expand Down Expand Up @@ -93,6 +124,104 @@ fn registry_exposes_policy_snapshot() {
assert!(!policies["echo"].classified);
}

#[test]
fn default_display_label_humanizes_tool_name() {
let tool = PolicyTool {
policy: ToolPolicy::read_only(),
};
let call = ToolCall::new("c-1", tool.name(), json!({}));

assert_eq!(tool.display_label(&call).as_deref(), Some("File Read"));
assert_eq!(
humanize_tool_name("gmail_read_message"),
"Gmail Read Message"
);
assert_eq!(
humanize_tool_name("composio_gmail_send_email"),
"Gmail Send Email"
);
assert_eq!(humanize_tool_name("read-diff"), "Read Diff");
assert_eq!(humanize_tool_name("___"), "___");
}

#[test]
fn default_display_detail_extracts_common_context_args() {
let tool = EchoTool;

let missing = ToolCall::new("c-1", "echo", Value::Null);
assert!(tool.display_detail(&missing).is_none());

let call = ToolCall::new(
"c-2",
"echo",
json!({"name": "ignored", "to": "steven@example.com"}),
);
assert_eq!(
tool.display_detail(&call).as_deref(),
Some("steven@example.com")
);

let spaced = ToolCall::new("c-3", "echo", json!({"command": " ls -la "}));
assert_eq!(tool.display_detail(&spaced).as_deref(), Some("ls -la"));

let long = "x".repeat(200);
let long_call = ToolCall::new("c-4", "echo", json!({"query": long}));
let detail = tool.display_detail(&long_call).unwrap();
assert!(detail.chars().count() <= 80);
assert!(detail.ends_with("..."));
}

#[test]
fn policy_display_metadata_overrides_derived_defaults() {
let tool = PolicyTool {
policy: ToolPolicy::read_only()
.with_display(ToolDisplay::label("Reading file").with_detail("README.md")),
};
let call = ToolCall::new("c-1", tool.name(), json!({"path": "src/lib.rs"}));

assert_eq!(tool.display_label(&call).as_deref(), Some("Reading file"));
assert_eq!(tool.display_detail(&call).as_deref(), Some("README.md"));

let serialized = serde_json::to_value(tool.policy()).unwrap();
assert_eq!(serialized["display"]["label"], "Reading file");
}

#[test]
fn tool_policy_deserializes_without_display_metadata() {
let mut json = serde_json::to_value(ToolPolicy::read_only()).unwrap();
json.as_object_mut().unwrap().remove("display");

let policy: ToolPolicy = serde_json::from_value(json).unwrap();
assert!(policy.display.is_empty());
}

#[test]
fn timeout_policy_uses_richer_timeout_semantics() {
let call = ToolCall::new("c-1", "mcp_file-read", json!({}));

let inherited = PolicyTool {
policy: ToolPolicy::read_only(),
};
assert_eq!(inherited.timeout_policy(&call), ToolTimeout::Inherit);

let legacy_ms = PolicyTool {
policy: ToolPolicy::read_only().with_runtime(ToolRuntime {
timeout_ms: Some(12_000),
..ToolRuntime::default()
}),
};
assert_eq!(legacy_ms.timeout_policy(&call), ToolTimeout::Millis(12_000));

let unbounded = PolicyTool {
policy: ToolPolicy::read_only().with_runtime(ToolRuntime {
timeout: ToolTimeout::Unbounded,
timeout_ms: Some(12_000),
..ToolRuntime::default()
}),
};
assert_eq!(unbounded.timeout_policy(&call), ToolTimeout::Unbounded);
}

#[tokio::test]
async fn tool_call_round_trips() {
let tool = EchoTool;
Expand Down
Loading