Skip to main content

construct/tools/
composio.rs

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