diff --git a/src/harness/tool/mod.rs b/src/harness/tool/mod.rs index 3e47577..bf1c1aa 100644 --- a/src/harness/tool/mod.rs +++ b/src/harness/tool/mod.rs @@ -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>, detail: Option>) -> 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) -> Self { + Self { + label: Some(label.into()), + detail: None, + } + } + + /// Sets the static display detail. + pub fn with_detail(mut self, detail: impl Into) -> 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, name: impl Into, arguments: Value) -> Self { @@ -140,6 +176,7 @@ impl ToolPolicy { background_safe: true, ..ToolAccess::default() }, + display: ToolDisplay::default(), } } @@ -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; @@ -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 { + 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 { + 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::>() + .join(", "), + _ => String::new(), + }; + let raw = raw.split_whitespace().collect::>().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 ToolRegistry { /// Creates an empty registry. pub fn new() -> Self { diff --git a/src/harness/tool/test.rs b/src/harness/tool/test.rs index 9bdae17..dff5832 100644 --- a/src/harness/tool/test.rs +++ b/src/harness/tool/test.rs @@ -6,7 +6,7 @@ use super::*; use async_trait::async_trait; -use serde_json::json; +use serde_json::{Value, json}; struct EchoTool; @@ -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 { + Ok(ToolResult::text(call.id, self.name(), "ok")) + } +} + #[test] fn registry_register_get_names_schemas() { let mut registry: ToolRegistry<()> = ToolRegistry::new(); @@ -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; diff --git a/src/harness/tool/types.rs b/src/harness/tool/types.rs index 7069611..c84219f 100644 --- a/src/harness/tool/types.rs +++ b/src/harness/tool/types.rs @@ -19,6 +19,7 @@ use crate::Result; use crate::harness::context::RunContext; use crate::harness::events::EventSink; use crate::harness::ids::{RunId, ThreadId}; +use crate::harness::tool::{context_detail_from_args, humanize_tool_name}; /// The model-visible syntax a tool declaration prefers. /// @@ -200,12 +201,49 @@ pub struct ToolSideEffects { pub payment: bool, } +/// How the harness should bound a single tool invocation in wall-clock time. +/// +/// Most tools should inherit the run's global tool timeout. Long-running +/// scripting or build tools can opt out with [`ToolTimeout::Unbounded`] when the +/// caller did not supply a deadline, and can return [`ToolTimeout::Millis`] for +/// an explicit per-call budget. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "mode", content = "timeout_ms")] +pub enum ToolTimeout { + /// Use the run/global timeout policy. + #[default] + Inherit, + /// Run without a harness-imposed wall-clock deadline. + Unbounded, + /// Enforce this exact deadline in milliseconds. + Millis(u64), +} + +/// Human-facing presentation metadata for a tool invocation. +/// +/// This metadata is never sent to the model as part of [`ToolSchema`]. It is +/// intended for timelines, audit logs, and application UIs that need compact +/// labels such as `Read(src/lib.rs)` instead of raw machine names. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolDisplay { + /// Short verb phrase or title-cased label shown for the call. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + /// Optional static detail. Dynamic details usually come from call args via + /// [`Tool::display_detail`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + /// Runtime requirements a tool declares for safe execution. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ToolRuntime { /// Suggested per-call wall-clock timeout in milliseconds. #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout_ms: Option, + /// Invocation timeout behavior when a simple numeric timeout is not enough. + #[serde(default, skip_serializing_if = "ToolTimeout::is_inherit")] + pub timeout: ToolTimeout, /// Maximum automatic retries permitted for this tool. #[serde(default, skip_serializing_if = "Option::is_none")] pub max_retries: Option, @@ -261,6 +299,9 @@ pub struct ToolPolicy { pub runtime: ToolRuntime, /// Declared access requirements. pub access: ToolAccess, + /// Human-facing presentation metadata. + #[serde(default, skip_serializing_if = "ToolDisplay::is_empty")] + pub display: ToolDisplay, } /// An incremental progress update emitted while a tool runs (streaming). @@ -297,6 +338,44 @@ pub trait Tool: Send + Sync { ToolPolicy::default() } + /// Returns the human-facing label for this specific call. + /// + /// The default prefers [`ToolPolicy::display`] and otherwise derives a + /// compact title-cased label from [`Self::name`]. Applications can use this + /// for timelines and audit logs without exposing presentation text to model + /// tool declarations. + fn display_label(&self, _call: &ToolCall) -> Option { + self.policy() + .display + .label + .or_else(|| Some(humanize_tool_name(self.name()))) + } + + /// Returns the human-facing detail for this specific call. + /// + /// The default prefers a static [`ToolPolicy::display`] detail and + /// otherwise extracts the most relevant common argument from the call. + fn display_detail(&self, call: &ToolCall) -> Option { + self.policy() + .display + .detail + .or_else(|| context_detail_from_args(&call.arguments)) + } + + /// Returns the invocation timeout behavior for this specific call. + /// + /// The default reads [`ToolPolicy::runtime`]. Static `timeout_ms` values are + /// promoted to [`ToolTimeout::Millis`] for callers that consume the richer + /// timeout vocabulary; tools with argument-dependent deadlines can override + /// this method. + fn timeout_policy(&self, _call: &ToolCall) -> ToolTimeout { + let runtime = self.policy().runtime; + match (runtime.timeout, runtime.timeout_ms) { + (ToolTimeout::Inherit, Some(timeout_ms)) => ToolTimeout::Millis(timeout_ms), + (timeout, _) => timeout, + } + } + /// Executes the tool against application state and a validated call. async fn call(&self, state: &State, call: ToolCall) -> Result;