From 7aeb668ecb2c19d06fdb39183f7b34a9d4f32076 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 4 Jul 2026 00:22:22 +0000 Subject: [PATCH 1/3] fix(agent-harness): escalate user-actionable blockers to the user with a concrete ask (#4092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an agent is stuck on something only the user can unblock — the issue's canonical case is acting on a service that isn't connected — the no-progress ladder halts with the crate's generic "the goal looks unreachable in this environment; report this back" summary. That's the wrong framing for a missing connection: it's not an unreachable environment, it's a blocker the user can clear, and the agent should say so with a concrete next step. In `RepeatedToolFailureMiddleware`'s halt path, detect a user-actionable blocker (a missing service connection — the same not-connected signal the composio error mapping already keys on, and which the tools surface as "connect … in Settings → Connections") and replace the generic halt summary with a direct ask: "I can't continue without your input: needs a service that isn't connected. Connect it (Settings → Connections), then tell me to retry — or tell me how you'd like to proceed." The run still pauses so the ask surfaces; non-user-actionable failures keep the crate's summary unchanged. Tests: `user_actionable_escalation` detects a missing connection and phrases the ask (and returns None for plain environment failures); a repeated not-connected failure halts with the user-directed ask (not the generic report-back) and still pauses. Closes #4092. Claude-Session: https://claude.ai/code/session_01KcmdqJVpjmnH31HqTHRLwG --- src/openhuman/tinyagents/middleware.rs | 94 ++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/src/openhuman/tinyagents/middleware.rs b/src/openhuman/tinyagents/middleware.rs index 7ec1bd65e6..320c66bc8b 100644 --- a/src/openhuman/tinyagents/middleware.rs +++ b/src/openhuman/tinyagents/middleware.rs @@ -1570,6 +1570,35 @@ impl RepeatedToolFailureMiddleware { /// A stable, bounded fingerprint of a tool call's arguments for the identical- /// repeat signature (hashed so a huge payload doesn't bloat the map/comparison). +/// Recognise a **user-actionable** blocker in a failing tool result — one only +/// the user can clear — and phrase the halt as a direct ask instead of the +/// crate's generic "the goal looks unreachable in this environment, report this +/// back" summary (issue #4092). Today that's a missing service connection (the +/// issue's canonical example: acting on a service that isn't connected). Such a +/// failure will never self-resolve by retrying, and the fix is the user's, so +/// escalate with a concrete next step instead of looping or reporting a generic +/// dead-end. Returns `None` for failures that are not user-actionable, leaving +/// the crate's summary in place. +fn user_actionable_escalation(tool: &str, error: &str) -> Option { + let lower = error.to_lowercase(); + // Mirrors the not-connected detection openhuman's composio error mapping + // already uses; the tools themselves emit "connect … in Settings → + // Connections" text on these failures. + let missing_connection = lower.contains("not connected") + || lower.contains("isn't connected") + || lower.contains("is not connected") + || (lower.contains("connect") && lower.contains("settings")); + if !missing_connection { + return None; + } + Some(format!( + "I can't continue without your input: the `{tool}` action needs a service that isn't \ + connected. {}\n\nConnect it (Settings \u{2192} Connections), then tell me to retry — or \ + tell me how you'd like to proceed instead.", + crate::openhuman::util::truncate_with_ellipsis(error, 400), + )) +} + fn args_fingerprint(arguments: &serde_json::Value) -> String { use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); @@ -1645,10 +1674,20 @@ impl Middleware<()> for RepeatedToolFailureMiddleware { self.handle.send(SteeringCommand::Redirect { instruction }); } NoProgress::Halt(summary) => { + // #4092: if the blocker is user-actionable (a missing connection), + // escalate with a concrete ask instead of the crate's generic + // "unreachable environment, report back" summary. + let escalation = user_actionable_escalation( + &result.name, + result.error.as_deref().unwrap_or(result.content.as_str()), + ); + let user_actionable = escalation.is_some(); + let summary = escalation.unwrap_or(summary); tracing::warn!( tool = %result.name, step, hard_reject, + user_actionable, "[tinyagents::mw] repeated tool failure — halting run so the root cause surfaces" ); if let Ok(mut slot) = self.halt_summary.lock() { @@ -2093,6 +2132,61 @@ mod tests { ); } + #[test] + fn user_actionable_escalation_detects_missing_connection() { + // A not-connected blocker → a user-directed ask with a concrete next step. + let ask = user_actionable_escalation( + "gmail_send", + "Gmail is not connected. Ask the user to connect 'gmail' in Settings → Connections.", + ) + .expect("a missing-connection failure is user-actionable"); + assert!(ask.contains("without your input")); + assert!(ask.contains("Settings")); + assert!(ask.to_lowercase().contains("connect")); + assert!(ask.contains("gmail_send")); + // The original tool text is relayed so the user sees which service. + assert!(ask.to_lowercase().contains("gmail")); + + // A plain environment failure is NOT user-actionable → keep crate summary. + assert!(user_actionable_escalation("read_file", "file not found").is_none()); + assert!(user_actionable_escalation("shell", "exit code 1: segfault").is_none()); + } + + #[tokio::test] + async fn halt_on_missing_connection_asks_the_user_instead_of_reporting_back() { + // #4092: a repeated not-connected failure halts with a user-directed ask, + // not the crate's generic "unreachable environment, report this back". + let handle = SteeringHandle::allow_all(); + let slot = std::sync::Arc::new(std::sync::Mutex::new(None)); + let mw = RepeatedToolFailureMiddleware::new(handle.clone(), 3, slot.clone()); + // Three identical not-connected failures → halt. + for _ in 0..3 { + let mut r = failing_result( + "slack_post", + "Slack is not connected — connect it in Settings → Connections.", + ); + mw.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); + } + let summary = slot + .lock() + .unwrap() + .clone() + .expect("halt records a summary"); + assert!( + summary.contains("without your input") && summary.contains("Settings"), + "the halt should ask the user to connect the service: {summary}" + ); + assert!( + !summary.contains("Report this back"), + "a user-actionable blocker must not use the generic report-back summary: {summary}" + ); + assert_eq!( + drain_pause_count(&handle), + 1, + "it still pauses the run to surface the ask" + ); + } + // ── ApprovalSecurityMiddleware ────────────────────────────────────────── #[test] From f20fe17edf641b5568621af1255034fa789d1902 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 4 Jul 2026 00:22:23 +0000 Subject: [PATCH 2/3] test(ci): add privacy_mode config methods to the schema-catalog golden Unrelated CI unblock (#4446 golden drift; 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", From 72ecf82d02704e89eaa251b9c5ddecd9310bb5cb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 3 Jul 2026 20:03:33 -0700 Subject: [PATCH 3/3] fix(agent-harness): narrow user-actionable reconnect escalation --- src/openhuman/tinyagents/middleware.rs | 42 +++++++++++++++++++++----- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/src/openhuman/tinyagents/middleware.rs b/src/openhuman/tinyagents/middleware.rs index 5c99b91ee0..4bde1cb193 100644 --- a/src/openhuman/tinyagents/middleware.rs +++ b/src/openhuman/tinyagents/middleware.rs @@ -1638,8 +1638,6 @@ impl RepeatedToolFailureMiddleware { } } -/// A stable, bounded fingerprint of a tool call's arguments for the identical- -/// repeat signature (hashed so a huge payload doesn't bloat the map/comparison). /// Recognise a **user-actionable** blocker in a failing tool result — one only /// the user can clear — and phrase the halt as a direct ask instead of the /// crate's generic "the goal looks unreachable in this environment, report this @@ -1651,13 +1649,25 @@ impl RepeatedToolFailureMiddleware { /// the crate's summary in place. fn user_actionable_escalation(tool: &str, error: &str) -> Option { let lower = error.to_lowercase(); - // Mirrors the not-connected detection openhuman's composio error mapping - // already uses; the tools themselves emit "connect … in Settings → - // Connections" text on these failures. - let missing_connection = lower.contains("not connected") + let permission_or_scope_failure = lower.contains("[composio:error:insufficient_scope]") + || lower.contains("[composio:error:trigger_permission]") + || lower.contains("insufficient scope") + || lower.contains("insufficient authentication scopes") + || lower.contains("insufficient permissions") + || lower.contains("missing required permissions") + || lower.contains("permission to manage triggers"); + if permission_or_scope_failure { + return None; + } + // Keep this narrow: some scope/permission failures legitimately tell the + // user to reconnect in Settings, but they are not missing connections. + let missing_connection = lower.contains("[composio:error:composio_platform]") + || lower.contains("not connected") || lower.contains("isn't connected") || lower.contains("is not connected") - || (lower.contains("connect") && lower.contains("settings")); + || lower.contains("not enabled") + || lower.contains("token revoked") + || lower.contains("connection error, try to authenticate"); if !missing_connection { return None; } @@ -1669,6 +1679,8 @@ fn user_actionable_escalation(tool: &str, error: &str) -> Option { )) } +/// A stable, bounded fingerprint of a tool call's arguments for the identical- +/// repeat signature (hashed so a huge payload doesn't bloat the map/comparison). fn args_fingerprint(arguments: &serde_json::Value) -> String { use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); @@ -2315,6 +2327,22 @@ mod tests { // A plain environment failure is NOT user-actionable → keep crate summary. assert!(user_actionable_escalation("read_file", "file not found").is_none()); assert!(user_actionable_escalation("shell", "exit code 1: segfault").is_none()); + assert!(user_actionable_escalation( + "gmail_send", + "[composio:error:insufficient_scope] `gmail_send` was rejected because the connected \ + gmail account is missing required permissions (insufficient authentication scopes). \ + Reconnect the integration in Settings → Connections → gmail and grant the scopes \ + requested during OAuth." + ) + .is_none()); + assert!(user_actionable_escalation( + "gmail_trigger", + "[composio:error:trigger_permission] Couldn't enable this trigger: the connected \ + gmail account doesn't have permission to manage triggers. Reconnect gmail in \ + Settings → Connections → gmail and grant the permissions requested during OAuth, \ + then try again." + ) + .is_none()); } #[tokio::test]