From a0f954fc265dd7ef7f37f915bbf93b6dc9860398 Mon Sep 17 00:00:00 2001 From: aqilaziz Date: Thu, 21 May 2026 12:46:13 +0700 Subject: [PATCH 1/2] fix(composio): surface connected Gmail scope errors --- .../agent/agents/integrations_agent/prompt.md | 6 ++++-- .../agent/agents/integrations_agent/prompt.rs | 10 ++++++++++ .../agent/agents/orchestrator/prompt.md | 1 + .../agent/agents/orchestrator/prompt.rs | 10 ++++++++++ src/openhuman/composio/error_mapping.rs | 4 ++-- src/openhuman/composio/error_mapping_tests.rs | 19 ++++++++++++++++++- 6 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/openhuman/agent/agents/integrations_agent/prompt.md b/src/openhuman/agent/agents/integrations_agent/prompt.md index c36221fe5c..487101fe90 100644 --- a/src/openhuman/agent/agents/integrations_agent/prompt.md +++ b/src/openhuman/agent/agents/integrations_agent/prompt.md @@ -15,13 +15,15 @@ You do **not** have shell, file I/O, or any other capability beyond these permit 1. You already have the toolkit's action tools in your tool list — start there. If you need a schema reminder or a slug you don't see, call `composio_list_tools`. 2. Call the per-action tool (or `composio_execute` with the slug) using the caller's task as your guide. -3. If the call fails with an authentication / authorization / connection error, stop and return: **"Connection error, try to authenticate"** — the orchestrator will take over and route the user to settings. +3. If the call fails with `[composio:error:insufficient_scope]`, `insufficient authentication scopes`, or `missing required permissions`, do **not** call the service disconnected. Say the connected account is missing the permissions needed for the requested action and point the user to Settings → Connections → the toolkit to reconnect or enable the required scope. +4. If the call fails with a true authentication / authorization / connection error that is **not** a scope or permission error, stop and return: **"Connection error, try to authenticate"** — the orchestrator will take over and route the user to settings. ## Rules - **Never fabricate action slugs.** Pull them from `composio_list_tools` or use the per-action tools already in your list. - **Respect rate limits** — Composio and upstream providers both throttle. Back off on errors rather than retrying tightly. -- **Auth errors bubble up.** On any auth / connection failure reply exactly: `Connection error, try to authenticate`. Do not retry, do not attempt to re-authorise yourself — you have no tools for that. +- **Scope errors are not disconnections.** If Gmail or another connected toolkit returns insufficient scope / missing permissions, report the missing permission plainly and direct the user to Settings → Connections → that toolkit. Never say the toolkit is disconnected for this case. +- **Auth errors bubble up.** On true auth / connection failures only, reply exactly: `Connection error, try to authenticate`. Do not retry, do not attempt to re-authorise yourself — you have no tools for that. - **Be precise** — every action expects a specific argument shape. Validate against the schema before calling. - **Report results** — state what action was taken and the outcome, including any cost reported by Composio. diff --git a/src/openhuman/agent/agents/integrations_agent/prompt.rs b/src/openhuman/agent/agents/integrations_agent/prompt.rs index 2913d220f0..9a1301c4dc 100644 --- a/src/openhuman/agent/agents/integrations_agent/prompt.rs +++ b/src/openhuman/agent/agents/integrations_agent/prompt.rs @@ -257,6 +257,16 @@ mod tests { assert!(!body.contains("spawn_subagent")); } + #[test] + fn build_distinguishes_scope_errors_from_disconnected_auth() { + let body = build(&ctx_with(&[], &[])).unwrap(); + assert!(body.contains("[composio:error:insufficient_scope]")); + assert!(body.contains("Scope errors are not disconnections")); + assert!(body.contains("Never say the toolkit is disconnected")); + assert!(body.contains("Settings")); + assert!(body.contains("Connections")); + } + #[test] fn build_skips_unconnected_integrations() { let integrations = vec![ConnectedIntegration { diff --git a/src/openhuman/agent/agents/orchestrator/prompt.md b/src/openhuman/agent/agents/orchestrator/prompt.md index 95eb36ba0c..f0d51466bd 100644 --- a/src/openhuman/agent/agents/orchestrator/prompt.md +++ b/src/openhuman/agent/agents/orchestrator/prompt.md @@ -78,6 +78,7 @@ When the user asks to connect a service (Gmail, Notion, WhatsApp, Calendar, Driv - **Never** explain OAuth, Composio, or any backend mechanic by name. - Reply with one short bubble pointing to the in-app path: **Settings → Connections → [Service]**. Example: `head to Settings → Connections → Gmail to hook it up, ping me when it's connected`. - If the user already said they connected it, call `composio_list_connections` to verify before continuing. +- Do **not** apply this rule to scope / permission failures such as `[composio:error:insufficient_scope]` or "missing required permissions". For those, say the connection exists but needs additional permissions in **Settings → Connections → [Service]**. ## Response Style diff --git a/src/openhuman/agent/agents/orchestrator/prompt.rs b/src/openhuman/agent/agents/orchestrator/prompt.rs index ccc224a364..25d2eb45e6 100644 --- a/src/openhuman/agent/agents/orchestrator/prompt.rs +++ b/src/openhuman/agent/agents/orchestrator/prompt.rs @@ -250,6 +250,16 @@ mod tests { assert!(!body.contains("You have direct access")); } + #[test] + fn build_does_not_route_scope_errors_as_disconnected() { + let body = build(&ctx_with(&[])).unwrap(); + assert!(body.contains("[composio:error:insufficient_scope]")); + assert!(body.contains("missing required permissions")); + assert!(body.contains("connection exists but needs additional permissions")); + assert!(body.contains("Settings")); + assert!(body.contains("Connections")); + } + #[test] fn delegation_guide_uses_compact_collapsed_format() { let integrations = vec![ConnectedIntegration { diff --git a/src/openhuman/composio/error_mapping.rs b/src/openhuman/composio/error_mapping.rs index 46df293db6..674b7483c6 100644 --- a/src/openhuman/composio/error_mapping.rs +++ b/src/openhuman/composio/error_mapping.rs @@ -120,8 +120,8 @@ fn format_insufficient_scope_message(tool: &str, detail: &str) -> String { .to_ascii_lowercase(); format!( "`{tool}` was rejected because the connected {toolkit} account is missing required \ - permissions ({detail}). Reconnect the integration in Settings → Skills and grant the \ - scopes requested during OAuth." + permissions ({detail}). Reconnect the integration in Settings → Connections → \ + {toolkit} and grant the scopes requested during OAuth." ) } diff --git a/src/openhuman/composio/error_mapping_tests.rs b/src/openhuman/composio/error_mapping_tests.rs index 4933c2d959..acc3ac4db8 100644 --- a/src/openhuman/composio/error_mapping_tests.rs +++ b/src/openhuman/composio/error_mapping_tests.rs @@ -1,4 +1,6 @@ -use super::{classify_composio_error, remap_transport_error, ComposioErrorClass}; +use super::{ + classify_composio_error, format_provider_error, remap_transport_error, ComposioErrorClass, +}; #[test] fn classifies_gmail_insufficient_scope() { @@ -9,6 +11,21 @@ fn classifies_gmail_insufficient_scope() { ); } +#[test] +fn formats_gmail_insufficient_scope_as_missing_permissions_not_disconnected() { + let mapped = format_provider_error( + "GMAIL_SEND_EMAIL", + "HTTP 403: Request had insufficient authentication scopes.", + ); + assert!(mapped.contains("[composio:error:insufficient_scope]")); + assert!(mapped.contains("connected gmail account is missing required permissions")); + assert!(mapped.contains("Settings")); + assert!(mapped.contains("Connections")); + assert!(mapped.contains("gmail")); + assert!(!mapped.contains("not connected")); + assert!(!mapped.contains("Settings → Skills")); +} + #[test] fn classifies_slack_rate_limit() { let msg = "Slack API error: ratelimited"; From 4156d29a612661e7987cfe1db5c5299972a3bbdb Mon Sep 17 00:00:00 2001 From: aqilaziz Date: Thu, 21 May 2026 14:05:00 +0700 Subject: [PATCH 2/2] test(app-state): serialize global cache tests --- src/openhuman/app_state/ops_tests.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/openhuman/app_state/ops_tests.rs b/src/openhuman/app_state/ops_tests.rs index 0cdb42ef6c..eab67d560b 100644 --- a/src/openhuman/app_state/ops_tests.rs +++ b/src/openhuman/app_state/ops_tests.rs @@ -1,7 +1,11 @@ use super::*; +use once_cell::sync::Lazy as TestLazy; +use parking_lot::Mutex as TestMutex; use serde_json::json; use tempfile::tempdir; +static APP_STATE_CACHE_TEST_LOCK: TestLazy> = TestLazy::new(|| TestMutex::new(())); + #[test] fn sanitize_snapshot_user_drops_empty_payloads() { assert_eq!(sanitize_snapshot_user(Some(json!({}))), None); @@ -137,6 +141,7 @@ fn save_and_reload_stored_app_state_round_trips() { #[test] fn peek_cached_current_user_identity_plucks_known_fields() { + let _cache_lock = APP_STATE_CACHE_TEST_LOCK.lock(); struct CacheResetGuard; impl Drop for CacheResetGuard { fn drop(&mut self) { @@ -164,6 +169,7 @@ fn peek_cached_current_user_identity_plucks_known_fields() { #[test] fn peek_cached_current_user_identity_returns_none_when_only_empty_fields_exist() { + let _cache_lock = APP_STATE_CACHE_TEST_LOCK.lock(); struct CacheResetGuard; impl Drop for CacheResetGuard { fn drop(&mut self) { @@ -196,6 +202,7 @@ impl Drop for SnapshotCacheResetGuard { #[test] fn runtime_snapshot_cache_hit_within_ttl() { + let _cache_lock = APP_STATE_CACHE_TEST_LOCK.lock(); let _reset = SnapshotCacheResetGuard; let dummy = build_dummy_runtime_snapshot(); @@ -215,6 +222,7 @@ fn runtime_snapshot_cache_hit_within_ttl() { #[test] fn runtime_snapshot_cache_miss_after_ttl() { + let _cache_lock = APP_STATE_CACHE_TEST_LOCK.lock(); let _reset = SnapshotCacheResetGuard; *RUNTIME_SNAPSHOT_CACHE.lock() = Some(CachedRuntimeSnapshot {