From 8669899f0b8a98829f6d0b26582bcc2bb027ac9d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 19 Sep 2026 20:57:14 +0300 Subject: [PATCH 1/2] fix(harness): only emit DeferredToolCall for tools in the catalogue The agent loop was unconditionally emitting a DeferredToolCall event for every tool call that passed unwrap_tool_call, even when the tool name was not actually in the deferred catalogue. This misrepresented the outcome to audit consumers, since admission would either execute the call as a direct tool or reject it as unknown or hidden. The event is now only emitted when the tool name is found in the catalogue that the bridge searched. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent_loop/tools.rs | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/tinyagents-harness/src/agent_loop/tools.rs b/crates/tinyagents-harness/src/agent_loop/tools.rs index b9acaf12..e6ed26c7 100644 --- a/crates/tinyagents-harness/src/agent_loop/tools.rs +++ b/crates/tinyagents-harness/src/agent_loop/tools.rs @@ -218,11 +218,25 @@ impl AgentHarness { } match crate::tool::discover::unwrap_tool_call(&call.arguments) { Ok((name, arguments)) => { - let record = ctx.emit(AgentEvent::DeferredToolCall { - call_id: CallId::new(call.id.clone()), - tool_name: name.clone(), - }); - status.set_last_event(record.id); + // `unwrap_tool_call` accepts any non-empty `name` — it only + // validates the wrapper's shape, not that `name` is actually + // in the deferred catalogue. A model can wrap a direct, + // hidden, or entirely fabricated name in a `tool_call` + // payload just as validly, and admission (via + // `model_dispatch`/the unknown-tool policy below) decides + // what happens to it next. Emitting `DeferredToolCall` + // unconditionally would misrepresent that outcome to an + // audit consumer — recording "a deferred call happened" for + // a call that admission is about to execute as a direct + // tool or reject as unknown/hidden. Only emit it when the + // target is actually in the catalogue this bridge searched. + if catalog.get(&name).is_some() { + let record = ctx.emit(AgentEvent::DeferredToolCall { + call_id: CallId::new(call.id.clone()), + tool_name: name.clone(), + }); + status.set_last_event(record.id); + } call.name = name; call.arguments = arguments; Ok(None) From 1bebc8e7ad91809dd1f1e65a9d43db8673bfeca2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 19 Sep 2026 20:58:01 +0300 Subject: [PATCH 2/2] test(tool-deferral): add test that non-deferred wrapped calls do not emit deferred events Add an integration test that verifies only tools registered with `ToolExposure::Deferred` produce a `DeferredToolCall` event when invoked through the wrapping middleware, while direct and hidden tools do not. This ensures the deferral logic correctly filters by exposure level rather than by tool name alone. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tests/tool_deferral.rs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/tinyagents-integration-tests/tests/tool_deferral.rs b/crates/tinyagents-integration-tests/tests/tool_deferral.rs index 2a700690..7005fa39 100644 --- a/crates/tinyagents-integration-tests/tests/tool_deferral.rs +++ b/crates/tinyagents-integration-tests/tests/tool_deferral.rs @@ -537,3 +537,62 @@ async fn tool_schemas_projection_applies_to_wire_and_catalog() { "bridge schema description was not projected through the run's SchemaPreparation" ); } + +#[tokio::test] +async fn tool_call_wrapping_a_non_deferred_name_emits_no_deferred_event() { + let listener = Arc::new(RecordingListener::new()); + let deferred = ExposedTool::new("stock_quote", "Quote.", ToolExposure::Deferred); + let direct = ExposedTool::new("read_file", "Read.", ToolExposure::Direct); + let hidden = ExposedTool::new("internal_step", "Host-only.", ToolExposure::Hidden); + let model = RecordingModel::new(vec![ + // A wrapped *direct* tool: runs, but is not a deferred call. + tool_call( + "c1", + TOOL_CALL_NAME, + json!({"name": "read_file", "arguments": {"symbol": "A"}}), + ), + // A wrapped *hidden* tool: rejected as unknown, not a deferred call. + tool_call( + "c2", + TOOL_CALL_NAME, + json!({"name": "internal_step", "arguments": {"symbol": "B"}}), + ), + // A wrapped deferred tool: the one case that is a deferred call. + tool_call( + "c3", + TOOL_CALL_NAME, + json!({"name": "stock_quote", "arguments": {"symbol": "C"}}), + ), + text("done"), + ]); + + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness + .register_model("mock", model.clone()) + .set_default_model("mock") + .register_tool(direct.clone()) + .register_tool(deferred.clone()) + .register_tool(hidden.clone()) + .push_middleware(Arc::new(CaptureMiddleware { + listener: listener.clone(), + })); + + harness + .invoke_default(&(), vec![Message::user("go")]) + .await + .expect("run succeeds"); + + assert_eq!(direct.calls.lock().unwrap().len(), 1); + assert!(hidden.calls.lock().unwrap().is_empty()); + assert_eq!(deferred.calls.lock().unwrap().len(), 1); + + let deferred_events: Vec = listener + .events() + .into_iter() + .filter_map(|record| match record.event { + AgentEvent::DeferredToolCall { tool_name, .. } => Some(tool_name), + _ => None, + }) + .collect(); + assert_eq!(deferred_events, vec!["stock_quote"]); +}