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) 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"]); +}