fix(composio): align v3 execute path and honor configured entity_id (#322)

This commit is contained in:
Chummy 2026-02-16 23:40:37 +08:00 committed by GitHub
parent a403b5f5b1
commit 23b0f360c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 86 additions and 32 deletions

View file

@ -315,6 +315,8 @@ native_webdriver_url = "http://127.0.0.1:9515" # WebDriver endpoint (chromedrive
[composio] [composio]
enabled = false # opt-in: 1000+ OAuth apps via composio.dev enabled = false # opt-in: 1000+ OAuth apps via composio.dev
# api_key = "cmp_..." # optional: stored encrypted when [secrets].encrypt = true
entity_id = "default" # default user_id for Composio tool calls
[identity] [identity]
format = "openclaw" # "openclaw" (default, markdown files) or "aieos" (JSON) format = "openclaw" # "openclaw" (default, markdown files) or "aieos" (JSON)

View file

@ -583,16 +583,20 @@ pub async fn run(
tracing::info!(backend = mem.name(), "Memory initialized"); tracing::info!(backend = mem.name(), "Memory initialized");
// ── Tools (including memory tools) ──────────────────────────── // ── Tools (including memory tools) ────────────────────────────
let composio_key = if config.composio.enabled { let (composio_key, composio_entity_id) = if config.composio.enabled {
config.composio.api_key.as_deref() (
config.composio.api_key.as_deref(),
Some(config.composio.entity_id.as_str()),
)
} else { } else {
None (None, None)
}; };
let tools_registry = tools::all_tools_with_runtime( let tools_registry = tools::all_tools_with_runtime(
&security, &security,
runtime, runtime,
mem.clone(), mem.clone(),
composio_key, composio_key,
composio_entity_id,
&config.browser, &config.browser,
&config.http_request, &config.http_request,
&config.workspace_dir, &config.workspace_dir,
@ -670,7 +674,7 @@ pub async fn run(
if config.composio.enabled { if config.composio.enabled {
tool_descs.push(( tool_descs.push((
"composio", "composio",
"Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to discover, 'execute' to run, 'connect' to OAuth.", "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to discover, 'execute' to run (optionally with connected_account_id), 'connect' to OAuth.",
)); ));
} }
tool_descs.push(( tool_descs.push((

View file

@ -715,16 +715,20 @@ pub async fn start_channels(config: Config) -> Result<()> {
config.api_key.as_deref(), config.api_key.as_deref(),
)?); )?);
let composio_key = if config.composio.enabled { let (composio_key, composio_entity_id) = if config.composio.enabled {
config.composio.api_key.as_deref() (
config.composio.api_key.as_deref(),
Some(config.composio.entity_id.as_str()),
)
} else { } else {
None (None, None)
}; };
let tools_registry = Arc::new(tools::all_tools_with_runtime( let tools_registry = Arc::new(tools::all_tools_with_runtime(
&security, &security,
runtime, runtime,
Arc::clone(&mem), Arc::clone(&mem),
composio_key, composio_key,
composio_entity_id,
&config.browser, &config.browser,
&config.http_request, &config.http_request,
&config.workspace_dir, &config.workspace_dir,
@ -774,7 +778,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
if config.composio.enabled { if config.composio.enabled {
tool_descs.push(( tool_descs.push((
"composio", "composio",
"Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to discover, 'execute' to run, 'connect' to OAuth.", "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to discover, 'execute' to run (optionally with connected_account_id), 'connect' to OAuth.",
)); ));
} }
tool_descs.push(( tool_descs.push((

View file

@ -251,10 +251,13 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
&config.workspace_dir, &config.workspace_dir,
)); ));
let composio_key = if config.composio.enabled { let (composio_key, composio_entity_id) = if config.composio.enabled {
config.composio.api_key.as_deref() (
config.composio.api_key.as_deref(),
Some(config.composio.entity_id.as_str()),
)
} else { } else {
None (None, None)
}; };
let tools_registry = Arc::new(tools::all_tools_with_runtime( let tools_registry = Arc::new(tools::all_tools_with_runtime(
@ -262,6 +265,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
runtime, runtime,
Arc::clone(&mem), Arc::clone(&mem),
composio_key, composio_key,
composio_entity_id,
&config.browser, &config.browser,
&config.http_request, &config.http_request,
&config.workspace_dir, &config.workspace_dir,

View file

@ -19,13 +19,15 @@ const COMPOSIO_API_BASE_V3: &str = "https://backend.composio.dev/api/v3";
/// A tool that proxies actions to the Composio managed tool platform. /// A tool that proxies actions to the Composio managed tool platform.
pub struct ComposioTool { pub struct ComposioTool {
api_key: String, api_key: String,
default_entity_id: String,
client: Client, client: Client,
} }
impl ComposioTool { impl ComposioTool {
pub fn new(api_key: &str) -> Self { pub fn new(api_key: &str, default_entity_id: Option<&str>) -> Self {
Self { Self {
api_key: api_key.to_string(), api_key: api_key.to_string(),
default_entity_id: normalize_entity_id(default_entity_id.unwrap_or("default")),
client: Client::builder() client: Client::builder()
.timeout(std::time::Duration::from_secs(60)) .timeout(std::time::Duration::from_secs(60))
.connect_timeout(std::time::Duration::from_secs(10)) .connect_timeout(std::time::Duration::from_secs(10))
@ -59,9 +61,9 @@ impl ComposioTool {
let url = format!("{COMPOSIO_API_BASE_V3}/tools"); let url = format!("{COMPOSIO_API_BASE_V3}/tools");
let mut req = self.client.get(&url).header("x-api-key", &self.api_key); let mut req = self.client.get(&url).header("x-api-key", &self.api_key);
req = req.query(&[("limit", 200_u16)]); req = req.query(&[("limit", "200")]);
if let Some(app) = app_name { if let Some(app) = app_name.map(str::trim).filter(|app| !app.is_empty()) {
req = req.query(&[("toolkit_slug", app)]); req = req.query(&[("toolkits", app), ("toolkit_slug", app)]);
} }
let resp = req.send().await?; let resp = req.send().await?;
@ -110,11 +112,12 @@ impl ComposioTool {
action_name: &str, action_name: &str,
params: serde_json::Value, params: serde_json::Value,
entity_id: Option<&str>, entity_id: Option<&str>,
connected_account_id: Option<&str>,
) -> anyhow::Result<serde_json::Value> { ) -> anyhow::Result<serde_json::Value> {
let tool_slug = normalize_tool_slug(action_name); let tool_slug = normalize_tool_slug(action_name);
match self match self
.execute_action_v3(&tool_slug, params.clone(), entity_id) .execute_action_v3(&tool_slug, params.clone(), entity_id, connected_account_id)
.await .await
{ {
Ok(result) => Ok(result), Ok(result) => Ok(result),
@ -132,8 +135,16 @@ impl ComposioTool {
tool_slug: &str, tool_slug: &str,
params: serde_json::Value, params: serde_json::Value,
entity_id: Option<&str>, entity_id: Option<&str>,
connected_account_id: Option<&str>,
) -> anyhow::Result<serde_json::Value> { ) -> anyhow::Result<serde_json::Value> {
let url = format!("{COMPOSIO_API_BASE_V3}/tools/execute/{tool_slug}"); let url = if let Some(connected_account_id) = connected_account_id
.map(str::trim)
.filter(|id| !id.is_empty())
{
format!("{COMPOSIO_API_BASE_V3}/tools/{tool_slug}/execute/{connected_account_id}")
} else {
format!("{COMPOSIO_API_BASE_V3}/tools/{tool_slug}/execute")
};
let mut body = json!({ let mut body = json!({
"arguments": params, "arguments": params,
@ -355,7 +366,7 @@ impl Tool for ComposioTool {
fn description(&self) -> &str { fn description(&self) -> &str {
"Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). \ "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). \
Use action='list' to see available actions, action='execute' with action_name/tool_slug and params, \ Use action='list' to see available actions, action='execute' with action_name/tool_slug, params, and optional connected_account_id, \
or action='connect' with app/auth_config_id to get OAuth URL." or action='connect' with app/auth_config_id to get OAuth URL."
} }
@ -386,11 +397,15 @@ impl Tool for ComposioTool {
}, },
"entity_id": { "entity_id": {
"type": "string", "type": "string",
"description": "Entity/user ID for multi-user setups (defaults to 'default')" "description": "Entity/user ID for multi-user setups (defaults to composio.entity_id from config)"
}, },
"auth_config_id": { "auth_config_id": {
"type": "string", "type": "string",
"description": "Optional Composio v3 auth config id for connect flow" "description": "Optional Composio v3 auth config id for connect flow"
},
"connected_account_id": {
"type": "string",
"description": "Optional connected account ID for execute flow when a specific account is required"
} }
}, },
"required": ["action"] "required": ["action"]
@ -406,7 +421,7 @@ impl Tool for ComposioTool {
let entity_id = args let entity_id = args
.get("entity_id") .get("entity_id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("default"); .unwrap_or(self.default_entity_id.as_str());
match action { match action {
"list" => { "list" => {
@ -459,9 +474,11 @@ impl Tool for ComposioTool {
})?; })?;
let params = args.get("params").cloned().unwrap_or(json!({})); let params = args.get("params").cloned().unwrap_or(json!({}));
let connected_account_id =
args.get("connected_account_id").and_then(|v| v.as_str());
match self match self
.execute_action(action_name, params, Some(entity_id)) .execute_action(action_name, params, Some(entity_id), connected_account_id)
.await .await
{ {
Ok(result) => { Ok(result) => {
@ -521,6 +538,15 @@ impl Tool for ComposioTool {
} }
} }
fn normalize_entity_id(entity_id: &str) -> String {
let trimmed = entity_id.trim();
if trimmed.is_empty() {
"default".to_string()
} else {
trimmed.to_string()
}
}
fn normalize_tool_slug(action_name: &str) -> String { fn normalize_tool_slug(action_name: &str) -> String {
action_name.trim().replace('_', "-").to_ascii_lowercase() action_name.trim().replace('_', "-").to_ascii_lowercase()
} }
@ -668,20 +694,20 @@ mod tests {
#[test] #[test]
fn composio_tool_has_correct_name() { fn composio_tool_has_correct_name() {
let tool = ComposioTool::new("test-key"); let tool = ComposioTool::new("test-key", None);
assert_eq!(tool.name(), "composio"); assert_eq!(tool.name(), "composio");
} }
#[test] #[test]
fn composio_tool_has_description() { fn composio_tool_has_description() {
let tool = ComposioTool::new("test-key"); let tool = ComposioTool::new("test-key", None);
assert!(!tool.description().is_empty()); assert!(!tool.description().is_empty());
assert!(tool.description().contains("1000+")); assert!(tool.description().contains("1000+"));
} }
#[test] #[test]
fn composio_tool_schema_has_required_fields() { fn composio_tool_schema_has_required_fields() {
let tool = ComposioTool::new("test-key"); let tool = ComposioTool::new("test-key", None);
let schema = tool.parameters_schema(); let schema = tool.parameters_schema();
assert!(schema["properties"]["action"].is_object()); assert!(schema["properties"]["action"].is_object());
assert!(schema["properties"]["action_name"].is_object()); assert!(schema["properties"]["action_name"].is_object());
@ -689,13 +715,14 @@ mod tests {
assert!(schema["properties"]["params"].is_object()); assert!(schema["properties"]["params"].is_object());
assert!(schema["properties"]["app"].is_object()); assert!(schema["properties"]["app"].is_object());
assert!(schema["properties"]["auth_config_id"].is_object()); assert!(schema["properties"]["auth_config_id"].is_object());
assert!(schema["properties"]["connected_account_id"].is_object());
let required = schema["required"].as_array().unwrap(); let required = schema["required"].as_array().unwrap();
assert!(required.contains(&json!("action"))); assert!(required.contains(&json!("action")));
} }
#[test] #[test]
fn composio_tool_spec_roundtrip() { fn composio_tool_spec_roundtrip() {
let tool = ComposioTool::new("test-key"); let tool = ComposioTool::new("test-key", None);
let spec = tool.spec(); let spec = tool.spec();
assert_eq!(spec.name, "composio"); assert_eq!(spec.name, "composio");
assert!(spec.parameters.is_object()); assert!(spec.parameters.is_object());
@ -705,14 +732,14 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn execute_missing_action_returns_error() { async fn execute_missing_action_returns_error() {
let tool = ComposioTool::new("test-key"); let tool = ComposioTool::new("test-key", None);
let result = tool.execute(json!({})).await; let result = tool.execute(json!({})).await;
assert!(result.is_err()); assert!(result.is_err());
} }
#[tokio::test] #[tokio::test]
async fn execute_unknown_action_returns_error() { async fn execute_unknown_action_returns_error() {
let tool = ComposioTool::new("test-key"); let tool = ComposioTool::new("test-key", None);
let result = tool.execute(json!({"action": "unknown"})).await.unwrap(); let result = tool.execute(json!({"action": "unknown"})).await.unwrap();
assert!(!result.success); assert!(!result.success);
assert!(result.error.as_ref().unwrap().contains("Unknown action")); assert!(result.error.as_ref().unwrap().contains("Unknown action"));
@ -720,14 +747,14 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn execute_without_action_name_returns_error() { async fn execute_without_action_name_returns_error() {
let tool = ComposioTool::new("test-key"); let tool = ComposioTool::new("test-key", None);
let result = tool.execute(json!({"action": "execute"})).await; let result = tool.execute(json!({"action": "execute"})).await;
assert!(result.is_err()); assert!(result.is_err());
} }
#[tokio::test] #[tokio::test]
async fn connect_without_target_returns_error() { async fn connect_without_target_returns_error() {
let tool = ComposioTool::new("test-key"); let tool = ComposioTool::new("test-key", None);
let result = tool.execute(json!({"action": "connect"})).await; let result = tool.execute(json!({"action": "connect"})).await;
assert!(result.is_err()); assert!(result.is_err());
} }
@ -788,6 +815,12 @@ mod tests {
); );
} }
#[test]
fn normalize_entity_id_falls_back_to_default_when_blank() {
assert_eq!(normalize_entity_id(" "), "default");
assert_eq!(normalize_entity_id("workspace-user"), "workspace-user");
}
#[test] #[test]
fn normalize_tool_slug_supports_legacy_action_name() { fn normalize_tool_slug_supports_legacy_action_name() {
assert_eq!( assert_eq!(

View file

@ -59,11 +59,12 @@ pub fn default_tools_with_runtime(
} }
/// Create full tool registry including memory tools and optional Composio /// Create full tool registry including memory tools and optional Composio
#[allow(clippy::implicit_hasher)] #[allow(clippy::implicit_hasher, clippy::too_many_arguments)]
pub fn all_tools( pub fn all_tools(
security: &Arc<SecurityPolicy>, security: &Arc<SecurityPolicy>,
memory: Arc<dyn Memory>, memory: Arc<dyn Memory>,
composio_key: Option<&str>, composio_key: Option<&str>,
composio_entity_id: Option<&str>,
browser_config: &crate::config::BrowserConfig, browser_config: &crate::config::BrowserConfig,
http_config: &crate::config::HttpRequestConfig, http_config: &crate::config::HttpRequestConfig,
workspace_dir: &std::path::Path, workspace_dir: &std::path::Path,
@ -76,6 +77,7 @@ pub fn all_tools(
Arc::new(NativeRuntime::new()), Arc::new(NativeRuntime::new()),
memory, memory,
composio_key, composio_key,
composio_entity_id,
browser_config, browser_config,
http_config, http_config,
workspace_dir, workspace_dir,
@ -86,12 +88,13 @@ pub fn all_tools(
} }
/// Create full tool registry including memory tools and optional Composio. /// Create full tool registry including memory tools and optional Composio.
#[allow(clippy::implicit_hasher)] #[allow(clippy::implicit_hasher, clippy::too_many_arguments)]
pub fn all_tools_with_runtime( pub fn all_tools_with_runtime(
security: &Arc<SecurityPolicy>, security: &Arc<SecurityPolicy>,
runtime: Arc<dyn RuntimeAdapter>, runtime: Arc<dyn RuntimeAdapter>,
memory: Arc<dyn Memory>, memory: Arc<dyn Memory>,
composio_key: Option<&str>, composio_key: Option<&str>,
composio_entity_id: Option<&str>,
browser_config: &crate::config::BrowserConfig, browser_config: &crate::config::BrowserConfig,
http_config: &crate::config::HttpRequestConfig, http_config: &crate::config::HttpRequestConfig,
workspace_dir: &std::path::Path, workspace_dir: &std::path::Path,
@ -146,7 +149,7 @@ pub fn all_tools_with_runtime(
if let Some(key) = composio_key { if let Some(key) = composio_key {
if !key.is_empty() { if !key.is_empty() {
tools.push(Box::new(ComposioTool::new(key))); tools.push(Box::new(ComposioTool::new(key, composio_entity_id)));
} }
} }
@ -206,6 +209,7 @@ mod tests {
&security, &security,
mem, mem,
None, None,
None,
&browser, &browser,
&http, &http,
tmp.path(), tmp.path(),
@ -242,6 +246,7 @@ mod tests {
&security, &security,
mem, mem,
None, None,
None,
&browser, &browser,
&http, &http,
tmp.path(), tmp.path(),
@ -379,6 +384,7 @@ mod tests {
&security, &security,
mem, mem,
None, None,
None,
&browser, &browser,
&http, &http,
tmp.path(), tmp.path(),
@ -409,6 +415,7 @@ mod tests {
&security, &security,
mem, mem,
None, None,
None,
&browser, &browser,
&http, &http,
tmp.path(), tmp.path(),