diff --git a/crates/tinyagents-harness/src/agent_loop/nested_tests.rs b/crates/tinyagents-harness/src/agent_loop/nested_tests.rs index 405900fac..05efdc11d 100644 --- a/crates/tinyagents-harness/src/agent_loop/nested_tests.rs +++ b/crates/tinyagents-harness/src/agent_loop/nested_tests.rs @@ -2048,3 +2048,58 @@ async fn a_parent_dropped_while_a_nested_result_is_observed_still_closes_the_cal assert_eq!(nested_started(&recorder), 1); every_start_has_one_terminal_event(&recorder); } + +fn with_tool_rules(harness: &mut AgentHarness<()>, rules: serde_json::Value) { + let mut policy = harness.policy().clone(); + policy.tool_rules = crate::tool::ToolRulePolicy::new( + serde_json::from_value::(rules).unwrap(), + ); + harness.with_policy(policy); +} + +#[tokio::test] +async fn a_tool_rule_refuses_a_nested_call() { + let (outcome, runs) = nested_refusal(Leaf::new("leaf"), |harness| { + with_tool_rules( + harness, + json!({ "rules": [ { "id": "no-leaf", "effect": "deny", "match": { "name": "leaf" } } ] }), + ); + }) + .await; + + assert!( + outcome.unwrap_err().contains("rule 'no-leaf'"), + "tool rules must bind nested calls" + ); + assert_eq!(runs, 0); +} + +#[tokio::test] +async fn a_require_approval_rule_fails_a_nested_call_instead_of_deferring() { + let (outcome, runs) = nested_refusal(Leaf::new("leaf"), |harness| { + with_tool_rules( + harness, + json!({ "rules": [ { "effect": "require_approval", "match": { "name": "leaf" } } ] }), + ); + }) + .await; + + assert!(outcome.unwrap_err().contains("requires approval")); + assert_eq!(runs, 0); +} + +#[tokio::test] +async fn an_auto_approve_rule_waives_a_nested_declared_approval() { + let mut policy = ToolPolicy::classified(); + policy.access.approval_required = true; + let (outcome, runs) = nested_refusal(leaf_with("gated", policy), |harness| { + with_tool_rules( + harness, + json!({ "rules": [ { "effect": "auto_approve", "match": { "name": "gated" } } ] }), + ); + }) + .await; + + assert!(outcome.is_ok(), "{outcome:?}"); + assert_eq!(runs, 1); +} diff --git a/crates/tinyagents-harness/src/agent_loop/tool_rules_tests.rs b/crates/tinyagents-harness/src/agent_loop/tool_rules_tests.rs index 6c33beac2..2dc1414ae 100644 --- a/crates/tinyagents-harness/src/agent_loop/tool_rules_tests.rs +++ b/crates/tinyagents-harness/src/agent_loop/tool_rules_tests.rs @@ -2,22 +2,21 @@ //! `tool_search` results and every call, including approval, nested calls, //! indirect targets and a hosted definition's own rules. -use std::sync::{Arc, Mutex}; +use super::*; + +use std::sync::Mutex; use async_trait::async_trait; -use serde_json::{Value, json}; +use serde_json::json; -use crate::context::{RunConfig, RunContext}; use crate::host::{ AllowAllSecurityGate, FixedModelResolver, HostCapabilities, StaticContextComposer, }; -use crate::runtime::{AgentHarness, AgentInvocation, AgentTurnRequest, RunPolicy}; +use crate::runtime::{AgentInvocation, AgentTurnRequest, RunPolicy}; use crate::testkit::ScriptedModel; use crate::tool::ToolRulePolicy; use tinyagents_definition::{AgentDefinition, InMemoryDefinitionRegistry}; -use tinyinference_llm::message::Message; use tinyinference_llm::model::ModelResponse; -use tinyinference_llm::tool::ToolCall; use tinytools::{ RuleContext, Tool, ToolExposure, ToolPolicy, ToolResult, ToolRuleSet, ToolRules, ToolSubject, }; @@ -92,11 +91,13 @@ impl Tool for RuleTool { fn policy(&self) -> ToolPolicy { self.policy.clone() } - fn indirect_target(&self, args: &Value) -> Option { + fn indirect_target(&self, args: &Value) -> Option { if !self.dispatches { return None; } - args.get("action")?.as_str().map(ToolSubject::named) + args.get("action")? + .as_str() + .map(|name| ToolSubject::named(name).into()) } async fn execute(&self, arguments: Value) -> anyhow::Result { self.seen.lock().unwrap().push(arguments); @@ -366,3 +367,193 @@ async fn a_hosted_definition_stacks_its_rules_on_the_policy() { assert_eq!(web.calls(), 0); assert!(tool_text(&run.messages, "c1").contains("not permitted by tool rules")); } + +// ── Review follow-ups ─────────────────────────────────────────────────────── + +#[tokio::test] +async fn a_rewrite_target_carries_its_own_approval_rule() { + let model = Arc::new(ScriptedModel::new(vec![ + calls(vec![("c1", "missing", json!({}))]), + ModelResponse::assistant("never reached"), + ])); + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.register_model("scripted", model as _); + harness.with_policy(RunPolicy { + unknown_tool: UnknownToolPolicy::Rewrite { + tool_name: "send".to_string(), + }, + tool_rules: ToolRulePolicy::new(rules(json!({ "rules": [ + { "effect": "require_approval", "match": { "name": "send" } }, + ] }))), + ..RunPolicy::default() + }); + let send = RuleTool::new("send"); + harness.register_tool(send.clone()); + + let run = run(&harness, "rewrite").await; + + assert_eq!(send.calls(), 0, "the rewritten call waits for approval"); + let deferred = run.deferred.expect("the run waits for approval"); + assert_eq!(deferred.approvals.len(), 1); +} + +#[tokio::test] +async fn repaired_arguments_are_checked_against_the_rules_again() { + let mut malformed = ToolCall::new( + "c1", + "execute", + Value::String("{action: \"GMAIL_DELETE_EMAIL\"}".to_string()), + ); + malformed.invalid = Some("unquoted key".to_string()); + let mut response = ModelResponse::assistant(""); + response.message.tool_calls.push(malformed); + let model = Arc::new(ScriptedModel::new(vec![ + response, + ModelResponse::assistant("done"), + ])); + let policy = ToolRulePolicy::new(rules(json!({ "rules": [ + { "id": "no-delete", "effect": "deny", "match": { "name": "*_delete_*" } }, + ] }))); + let execute = RuleTool::dispatcher("execute"); + let mut harness = harness_with(model, policy); + harness.register_tool(execute.clone()); + + let run = run(&harness, "repaired").await; + + assert_eq!( + execute.calls(), + 0, + "the repaired call names a denied target" + ); + assert!(tool_text(&run.messages, "c1").contains("rule 'no-delete'")); +} + +#[tokio::test] +async fn a_rule_can_withhold_tool_search_itself() { + let model = Arc::new(ScriptedModel::new(vec![ + calls(vec![("s1", "tool_search", json!({"query": "deferred"}))]), + ModelResponse::assistant("done"), + ])); + let policy = ToolRulePolicy::new(rules(json!({ "rules": [ + { "id": "no-discovery", "effect": "deny", "match": { "name": "tool_search" } }, + ] }))); + let mut harness = harness_with(model.clone(), policy); + harness.register_tool(RuleTool::deferred("deferred_open")); + + let run = run(&harness, "no-search").await; + + assert!(!tool_names(&model.requests()[0]).contains(&"tool_search".to_string())); + let answer = tool_text(&run.messages, "s1"); + assert!(answer.contains("rule 'no-discovery'"), "{answer}"); + assert!(!answer.contains("deferred_open"), "{answer}"); +} + +struct FamilyTool(&'static str); + +#[async_trait] +impl Tool for FamilyTool { + fn name(&self) -> &str { + "dup" + } + fn description(&self) -> &str { + "same name, different family" + } + fn parameters_schema(&self) -> Value { + json!({ "type": "object" }) + } + fn family(&self) -> Option<&str> { + Some(self.0) + } + async fn execute(&self, _arguments: Value) -> anyhow::Result { + Ok(ToolResult::success(self.0)) + } +} + +#[tokio::test] +async fn a_toolset_tool_never_takes_a_denied_registered_tools_name() { + let model = Arc::new(ScriptedModel::new(vec![ModelResponse::assistant("done")])); + let policy = ToolRulePolicy::new(rules(json!({ "rules": [ + { "effect": "deny", "match": { "family": "registered" } }, + ] }))); + let mut harness = harness_with(model.clone(), policy); + harness.register_tool(Arc::new(FamilyTool("registered"))); + let mut extra: crate::tool::ToolRegistry<(), ()> = crate::tool::ToolRegistry::new(); + extra.register(Arc::new(FamilyTool("toolset"))); + harness.with_toolset(Arc::new(extra)); + + run(&harness, "collision").await; + + assert!(!tool_names(&model.requests()[0]).contains(&"dup".to_string())); +} + +#[tokio::test] +async fn normalized_arguments_are_checked_against_the_rules_again() { + // A JSON-encoded object is decoded by normalization before dispatch; the + // final rule check sees the decoded target. + let model = Arc::new(ScriptedModel::new(vec![ + calls(vec![( + "c1", + "execute", + Value::String("{\"action\":\"GMAIL_DELETE_EMAIL\"}".to_string()), + )]), + ModelResponse::assistant("done"), + ])); + let policy = ToolRulePolicy::new(rules(json!({ "rules": [ + { "id": "no-delete", "effect": "deny", "match": { "name": "*_delete_*" } }, + ] }))); + let execute = RuleTool::dispatcher("execute"); + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.register_model("scripted", model as _); + harness.with_policy(RunPolicy { + invalid_args: crate::runtime::InvalidArgsPolicy::NormalizeThenReturnToolError, + tool_rules: policy, + ..RunPolicy::default() + }); + harness.register_tool(execute.clone()); + + let run = run(&harness, "normalized").await; + + assert_eq!(execute.calls(), 0); + let text = tool_text(&run.messages, "c1"); + assert!(text.contains("rule 'no-delete'"), "{text}"); +} + +#[tokio::test] +async fn a_require_approval_rule_on_tool_search_refuses_discovery() { + let model = Arc::new(ScriptedModel::new(vec![ + calls(vec![("s1", "tool_search", json!({"query": "deferred"}))]), + ModelResponse::assistant("done"), + ])); + let policy = ToolRulePolicy::new(rules(json!({ "rules": [ + { "effect": "require_approval", "match": { "name": "tool_search" } }, + ] }))); + let mut harness = harness_with(model.clone(), policy); + harness.register_tool(RuleTool::deferred("deferred_open")); + + let run = run(&harness, "search-approval").await; + + let answer = tool_text(&run.messages, "s1"); + assert!(answer.contains("requires approval"), "{answer}"); + assert!( + !tool_names(&model.requests()[0]).contains(&"tool_search".to_string()), + "an approval-gated bridge is not advertised" + ); + assert!(!answer.contains("deferred_open"), "{answer}"); +} + +#[tokio::test] +async fn unknown_tool_recovery_does_not_point_at_a_denied_tool_search() { + let model = Arc::new(ScriptedModel::new(vec![ + calls(vec![("c1", "nope", json!({}))]), + ModelResponse::assistant("done"), + ])); + let policy = ToolRulePolicy::new(rules(json!({ "rules": [ + { "effect": "deny", "match": { "name": "tool_search" } }, + ] }))); + let mut harness = harness_with(model, policy); + harness.register_tool(RuleTool::deferred("deferred_open")); + + let run = run(&harness, "unknown").await; + + assert!(!tool_text(&run.messages, "c1").contains("tool_search")); +} diff --git a/crates/tinyagents-harness/src/agent_loop/tool_surface.rs b/crates/tinyagents-harness/src/agent_loop/tool_surface.rs index 9527ef2eb..66af7d564 100644 --- a/crates/tinyagents-harness/src/agent_loop/tool_surface.rs +++ b/crates/tinyagents-harness/src/agent_loop/tool_surface.rs @@ -42,8 +42,11 @@ impl AgentHarness { }) .collect::>(); if let Some(toolset) = &self.toolset { - let existing: HashSet<&str> = - schemas.iter().map(|schema| schema.name.as_str()).collect(); + // Every registered name, listed or not: a registered tool owns its + // name even when the rules keep it off this catalogue, so a + // toolset tool may never take its place on the wire. + let registered = self.tools.names(); + let existing: HashSet<&str> = registered.iter().map(String::as_str).collect(); let extra: Vec<_> = toolset .tools(ctx) .await? @@ -102,7 +105,21 @@ impl AgentHarness { .collect(); let promoted_names: BTreeSet = promoted_schemas.keys().cloned().collect(); let recorded_promotions = promoted_names.clone(); - if !deferred_catalog.is_empty() { + // Listed only when a call to it would be answered: hidden or denied + // on the catalogue, or approval-gated (the bridge is answered in place + // and cannot be deferred to an approver), keeps it off the wire. + let search = crate::tool::discover::TOOL_SEARCH_NAME; + let answerable = |surface| { + matches!( + gate.admits_intrinsic(search, surface), + crate::tool::CallGate::Admit( + tinytools::ApprovalDirective::Default | tinytools::ApprovalDirective::Waived + ) + ) + }; + let bridge_listed = + answerable(tinytools::Surface::Catalog) && answerable(tinytools::Surface::Call); + if bridge_listed && !deferred_catalog.is_empty() { // A host-registered `tool_search` keeps its slot: the intrinsic // bridge only fills a name nobody registered. Check the full // registry (`self.tools.dispatch`), not just the direct set — a diff --git a/crates/tinyagents-harness/src/agent_loop/tools.rs b/crates/tinyagents-harness/src/agent_loop/tools.rs index 69917d35b..09b3970c3 100644 --- a/crates/tinyagents-harness/src/agent_loop/tools.rs +++ b/crates/tinyagents-harness/src/agent_loop/tools.rs @@ -300,6 +300,22 @@ impl AgentHarness { )) } + /// The tool rules' answer for a model call of `name` with `args`. + /// + /// An unregistered or allowlist-excluded name is admitted here with no + /// directive: it is not a tool the rules can describe, and the + /// unknown-tool policy answers it below. + fn rule_admission(&self, gate: &ToolGate, name: &str, args: &serde_json::Value) -> CallGate { + match gate + .allows_name(name) + .then(|| self.tools.model_dispatch(name)) + .flatten() + { + Some(dispatch) => gate.admit_call(dispatch.tool().as_ref(), args), + None => CallGate::Admit(tinytools::ApprovalDirective::Default), + } + } + /// Builds the run's deferred-tool catalogue: every /// [`tinytools::ToolExposure::Deferred`] registration the gate lets the /// model search for, or an empty catalogue when discovery is disabled. @@ -358,6 +374,22 @@ impl AgentHarness { // `run_loop_body` — an empty declared list never falls back to // "unrestricted" here either. let gate = self.resolve_tool_gate(ctx)?; + let intrinsic = match gate.admits_intrinsic(TOOL_SEARCH_NAME, tinytools::Surface::Call) { + // The bridge is answered in place and cannot be deferred to an + // approver, so a rule requiring approval for discovery refuses it. + CallGate::Admit(tinytools::ApprovalDirective::Required) => CallGate::Refuse(format!( + "Tool '{TOOL_SEARCH_NAME}' requires approval by tool rules; discovery cannot be deferred." + )), + other => other, + }; + if let CallGate::Refuse(message) = intrinsic { + // Discovery itself is ruled out: refuse rather than answer, so the + // bridge cannot reveal what the rules withhold from the model. Like + // the other answered recoveries, the call keeps its budget slot. + return Ok(Some(ResolvedToolCall::Answered( + tinytools::ToolResult::error(message), + ))); + } let catalog = self.deferred_catalog(&gate); if catalog.is_empty() { // Nothing was deferred, so the bridge was never advertised; let @@ -600,22 +632,17 @@ impl AgentHarness { // unknown-tool policy below) and any target it dispatches to, on the // raw provider arguments the host gate also sees. let gate = self.resolve_tool_gate(ctx)?; - let mut rule_approval = tinytools::ApprovalDirective::Default; - if gate.allows_name(&call.name) - && let Some(dispatch) = self.tools.model_dispatch(&call.name) - { - match gate.admit_call(dispatch.tool().as_ref(), &model_arguments) { - CallGate::Admit(approval) => rule_approval = approval, - CallGate::Refuse(message) => { - ctx.limits.rollback_tool_calls(1); - return Ok(ResolvedToolCall::Answered(with_refusal_metadata( - ctx, - &call.id, - tinytools::ToolResult::error(message), - ))); - } + let mut rule_approval = match self.rule_admission(&gate, &call.name, &model_arguments) { + CallGate::Admit(approval) => approval, + CallGate::Refuse(message) => { + ctx.limits.rollback_tool_calls(1); + return Ok(ResolvedToolCall::Answered(with_refusal_metadata( + ctx, + &call.id, + tinytools::ToolResult::error(message), + ))); } - } + }; // The slot is *reserved* above (cap-first, so a middleware hook never // runs for a call the budget has already refused) and *released* here @@ -746,18 +773,22 @@ impl AgentHarness { UnknownToolPolicy::Rewrite { tool_name } => self .tools .dispatch(tool_name) - .filter(|dispatch| { - gate.allows_name(tool_name) - && matches!( - gate.admit_call(dispatch.tool().as_ref(), &arguments), - CallGate::Admit(_) - ) - }) - .map(|dispatch| (tool_name.clone(), dispatch)), + .filter(|_| gate.allows_name(tool_name)) + .and_then(|dispatch| { + match gate.admit_call(dispatch.tool().as_ref(), &arguments) { + CallGate::Admit(approval) => { + Some((tool_name.clone(), dispatch, approval)) + } + CallGate::Refuse(_) => None, + } + }), _ => None, }; - if let Some((tool_name, dispatch)) = rewrite_target { + if let Some((tool_name, dispatch, approval)) = rewrite_target { + // The rewrite target's own rules decide its approval, not + // the unknown name's. + rule_approval = approval; call.name = tool_name.clone(); let record = ctx.emit(AgentEvent::UnknownToolCall { call_id, @@ -797,6 +828,14 @@ impl AgentHarness { .tools .dispatch(crate::tool::discover::TOOL_SEARCH_NAME) .is_none() + && matches!( + gate.admits_intrinsic( + crate::tool::discover::TOOL_SEARCH_NAME, + tinytools::Surface::Call + ), + CallGate::Admit(tinytools::ApprovalDirective::Default) + | CallGate::Admit(tinytools::ApprovalDirective::Waived) + ) && !self.deferred_catalog(&gate).is_empty(); let message = super::unknown_tool::unknown_tool_message( &requested, @@ -910,6 +949,22 @@ impl AgentHarness { message, ))); } + // Tool rules once more, on the arguments that will actually run. The + // early check saw the raw provider payload; repair, normalization + // (a JSON-encoded object decoded) or preparation can change what an + // indirect target or an argument condition reads, so the final + // decision is made here, before approval and dispatch. + match self.rule_admission(&gate, &call.name, &call.arguments) { + CallGate::Admit(approval) => rule_approval = rule_approval.strictest(approval), + CallGate::Refuse(message) => { + ctx.limits.rollback_tool_calls(1); + return Ok(ResolvedToolCall::Answered(with_refusal_metadata( + ctx, + &call.id, + tinytools::ToolResult::error(message), + ))); + } + } // Deferral (A2), after validation so an approver only ever sees a // call the tool would actually accept, and before host authorization // so a host's own gate is not consulted for a call a human has not diff --git a/crates/tinyagents-harness/src/tool/rules/mod_tests.rs b/crates/tinyagents-harness/src/tool/rules/mod_tests.rs index d1e83c0a3..899ccb6e8 100644 --- a/crates/tinyagents-harness/src/tool/rules/mod_tests.rs +++ b/crates/tinyagents-harness/src/tool/rules/mod_tests.rs @@ -123,3 +123,41 @@ fn admit_call_reports_approval_and_refusals() { CallGate::Admit(ApprovalDirective::Default) ); } + +#[test] +fn an_intrinsic_is_admitted_without_rules_and_ignores_the_allowlist() { + let allowed: HashSet = ["registered".to_string()].into(); + let gate = ToolGate::new(Some(allowed), &ToolRulePolicy::default(), None); + assert_eq!( + gate.admits_intrinsic("tool_search", Surface::Call), + CallGate::Admit(ApprovalDirective::Default), + "the registration allowlist does not name intrinsics" + ); +} + +#[test] +fn an_intrinsic_follows_the_rules_per_surface() { + let rules: ToolRules = serde_json::from_value(json!({ "rules": [ + { "id": "quiet", "effect": "hide", "match": { "name": "tool_search" } }, + { "effect": "require_approval", "match": { "name": "tool_search" } }, + ] })) + .expect("rules"); + let gate = ToolGate::new(None, &ToolRulePolicy::new(rules), None); + match gate.admits_intrinsic("tool_search", Surface::Catalog) { + CallGate::Refuse(message) => assert!(message.contains("rule 'quiet'"), "{message}"), + other => panic!("hidden from the catalogue, got {other:?}"), + } + assert_eq!( + gate.admits_intrinsic("tool_search", Surface::Call), + CallGate::Admit(ApprovalDirective::Required) + ); + let deny: ToolRules = serde_json::from_value(json!({ "rules": [ + { "effect": "deny", "match": { "name": "tool_search" } }, + ] })) + .expect("rules"); + let gate = ToolGate::new(None, &ToolRulePolicy::new(deny), None); + assert!(matches!( + gate.admits_intrinsic("tool_search", Surface::Call), + CallGate::Refuse(_) + )); +} diff --git a/crates/tinyagents-harness/src/tool/rules/types.rs b/crates/tinyagents-harness/src/tool/rules/types.rs index 1a27400b5..710c79e6f 100644 --- a/crates/tinyagents-harness/src/tool/rules/types.rs +++ b/crates/tinyagents-harness/src/tool/rules/types.rs @@ -130,6 +130,31 @@ impl ToolGate { self.lists(tool.name(), Some(tool), surface) } + /// The rules' answer for a harness-intrinsic tool such as the + /// `tool_search` bridge, on `surface`. + /// + /// Intrinsics are not registrations, so a definition's exact allowlist + /// (which names registered tools) does not apply; the rules do, so a + /// host can withhold discovery itself. + pub(crate) fn admits_intrinsic(&self, name: &str, surface: Surface) -> CallGate { + let Some(rules) = &self.rules else { + return CallGate::Admit(ApprovalDirective::Default); + }; + let decision = rules.evaluate(&ToolSubject::named(name), &self.context, surface, None); + if decision.admits(surface) { + CallGate::Admit(decision.approval) + } else { + tracing::debug!( + target: "tinyagents::tool_rules", + tool = %name, + surface = ?surface, + rule = ?decision.blocked_by, + "[tool_rules] intrinsic tool withheld" + ); + CallGate::Refuse(decision.refusal(name)) + } + } + /// Decides a concrete call. The allowlist is checked by the caller before /// lookup; this applies the rules, including any indirect target. pub(crate) fn admit_call(&self, tool: &dyn Tool, args: &Value) -> CallGate { diff --git a/crates/tinyagents-harness/src/tool/shared/mod.rs b/crates/tinyagents-harness/src/tool/shared/mod.rs index f2abe35dc..f52818743 100644 --- a/crates/tinyagents-harness/src/tool/shared/mod.rs +++ b/crates/tinyagents-harness/src/tool/shared/mod.rs @@ -113,6 +113,17 @@ impl Tool for CanonicalSharedToolAdapter { self.resolved_tool().and_then(Tool::family) } + // Tool rules read these: a dropped tag lets a tag rule miss, and a + // dropped indirect target lets a dispatcher's real target escape. + fn tags(&self) -> Vec { + self.resolved_tool().map(Tool::tags).unwrap_or_default() + } + + fn indirect_target(&self, args: &serde_json::Value) -> Option { + self.resolved_tool() + .and_then(|tool| tool.indirect_target(args)) + } + fn injected_arguments(&self) -> Vec { self.resolved_tool() .map(Tool::injected_arguments) diff --git a/crates/tinyagents-harness/src/tool/shared/mod_tests.rs b/crates/tinyagents-harness/src/tool/shared/mod_tests.rs index 29951b38f..c31b74965 100644 --- a/crates/tinyagents-harness/src/tool/shared/mod_tests.rs +++ b/crates/tinyagents-harness/src/tool/shared/mod_tests.rs @@ -237,6 +237,20 @@ impl Tool for BehaviorTool { true } + fn family(&self) -> Option<&str> { + Some("behaviors") + } + + fn tags(&self) -> Vec { + vec!["pack:behaviors".into()] + } + + fn indirect_target(&self, args: &serde_json::Value) -> Option { + args["action"] + .as_str() + .map(|name| tinytools::ToolSubject::named(name).into()) + } + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { Ok(ToolResult::default()) } @@ -257,6 +271,19 @@ fn canonical_adapter_forwards_behavior_bearing_tool_methods() { assert!(adapter.return_direct()); } +#[test] +fn canonical_adapter_forwards_tool_rule_metadata() { + let sets: Vec>>> = vec![Arc::new(vec![Box::new(BehaviorTool)])]; + let adapter = CanonicalSharedToolAdapter::for_name(sets, "behavior").expect("registered tool"); + + assert_eq!(adapter.family(), Some("behaviors")); + assert_eq!(adapter.tags(), ["pack:behaviors"]); + assert_eq!( + adapter.indirect_target(&serde_json::json!({ "action": "GMAIL_DELETE_EMAIL" })), + Some(tinytools::ToolSubject::named("GMAIL_DELETE_EMAIL").into()) + ); +} + #[tokio::test] async fn early_exit_only_fires_after_a_successful_canonical_result() { let sets: Vec>>> = vec![Arc::new(vec![Box::new(RecordingTool { diff --git a/crates/tinyagents-harness/src/tool/toolset/mod.rs b/crates/tinyagents-harness/src/tool/toolset/mod.rs index 1f7c20903..78bf67c78 100644 --- a/crates/tinyagents-harness/src/tool/toolset/mod.rs +++ b/crates/tinyagents-harness/src/tool/toolset/mod.rs @@ -382,6 +382,20 @@ impl Tool for OverrideTool { .unwrap_or_else(|| self.inner.exposure()) } + // A renamed or prefixed tool is still the same tool to the host's tool + // rules: keep its family, tags and indirect target. + fn family(&self) -> Option<&str> { + self.inner.family() + } + + fn tags(&self) -> Vec { + self.inner.tags() + } + + fn indirect_target(&self, args: &Value) -> Option { + self.inner.indirect_target(args) + } + fn is_concurrency_safe(&self, args: &Value) -> bool { self.inner.is_concurrency_safe(args) } diff --git a/crates/tinyagents-harness/src/tool/toolset/mod_tests.rs b/crates/tinyagents-harness/src/tool/toolset/mod_tests.rs index 6f79eb2c4..e5705c68b 100644 --- a/crates/tinyagents-harness/src/tool/toolset/mod_tests.rs +++ b/crates/tinyagents-harness/src/tool/toolset/mod_tests.rs @@ -120,3 +120,49 @@ async fn unknown_tool_call_reports_tool_not_found() { .expect_err("nope is not registered"); assert!(matches!(err, crate::error::TinyAgentsError::ToolNotFound(name) if name == "nope")); } + +struct TaggedDispatcher; + +use super::OverrideTool; +use serde_json::Value; +use tinytools::{Tool, ToolResult}; + +#[async_trait::async_trait] +impl Tool for TaggedDispatcher { + fn name(&self) -> &str { + "execute" + } + fn description(&self) -> &str { + "dispatches to a connector action" + } + fn parameters_schema(&self) -> Value { + serde_json::json!({ "type": "object" }) + } + fn family(&self) -> Option<&str> { + Some("connector") + } + fn tags(&self) -> Vec { + vec!["composio.scope:write".into()] + } + fn indirect_target(&self, args: &Value) -> Option { + args["action"] + .as_str() + .map(|name| tinytools::ToolSubject::named(name).into()) + } + async fn execute(&self, _args: Value) -> anyhow::Result { + Ok(ToolResult::success("ok")) + } +} + +/// A renamed or prefixed tool is the same tool to the host's tool rules. +#[test] +fn an_override_keeps_what_tool_rules_read() { + let tool = OverrideTool::new(Arc::new(TaggedDispatcher)).with_name("connector_execute"); + assert_eq!(tool.name(), "connector_execute"); + assert_eq!(tool.family(), Some("connector")); + assert_eq!(tool.tags(), ["composio.scope:write"]); + assert_eq!( + tool.indirect_target(&serde_json::json!({ "action": "GMAIL_DELETE_EMAIL" })), + Some(tinytools::ToolSubject::named("GMAIL_DELETE_EMAIL").into()) + ); +} diff --git a/vendor/tinytools b/vendor/tinytools index 59e1cd3bb..bd60b9b52 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 59e1cd3bbdc96ad54c8647537601b7eb6ed5a6f4 +Subproject commit bd60b9b52f1998b4483b9cf107a72d3338e672f8