From fff273ef582dea84e41513488cee81b84adedc99 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 12:30:03 +0300 Subject: [PATCH 01/19] chore(vendor): bump tinytools submodule Update the vendored tinytools submodule to the latest commit. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 59e1cd3b..93996835 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 59e1cd3bbdc96ad54c8647537601b7eb6ed5a6f4 +Subproject commit 939968354bff86c36c5af620a5dc9dbfb9331c16 From c9a40d8191f8ee9b79cb29e5f70da762c97bea6c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 12:31:24 +0300 Subject: [PATCH 02/19] fix(tool): forward family, tags and indirect target through tool wrappers The shared-tool adapter and the override tool now delegate family, tags and indirect target to the wrapped tool instead of falling back to defaults, so tool rules no longer miss tags or lose a dispatcher's real target when a tool is renamed or prefixed. The vendored tinytools submodule is bumped to the matching revision. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyagents-harness/src/tool/shared/mod.rs | 11 +++++++++++ crates/tinyagents-harness/src/tool/toolset/mod.rs | 14 ++++++++++++++ vendor/tinytools | 2 +- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/tinyagents-harness/src/tool/shared/mod.rs b/crates/tinyagents-harness/src/tool/shared/mod.rs index f2abe35d..ef094074 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/toolset/mod.rs b/crates/tinyagents-harness/src/tool/toolset/mod.rs index 1f7c2090..9cbb9483 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/vendor/tinytools b/vendor/tinytools index 93996835..26b8ce05 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 939968354bff86c36c5af620a5dc9dbfb9331c16 +Subproject commit 26b8ce05980fbf1212a309e113a25be63711ff20 From fb4b4538e45c26dc4575e993a1c42f1290ac6af3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 12:31:33 +0300 Subject: [PATCH 03/19] refactor(tool): move shared tool tests into a dedicated module The shared tool tests now live in their own module file instead of being inlined alongside the implementation. This keeps the test code separate from production code without changing any behaviour. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tool/shared/mod_tests.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/tinyagents-harness/src/tool/shared/mod_tests.rs b/crates/tinyagents-harness/src/tool/shared/mod_tests.rs index 29951b38..531a4f54 100644 --- a/crates/tinyagents-harness/src/tool/shared/mod_tests.rs +++ b/crates/tinyagents-harness/src/tool/shared/mod_tests.rs @@ -237,6 +237,18 @@ 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(tinytools::ToolSubject::named) + } + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { Ok(ToolResult::default()) } @@ -257,6 +269,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")) + ); +} + #[tokio::test] async fn early_exit_only_fires_after_a_successful_canonical_result() { let sets: Vec>>> = vec![Arc::new(vec![Box::new(RecordingTool { From 05f5677c65e4717fea7e5446820689e1969adc10 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 12:32:30 +0300 Subject: [PATCH 04/19] test(toolset): cover override tool metadata passthrough Add a test asserting that renaming a tool through OverrideTool preserves the family, tags, and indirect target that the host's tool rules rely on, so a renamed or prefixed tool is still treated as the same tool. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tool/toolset/mod_tests.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/tinyagents-harness/src/tool/toolset/mod_tests.rs b/crates/tinyagents-harness/src/tool/toolset/mod_tests.rs index 6f79eb2c..68e35eb6 100644 --- a/crates/tinyagents-harness/src/tool/toolset/mod_tests.rs +++ b/crates/tinyagents-harness/src/tool/toolset/mod_tests.rs @@ -120,3 +120,47 @@ 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(tinytools::ToolSubject::named) + } + 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")) + ); +} From a1ef7805d1fdc5410663cee48f126b5434c987f1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 12:35:41 +0300 Subject: [PATCH 05/19] chore(vendor): bump tinytools to the reviewed tool-rules head Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 26b8ce05..e7d83ec5 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 26b8ce05980fbf1212a309e113a25be63711ff20 +Subproject commit e7d83ec536816bc79712112d66818d6e1c98ac48 From fe6cc542836f1f42d1a404fb02d939d6244677db Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 12:51:48 +0300 Subject: [PATCH 06/19] chore(vendor): bump tinytools submodule Update the vendored tinytools submodule to the latest upstream commit. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index e7d83ec5..138920ee 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit e7d83ec536816bc79712112d66818d6e1c98ac48 +Subproject commit 138920eeea0f79442609ea57ea6a98911713fa08 From 9d9417c699328afcd08d9db729724091ef1fdaf5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 12:53:38 +0300 Subject: [PATCH 07/19] chore(vendor): adopt tinytools IndirectCall for indirect targets Co-authored-by: Medulla --- .../tinyagents-harness/src/agent_loop/tool_rules_tests.rs | 6 ++++-- crates/tinyagents-harness/src/tool/shared/mod.rs | 2 +- crates/tinyagents-harness/src/tool/shared/mod_tests.rs | 8 +++++--- crates/tinyagents-harness/src/tool/toolset/mod.rs | 2 +- crates/tinyagents-harness/src/tool/toolset/mod_tests.rs | 8 +++++--- 5 files changed, 16 insertions(+), 10 deletions(-) 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 6c33beac..6eb1fc1e 100644 --- a/crates/tinyagents-harness/src/agent_loop/tool_rules_tests.rs +++ b/crates/tinyagents-harness/src/agent_loop/tool_rules_tests.rs @@ -92,11 +92,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); diff --git a/crates/tinyagents-harness/src/tool/shared/mod.rs b/crates/tinyagents-harness/src/tool/shared/mod.rs index ef094074..f5281874 100644 --- a/crates/tinyagents-harness/src/tool/shared/mod.rs +++ b/crates/tinyagents-harness/src/tool/shared/mod.rs @@ -119,7 +119,7 @@ impl Tool for CanonicalSharedToolAdapter { self.resolved_tool().map(Tool::tags).unwrap_or_default() } - fn indirect_target(&self, args: &serde_json::Value) -> Option { + fn indirect_target(&self, args: &serde_json::Value) -> Option { self.resolved_tool() .and_then(|tool| tool.indirect_target(args)) } diff --git a/crates/tinyagents-harness/src/tool/shared/mod_tests.rs b/crates/tinyagents-harness/src/tool/shared/mod_tests.rs index 531a4f54..c31b7496 100644 --- a/crates/tinyagents-harness/src/tool/shared/mod_tests.rs +++ b/crates/tinyagents-harness/src/tool/shared/mod_tests.rs @@ -245,8 +245,10 @@ impl Tool for BehaviorTool { vec!["pack:behaviors".into()] } - fn indirect_target(&self, args: &serde_json::Value) -> Option { - args["action"].as_str().map(tinytools::ToolSubject::named) + 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 { @@ -278,7 +280,7 @@ fn canonical_adapter_forwards_tool_rule_metadata() { 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")) + Some(tinytools::ToolSubject::named("GMAIL_DELETE_EMAIL").into()) ); } diff --git a/crates/tinyagents-harness/src/tool/toolset/mod.rs b/crates/tinyagents-harness/src/tool/toolset/mod.rs index 9cbb9483..78bf67c7 100644 --- a/crates/tinyagents-harness/src/tool/toolset/mod.rs +++ b/crates/tinyagents-harness/src/tool/toolset/mod.rs @@ -392,7 +392,7 @@ impl Tool for OverrideTool { self.inner.tags() } - fn indirect_target(&self, args: &Value) -> Option { + fn indirect_target(&self, args: &Value) -> Option { self.inner.indirect_target(args) } diff --git a/crates/tinyagents-harness/src/tool/toolset/mod_tests.rs b/crates/tinyagents-harness/src/tool/toolset/mod_tests.rs index 68e35eb6..e5705c68 100644 --- a/crates/tinyagents-harness/src/tool/toolset/mod_tests.rs +++ b/crates/tinyagents-harness/src/tool/toolset/mod_tests.rs @@ -144,8 +144,10 @@ impl Tool for TaggedDispatcher { fn tags(&self) -> Vec { vec!["composio.scope:write".into()] } - fn indirect_target(&self, args: &Value) -> Option { - args["action"].as_str().map(tinytools::ToolSubject::named) + 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")) @@ -161,6 +163,6 @@ fn an_override_keeps_what_tool_rules_read() { 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")) + Some(tinytools::ToolSubject::named("GMAIL_DELETE_EMAIL").into()) ); } From 0d1ce7e970422d65a221f60f6638bbdefcf61013 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:01:49 +0300 Subject: [PATCH 08/19] chore(vendor): bump tinytools to the reviewed tool-rules head Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 138920ee..fabda180 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 138920eeea0f79442609ea57ea6a98911713fa08 +Subproject commit fabda18037f732f0a5d3427e000a189d6345b939 From 275b1028e370a6df0a4a4396e3d2812355c17972 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:08:30 +0300 Subject: [PATCH 09/19] chore(vendor): bump tinytools to the reviewed tool-rules head Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index fabda180..738ce29d 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit fabda18037f732f0a5d3427e000a189d6345b939 +Subproject commit 738ce29dc4ca5fb664baf34dfc465679a2781f19 From 2ad7d0673829ab9df884b6e52bacaba360958315 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:11:14 +0300 Subject: [PATCH 10/19] chore(vendor): pin tinytools to the tool-rules merge (tinytools#57) Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 738ce29d..bd60b9b5 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 738ce29dc4ca5fb664baf34dfc465679a2781f19 +Subproject commit bd60b9b52f1998b4483b9cf107a72d3338e672f8 From 60fccadeeaf40a36f4cd93e9a35b934435dbb79e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:21:41 +0300 Subject: [PATCH 11/19] refactor(tools): extract tool call handling into helper functions Split the tool call execution path into smaller helpers so the agent loop is easier to follow and each step can be tested in isolation. Behaviour is unchanged. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent_loop/tools.rs | 76 +++++++++++++------ 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/tools.rs b/crates/tinyagents-harness/src/agent_loop/tools.rs index 69917d35..56901740 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. @@ -600,22 +616,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 @@ -680,6 +691,19 @@ impl AgentHarness { status.set_last_event(record.id); call.arguments = repaired; call.invalid = None; + // The rules first saw an unparseable string, which names no + // indirect target and matches no argument condition; decide again + // on what will actually run. + match self.rule_admission(&gate, &call.name, &call.arguments) { + CallGate::Admit(approval) => rule_approval = rule_approval.strictest(approval), + CallGate::Refuse(message) => { + return Ok(ResolvedToolCall::Answered(with_refusal_metadata( + ctx, + &call.id, + tinytools::ToolResult::error(message), + ))); + } + } } // The provider marked this call's arguments unparseable (a small local @@ -746,18 +770,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, From 963bc70a82ab8b8a47eb822db0d468f99d8c5cd9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:22:19 +0300 Subject: [PATCH 12/19] feat(tool-rules): gate the tool_search bridge through the rules The intrinsic tool_search bridge now consults the tool rules on both the catalog and call surfaces, so a host can withhold discovery itself rather than only the tools it would reveal. Registered names are also reserved against toolset tools even when the rules keep them off the catalogue. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent_loop/tool_surface.rs | 16 +++++++++--- .../src/agent_loop/tools.rs | 10 ++++++++ .../src/tool/rules/types.rs | 25 +++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/tool_surface.rs b/crates/tinyagents-harness/src/agent_loop/tool_surface.rs index 9527ef2e..84f865dc 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,14 @@ impl AgentHarness { .collect(); let promoted_names: BTreeSet = promoted_schemas.keys().cloned().collect(); let recorded_promotions = promoted_names.clone(); - if !deferred_catalog.is_empty() { + let bridge_listed = matches!( + gate.admits_intrinsic( + crate::tool::discover::TOOL_SEARCH_NAME, + tinytools::Surface::Catalog + ), + crate::tool::CallGate::Admit(_) + ); + 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 56901740..d625a6eb 100644 --- a/crates/tinyagents-harness/src/agent_loop/tools.rs +++ b/crates/tinyagents-harness/src/agent_loop/tools.rs @@ -374,6 +374,16 @@ impl AgentHarness { // `run_loop_body` — an empty declared list never falls back to // "unrestricted" here either. let gate = self.resolve_tool_gate(ctx)?; + if let CallGate::Refuse(message) = + gate.admits_intrinsic(TOOL_SEARCH_NAME, tinytools::Surface::Call) + { + // Discovery itself is ruled out: refuse rather than answer, so the + // bridge cannot reveal what the rules withhold from the model. + ctx.limits.rollback_tool_calls(1); + 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 diff --git a/crates/tinyagents-harness/src/tool/rules/types.rs b/crates/tinyagents-harness/src/tool/rules/types.rs index 1a27400b..710c79e6 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 { From 9f53f684b9599da69f0d1ab5d1aaa7d73d7fd3b9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:22:33 +0300 Subject: [PATCH 13/19] fix(tinyagents-harness): keep budget slot for refused tool search calls Refused tool search calls no longer roll back their budget slot, matching the other answered recoveries that consume a slot. The comment was updated to explain why the call keeps its budget. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinyagents-harness/src/agent_loop/tools.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/tools.rs b/crates/tinyagents-harness/src/agent_loop/tools.rs index d625a6eb..f84288f2 100644 --- a/crates/tinyagents-harness/src/agent_loop/tools.rs +++ b/crates/tinyagents-harness/src/agent_loop/tools.rs @@ -378,8 +378,8 @@ impl AgentHarness { gate.admits_intrinsic(TOOL_SEARCH_NAME, tinytools::Surface::Call) { // Discovery itself is ruled out: refuse rather than answer, so the - // bridge cannot reveal what the rules withhold from the model. - ctx.limits.rollback_tool_calls(1); + // 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), ))); From efe5bafa26099fe2af8a6cb35dde29b42143ed08 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:23:16 +0300 Subject: [PATCH 14/19] test(harness): add nested agent loop tests Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent_loop/nested_tests.rs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/crates/tinyagents-harness/src/agent_loop/nested_tests.rs b/crates/tinyagents-harness/src/agent_loop/nested_tests.rs index 405900fa..c9c938d7 100644 --- a/crates/tinyagents-harness/src/agent_loop/nested_tests.rs +++ b/crates/tinyagents-harness/src/agent_loop/nested_tests.rs @@ -2048,3 +2048,57 @@ 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); +} From 2767cca5b04ad6c8bbc97da54b5f057f23fd1f0d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:24:09 +0300 Subject: [PATCH 15/19] test(agent_loop): cover nested tool calls and tool rule enforcement Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent_loop/nested_tests.rs | 5 +- .../src/agent_loop/tool_rules_tests.rs | 124 +++++++++++++++++- 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/nested_tests.rs b/crates/tinyagents-harness/src/agent_loop/nested_tests.rs index c9c938d7..05efdc11 100644 --- a/crates/tinyagents-harness/src/agent_loop/nested_tests.rs +++ b/crates/tinyagents-harness/src/agent_loop/nested_tests.rs @@ -2051,8 +2051,9 @@ async fn a_parent_dropped_while_a_nested_result_is_observed_still_closes_the_cal 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()); + policy.tool_rules = crate::tool::ToolRulePolicy::new( + serde_json::from_value::(rules).unwrap(), + ); harness.with_policy(policy); } 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 6eb1fc1e..594448e3 100644 --- a/crates/tinyagents-harness/src/agent_loop/tool_rules_tests.rs +++ b/crates/tinyagents-harness/src/agent_loop/tool_rules_tests.rs @@ -2,6 +2,8 @@ //! `tool_search` results and every call, including approval, nested calls, //! indirect targets and a hosted definition's own rules. +use super::*; + use std::sync::{Arc, Mutex}; use async_trait::async_trait; @@ -11,7 +13,9 @@ use crate::context::{RunConfig, RunContext}; use crate::host::{ AllowAllSecurityGate, FixedModelResolver, HostCapabilities, StaticContextComposer, }; -use crate::runtime::{AgentHarness, AgentInvocation, AgentTurnRequest, RunPolicy}; +use crate::runtime::{ + AgentHarness, AgentInvocation, AgentTurnRequest, RunPolicy, UnknownToolPolicy, +}; use crate::testkit::ScriptedModel; use crate::tool::ToolRulePolicy; use tinyagents_definition::{AgentDefinition, InMemoryDefinitionRegistry}; @@ -368,3 +372,121 @@ 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())); +} From df5a71eaf680a6b0ce8b3c514b9dfe2dfd839226 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:26:19 +0300 Subject: [PATCH 16/19] fix(agent_loop): close tool-rule gaps from review Re-check rules on repaired arguments, keep a rewrite target's approval directive, gate the intrinsic tool_search bridge, reserve every registered name against toolset collisions, and cover nested-call rules. Co-authored-by: Medulla --- .../src/agent_loop/tool_rules_tests.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) 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 594448e3..b53a6953 100644 --- a/crates/tinyagents-harness/src/agent_loop/tool_rules_tests.rs +++ b/crates/tinyagents-harness/src/agent_loop/tool_rules_tests.rs @@ -4,24 +4,19 @@ use super::*; -use std::sync::{Arc, Mutex}; +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, UnknownToolPolicy, -}; +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, }; From d9abc926c28d06b055023ff2b163e41a203df697 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:48:17 +0300 Subject: [PATCH 17/19] fix(agent_loop): re-check tool rules on normalized arguments Rule admission now runs again after repair and normalization, so a JSON-encoded payload that decodes into a denied target is refused before dispatch. A require_approval rule on tool_search also refuses discovery since the bridge cannot be deferred, and unknown-tool recovery no longer suggests a tool_search that the rules withhold. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent_loop/tool_rules_tests.rs | 62 +++++++++++++++++++ .../src/agent_loop/tools.rs | 49 ++++++++++----- .../src/tool/rules/mod_tests.rs | 38 ++++++++++++ 3 files changed, 133 insertions(+), 16 deletions(-) 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 b53a6953..4d2df5e8 100644 --- a/crates/tinyagents-harness/src/agent_loop/tool_rules_tests.rs +++ b/crates/tinyagents-harness/src/agent_loop/tool_rules_tests.rs @@ -485,3 +485,65 @@ async fn a_toolset_tool_never_takes_a_denied_registered_tools_name() { 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 = harness_with(model, policy); + 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, 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!(!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/tools.rs b/crates/tinyagents-harness/src/agent_loop/tools.rs index f84288f2..09b3970c 100644 --- a/crates/tinyagents-harness/src/agent_loop/tools.rs +++ b/crates/tinyagents-harness/src/agent_loop/tools.rs @@ -374,9 +374,15 @@ impl AgentHarness { // `run_loop_body` — an empty declared list never falls back to // "unrestricted" here either. let gate = self.resolve_tool_gate(ctx)?; - if let CallGate::Refuse(message) = - gate.admits_intrinsic(TOOL_SEARCH_NAME, tinytools::Surface::Call) - { + 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. @@ -701,19 +707,6 @@ impl AgentHarness { status.set_last_event(record.id); call.arguments = repaired; call.invalid = None; - // The rules first saw an unparseable string, which names no - // indirect target and matches no argument condition; decide again - // on what will actually run. - match self.rule_admission(&gate, &call.name, &call.arguments) { - CallGate::Admit(approval) => rule_approval = rule_approval.strictest(approval), - CallGate::Refuse(message) => { - return Ok(ResolvedToolCall::Answered(with_refusal_metadata( - ctx, - &call.id, - tinytools::ToolResult::error(message), - ))); - } - } } // The provider marked this call's arguments unparseable (a small local @@ -835,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, @@ -948,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 d1e83c0a..899ccb6e 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(_) + )); +} From 58cdbf43b301c5bd72078568536aab6e98f064b6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:48:57 +0300 Subject: [PATCH 18/19] test(agent_loop): build harness explicitly in tool rules test Replace the harness_with helper with direct AgentHarness construction in the normalized-arguments test so the policy is set up inline. This keeps the test self-contained and avoids relying on the shared helper. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinyagents-harness/src/agent_loop/tool_rules_tests.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 4d2df5e8..77a85db5 100644 --- a/crates/tinyagents-harness/src/agent_loop/tool_rules_tests.rs +++ b/crates/tinyagents-harness/src/agent_loop/tool_rules_tests.rs @@ -502,7 +502,13 @@ async fn normalized_arguments_are_checked_against_the_rules_again() { { "id": "no-delete", "effect": "deny", "match": { "name": "*_delete_*" } }, ] }))); let execute = RuleTool::dispatcher("execute"); - let mut harness = harness_with(model, policy); + 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; From 8d794766e13e7db1841d40028bf68b72e9a34667 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 14:03:06 +0300 Subject: [PATCH 19/19] fix(agent_loop): hide approval-gated tool_search bridge from the wire The bridge is now advertised only when a call to it would actually be answered, so an approval-gated bridge no longer appears in the tool surface since it cannot be deferred to an approver. A test asserts the bridge stays unadvertised under a require_approval rule. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent_loop/tool_rules_tests.rs | 6 +++++- .../src/agent_loop/tool_surface.rs | 21 ++++++++++++------- 2 files changed, 19 insertions(+), 8 deletions(-) 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 77a85db5..2dc1414a 100644 --- a/crates/tinyagents-harness/src/agent_loop/tool_rules_tests.rs +++ b/crates/tinyagents-harness/src/agent_loop/tool_rules_tests.rs @@ -527,13 +527,17 @@ async fn a_require_approval_rule_on_tool_search_refuses_discovery() { let policy = ToolRulePolicy::new(rules(json!({ "rules": [ { "effect": "require_approval", "match": { "name": "tool_search" } }, ] }))); - let mut harness = harness_with(model, policy); + 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}"); } diff --git a/crates/tinyagents-harness/src/agent_loop/tool_surface.rs b/crates/tinyagents-harness/src/agent_loop/tool_surface.rs index 84f865dc..66af7d56 100644 --- a/crates/tinyagents-harness/src/agent_loop/tool_surface.rs +++ b/crates/tinyagents-harness/src/agent_loop/tool_surface.rs @@ -105,13 +105,20 @@ impl AgentHarness { .collect(); let promoted_names: BTreeSet = promoted_schemas.keys().cloned().collect(); let recorded_promotions = promoted_names.clone(); - let bridge_listed = matches!( - gate.admits_intrinsic( - crate::tool::discover::TOOL_SEARCH_NAME, - tinytools::Surface::Catalog - ), - crate::tool::CallGate::Admit(_) - ); + // 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