From 66733ed10670809012af97ddcf824ab54bd8b0a3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 3 Jul 2026 23:27:04 +0000 Subject: [PATCH 1/2] fix(agent-harness): surface attempted unavailable tool names on the timeline (#4118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-dispatch validation of tool names against the live registry, and the structured "unknown tool ``; valid tools: [...]" corrective that names the available set, are already handled end-to-end by the crate `UnknownToolPolicy::ReturnToolError` path (post-#4249) — a hallucinated / out-of-scope tool never executes and the model gets a recoverable error listing the valid tools. What was lost in that migration: the *attempted* tool no longer appears on the "View processing" timeline. `ReturnToolError` recovers the call without emitting Started/Completed, and the event bridge's `UnknownToolCall` arm only logged to tracing — so an agent trying an unavailable tool showed nothing, and the recovery was invisible (the intent of #4419, dropped by the #4249 sentinel removal). Project the attempted tool as a failed timeline row: on `UnknownToolCall` the bridge now emits `ToolCallStarted` + a failed `ToolCallCompleted` (both parent and subagent scopes) keyed by the same call_id, labelled " (unavailable)" with a `tool not available` detail and an `Unknown` (recoverable) failure class. Test asserts the attempted name surfaces as a failed call. Claude-Session: https://claude.ai/code/session_01KcmdqJVpjmnH31HqTHRLwG --- src/openhuman/tinyagents/observability.rs | 98 +++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/openhuman/tinyagents/observability.rs b/src/openhuman/tinyagents/observability.rs index cb55908316..04c7820ddd 100644 --- a/src/openhuman/tinyagents/observability.rs +++ b/src/openhuman/tinyagents/observability.rs @@ -481,6 +481,63 @@ impl EventListener for OpenhumanEventBridge { arguments = %arguments, "[tinyagents] recovered unknown tool call without executing a tool" ); + // #4118: surface the *attempted* unavailable tool on the timeline + // as a failed call so the UI shows what the agent tried (and + // recovered from) rather than silently dropping it — the crate + // recovers the call without ever emitting Started/Completed for it, + // so nothing else in this bridge projects it. Two rows (start + + // failed-complete) keyed by the same call_id, mirroring a real + // tool call. Classified `Unknown` (recoverable) — the model got the + // "valid tools: [...]" corrective and can retry a real tool. + let iteration = self.iteration(); + let failure = Some(crate::openhuman::tool_status::describe( + crate::openhuman::tool_status::ToolFailureClass::Unknown, + )); + let label = format!("{} (unavailable)", humanize_tool_name(requested_name)); + match &self.scope { + None => { + self.send(AgentProgress::ToolCallStarted { + call_id: call_id.as_str().to_string(), + tool_name: requested_name.clone(), + arguments: arguments.clone(), + iteration, + display_label: Some(label), + display_detail: Some("tool not available".to_string()), + }); + self.send(AgentProgress::ToolCallCompleted { + call_id: call_id.as_str().to_string(), + tool_name: requested_name.clone(), + success: false, + output_chars: 0, + elapsed_ms: 0, + iteration, + failure, + }); + } + Some(s) => { + self.send(AgentProgress::SubagentToolCallStarted { + agent_id: s.agent_id.clone(), + task_id: s.task_id.clone(), + call_id: call_id.as_str().to_string(), + tool_name: requested_name.clone(), + arguments: arguments.clone(), + iteration, + display_label: Some(label), + display_detail: Some("tool not available".to_string()), + }); + self.send(AgentProgress::SubagentToolCallCompleted { + agent_id: s.agent_id.clone(), + task_id: s.task_id.clone(), + call_id: call_id.as_str().to_string(), + tool_name: requested_name.clone(), + success: false, + output_chars: 0, + output: String::new(), + elapsed_ms: 0, + iteration, + }); + } + } } AgentEvent::ToolStarted { call_id, tool_name } => { // Unknown/invisible tool calls no longer produce a sentinel-named @@ -683,6 +740,47 @@ mod tests { assert_eq!((input, output), (100, 40)); } + #[tokio::test] + async fn unknown_tool_call_projects_attempted_name_as_failed_timeline_row() { + // #4118: the crate recovers an unavailable-tool call via ReturnToolError + // without ever emitting Started/Completed for it. The bridge must still + // surface the *attempted* tool on the timeline (a failed call) so the UI + // shows what the agent tried, instead of the attempt vanishing. + let (tx, mut rx) = tokio::sync::mpsc::channel(64); + let bridge = OpenhumanEventBridge::new(Some(tx), "mock-model", 10); + let sink = EventSink::new(); + sink.subscribe(bridge.clone()); + + sink.emit(AgentEvent::UnknownToolCall { + call_id: "c9".into(), + requested_name: "search_files".to_string(), + arguments: serde_json::json!({ "query": "config" }), + recovery: "tool_error".to_string(), + }); + + let mut started_name = None; + let mut completed: Option<(String, bool)> = None; + while let Ok(p) = rx.try_recv() { + match p { + AgentProgress::ToolCallStarted { tool_name, .. } => started_name = Some(tool_name), + AgentProgress::ToolCallCompleted { + tool_name, success, .. + } => completed = Some((tool_name, success)), + _ => {} + } + } + assert_eq!( + started_name.as_deref(), + Some("search_files"), + "the attempted unavailable tool name must appear on the timeline" + ); + assert_eq!( + completed, + Some(("search_files".to_string(), false)), + "the attempted tool must be projected as a *failed* call" + ); + } + // NOTE: the former `sentinel_tool_started_is_not_forwarded` test was removed // here. The #4249 migration (commit 60097ba8d, "use sdk unknown tool // recovery") deleted `UNKNOWN_TOOL_SENTINEL` + `UnknownToolRewriteMiddleware` From 2d17d018d60ea2fe6a4432bf75b06b349eb1afe7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 3 Jul 2026 23:27:04 +0000 Subject: [PATCH 2/2] test(ci): add privacy_mode config methods to the schema-catalog golden Unrelated CI unblock: #4446 (Privacy Mode) registered `config_get_privacy_mode` / `config_set_privacy_mode` unconditionally but did not update the `worker_a_controller_schemas_are_fully_exposed` golden, so that test fails on main for every PR without the fix (same fix as #4475). Claude-Session: https://claude.ai/code/session_01KcmdqJVpjmnH31HqTHRLwG --- tests/config_auth_app_state_connectivity_e2e.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/config_auth_app_state_connectivity_e2e.rs b/tests/config_auth_app_state_connectivity_e2e.rs index 266d46584c..802855f2fe 100644 --- a/tests/config_auth_app_state_connectivity_e2e.rs +++ b/tests/config_auth_app_state_connectivity_e2e.rs @@ -2802,6 +2802,7 @@ async fn worker_a_controller_schemas_are_fully_exposed() { "openhuman.config_get_meet_settings", "openhuman.config_get_memory_sync_settings", "openhuman.config_get_onboarding_completed", + "openhuman.config_get_privacy_mode", "openhuman.config_get_runtime_flags", "openhuman.config_get_sandbox_settings", "openhuman.config_get_search_settings", @@ -2811,6 +2812,7 @@ async fn worker_a_controller_schemas_are_fully_exposed() { "openhuman.config_resolve_api_url", "openhuman.config_set_browser_allow_all", "openhuman.config_set_onboarding_completed", + "openhuman.config_set_privacy_mode", "openhuman.config_set_super_context_enabled", "openhuman.config_update_activity_level_settings", "openhuman.config_update_agent_paths",