diff --git a/src/openhuman/composio/tools.rs b/src/openhuman/composio/tools.rs index a9322cddd..f2ec8629c 100644 --- a/src/openhuman/composio/tools.rs +++ b/src/openhuman/composio/tools.rs @@ -24,6 +24,7 @@ //! the right slug and supply valid arguments without a separate round //! trip. +use std::collections::{BTreeSet, HashSet}; use std::sync::Arc; use async_trait::async_trait; @@ -122,7 +123,7 @@ async fn evaluate_tool_visibility(slug: &str) -> ToolDecision { /// is direct against entries the caller has already lowercased. fn retain_connected_tools( resp: &mut super::types::ComposioToolsResponse, - connected: &std::collections::HashSet, + connected: &HashSet, ) -> usize { let before = resp.tools.len(); resp.tools.retain(|t| { @@ -133,6 +134,55 @@ fn retain_connected_tools( before - resp.tools.len() } +fn normalized_scope_toolkits( + requested: Option<&[String]>, + connected: Option<&HashSet>, +) -> Vec { + let mut out = BTreeSet::new(); + if let Some(requested) = requested { + for toolkit in requested { + let normalized = toolkit.trim().to_ascii_lowercase(); + if !normalized.is_empty() { + out.insert(normalized); + } + } + } else if let Some(connected) = connected { + out.extend(connected.iter().filter(|t| !t.is_empty()).cloned()); + } + out.into_iter().collect() +} + +fn uncatalogued_toolkits(toolkits: &[String]) -> Vec { + toolkits + .iter() + .filter(|toolkit| { + get_provider(toolkit) + .and_then(|provider| provider.curated_tools()) + .or_else(|| catalog_for_toolkit(toolkit)) + .is_none() + }) + .cloned() + .collect() +} + +fn empty_uncurated_toolkits_message(toolkits: &[String]) -> Option { + let unsupported = uncatalogued_toolkits(toolkits); + if unsupported.is_empty() { + return None; + } + let names = unsupported + .iter() + .map(|toolkit| format!("`{toolkit}`")) + .collect::>() + .join(", "); + Some(format!( + "composio_list_tools: no agent-ready actions are available for toolkit(s) {names}. \ + These integrations can be connected, but OpenHuman does not yet ship curated agent \ + tool catalogs for them. Use a supported toolkit such as Google Drive or Google Sheets \ + for now, or try again after catalog support lands." + )) +} + /// Filter a freshly-fetched [`super::types::ComposioToolsResponse`] in /// place: drop tools that aren't curated for their toolkit and tools /// whose scope is disabled in the user's pref. @@ -736,6 +786,7 @@ impl Tool for ComposioListToolsTool { match client.list_tools(toolkits.as_deref()).await { Ok(mut resp) => { filter_list_tools_response(&mut resp).await; + let mut connected_toolkits: Option> = None; if !include_unconnected { // Restrict to toolkits with an ACTIVE / CONNECTED @@ -744,7 +795,7 @@ impl Tool for ComposioListToolsTool { // prompt's Delegation Guide stay in sync. match client.list_connections().await { Ok(conns) => { - let connected: std::collections::HashSet = conns + let connected: HashSet = conns .connections .iter() .filter(|c| c.is_active()) @@ -758,6 +809,7 @@ impl Tool for ComposioListToolsTool { kept = resp.tools.len(), "[composio] list_tools restricted to connected toolkits" ); + connected_toolkits = Some(connected); } Err(e) => { // Soft-fail: surface the issue to the agent @@ -772,6 +824,18 @@ impl Tool for ComposioListToolsTool { } } + if resp.tools.is_empty() { + let scoped_toolkits = + normalized_scope_toolkits(toolkits.as_deref(), connected_toolkits.as_ref()); + if let Some(message) = empty_uncurated_toolkits_message(&scoped_toolkits) { + tracing::debug!( + toolkits = ?scoped_toolkits, + "[composio] list_tools empty for uncurated toolkit scope" + ); + return Ok(ToolResult::error(message)); + } + } + let mut result = ToolResult::success( serde_json::to_string(&resp).unwrap_or_else(|_| "{}".into()), ); diff --git a/src/openhuman/composio/tools_tests.rs b/src/openhuman/composio/tools_tests.rs index 4174395dc..86eca710b 100644 --- a/src/openhuman/composio/tools_tests.rs +++ b/src/openhuman/composio/tools_tests.rs @@ -1,7 +1,46 @@ use super::*; +use crate::openhuman::composio::providers::tool_scope::{CuratedTool, ToolScope}; +use crate::openhuman::composio::providers::{ + registry::register_provider, ComposioProvider, ProviderContext, ProviderUserProfile, + SyncOutcome, SyncReason, +}; +use async_trait::async_trait; use std::path::Path; use std::sync::Arc; +static PROVIDER_ONLY_CURATED: &[CuratedTool] = &[CuratedTool { + slug: "PROVIDERONLY_LIST_ITEMS", + scope: ToolScope::Read, +}]; + +struct ProviderOnlyCatalog; + +#[async_trait] +impl ComposioProvider for ProviderOnlyCatalog { + fn toolkit_slug(&self) -> &'static str { + "provideronly" + } + + fn curated_tools(&self) -> Option<&'static [CuratedTool]> { + Some(PROVIDER_ONLY_CURATED) + } + + async fn fetch_user_profile( + &self, + _ctx: &ProviderContext, + ) -> Result { + Ok(ProviderUserProfile::default()) + } + + async fn sync( + &self, + _ctx: &ProviderContext, + _reason: SyncReason, + ) -> Result { + Ok(SyncOutcome::default()) + } +} + struct WorkspaceEnvGuard { previous: Option, } @@ -565,6 +604,48 @@ fn retain_connected_tools_drops_unconnected_toolkits_case_insensitively() { assert!(!names.contains(&"NOTION_CREATE_PAGE")); } +#[test] +fn normalized_scope_toolkits_prefers_requested_filter() { + use std::collections::HashSet; + + let requested = vec![" OneDrive ".to_string(), "excel".to_string()]; + let connected: HashSet = ["gmail".to_string()].into_iter().collect(); + + assert_eq!( + normalized_scope_toolkits(Some(&requested), Some(&connected)), + vec!["excel".to_string(), "onedrive".to_string()] + ); +} + +#[test] +fn empty_uncurated_toolkits_message_names_agent_unsupported_toolkits() { + let message = empty_uncurated_toolkits_message(&[ + "onedrive".to_string(), + "excel".to_string(), + "todoist".to_string(), + ]) + .expect("uncurated toolkit message"); + + assert!(message.contains("no agent-ready actions")); + assert!(message.contains("`onedrive`")); + assert!(message.contains("`excel`")); + assert!(message.contains("`todoist`")); + assert!(message.contains("curated agent tool catalogs")); +} + +#[test] +fn empty_uncurated_toolkits_message_ignores_catalogued_toolkits() { + assert!(empty_uncurated_toolkits_message(&["gmail".to_string()]).is_none()); + assert!(empty_uncurated_toolkits_message(&["googlesheets".to_string()]).is_none()); +} + +#[test] +fn empty_uncurated_toolkits_message_uses_provider_curated_tools() { + register_provider(Arc::new(ProviderOnlyCatalog)); + + assert!(empty_uncurated_toolkits_message(&["provideronly".to_string()]).is_none()); +} + #[test] fn render_tools_markdown_handles_empty_response() { use crate::openhuman::composio::types::ComposioToolsResponse;