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

@ -583,16 +583,20 @@ pub async fn run(
tracing::info!(backend = mem.name(), "Memory initialized");
// ── Tools (including memory tools) ────────────────────────────
let composio_key = if config.composio.enabled {
config.composio.api_key.as_deref()
let (composio_key, composio_entity_id) = if config.composio.enabled {
(
config.composio.api_key.as_deref(),
Some(config.composio.entity_id.as_str()),
)
} else {
None
(None, None)
};
let tools_registry = tools::all_tools_with_runtime(
&security,
runtime,
mem.clone(),
composio_key,
composio_entity_id,
&config.browser,
&config.http_request,
&config.workspace_dir,
@ -670,7 +674,7 @@ pub async fn run(
if config.composio.enabled {
tool_descs.push((
"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((

View file

@ -715,16 +715,20 @@ pub async fn start_channels(config: Config) -> Result<()> {
config.api_key.as_deref(),
)?);
let composio_key = if config.composio.enabled {
config.composio.api_key.as_deref()
let (composio_key, composio_entity_id) = if config.composio.enabled {
(
config.composio.api_key.as_deref(),
Some(config.composio.entity_id.as_str()),
)
} else {
None
(None, None)
};
let tools_registry = Arc::new(tools::all_tools_with_runtime(
&security,
runtime,
Arc::clone(&mem),
composio_key,
composio_entity_id,
&config.browser,
&config.http_request,
&config.workspace_dir,
@ -774,7 +778,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
if config.composio.enabled {
tool_descs.push((
"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((

View file

@ -251,10 +251,13 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
&config.workspace_dir,
));
let composio_key = if config.composio.enabled {
config.composio.api_key.as_deref()
let (composio_key, composio_entity_id) = if config.composio.enabled {
(
config.composio.api_key.as_deref(),
Some(config.composio.entity_id.as_str()),
)
} else {
None
(None, None)
};
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,
Arc::clone(&mem),
composio_key,
composio_entity_id,
&config.browser,
&config.http_request,
&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.
pub struct ComposioTool {
api_key: String,
default_entity_id: String,
client: Client,
}
impl ComposioTool {
pub fn new(api_key: &str) -> Self {
pub fn new(api_key: &str, default_entity_id: Option<&str>) -> Self {
Self {
api_key: api_key.to_string(),
default_entity_id: normalize_entity_id(default_entity_id.unwrap_or("default")),
client: Client::builder()
.timeout(std::time::Duration::from_secs(60))
.connect_timeout(std::time::Duration::from_secs(10))
@ -59,9 +61,9 @@ impl ComposioTool {
let url = format!("{COMPOSIO_API_BASE_V3}/tools");
let mut req = self.client.get(&url).header("x-api-key", &self.api_key);
req = req.query(&[("limit", 200_u16)]);
if let Some(app) = app_name {
req = req.query(&[("toolkit_slug", app)]);
req = req.query(&[("limit", "200")]);
if let Some(app) = app_name.map(str::trim).filter(|app| !app.is_empty()) {
req = req.query(&[("toolkits", app), ("toolkit_slug", app)]);
}
let resp = req.send().await?;
@ -110,11 +112,12 @@ impl ComposioTool {
action_name: &str,
params: serde_json::Value,
entity_id: Option<&str>,
connected_account_id: Option<&str>,
) -> anyhow::Result<serde_json::Value> {
let tool_slug = normalize_tool_slug(action_name);
match self
.execute_action_v3(&tool_slug, params.clone(), entity_id)
.execute_action_v3(&tool_slug, params.clone(), entity_id, connected_account_id)
.await
{
Ok(result) => Ok(result),
@ -132,8 +135,16 @@ impl ComposioTool {
tool_slug: &str,
params: serde_json::Value,
entity_id: Option<&str>,
connected_account_id: Option<&str>,
) -> 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!({
"arguments": params,
@ -355,7 +366,7 @@ impl Tool for ComposioTool {
fn description(&self) -> &str {
"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."
}
@ -386,11 +397,15 @@ impl Tool for ComposioTool {
},
"entity_id": {
"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": {
"type": "string",
"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"]
@ -406,7 +421,7 @@ impl Tool for ComposioTool {
let entity_id = args
.get("entity_id")
.and_then(|v| v.as_str())
.unwrap_or("default");
.unwrap_or(self.default_entity_id.as_str());
match action {
"list" => {
@ -459,9 +474,11 @@ impl Tool for ComposioTool {
})?;
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
.execute_action(action_name, params, Some(entity_id))
.execute_action(action_name, params, Some(entity_id), connected_account_id)
.await
{
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 {
action_name.trim().replace('_', "-").to_ascii_lowercase()
}
@ -668,20 +694,20 @@ mod tests {
#[test]
fn composio_tool_has_correct_name() {
let tool = ComposioTool::new("test-key");
let tool = ComposioTool::new("test-key", None);
assert_eq!(tool.name(), "composio");
}
#[test]
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().contains("1000+"));
}
#[test]
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();
assert!(schema["properties"]["action"].is_object());
assert!(schema["properties"]["action_name"].is_object());
@ -689,13 +715,14 @@ mod tests {
assert!(schema["properties"]["params"].is_object());
assert!(schema["properties"]["app"].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();
assert!(required.contains(&json!("action")));
}
#[test]
fn composio_tool_spec_roundtrip() {
let tool = ComposioTool::new("test-key");
let tool = ComposioTool::new("test-key", None);
let spec = tool.spec();
assert_eq!(spec.name, "composio");
assert!(spec.parameters.is_object());
@ -705,14 +732,14 @@ mod tests {
#[tokio::test]
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;
assert!(result.is_err());
}
#[tokio::test]
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();
assert!(!result.success);
assert!(result.error.as_ref().unwrap().contains("Unknown action"));
@ -720,14 +747,14 @@ mod tests {
#[tokio::test]
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;
assert!(result.is_err());
}
#[tokio::test]
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;
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]
fn normalize_tool_slug_supports_legacy_action_name() {
assert_eq!(

View file

@ -59,11 +59,12 @@ pub fn default_tools_with_runtime(
}
/// 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(
security: &Arc<SecurityPolicy>,
memory: Arc<dyn Memory>,
composio_key: Option<&str>,
composio_entity_id: Option<&str>,
browser_config: &crate::config::BrowserConfig,
http_config: &crate::config::HttpRequestConfig,
workspace_dir: &std::path::Path,
@ -76,6 +77,7 @@ pub fn all_tools(
Arc::new(NativeRuntime::new()),
memory,
composio_key,
composio_entity_id,
browser_config,
http_config,
workspace_dir,
@ -86,12 +88,13 @@ pub fn all_tools(
}
/// 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(
security: &Arc<SecurityPolicy>,
runtime: Arc<dyn RuntimeAdapter>,
memory: Arc<dyn Memory>,
composio_key: Option<&str>,
composio_entity_id: Option<&str>,
browser_config: &crate::config::BrowserConfig,
http_config: &crate::config::HttpRequestConfig,
workspace_dir: &std::path::Path,
@ -146,7 +149,7 @@ pub fn all_tools_with_runtime(
if let Some(key) = composio_key {
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,
mem,
None,
None,
&browser,
&http,
tmp.path(),
@ -242,6 +246,7 @@ mod tests {
&security,
mem,
None,
None,
&browser,
&http,
tmp.path(),
@ -379,6 +384,7 @@ mod tests {
&security,
mem,
None,
None,
&browser,
&http,
tmp.path(),
@ -409,6 +415,7 @@ mod tests {
&security,
mem,
None,
None,
&browser,
&http,
tmp.path(),