Skip to main content

zeroclaw_tools/
composio.rs

1// Composio Tool ModelProvider — optional managed tool surface with 1000+ OAuth integrations.
2//
3// When enabled, ZeroClaw can execute actions on Gmail, Notion, GitHub, Slack, etc.
4// through Composio's API without storing raw OAuth tokens locally.
5//
6// This is opt-in. Users who prefer sovereign/local-only mode skip this entirely.
7// The Composio API key is stored in the encrypted secret store.
8
9use anyhow::Context;
10use async_trait::async_trait;
11use parking_lot::RwLock;
12use reqwest::Client;
13use serde::{Deserialize, Serialize};
14use serde_json::json;
15use std::collections::HashMap;
16use std::fmt::Write;
17use std::sync::Arc;
18use zeroclaw_api::tool::{Tool, ToolResult};
19use zeroclaw_config::policy::SecurityPolicy;
20use zeroclaw_config::policy::ToolOperation;
21
22const COMPOSIO_API_BASE_V3: &str = "https://backend.composio.dev/api/v3";
23const COMPOSIO_TOOL_VERSION_LATEST: &str = "latest";
24
25fn ensure_https(url: &str) -> anyhow::Result<()> {
26    if !url.starts_with("https://") {
27        anyhow::bail!(
28            "Refusing to transmit sensitive data over non-HTTPS URL: URL scheme must be https"
29        );
30    }
31    Ok(())
32}
33
34/// A tool that proxies actions to the Composio managed tool platform.
35pub struct ComposioTool {
36    api_key: String,
37    default_entity_id: String,
38    security: Arc<SecurityPolicy>,
39    recent_connected_accounts: RwLock<HashMap<String, String>>,
40    action_slug_cache: RwLock<HashMap<String, String>>,
41}
42
43impl ComposioTool {
44    pub fn new(
45        api_key: &str,
46        default_entity_id: Option<&str>,
47        security: Arc<SecurityPolicy>,
48    ) -> Self {
49        Self {
50            api_key: api_key.to_string(),
51            default_entity_id: normalize_entity_id(default_entity_id.unwrap_or("default")),
52            security,
53            recent_connected_accounts: RwLock::new(HashMap::new()),
54            action_slug_cache: RwLock::new(HashMap::new()),
55        }
56    }
57
58    fn client(&self) -> Client {
59        zeroclaw_config::schema::build_runtime_proxy_client_with_timeouts("tool.composio", 60, 10)
60    }
61
62    /// List available Composio apps/actions for the authenticated user.
63    ///
64    /// Uses the v3 endpoint.
65    pub async fn list_actions(
66        &self,
67        app_name: Option<&str>,
68    ) -> anyhow::Result<Vec<ComposioAction>> {
69        self.list_actions_v3(app_name).await
70    }
71
72    async fn list_actions_v3(&self, app_name: Option<&str>) -> anyhow::Result<Vec<ComposioAction>> {
73        let url = format!("{COMPOSIO_API_BASE_V3}/tools");
74        let req = self
75            .client()
76            .get(&url)
77            .header("x-api-key", &self.api_key)
78            .query(&Self::build_list_actions_v3_query(app_name));
79
80        let resp = req.send().await?;
81        if !resp.status().is_success() {
82            let err = response_error(resp).await;
83            anyhow::bail!("Composio v3 API error: {err}");
84        }
85
86        let body: ComposioToolsResponse = resp
87            .json()
88            .await
89            .context("Failed to decode Composio v3 tools response")?;
90        self.update_action_slug_cache_from_v3_items(&body.items);
91        Ok(map_v3_tools_to_actions(body.items))
92    }
93
94    fn update_action_slug_cache_from_v3_items(&self, items: &[ComposioV3Tool]) {
95        for item in items {
96            let Some(slug) = item.slug.as_deref().or(item.name.as_deref()) else {
97                continue;
98            };
99            self.cache_action_slug(slug, slug);
100            if let Some(name) = item.name.as_deref() {
101                self.cache_action_slug(name, slug);
102            }
103        }
104    }
105
106    /// List connected accounts for a user and optional toolkit/app.
107    async fn list_connected_accounts(
108        &self,
109        app_name: Option<&str>,
110        entity_id: Option<&str>,
111    ) -> anyhow::Result<Vec<ComposioConnectedAccount>> {
112        let url = format!("{COMPOSIO_API_BASE_V3}/connected_accounts");
113        let mut req = self.client().get(&url).header("x-api-key", &self.api_key);
114
115        req = req.query(&[
116            ("limit", "50"),
117            ("order_by", "updated_at"),
118            ("order_direction", "desc"),
119            ("statuses", "INITIALIZING"),
120            ("statuses", "ACTIVE"),
121            ("statuses", "INITIATED"),
122        ]);
123
124        if let Some(app) = app_name
125            .map(normalize_app_slug)
126            .filter(|app| !app.is_empty())
127        {
128            req = req.query(&[("toolkit_slugs", app.as_str())]);
129        }
130
131        if let Some(entity) = entity_id {
132            req = req.query(&[("user_ids", entity)]);
133        }
134
135        let resp = req.send().await?;
136        if !resp.status().is_success() {
137            let err = response_error(resp).await;
138            anyhow::bail!("Composio v3 connected accounts lookup failed: {err}");
139        }
140
141        let body: ComposioConnectedAccountsResponse = resp
142            .json()
143            .await
144            .context("Failed to decode Composio v3 connected accounts response")?;
145        Ok(body.items)
146    }
147
148    fn cache_connected_account(&self, app_name: &str, entity_id: &str, connected_account_id: &str) {
149        let key = connected_account_cache_key(app_name, entity_id);
150        self.recent_connected_accounts
151            .write()
152            .insert(key, connected_account_id.to_string());
153    }
154
155    fn get_cached_connected_account(&self, app_name: &str, entity_id: &str) -> Option<String> {
156        let key = connected_account_cache_key(app_name, entity_id);
157        self.recent_connected_accounts.read().get(&key).cloned()
158    }
159
160    async fn resolve_connected_account_ref(
161        &self,
162        app_name: Option<&str>,
163        entity_id: Option<&str>,
164    ) -> anyhow::Result<Option<String>> {
165        let app = app_name
166            .map(normalize_app_slug)
167            .filter(|app| !app.is_empty());
168        let entity = entity_id.map(normalize_entity_id);
169        let (Some(app), Some(entity)) = (app, entity) else {
170            return Ok(None);
171        };
172
173        if let Some(cached) = self.get_cached_connected_account(&app, &entity) {
174            return Ok(Some(cached));
175        }
176
177        let accounts = self
178            .list_connected_accounts(Some(&app), Some(&entity))
179            .await?;
180        // The API returns accounts ordered by updated_at DESC, so the first
181        // usable account is the most recently active one.  We always pick it
182        // rather than giving up when multiple accounts exist — giving up was
183        // the root cause of the "cannot find connected account" loop reported
184        // in issue #959.
185        let Some(first) = accounts.into_iter().find(|acct| acct.is_usable()) else {
186            return Ok(None);
187        };
188
189        self.cache_connected_account(&app, &entity, &first.id);
190        Ok(Some(first.id))
191    }
192
193    /// Execute a Composio action/tool with given parameters.
194    ///
195    /// Uses the v3 endpoint.
196    pub async fn execute_action(
197        &self,
198        action_name: &str,
199        app_name_hint: Option<&str>,
200        params: serde_json::Value,
201        text: Option<&str>,
202        entity_id: Option<&str>,
203        connected_account_ref: Option<&str>,
204    ) -> anyhow::Result<serde_json::Value> {
205        let app_hint = app_name_hint
206            .map(normalize_app_slug)
207            .filter(|app| !app.is_empty())
208            .or_else(|| infer_app_slug_from_action_name(action_name));
209        let normalized_entity_id = entity_id.map(normalize_entity_id);
210        let explicit_account_ref = connected_account_ref.and_then(|candidate| {
211            let trimmed = candidate.trim();
212            (!trimmed.is_empty()).then_some(trimmed.to_string())
213        });
214        let resolved_account_ref = if explicit_account_ref.is_some() {
215            explicit_account_ref
216        } else {
217            self.resolve_connected_account_ref(app_hint.as_deref(), normalized_entity_id.as_deref())
218                .await?
219        };
220
221        let mut slug_candidates = self.build_v3_slug_candidates(action_name);
222        let mut prime_error = None;
223        if slug_candidates.is_empty()
224            && let Some(app) = app_hint.as_deref()
225        {
226            match self.list_actions(Some(app)).await {
227                Ok(_) => {
228                    slug_candidates = self.build_v3_slug_candidates(action_name);
229                }
230                Err(err) => {
231                    prime_error = Some(format!(
232                        "Failed to refresh action list for app '{app}': {err}"
233                    ));
234                }
235            }
236        }
237
238        if slug_candidates.is_empty() {
239            anyhow::bail!(
240                "Unable to determine tool slug for '{action_name}'. Run action='list' with the relevant app first to prime the cache.{}",
241                prime_error
242                    .as_deref()
243                    .map(|msg| format!(" ({msg})"))
244                    .unwrap_or_default()
245            );
246        }
247
248        let mut v3_errors = Vec::new();
249        for slug in slug_candidates {
250            self.cache_action_slug(action_name, &slug);
251            match self
252                .execute_action_v3(
253                    &slug,
254                    params.clone(),
255                    text,
256                    normalized_entity_id.as_deref(),
257                    resolved_account_ref.as_deref(),
258                )
259                .await
260            {
261                Ok(result) => return Ok(result),
262                Err(err) => v3_errors.push(format!("{slug}: {err}")),
263            }
264        }
265
266        let v3_error_summary = if v3_errors.is_empty() {
267            "no v3 candidates attempted".to_string()
268        } else {
269            v3_errors.join(" | ")
270        };
271
272        let prime_suffix = prime_error
273            .as_deref()
274            .map(|msg| format!(" ({msg})"))
275            .unwrap_or_default();
276
277        if text.is_some() {
278            anyhow::bail!(
279                "Composio v3 NLP execute failed on candidates ({v3_error_summary}){prime_suffix}{}",
280                build_connected_account_hint(
281                    app_hint.as_deref(),
282                    normalized_entity_id.as_deref(),
283                    resolved_account_ref.as_deref(),
284                )
285            );
286        }
287
288        anyhow::bail!(
289            "Composio execute failed on v3 ({v3_error_summary}){prime_suffix}{}",
290            build_connected_account_hint(
291                app_hint.as_deref(),
292                normalized_entity_id.as_deref(),
293                resolved_account_ref.as_deref(),
294            )
295        );
296    }
297
298    fn build_v3_slug_candidates(&self, action_name: &str) -> Vec<String> {
299        let mut candidates = Vec::new();
300        let mut push_candidate = |candidate: String| {
301            if !candidate.is_empty() && !candidates.contains(&candidate) {
302                candidates.push(candidate);
303            }
304        };
305
306        if let Some(hit) = self.lookup_cached_action_slug(action_name) {
307            push_candidate(hit);
308        }
309
310        for slug in build_tool_slug_candidates(action_name) {
311            push_candidate(slug);
312        }
313
314        candidates
315    }
316
317    fn cache_action_slug(&self, alias: &str, slug: &str) {
318        let Some(key) = normalize_action_cache_key(alias) else {
319            return;
320        };
321        let trimmed_slug = slug.trim();
322        if trimmed_slug.is_empty() {
323            return;
324        }
325        self.action_slug_cache
326            .write()
327            .insert(key, trimmed_slug.to_string());
328    }
329
330    fn lookup_cached_action_slug(&self, action_name: &str) -> Option<String> {
331        let key = normalize_action_cache_key(action_name)?;
332        self.action_slug_cache.read().get(&key).cloned()
333    }
334
335    fn build_list_actions_v3_query(app_name: Option<&str>) -> Vec<(String, String)> {
336        let mut query = vec![
337            ("limit".to_string(), "200".to_string()),
338            (
339                "toolkit_versions".to_string(),
340                COMPOSIO_TOOL_VERSION_LATEST.to_string(),
341            ),
342        ];
343
344        if let Some(app) = app_name.map(str::trim).filter(|app| !app.is_empty()) {
345            query.push(("toolkits".to_string(), app.to_string()));
346            query.push(("toolkit_slug".to_string(), app.to_string()));
347        }
348
349        query
350    }
351
352    fn build_execute_action_v3_request(
353        tool_slug: &str,
354        params: serde_json::Value,
355        text: Option<&str>,
356        entity_id: Option<&str>,
357        connected_account_ref: Option<&str>,
358    ) -> (String, serde_json::Value) {
359        let url = format!("{COMPOSIO_API_BASE_V3}/tools/execute/{tool_slug}");
360        let account_ref = connected_account_ref.and_then(|candidate| {
361            let trimmed_candidate = candidate.trim();
362            (!trimmed_candidate.is_empty()).then_some(trimmed_candidate)
363        });
364
365        let mut body = json!({
366            "version": COMPOSIO_TOOL_VERSION_LATEST,
367        });
368
369        // The v3 execute endpoint accepts either structured `arguments` or a
370        // natural-language `text` description (mutually exclusive).  Prefer
371        // `text` when the caller provides it so Composio's NLP resolves the
372        // correct parameters — this is the primary fix for the "keeps guessing
373        // and failing" issue reported by the community.
374        if let Some(nl_text) = text {
375            body["text"] = json!(nl_text);
376        } else {
377            body["arguments"] = params;
378        }
379
380        if let Some(entity) = entity_id {
381            body["user_id"] = json!(entity);
382        }
383        if let Some(account_ref) = account_ref {
384            body["connected_account_id"] = json!(account_ref);
385        }
386
387        (url, body)
388    }
389
390    async fn execute_action_v3(
391        &self,
392        tool_slug: &str,
393        params: serde_json::Value,
394        text: Option<&str>,
395        entity_id: Option<&str>,
396        connected_account_ref: Option<&str>,
397    ) -> anyhow::Result<serde_json::Value> {
398        let (url, body) = Self::build_execute_action_v3_request(
399            tool_slug,
400            params,
401            text,
402            entity_id,
403            connected_account_ref,
404        );
405
406        ensure_https(&url)?;
407
408        let resp = self
409            .client()
410            .post(&url)
411            .header("x-api-key", &self.api_key)
412            .json(&body)
413            .send()
414            .await?;
415
416        if !resp.status().is_success() {
417            let err = response_error(resp).await;
418            anyhow::bail!("Composio v3 action execution failed: {err}");
419        }
420
421        let result: serde_json::Value = resp
422            .json()
423            .await
424            .context("Failed to decode Composio v3 execute response")?;
425        Ok(result)
426    }
427
428    /// Get the OAuth connection URL for a specific app/toolkit or auth config.
429    ///
430    /// Uses the v3 endpoint.
431    pub async fn get_connection_url(
432        &self,
433        app_name: Option<&str>,
434        auth_config_id: Option<&str>,
435        entity_id: &str,
436    ) -> anyhow::Result<ComposioConnectionLink> {
437        self.get_connection_url_v3(app_name, auth_config_id, entity_id)
438            .await
439    }
440
441    async fn get_connection_url_v3(
442        &self,
443        app_name: Option<&str>,
444        auth_config_id: Option<&str>,
445        entity_id: &str,
446    ) -> anyhow::Result<ComposioConnectionLink> {
447        let auth_config_id = match auth_config_id {
448            Some(id) => id.to_string(),
449            None => {
450                let app = app_name.ok_or_else(|| {
451                    ::zeroclaw_log::record!(
452                        WARN,
453                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
454                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
455                            .with_attrs(::serde_json::json!({"missing": "app_or_auth_config_id"})),
456                        "composio: v3 connect missing app or auth_config_id"
457                    );
458                    anyhow::Error::msg("Missing 'app' or 'auth_config_id' for v3 connect")
459                })?;
460                self.resolve_auth_config_id(app).await?
461            }
462        };
463
464        let url = format!("{COMPOSIO_API_BASE_V3}/connected_accounts/link");
465        let body = json!({
466            "auth_config_id": auth_config_id,
467            "user_id": entity_id,
468        });
469
470        let resp = self
471            .client()
472            .post(&url)
473            .header("x-api-key", &self.api_key)
474            .json(&body)
475            .send()
476            .await?;
477
478        if !resp.status().is_success() {
479            let err = response_error(resp).await;
480            anyhow::bail!("Composio v3 connect failed: {err}");
481        }
482
483        let result: serde_json::Value = resp
484            .json()
485            .await
486            .context("Failed to decode Composio v3 connect response")?;
487        let redirect_url = extract_redirect_url(&result).ok_or_else(|| {
488            ::zeroclaw_log::record!(
489                ERROR,
490                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
491                    .with_outcome(::zeroclaw_log::EventOutcome::Failure),
492                "composio: v3 response missing redirect URL"
493            );
494            anyhow::Error::msg("No redirect URL in Composio v3 response")
495        })?;
496        Ok(ComposioConnectionLink {
497            redirect_url,
498            connected_account_id: extract_connected_account_id(&result),
499        })
500    }
501
502    /// Fetch full metadata for a single tool by slug, including input/output parameter schemas.
503    ///
504    /// Calls `GET /api/v3/tools/{tool_slug}` which returns the detailed schema
505    /// the LLM needs to construct correct `params` for `execute`.
506    async fn get_tool_schema(&self, tool_slug: &str) -> anyhow::Result<serde_json::Value> {
507        let slug = normalize_tool_slug(tool_slug);
508        let url = format!("{COMPOSIO_API_BASE_V3}/tools/{slug}");
509        ensure_https(&url)?;
510
511        let resp = self
512            .client()
513            .get(&url)
514            .header("x-api-key", &self.api_key)
515            .query(&[("version", COMPOSIO_TOOL_VERSION_LATEST)])
516            .send()
517            .await?;
518
519        if !resp.status().is_success() {
520            let err = response_error(resp).await;
521            anyhow::bail!("Composio v3 tool schema lookup failed for '{slug}': {err}");
522        }
523
524        let body: serde_json::Value = resp
525            .json()
526            .await
527            .context("Failed to decode Composio v3 tool schema response")?;
528        Ok(body)
529    }
530
531    async fn resolve_auth_config_id(&self, app_name: &str) -> anyhow::Result<String> {
532        let url = format!("{COMPOSIO_API_BASE_V3}/auth_configs");
533
534        let resp = self
535            .client()
536            .get(&url)
537            .header("x-api-key", &self.api_key)
538            .query(&[
539                ("toolkit_slug", app_name),
540                ("show_disabled", "true"),
541                ("limit", "25"),
542            ])
543            .send()
544            .await?;
545
546        if !resp.status().is_success() {
547            let err = response_error(resp).await;
548            anyhow::bail!("Composio v3 auth config lookup failed: {err}");
549        }
550
551        let body: ComposioAuthConfigsResponse = resp
552            .json()
553            .await
554            .context("Failed to decode Composio v3 auth configs response")?;
555
556        if body.items.is_empty() {
557            anyhow::bail!(
558                "No auth config found for toolkit '{app_name}'. Create one in Composio first."
559            );
560        }
561
562        let preferred = body
563            .items
564            .iter()
565            .find(|cfg| cfg.is_enabled())
566            .or_else(|| body.items.first())
567            .context("No usable auth config returned by Composio")?;
568
569        Ok(preferred.id.clone())
570    }
571}
572
573#[async_trait]
574impl Tool for ComposioTool {
575    fn name(&self) -> &str {
576        "composio"
577    }
578
579    fn description(&self) -> &str {
580        "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). \
581         Use action='list' to see available actions (includes parameter names). \
582         action='execute' with action_name/tool_slug and params to run an action. \
583         If you are unsure of the exact params, pass 'text' instead with a natural-language description \
584         of what you want (Composio will resolve the correct parameters via NLP). \
585         action='list_accounts' or action='connected_accounts' to list OAuth-connected accounts. \
586         action='connect' with app/auth_config_id to get OAuth URL. \
587         connected_account_id is auto-resolved when omitted."
588    }
589
590    fn parameters_schema(&self) -> serde_json::Value {
591        json!({
592            "type": "object",
593            "properties": {
594                "action": {
595                    "type": "string",
596                    "description": "The operation: 'list' (list available actions), 'list_accounts'/'connected_accounts' (list connected accounts), 'execute' (run an action), or 'connect' (get OAuth URL)",
597                    "enum": ["list", "list_accounts", "connected_accounts", "execute", "connect"]
598                },
599                "app": {
600                    "type": "string",
601                    "description": "Toolkit slug filter for 'list' or 'list_accounts', optional app hint for 'execute', or toolkit/app for 'connect' (e.g. 'gmail', 'notion', 'github')"
602                },
603                "action_name": {
604                    "type": "string",
605                    "description": "Action/tool identifier to execute (legacy aliases supported)"
606                },
607                "tool_slug": {
608                    "type": "string",
609                    "description": "Preferred v3 tool slug to execute (alias of action_name)"
610                },
611                "params": {
612                    "type": "object",
613                    "description": "Structured parameters to pass to the action (use the key names shown by action='list')"
614                },
615                "text": {
616                    "type": "string",
617                    "description": "Natural-language description of what you want the action to do (alternative to 'params' when you are unsure of the exact parameter names). Composio will resolve the correct parameters via NLP. Mutually exclusive with 'params'."
618                },
619                "entity_id": {
620                    "type": "string",
621                    "description": "Entity/user ID for multi-user setups (defaults to composio.entity_id from config)"
622                },
623                "auth_config_id": {
624                    "type": "string",
625                    "description": "Optional Composio v3 auth config id for connect flow"
626                },
627                "connected_account_id": {
628                    "type": "string",
629                    "description": "Optional connected account ID for execute flow when a specific account is required"
630                }
631            },
632            "required": ["action"]
633        })
634    }
635
636    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
637        let action = args.get("action").and_then(|v| v.as_str()).ok_or_else(|| {
638            ::zeroclaw_log::record!(
639                WARN,
640                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
641                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
642                    .with_attrs(::serde_json::json!({"param": "action"})),
643                "composio: missing action parameter"
644            );
645            anyhow::Error::msg("Missing 'action' parameter")
646        })?;
647
648        let entity_id = args
649            .get("entity_id")
650            .and_then(|v| v.as_str())
651            .unwrap_or(self.default_entity_id.as_str());
652
653        match action {
654            "list" => {
655                let app = args.get("app").and_then(|v| v.as_str());
656                match self.list_actions(app).await {
657                    Ok(actions) => {
658                        let summary: Vec<String> = actions
659                            .iter()
660                            .take(20)
661                            .map(|a| {
662                                let params_hint =
663                                    format_input_params_hint(a.input_parameters.as_ref());
664                                format!(
665                                    "- {} ({}): {}{}",
666                                    a.name,
667                                    a.app_name.as_deref().unwrap_or("?"),
668                                    a.description.as_deref().unwrap_or(""),
669                                    params_hint,
670                                )
671                            })
672                            .collect();
673                        let total = actions.len();
674                        let output = format!(
675                            "Found {total} available actions:\n{}{}",
676                            summary.join("\n"),
677                            if total > 20 {
678                                format!("\n... and {} more", total - 20)
679                            } else {
680                                String::new()
681                            }
682                        );
683                        Ok(ToolResult {
684                            success: true,
685                            output,
686                            error: None,
687                        })
688                    }
689                    Err(e) => Ok(ToolResult {
690                        success: false,
691                        output: String::new(),
692                        error: Some(format!("Failed to list actions: {e}")),
693                    }),
694                }
695            }
696
697            // Accept both spellings so the LLM can use either.
698            "list_accounts" | "connected_accounts" => {
699                let app = args.get("app").and_then(|v| v.as_str());
700                match self.list_connected_accounts(app, Some(entity_id)).await {
701                    Ok(accounts) => {
702                        if accounts.is_empty() {
703                            let app_hint = app
704                                .map(|value| format!(" for app '{value}'"))
705                                .unwrap_or_default();
706                            return Ok(ToolResult {
707                                success: true,
708                                output: format!(
709                                    "No connected accounts found{app_hint} for entity '{entity_id}'. Run action='connect' first."
710                                ),
711                                error: None,
712                            });
713                        }
714
715                        let summary: Vec<String> = accounts
716                            .iter()
717                            .take(20)
718                            .map(|account| {
719                                let toolkit = account.toolkit_slug().unwrap_or("?");
720                                format!("- {} [{}] toolkit={toolkit}", account.id, account.status)
721                            })
722                            .collect();
723                        let total = accounts.len();
724                        let output = format!(
725                            "Found {total} connected accounts (entity '{entity_id}'):\n{}{}\nUse connected_account_id in action='execute' when needed.",
726                            summary.join("\n"),
727                            if total > 20 {
728                                format!("\n... and {} more", total - 20)
729                            } else {
730                                String::new()
731                            }
732                        );
733                        Ok(ToolResult {
734                            success: true,
735                            output,
736                            error: None,
737                        })
738                    }
739                    Err(e) => Ok(ToolResult {
740                        success: false,
741                        output: String::new(),
742                        error: Some(format!("Failed to list connected accounts: {e}")),
743                    }),
744                }
745            }
746
747            "execute" => {
748                if let Err(error) = self
749                    .security
750                    .enforce_tool_operation(ToolOperation::Act, "composio.execute")
751                {
752                    return Ok(ToolResult {
753                        success: false,
754                        output: String::new(),
755                        error: Some(error),
756                    });
757                }
758
759                let action_name = args
760                    .get("tool_slug")
761                    .or_else(|| args.get("action_name"))
762                    .and_then(|v| v.as_str())
763                    .ok_or_else(|| {
764                        ::zeroclaw_log::record!(
765                            WARN,
766                            ::zeroclaw_log::Event::new(
767                                module_path!(),
768                                ::zeroclaw_log::Action::Reject
769                            )
770                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
771                            .with_attrs(
772                                ::serde_json::json!({"missing": "action_name_or_tool_slug"})
773                            ),
774                            "composio: execute missing action_name/tool_slug"
775                        );
776                        anyhow::Error::msg("Missing 'action_name' (or 'tool_slug') for execute")
777                    })?;
778
779                let app = args.get("app").and_then(|v| v.as_str());
780                let params = args.get("params").cloned().unwrap_or(json!({}));
781                let text = args.get("text").and_then(|v| v.as_str());
782                let acct_ref = args.get("connected_account_id").and_then(|v| v.as_str());
783
784                match self
785                    .execute_action(action_name, app, params, text, Some(entity_id), acct_ref)
786                    .await
787                {
788                    Ok(result) => {
789                        let output = serde_json::to_string_pretty(&result)
790                            .unwrap_or_else(|_| format!("{result:?}"));
791                        Ok(ToolResult {
792                            success: true,
793                            output,
794                            error: None,
795                        })
796                    }
797                    Err(e) => {
798                        // On failure, try to fetch the tool's parameter schema
799                        // so the LLM can self-correct on its next attempt.
800                        let schema_hint = self
801                            .get_tool_schema(action_name)
802                            .await
803                            .ok()
804                            .and_then(|s| format_schema_hint(&s))
805                            .unwrap_or_default();
806                        Ok(ToolResult {
807                            success: false,
808                            output: String::new(),
809                            error: Some(format!("Action execution failed: {e}{schema_hint}")),
810                        })
811                    }
812                }
813            }
814
815            "connect" => {
816                if let Err(error) = self
817                    .security
818                    .enforce_tool_operation(ToolOperation::Act, "composio.connect")
819                {
820                    return Ok(ToolResult {
821                        success: false,
822                        output: String::new(),
823                        error: Some(error),
824                    });
825                }
826
827                let app = args.get("app").and_then(|v| v.as_str());
828                let auth_config_id = args.get("auth_config_id").and_then(|v| v.as_str());
829
830                if app.is_none() && auth_config_id.is_none() {
831                    anyhow::bail!("Missing 'app' or 'auth_config_id' for connect");
832                }
833
834                match self
835                    .get_connection_url(app, auth_config_id, entity_id)
836                    .await
837                {
838                    Ok(link) => {
839                        let target =
840                            app.unwrap_or(auth_config_id.unwrap_or("provided auth config"));
841                        let mut output =
842                            format!("Open this URL to connect {target}:\n{}", link.redirect_url);
843                        if let Some(connected_account_id) = link.connected_account_id.as_deref() {
844                            if let Some(app_name) = app {
845                                self.cache_connected_account(
846                                    app_name,
847                                    entity_id,
848                                    connected_account_id,
849                                );
850                            }
851                            let _ =
852                                write!(output, "\nConnected account ID: {connected_account_id}");
853                        }
854                        Ok(ToolResult {
855                            success: true,
856                            output,
857                            error: None,
858                        })
859                    }
860                    Err(e) => Ok(ToolResult {
861                        success: false,
862                        output: String::new(),
863                        error: Some(format!("Failed to get connection URL: {e}")),
864                    }),
865                }
866            }
867
868            _ => Ok(ToolResult {
869                success: false,
870                output: String::new(),
871                error: Some(format!(
872                    "Unknown action '{action}'. Use 'list', 'list_accounts', 'execute', or 'connect'."
873                )),
874            }),
875        }
876    }
877}
878
879fn normalize_entity_id(entity_id: &str) -> String {
880    let trimmed = entity_id.trim();
881    if trimmed.is_empty() {
882        "default".to_string()
883    } else {
884        trimmed.to_string()
885    }
886}
887
888fn normalize_tool_slug(action_name: &str) -> String {
889    action_name.trim().replace('_', "-").to_ascii_lowercase()
890}
891
892fn build_tool_slug_candidates(action_name: &str) -> Vec<String> {
893    let trimmed = action_name.trim();
894    if trimmed.is_empty() {
895        return Vec::new();
896    }
897
898    let mut candidates = Vec::new();
899    let mut push_candidate = |candidate: String| {
900        if !candidate.is_empty() && !candidates.contains(&candidate) {
901            candidates.push(candidate);
902        }
903    };
904
905    // Keep the original slug/name first so execute() honors exact tool IDs
906    // returned by Composio list APIs before trying normalized variants.
907    push_candidate(trimmed.to_string());
908    push_candidate(normalize_tool_slug(trimmed));
909
910    let lower = trimmed.to_ascii_lowercase();
911    push_candidate(lower.clone());
912
913    let underscore_lower = lower.replace('-', "_");
914    push_candidate(underscore_lower);
915
916    let hyphen_lower = lower.replace('_', "-");
917    push_candidate(hyphen_lower);
918
919    let upper = trimmed.to_ascii_uppercase();
920    push_candidate(upper.clone());
921    push_candidate(upper.replace('-', "_"));
922    push_candidate(upper.replace('_', "-"));
923
924    candidates
925}
926
927fn normalize_app_slug(app_name: &str) -> String {
928    app_name
929        .trim()
930        .replace('_', "-")
931        .to_ascii_lowercase()
932        .split('-')
933        .filter(|part| !part.is_empty())
934        .collect::<Vec<_>>()
935        .join("-")
936}
937
938fn infer_app_slug_from_action_name(action_name: &str) -> Option<String> {
939    let trimmed = action_name.trim();
940    if trimmed.is_empty() {
941        return None;
942    }
943
944    let raw = if trimmed.contains('-') {
945        trimmed.split('-').next()
946    } else if trimmed.contains('_') {
947        trimmed.split('_').next()
948    } else {
949        None
950    }?;
951
952    let app = normalize_app_slug(raw);
953    (!app.is_empty()).then_some(app)
954}
955
956fn connected_account_cache_key(app_name: &str, entity_id: &str) -> String {
957    format!(
958        "{}:{}",
959        normalize_entity_id(entity_id),
960        normalize_app_slug(app_name)
961    )
962}
963
964fn normalize_action_cache_key(alias: &str) -> Option<String> {
965    let trimmed = alias.trim();
966    if trimmed.is_empty() {
967        return None;
968    }
969
970    Some(
971        trimmed
972            .to_ascii_lowercase()
973            .replace('_', "-")
974            .split('-')
975            .filter(|part| !part.is_empty())
976            .collect::<Vec<_>>()
977            .join("-"),
978    )
979}
980
981fn build_connected_account_hint(
982    app_hint: Option<&str>,
983    entity_id: Option<&str>,
984    connected_account_ref: Option<&str>,
985) -> String {
986    if connected_account_ref.is_some() {
987        return String::new();
988    }
989
990    let Some(entity) = entity_id else {
991        return String::new();
992    };
993
994    if let Some(app) = app_hint {
995        format!(
996            " Hint: use action='list_accounts' with app='{app}' and entity_id='{entity}' to retrieve connected_account_id."
997        )
998    } else {
999        format!(
1000            " Hint: use action='list_accounts' with entity_id='{entity}' to retrieve connected_account_id."
1001        )
1002    }
1003}
1004
1005fn map_v3_tools_to_actions(items: Vec<ComposioV3Tool>) -> Vec<ComposioAction> {
1006    items
1007        .into_iter()
1008        .filter_map(|item| {
1009            let name = item.slug.or(item.name.clone())?;
1010            let app_name = item
1011                .toolkit
1012                .as_ref()
1013                .and_then(|toolkit| toolkit.slug.clone().or(toolkit.name.clone()))
1014                .or(item.app_name);
1015            let description = item.description.or(item.name);
1016            Some(ComposioAction {
1017                name,
1018                app_name,
1019                description,
1020                enabled: true,
1021                input_parameters: item.input_parameters,
1022            })
1023        })
1024        .collect()
1025}
1026
1027fn extract_redirect_url(result: &serde_json::Value) -> Option<String> {
1028    result
1029        .get("redirect_url")
1030        .and_then(|v| v.as_str())
1031        .or_else(|| result.get("redirectUrl").and_then(|v| v.as_str()))
1032        .or_else(|| {
1033            result
1034                .get("data")
1035                .and_then(|v| v.get("redirect_url"))
1036                .and_then(|v| v.as_str())
1037        })
1038        .map(ToString::to_string)
1039}
1040
1041fn extract_connected_account_id(result: &serde_json::Value) -> Option<String> {
1042    result
1043        .get("connected_account_id")
1044        .and_then(|v| v.as_str())
1045        .or_else(|| result.get("connectedAccountId").and_then(|v| v.as_str()))
1046        .or_else(|| {
1047            result
1048                .get("data")
1049                .and_then(|v| v.get("connected_account_id"))
1050                .and_then(|v| v.as_str())
1051        })
1052        .or_else(|| {
1053            result
1054                .get("data")
1055                .and_then(|v| v.get("connectedAccountId"))
1056                .and_then(|v| v.as_str())
1057        })
1058        .map(ToString::to_string)
1059}
1060
1061async fn response_error(resp: reqwest::Response) -> String {
1062    let status = resp.status();
1063    let body = resp.text().await.unwrap_or_default();
1064    if body.trim().is_empty() {
1065        return format!("HTTP {}", status.as_u16());
1066    }
1067
1068    if let Some(api_error) = extract_api_error_message(&body) {
1069        return format!(
1070            "HTTP {}: {}",
1071            status.as_u16(),
1072            sanitize_error_message(&api_error)
1073        );
1074    }
1075
1076    format!("HTTP {}", status.as_u16())
1077}
1078
1079fn sanitize_error_message(message: &str) -> String {
1080    let mut sanitized = message.replace('\n', " ");
1081    for marker in [
1082        "connected_account_id",
1083        "connectedAccountId",
1084        "entity_id",
1085        "entityId",
1086        "user_id",
1087        "userId",
1088    ] {
1089        sanitized = sanitized.replace(marker, "[redacted]");
1090    }
1091
1092    let max_chars = 240;
1093    if sanitized.chars().count() <= max_chars {
1094        sanitized
1095    } else {
1096        let mut end = max_chars;
1097        while end > 0 && !sanitized.is_char_boundary(end) {
1098            end -= 1;
1099        }
1100        format!("{}...", &sanitized[..end])
1101    }
1102}
1103
1104fn extract_api_error_message(body: &str) -> Option<String> {
1105    let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
1106    parsed
1107        .get("error")
1108        .and_then(|v| v.get("message"))
1109        .and_then(|v| v.as_str())
1110        .map(ToString::to_string)
1111        .or_else(|| {
1112            parsed
1113                .get("message")
1114                .and_then(|v| v.as_str())
1115                .map(ToString::to_string)
1116        })
1117}
1118
1119/// Build a compact hint string showing parameter key names from an `input_parameters` JSON Schema.
1120///
1121/// Used in the `list` output so the LLM can see what keys each action expects
1122/// without dumping the full schema.
1123fn format_input_params_hint(schema: Option<&serde_json::Value>) -> String {
1124    let props = schema
1125        .and_then(|v| v.get("properties"))
1126        .and_then(|v| v.as_object());
1127    let required: Vec<&str> = schema
1128        .and_then(|v| v.get("required"))
1129        .and_then(|v| v.as_array())
1130        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
1131        .unwrap_or_default();
1132
1133    let Some(props) = props else {
1134        return String::new();
1135    };
1136    if props.is_empty() {
1137        return String::new();
1138    }
1139
1140    let keys: Vec<String> = props
1141        .keys()
1142        .map(|k| {
1143            if required.contains(&k.as_str()) {
1144                format!("{k}*")
1145            } else {
1146                k.clone()
1147            }
1148        })
1149        .collect();
1150    format!(" [params: {}]", keys.join(", "))
1151}
1152
1153fn floor_char_boundary_compat(text: &str, index: usize) -> usize {
1154    let mut end = index.min(text.len());
1155    while end > 0 && !text.is_char_boundary(end) {
1156        end -= 1;
1157    }
1158    end
1159}
1160
1161/// Build a human-readable schema hint from a full tool schema response.
1162///
1163/// Used in execute error messages so the LLM can see the expected parameter
1164/// names and types to self-correct on the next attempt.
1165fn format_schema_hint(schema: &serde_json::Value) -> Option<String> {
1166    let input_params = schema.get("input_parameters")?;
1167    let props = input_params.get("properties")?.as_object()?;
1168    if props.is_empty() {
1169        return None;
1170    }
1171
1172    let required: Vec<&str> = input_params
1173        .get("required")
1174        .and_then(|v| v.as_array())
1175        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
1176        .unwrap_or_default();
1177
1178    let mut lines = Vec::new();
1179    for (key, spec) in props {
1180        let type_str = spec.get("type").and_then(|v| v.as_str()).unwrap_or("any");
1181        let desc = spec
1182            .get("description")
1183            .and_then(|v| v.as_str())
1184            .unwrap_or("");
1185        let req = if required.contains(&key.as_str()) {
1186            " (required)"
1187        } else {
1188            ""
1189        };
1190        let desc_suffix = if desc.is_empty() {
1191            String::new()
1192        } else {
1193            // Truncate long descriptions to keep the hint concise.
1194            // Use char boundary to avoid panic on multi-byte UTF-8.
1195            let short = if desc.len() > 80 {
1196                let end = floor_char_boundary_compat(desc, 77);
1197                format!("{}...", &desc[..end])
1198            } else {
1199                desc.to_string()
1200            };
1201            format!(" - {short}")
1202        };
1203        lines.push(format!("  {key}: {type_str}{req}{desc_suffix}"));
1204    }
1205
1206    Some(format!(
1207        "\n\nExpected input parameters:\n{}",
1208        lines.join("\n")
1209    ))
1210}
1211
1212// ── API response types ──────────────────────────────────────────
1213
1214#[derive(Debug, Deserialize)]
1215struct ComposioToolsResponse {
1216    #[serde(default)]
1217    items: Vec<ComposioV3Tool>,
1218}
1219
1220#[derive(Debug, Deserialize)]
1221struct ComposioConnectedAccountsResponse {
1222    #[serde(default)]
1223    items: Vec<ComposioConnectedAccount>,
1224}
1225
1226#[derive(Debug, Clone, Deserialize)]
1227struct ComposioConnectedAccount {
1228    id: String,
1229    #[serde(default)]
1230    status: String,
1231    #[serde(default)]
1232    toolkit: Option<ComposioToolkitRef>,
1233}
1234
1235impl ComposioConnectedAccount {
1236    fn is_usable(&self) -> bool {
1237        self.status.eq_ignore_ascii_case("INITIALIZING")
1238            || self.status.eq_ignore_ascii_case("ACTIVE")
1239            || self.status.eq_ignore_ascii_case("INITIATED")
1240    }
1241
1242    fn toolkit_slug(&self) -> Option<&str> {
1243        self.toolkit
1244            .as_ref()
1245            .and_then(|toolkit| toolkit.slug.as_deref())
1246    }
1247}
1248
1249#[derive(Debug, Clone, Deserialize)]
1250struct ComposioV3Tool {
1251    #[serde(default)]
1252    slug: Option<String>,
1253    #[serde(default)]
1254    name: Option<String>,
1255    #[serde(default)]
1256    description: Option<String>,
1257    #[serde(rename = "appName", default)]
1258    app_name: Option<String>,
1259    #[serde(default)]
1260    toolkit: Option<ComposioToolkitRef>,
1261    /// Full JSON Schema for the tool's input parameters (returned by v3 API).
1262    #[serde(default)]
1263    input_parameters: Option<serde_json::Value>,
1264}
1265
1266#[derive(Debug, Clone, Deserialize)]
1267struct ComposioToolkitRef {
1268    #[serde(default)]
1269    slug: Option<String>,
1270    #[serde(default)]
1271    name: Option<String>,
1272}
1273
1274#[derive(Debug, Deserialize)]
1275struct ComposioAuthConfigsResponse {
1276    #[serde(default)]
1277    items: Vec<ComposioAuthConfig>,
1278}
1279
1280#[derive(Debug, Clone)]
1281pub struct ComposioConnectionLink {
1282    pub redirect_url: String,
1283    pub connected_account_id: Option<String>,
1284}
1285
1286#[derive(Debug, Clone, Deserialize)]
1287struct ComposioAuthConfig {
1288    id: String,
1289    #[serde(default)]
1290    status: Option<String>,
1291    #[serde(default)]
1292    enabled: Option<bool>,
1293}
1294
1295impl ComposioAuthConfig {
1296    fn is_enabled(&self) -> bool {
1297        self.enabled.unwrap_or(false)
1298            || self
1299                .status
1300                .as_deref()
1301                .is_some_and(|v| v.eq_ignore_ascii_case("enabled"))
1302    }
1303}
1304
1305#[derive(Debug, Clone, Serialize, Deserialize)]
1306pub struct ComposioAction {
1307    pub name: String,
1308    #[serde(rename = "appName")]
1309    pub app_name: Option<String>,
1310    pub description: Option<String>,
1311    #[serde(default)]
1312    pub enabled: bool,
1313    /// Input parameter schema returned by the v3 API (absent from v2 responses).
1314    #[serde(default, skip_serializing_if = "Option::is_none")]
1315    pub input_parameters: Option<serde_json::Value>,
1316}
1317
1318#[cfg(test)]
1319mod tests {
1320    use super::*;
1321    use zeroclaw_config::autonomy::AutonomyLevel;
1322    use zeroclaw_config::policy::SecurityPolicy;
1323
1324    fn test_security() -> Arc<SecurityPolicy> {
1325        Arc::new(SecurityPolicy::default())
1326    }
1327
1328    // ── Constructor ───────────────────────────────────────────
1329
1330    #[test]
1331    fn composio_tool_has_correct_name() {
1332        let tool = ComposioTool::new("test-key", None, test_security());
1333        assert_eq!(tool.name(), "composio");
1334    }
1335
1336    #[test]
1337    fn composio_tool_has_description() {
1338        let _tool = ComposioTool::new("test-key", None, test_security());
1339        assert!(
1340            !ComposioTool::new("test-key", None, test_security())
1341                .description()
1342                .is_empty()
1343        );
1344        assert!(
1345            ComposioTool::new("test-key", None, test_security())
1346                .description()
1347                .contains("1000+")
1348        );
1349    }
1350
1351    #[test]
1352    fn composio_tool_schema_has_required_fields() {
1353        let tool = ComposioTool::new("test-key", None, test_security());
1354        let schema = tool.parameters_schema();
1355        assert!(schema["properties"]["action"].is_object());
1356        assert!(schema["properties"]["action_name"].is_object());
1357        assert!(schema["properties"]["tool_slug"].is_object());
1358        assert!(schema["properties"]["params"].is_object());
1359        assert!(schema["properties"]["app"].is_object());
1360        assert!(schema["properties"]["auth_config_id"].is_object());
1361        assert!(schema["properties"]["connected_account_id"].is_object());
1362        let required = schema["required"].as_array().unwrap();
1363        assert!(required.contains(&json!("action")));
1364        let enum_values = schema["properties"]["action"]["enum"]
1365            .as_array()
1366            .unwrap()
1367            .iter()
1368            .filter_map(|v| v.as_str())
1369            .collect::<Vec<_>>();
1370        assert!(enum_values.contains(&"list_accounts"));
1371    }
1372
1373    #[test]
1374    fn composio_tool_spec_roundtrip() {
1375        let tool = ComposioTool::new("test-key", None, test_security());
1376        let spec = tool.spec();
1377        assert_eq!(spec.name, "composio");
1378        assert!(spec.parameters.is_object());
1379    }
1380
1381    // ── Execute validation ────────────────────────────────────
1382
1383    #[tokio::test]
1384    async fn execute_missing_action_returns_error() {
1385        let tool = ComposioTool::new("test-key", None, test_security());
1386        let result = tool.execute(json!({})).await;
1387        assert!(result.is_err());
1388    }
1389
1390    #[tokio::test]
1391    async fn execute_unknown_action_returns_error() {
1392        let tool = ComposioTool::new("test-key", None, test_security());
1393        let result = tool.execute(json!({"action": "unknown"})).await.unwrap();
1394        assert!(!result.success);
1395        assert!(result.error.as_ref().unwrap().contains("Unknown action"));
1396    }
1397
1398    #[tokio::test]
1399    async fn execute_without_action_name_returns_error() {
1400        let tool = ComposioTool::new("test-key", None, test_security());
1401        let result = tool.execute(json!({"action": "execute"})).await;
1402        assert!(result.is_err());
1403    }
1404
1405    #[tokio::test]
1406    async fn connect_without_target_returns_error() {
1407        let tool = ComposioTool::new("test-key", None, test_security());
1408        let result = tool.execute(json!({"action": "connect"})).await;
1409        assert!(result.is_err());
1410    }
1411
1412    #[tokio::test]
1413    async fn execute_blocked_in_readonly_mode() {
1414        let readonly = Arc::new(SecurityPolicy {
1415            autonomy: AutonomyLevel::ReadOnly,
1416            ..SecurityPolicy::default()
1417        });
1418        let tool = ComposioTool::new("test-key", None, readonly);
1419        let result = tool
1420            .execute(json!({
1421                "action": "execute",
1422                "action_name": "GITHUB_LIST_REPOS"
1423            }))
1424            .await
1425            .unwrap();
1426        assert!(!result.success);
1427        assert!(
1428            result
1429                .error
1430                .as_deref()
1431                .unwrap_or("")
1432                .contains("read-only mode")
1433        );
1434    }
1435
1436    #[tokio::test]
1437    async fn execute_blocked_when_rate_limited() {
1438        let limited = Arc::new(SecurityPolicy {
1439            max_actions_per_hour: 0,
1440            ..SecurityPolicy::default()
1441        });
1442        let tool = ComposioTool::new("test-key", None, limited);
1443        let result = tool
1444            .execute(json!({
1445                "action": "execute",
1446                "action_name": "GITHUB_LIST_REPOS"
1447            }))
1448            .await
1449            .unwrap();
1450        assert!(!result.success);
1451        assert!(
1452            result
1453                .error
1454                .as_deref()
1455                .unwrap_or("")
1456                .contains("Rate limit exceeded")
1457        );
1458    }
1459
1460    // ── API response parsing ──────────────────────────────────
1461
1462    #[test]
1463    fn composio_action_deserializes() {
1464        let json_str = r#"{"name": "GMAIL_FETCH_EMAILS", "appName": "gmail", "description": "Fetch emails", "enabled": true}"#;
1465        let action: ComposioAction = serde_json::from_str(json_str).unwrap();
1466        assert_eq!(action.name, "GMAIL_FETCH_EMAILS");
1467        assert_eq!(action.app_name.as_deref(), Some("gmail"));
1468        assert!(action.enabled);
1469    }
1470
1471    #[test]
1472    fn composio_tools_response_deserializes() {
1473        let json_str = r#"{"items": [{"slug": "test-action", "name": "TEST_ACTION", "appName": "test", "description": "A test"}]}"#;
1474        let resp: ComposioToolsResponse = serde_json::from_str(json_str).unwrap();
1475        assert_eq!(resp.items.len(), 1);
1476        assert_eq!(resp.items[0].slug.as_deref(), Some("test-action"));
1477    }
1478
1479    #[test]
1480    fn composio_tools_response_empty() {
1481        let json_str = r#"{"items": []}"#;
1482        let resp: ComposioToolsResponse = serde_json::from_str(json_str).unwrap();
1483        assert!(resp.items.is_empty());
1484    }
1485
1486    #[test]
1487    fn composio_tools_response_missing_items_defaults() {
1488        let json_str = r"{}";
1489        let resp: ComposioToolsResponse = serde_json::from_str(json_str).unwrap();
1490        assert!(resp.items.is_empty());
1491    }
1492
1493    #[test]
1494    fn composio_v3_tools_response_maps_to_actions() {
1495        let json_str = r#"{
1496            "items": [
1497                {
1498                    "slug": "gmail-fetch-emails",
1499                    "name": "Gmail Fetch Emails",
1500                    "description": "Fetch inbox emails",
1501                    "toolkit": { "slug": "gmail", "name": "Gmail" }
1502                }
1503            ]
1504        }"#;
1505        let resp: ComposioToolsResponse = serde_json::from_str(json_str).unwrap();
1506        let actions = map_v3_tools_to_actions(resp.items);
1507        assert_eq!(actions.len(), 1);
1508        assert_eq!(actions[0].name, "gmail-fetch-emails");
1509        assert_eq!(actions[0].app_name.as_deref(), Some("gmail"));
1510        assert_eq!(
1511            actions[0].description.as_deref(),
1512            Some("Fetch inbox emails")
1513        );
1514    }
1515
1516    #[test]
1517    fn normalize_entity_id_falls_back_to_default_when_blank() {
1518        assert_eq!(normalize_entity_id("   "), "default");
1519        assert_eq!(normalize_entity_id("workspace-user"), "workspace-user");
1520    }
1521
1522    #[test]
1523    fn normalize_tool_slug_supports_legacy_action_name() {
1524        assert_eq!(
1525            normalize_tool_slug("GMAIL_FETCH_EMAILS"),
1526            "gmail-fetch-emails"
1527        );
1528        assert_eq!(
1529            normalize_tool_slug(" github-list-repos "),
1530            "github-list-repos"
1531        );
1532    }
1533
1534    #[test]
1535    fn build_tool_slug_candidates_cover_common_variants() {
1536        let candidates = build_tool_slug_candidates("GMAIL_FETCH_EMAILS");
1537        assert_eq!(
1538            candidates.first().map(String::as_str),
1539            Some("GMAIL_FETCH_EMAILS")
1540        );
1541        assert!(candidates.contains(&"gmail-fetch-emails".to_string()));
1542        assert!(candidates.contains(&"gmail_fetch_emails".to_string()));
1543        assert!(candidates.contains(&"GMAIL_FETCH_EMAILS".to_string()));
1544
1545        let hyphen = build_tool_slug_candidates("github-list-repos");
1546        assert_eq!(
1547            hyphen.first().map(String::as_str),
1548            Some("github-list-repos")
1549        );
1550        assert!(hyphen.contains(&"github_list_repos".to_string()));
1551    }
1552
1553    #[test]
1554    fn floor_char_boundary_compat_handles_multibyte_offsets() {
1555        let text = "abc😀def";
1556        // Byte offset 5 is inside the 4-byte emoji, so boundary should floor to 3.
1557        assert_eq!(floor_char_boundary_compat(text, 5), 3);
1558        assert_eq!(floor_char_boundary_compat(text, usize::MAX), text.len());
1559    }
1560
1561    #[test]
1562    fn normalize_action_cache_key_merges_underscore_and_hyphen_variants() {
1563        assert_eq!(
1564            normalize_action_cache_key(" GMAIL_FETCH_EMAILS ").as_deref(),
1565            Some("gmail-fetch-emails")
1566        );
1567        assert_eq!(
1568            normalize_action_cache_key("gmail-fetch-emails").as_deref(),
1569            Some("gmail-fetch-emails")
1570        );
1571        assert_eq!(normalize_action_cache_key("  ").as_deref(), None);
1572    }
1573
1574    #[test]
1575    fn normalize_app_slug_removes_spaces_and_normalizes_case() {
1576        assert_eq!(normalize_app_slug(" Gmail "), "gmail");
1577        assert_eq!(normalize_app_slug("GITHUB_APP"), "github-app");
1578    }
1579
1580    #[test]
1581    fn infer_app_slug_from_action_name_handles_v2_and_v3_formats() {
1582        assert_eq!(
1583            infer_app_slug_from_action_name("gmail-fetch-emails").as_deref(),
1584            Some("gmail")
1585        );
1586        assert_eq!(
1587            infer_app_slug_from_action_name("GMAIL_FETCH_EMAILS").as_deref(),
1588            Some("gmail")
1589        );
1590        assert!(infer_app_slug_from_action_name("execute").is_none());
1591    }
1592
1593    #[test]
1594    fn connected_account_cache_key_is_stable() {
1595        assert_eq!(
1596            connected_account_cache_key("GMAIL", " default "),
1597            "default:gmail"
1598        );
1599    }
1600
1601    #[test]
1602    fn build_connected_account_hint_returns_guidance_when_missing_ref() {
1603        let hint = build_connected_account_hint(Some("gmail"), Some("default"), None);
1604        assert!(hint.contains("list_accounts"));
1605        assert!(hint.contains("gmail"));
1606        assert!(hint.contains("default"));
1607    }
1608
1609    #[test]
1610    fn build_connected_account_hint_without_app_is_still_actionable() {
1611        let hint = build_connected_account_hint(None, Some("default"), None);
1612        assert!(hint.contains("list_accounts"));
1613        assert!(hint.contains("entity_id='default'"));
1614        assert!(!hint.contains("app='"));
1615    }
1616
1617    #[test]
1618    fn connected_account_is_usable_for_initializing_active_and_initiated() {
1619        for status in ["INITIALIZING", "ACTIVE", "INITIATED"] {
1620            let account = ComposioConnectedAccount {
1621                id: "ca_1".to_string(),
1622                status: status.to_string(),
1623                toolkit: None,
1624            };
1625            assert!(account.is_usable(), "status {status} should be usable");
1626        }
1627    }
1628
1629    #[test]
1630    fn extract_connected_account_id_supports_common_shapes() {
1631        let root = json!({"connected_account_id": "ca_root"});
1632        let camel = json!({"connectedAccountId": "ca_camel"});
1633        let nested = json!({"data": {"connected_account_id": "ca_nested"}});
1634
1635        assert_eq!(
1636            extract_connected_account_id(&root).as_deref(),
1637            Some("ca_root")
1638        );
1639        assert_eq!(
1640            extract_connected_account_id(&camel).as_deref(),
1641            Some("ca_camel")
1642        );
1643        assert_eq!(
1644            extract_connected_account_id(&nested).as_deref(),
1645            Some("ca_nested")
1646        );
1647    }
1648
1649    #[test]
1650    fn extract_redirect_url_supports_v2_and_v3_shapes() {
1651        let v2 = json!({"redirectUrl": "https://app.composio.dev/connect-v2"});
1652        let v3 = json!({"redirect_url": "https://app.composio.dev/connect-v3"});
1653        let nested = json!({"data": {"redirect_url": "https://app.composio.dev/connect-nested"}});
1654
1655        assert_eq!(
1656            extract_redirect_url(&v2).as_deref(),
1657            Some("https://app.composio.dev/connect-v2")
1658        );
1659        assert_eq!(
1660            extract_redirect_url(&v3).as_deref(),
1661            Some("https://app.composio.dev/connect-v3")
1662        );
1663        assert_eq!(
1664            extract_redirect_url(&nested).as_deref(),
1665            Some("https://app.composio.dev/connect-nested")
1666        );
1667    }
1668
1669    #[test]
1670    fn auth_config_prefers_enabled_status() {
1671        let enabled = ComposioAuthConfig {
1672            id: "cfg_1".into(),
1673            status: Some("ENABLED".into()),
1674            enabled: None,
1675        };
1676        let disabled = ComposioAuthConfig {
1677            id: "cfg_2".into(),
1678            status: Some("DISABLED".into()),
1679            enabled: Some(false),
1680        };
1681
1682        assert!(enabled.is_enabled());
1683        assert!(!disabled.is_enabled());
1684    }
1685
1686    #[test]
1687    fn extract_api_error_message_from_common_shapes() {
1688        let nested = r#"{"error":{"message":"tool not found"}}"#;
1689        let flat = r#"{"message":"invalid api key"}"#;
1690
1691        assert_eq!(
1692            extract_api_error_message(nested).as_deref(),
1693            Some("tool not found")
1694        );
1695        assert_eq!(
1696            extract_api_error_message(flat).as_deref(),
1697            Some("invalid api key")
1698        );
1699        assert_eq!(extract_api_error_message("not-json"), None);
1700    }
1701
1702    #[test]
1703    fn composio_action_with_null_fields() {
1704        let json_str =
1705            r#"{"name": "TEST_ACTION", "appName": null, "description": null, "enabled": false}"#;
1706        let action: ComposioAction = serde_json::from_str(json_str).unwrap();
1707        assert_eq!(action.name, "TEST_ACTION");
1708        assert!(action.app_name.is_none());
1709        assert!(action.description.is_none());
1710        assert!(!action.enabled);
1711    }
1712
1713    #[test]
1714    fn composio_action_with_special_characters() {
1715        let json_str = r#"{"name": "GMAIL_SEND_EMAIL_WITH_ATTACHMENT", "appName": "gmail", "description": "Send email with attachment & special chars: <>'\"\"", "enabled": true}"#;
1716        let action: ComposioAction = serde_json::from_str(json_str).unwrap();
1717        assert_eq!(action.name, "GMAIL_SEND_EMAIL_WITH_ATTACHMENT");
1718        assert!(action.description.as_ref().unwrap().contains('&'));
1719        assert!(action.description.as_ref().unwrap().contains('<'));
1720    }
1721
1722    #[test]
1723    fn composio_action_with_unicode() {
1724        let json_str = r#"{"name": "SLACK_SEND_MESSAGE", "appName": "slack", "description": "Send message with emoji 🎉 and unicode Ω", "enabled": true}"#;
1725        let action: ComposioAction = serde_json::from_str(json_str).unwrap();
1726        assert!(action.description.as_ref().unwrap().contains("🎉"));
1727        assert!(action.description.as_ref().unwrap().contains("Ω"));
1728    }
1729
1730    #[test]
1731    fn composio_malformed_json_returns_error() {
1732        let json_str = r#"{"name": "TEST_ACTION", "appName": "gmail", }"#;
1733        let result: Result<ComposioAction, _> = serde_json::from_str(json_str);
1734        assert!(result.is_err());
1735    }
1736
1737    #[test]
1738    fn composio_empty_json_string_returns_error() {
1739        let json_str = r#" ""#;
1740        let result: Result<ComposioAction, _> = serde_json::from_str(json_str);
1741        assert!(result.is_err());
1742    }
1743
1744    #[test]
1745    fn composio_large_actions_list() {
1746        let mut items = Vec::new();
1747        for i in 0..100 {
1748            items.push(json!({
1749                "slug": format!("action-{i}"),
1750                "name": format!("ACTION_{i}"),
1751                "app_name": "test",
1752                "description": "Test action"
1753            }));
1754        }
1755        let json_str = json!({"items": items}).to_string();
1756        let resp: ComposioToolsResponse = serde_json::from_str(&json_str).unwrap();
1757        assert_eq!(resp.items.len(), 100);
1758    }
1759
1760    #[test]
1761    fn composio_api_base_url_is_v3() {
1762        assert_eq!(COMPOSIO_API_BASE_V3, "https://backend.composio.dev/api/v3");
1763    }
1764
1765    #[test]
1766    fn build_execute_action_v3_request_uses_fixed_endpoint_and_body_account_id() {
1767        let (url, body) = ComposioTool::build_execute_action_v3_request(
1768            "gmail-send-email",
1769            json!({"to": "test@example.com"}),
1770            None,
1771            Some("workspace-user"),
1772            Some("account-42"),
1773        );
1774
1775        assert_eq!(
1776            url,
1777            "https://backend.composio.dev/api/v3/tools/execute/gmail-send-email"
1778        );
1779        assert_eq!(body["arguments"]["to"], json!("test@example.com"));
1780        assert_eq!(body["version"], json!(COMPOSIO_TOOL_VERSION_LATEST));
1781        assert_eq!(body["user_id"], json!("workspace-user"));
1782        assert_eq!(body["connected_account_id"], json!("account-42"));
1783    }
1784
1785    #[test]
1786    fn build_list_actions_v3_query_requests_latest_versions() {
1787        let query = ComposioTool::build_list_actions_v3_query(None)
1788            .into_iter()
1789            .collect::<HashMap<String, String>>();
1790        assert_eq!(
1791            query.get("toolkit_versions"),
1792            Some(&COMPOSIO_TOOL_VERSION_LATEST.to_string())
1793        );
1794        assert_eq!(query.get("limit"), Some(&"200".to_string()));
1795        assert!(!query.contains_key("toolkits"));
1796        assert!(!query.contains_key("toolkit_slug"));
1797    }
1798
1799    #[test]
1800    fn build_list_actions_v3_query_adds_app_filters_when_present() {
1801        let query = ComposioTool::build_list_actions_v3_query(Some(" github "))
1802            .into_iter()
1803            .collect::<HashMap<String, String>>();
1804        assert_eq!(
1805            query.get("toolkit_versions"),
1806            Some(&COMPOSIO_TOOL_VERSION_LATEST.to_string())
1807        );
1808        assert_eq!(query.get("toolkits"), Some(&"github".to_string()));
1809        assert_eq!(query.get("toolkit_slug"), Some(&"github".to_string()));
1810    }
1811
1812    // ── resolve_connected_account_ref (multi-account fix) ────
1813
1814    #[test]
1815    fn resolve_picks_first_usable_when_multiple_accounts_exist() {
1816        // Regression test for issue #959: previously returned None when
1817        // multiple accounts existed, causing the LLM to loop on the OAuth URL.
1818        let accounts = vec![
1819            ComposioConnectedAccount {
1820                id: "ca_old".to_string(),
1821                status: "ACTIVE".to_string(),
1822                toolkit: None,
1823            },
1824            ComposioConnectedAccount {
1825                id: "ca_new".to_string(),
1826                status: "ACTIVE".to_string(),
1827                toolkit: None,
1828            },
1829        ];
1830        // Simulate what resolve_connected_account_ref does: find first usable.
1831        let resolved = accounts.into_iter().find(|a| a.is_usable()).map(|a| a.id);
1832        assert_eq!(resolved.as_deref(), Some("ca_old"));
1833    }
1834
1835    #[test]
1836    fn resolve_picks_first_usable_skipping_unusable_head() {
1837        let accounts = vec![
1838            ComposioConnectedAccount {
1839                id: "ca_dead".to_string(),
1840                status: "DISCONNECTED".to_string(),
1841                toolkit: None,
1842            },
1843            ComposioConnectedAccount {
1844                id: "ca_live".to_string(),
1845                status: "ACTIVE".to_string(),
1846                toolkit: None,
1847            },
1848        ];
1849        let resolved = accounts.into_iter().find(|a| a.is_usable()).map(|a| a.id);
1850        assert_eq!(resolved.as_deref(), Some("ca_live"));
1851    }
1852
1853    #[test]
1854    fn resolve_returns_none_when_no_usable_accounts() {
1855        let accounts = vec![ComposioConnectedAccount {
1856            id: "ca_dead".to_string(),
1857            status: "DISCONNECTED".to_string(),
1858            toolkit: None,
1859        }];
1860        let resolved = accounts.into_iter().find(|a| a.is_usable()).map(|a| a.id);
1861        assert!(resolved.is_none());
1862    }
1863
1864    #[test]
1865    fn resolve_returns_none_for_empty_accounts() {
1866        let accounts: Vec<ComposioConnectedAccount> = vec![];
1867        let resolved = accounts.into_iter().find(|a| a.is_usable()).map(|a| a.id);
1868        assert!(resolved.is_none());
1869    }
1870
1871    // ── connected_accounts alias ──────────────────────────────
1872
1873    #[tokio::test]
1874    async fn connected_accounts_alias_dispatches_same_as_list_accounts() {
1875        // Both spellings should reach the same handler and return the same
1876        // shape of error (network failure in test, not a dispatch error).
1877        let tool = ComposioTool::new("test-key", None, test_security());
1878        let r1 = tool
1879            .execute(json!({"action": "list_accounts"}))
1880            .await
1881            .unwrap();
1882        let r2 = tool
1883            .execute(json!({"action": "connected_accounts"}))
1884            .await
1885            .unwrap();
1886        // Both fail the same way (network) — neither is a dispatch error.
1887        assert!(!r1.success);
1888        assert!(!r2.success);
1889        let e1 = r1.error.unwrap_or_default();
1890        let e2 = r2.error.unwrap_or_default();
1891        assert!(!e1.contains("Unknown action"), "list_accounts: {e1}");
1892        assert!(!e2.contains("Unknown action"), "connected_accounts: {e2}");
1893    }
1894
1895    #[test]
1896    fn schema_enum_includes_connected_accounts_alias() {
1897        let tool = ComposioTool::new("test-key", None, test_security());
1898        let schema = tool.parameters_schema();
1899        let values: Vec<&str> = schema["properties"]["action"]["enum"]
1900            .as_array()
1901            .unwrap()
1902            .iter()
1903            .filter_map(|v| v.as_str())
1904            .collect();
1905        assert!(values.contains(&"connected_accounts"));
1906        assert!(values.contains(&"list_accounts"));
1907    }
1908
1909    #[test]
1910    fn description_mentions_connected_accounts() {
1911        let tool = ComposioTool::new("test-key", None, test_security());
1912        assert!(tool.description().contains("connected_accounts"));
1913    }
1914
1915    #[test]
1916    fn build_execute_action_v3_request_drops_blank_optional_fields() {
1917        let (url, body) = ComposioTool::build_execute_action_v3_request(
1918            "github-list-repos",
1919            json!({}),
1920            None,
1921            None,
1922            Some("   "),
1923        );
1924
1925        assert_eq!(
1926            url,
1927            "https://backend.composio.dev/api/v3/tools/execute/github-list-repos"
1928        );
1929        assert_eq!(body["arguments"], json!({}));
1930        assert_eq!(body["version"], json!(COMPOSIO_TOOL_VERSION_LATEST));
1931        assert!(body.get("connected_account_id").is_none());
1932        assert!(body.get("user_id").is_none());
1933    }
1934}