1use 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
35pub 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 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 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 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 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 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 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 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 "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 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 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
1125fn 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
1167fn 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 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#[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 #[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 #[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 #[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 #[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 #[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 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 #[test]
1820 fn resolve_picks_first_usable_when_multiple_accounts_exist() {
1821 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 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 #[tokio::test]
1879 async fn connected_accounts_alias_dispatches_same_as_list_accounts() {
1880 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 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}