Compare commits

...
Author SHA1 Message Date
MervinPraisonandMervin Praison 090b910800 fix(composio): never use api_key as user_id fallback in v3 SDK
The API key is an auth credential, not an entity identifier. Fall back
to "default" only. Add test for explicit user_id and correct the v3
call assertion.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-08 20:02:57 +00:00
MervinPraisonandpraisonai-triage-agent[bot] f2d1d3695d fix(composio): honor explicit api_key and use v3 toolkits API
- _check_composio_available() now accepts an explicit api_key and uses it
  as a fallback, fixing a broken contract where a key passed to
  composio_tools()/ComposioTools was silently ignored.
- Availability check requires the `composio` package only, removing the
  false-positive composio_openai branch that led to silent ImportError.
- get_tools() maps to the Composio v3 SDK (`toolkits`, required `user_id`)
  for the new client while preserving legacy ComposioToolSet behaviour.
- Remove redundant availability check inside _get_client().
- list_apps() handles string app entries defensively.

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-07-08 13:44:34 +00:00
MervinPraison 6a560069da feat: port Composio agent-callable tools from core SDK
Add composio_tools(), composio_list_apps(), ComposioTools class,
[composio] optional extra, tests, and README usage docs.
2026-07-08 14:39:10 +01:00
6 changed files with 318 additions and 22 deletions

No files matched your search

+23
View File
@@ -660,6 +660,29 @@ agent.start("Check the weather in London and send a Slack message about it")
---
## Composio Integration
Load 250+ managed app integrations (GitHub, Slack, Gmail, Jira, etc.) as agent-callable tools:
```bash
pip install praisonai-tools[composio]
export COMPOSIO_API_KEY=your_api_key
```
```python
from praisonaiagents import Agent
from praisonai_tools import composio_tools, composio_list_apps
print(composio_list_apps()[:5]) # ["github", "slack", ...]
agent = Agent(name="dev", tools=composio_tools(apps=["github"]))
agent.start("Star the praisonai/PraisonAI repository")
```
For explicit action execution, use `ComposioTool` or `composio_execute()`.
---
## Contributing
We welcome contributions! To add a new tool:
+6
View File
@@ -144,4 +144,10 @@ __all__ = [
"AgentFolioTool",
"check_behavioral_trust",
"verify_task_delegation_safety",
# Composio
"ComposioTool",
"ComposioTools",
"composio_execute",
"composio_tools",
"composio_list_apps",
]
+3
View File
@@ -442,7 +442,10 @@ def __getattr__(name):
"daytona_list_workspaces": "daytona_tool",
# Composio
"ComposioTool": "composio_tool",
"ComposioTools": "composio_tool",
"composio_execute": "composio_tool",
"composio_tools": "composio_tool",
"composio_list_apps": "composio_tool",
# JSON
"JSONTool": "json_tool",
"read_json": "json_tool",
+165 -22
View File
@@ -1,72 +1,215 @@
"""Composio Tool for PraisonAI Agents.
"""Composio integration tools for PraisonAI Agents.
Execute actions via Composio integrations.
Composio provides 250+ managed app integrations exposed as agent-callable tools.
Requires: pip install praisonai-tools[composio]
Environment: COMPOSIO_API_KEY
Usage:
from praisonai_tools import ComposioTool
composio = ComposioTool()
result = composio.execute("GITHUB_CREATE_ISSUE", {"repo": "owner/repo", "title": "Bug"})
from praisonaiagents import Agent
from praisonai_tools import composio_tools
Environment Variables:
COMPOSIO_API_KEY: Composio API key
agent = Agent(name="dev", tools=composio_tools(apps=["github"]))
agent.start("Star the praisonai/PraisonAI repository")
"""
import os
import logging
from typing import Any, Dict, List, Optional, Union
import os
from importlib import util
from typing import Any, Callable, Dict, List, Optional, Union
from praisonai_tools.tools.base import BaseTool
logger = logging.getLogger(__name__)
def _check_composio_available(api_key: Optional[str] = None) -> tuple[bool, Optional[str]]:
"""Return (is_available, error_message).
``api_key`` takes precedence over the ``COMPOSIO_API_KEY`` environment
variable so callers who pass a key explicitly are not forced to also set
the env var.
"""
if util.find_spec("composio") is None:
return False, "composio package is not installed. Install with: pip install praisonai-tools[composio]"
if not api_key and not os.environ.get("COMPOSIO_API_KEY"):
return False, (
"COMPOSIO_API_KEY environment variable is not set. "
"Please set it to use Composio tools."
)
return True, None
class ComposioTools:
"""Composio managed-integration tools for Agent(tools=[...])."""
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or os.environ.get("COMPOSIO_API_KEY")
self._client = None
def _get_client(self):
if self._client is None:
try:
from composio import Composio
self._client = Composio(api_key=self.api_key)
except ImportError:
from composio import ComposioToolSet
self._client = ComposioToolSet(api_key=self.api_key)
return self._client
def get_tools(
self,
apps: Optional[List[str]] = None,
actions: Optional[List[str]] = None,
tags: Optional[List[str]] = None,
user_id: Optional[str] = None,
) -> List[Callable]:
"""Fetch Composio tools as agent-callable Python functions."""
is_available, error = _check_composio_available(self.api_key)
if not is_available:
logger.error(error)
return []
try:
client = self._get_client()
if hasattr(client, "tools") and hasattr(client.tools, "get"):
# Composio v3 SDK: `apps` -> `toolkits`, `user_id` required.
# The API key is an auth credential, not an entity identifier,
# so never use it as a user_id fallback.
kwargs: Dict[str, Any] = {"user_id": user_id or "default"}
if apps:
kwargs["toolkits"] = apps
if actions:
kwargs["tools"] = actions
if tags:
kwargs["tags"] = tags
tools = client.tools.get(**kwargs)
else:
# Legacy ComposioToolSet SDK.
toolset_kwargs: Dict[str, Any] = {}
if apps:
toolset_kwargs["apps"] = apps
if actions:
toolset_kwargs["actions"] = actions
if tags:
toolset_kwargs["tags"] = tags
tools = client.get_tools(**toolset_kwargs)
return list(tools) if tools else []
except Exception as e:
logger.error(f"Composio get_tools error: {e}")
return []
def list_apps(self) -> List[str]:
"""List available Composio app slugs."""
is_available, error = _check_composio_available(self.api_key)
if not is_available:
logger.error(error)
return []
try:
client = self._get_client()
if hasattr(client, "apps") and hasattr(client.apps, "get"):
apps = client.apps.get()
elif hasattr(client, "get_apps"):
apps = client.get_apps()
else:
return []
result = []
for app in apps or []:
if isinstance(app, str):
slug = app
elif isinstance(app, dict):
slug = app.get("key") or app.get("name") or app.get("slug")
else:
slug = (
getattr(app, "key", None)
or getattr(app, "name", None)
or getattr(app, "slug", None)
)
if slug:
result.append(slug)
return result
except Exception as e:
logger.error(f"Composio list_apps error: {e}")
return []
def composio_tools(
apps: Optional[List[str]] = None,
actions: Optional[List[str]] = None,
tags: Optional[List[str]] = None,
user_id: Optional[str] = None,
api_key: Optional[str] = None,
) -> List[Callable]:
"""Load Composio tools as agent-callable Python functions."""
return ComposioTools(api_key=api_key).get_tools(
apps=apps, actions=actions, tags=tags, user_id=user_id
)
def composio_list_apps(api_key: Optional[str] = None) -> List[str]:
"""List available Composio app slugs."""
return ComposioTools(api_key=api_key).list_apps()
class ComposioTool(BaseTool):
"""Tool for Composio integrations."""
"""Single-tool wrapper for explicit Composio action execution."""
name = "composio"
description = "Execute actions via Composio integrations."
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or os.getenv("COMPOSIO_API_KEY")
self._toolset = None
super().__init__()
@property
def toolset(self):
if self._toolset is None:
try:
from composio import ComposioToolSet
except ImportError:
raise ImportError("composio-core not installed. Install with: pip install composio-core")
raise ImportError(
"composio not installed. Install with: pip install praisonai-tools[composio]"
)
self._toolset = ComposioToolSet(api_key=self.api_key)
return self._toolset
def run(
self,
action: str = "execute",
action_name: Optional[str] = None,
params: Optional[Dict] = None,
**kwargs
**kwargs,
) -> Union[str, Dict[str, Any], List[Dict[str, Any]]]:
if action == "execute":
return self.execute(action_name=action_name, params=params or kwargs)
elif action == "list_actions":
if action == "list_actions":
return self.list_actions(app=kwargs.get("app"))
return {"error": f"Unknown action: {action}"}
def execute(self, action_name: str, params: Dict = None) -> Dict[str, Any]:
"""Execute a Composio action."""
if not action_name:
return {"error": "action_name is required"}
try:
result = self.toolset.execute_action(action_name, params or {})
return {"result": result}
except Exception as e:
logger.error(f"Composio execute error: {e}")
return {"error": str(e)}
def list_actions(self, app: str = None) -> List[Dict[str, Any]]:
"""List available actions."""
try:
@@ -81,5 +224,5 @@ class ComposioTool(BaseTool):
def composio_execute(action_name: str, params: Dict = None) -> Dict[str, Any]:
"""Execute Composio action."""
"""Execute a Composio action."""
return ComposioTool().execute(action_name=action_name, params=params)
+3
View File
@@ -61,6 +61,9 @@ video-motion = [
"playwright>=1.40",
"imageio-ffmpeg>=0.5",
]
composio = [
"composio>=0.5.0",
]
[project.urls]
Homepage = "https://docs.praison.ai"
+118
View File
@@ -0,0 +1,118 @@
"""Unit tests for Composio tools."""
import os
from unittest.mock import MagicMock, patch
from praisonai_tools.tools.composio_tool import (
ComposioTool,
ComposioTools,
_check_composio_available,
composio_execute,
composio_list_apps,
composio_tools,
)
class TestCheckComposioAvailable:
def test_missing_package(self):
with patch("praisonai_tools.tools.composio_tool.util.find_spec", return_value=None):
ok, msg = _check_composio_available()
assert ok is False
assert "not installed" in msg
def test_missing_api_key(self):
with patch("praisonai_tools.tools.composio_tool.util.find_spec", return_value=MagicMock()):
with patch.dict(os.environ, {}, clear=True):
ok, msg = _check_composio_available()
assert ok is False
assert "COMPOSIO_API_KEY" in msg
def test_explicit_api_key_overrides_env(self):
with patch("praisonai_tools.tools.composio_tool.util.find_spec", return_value=MagicMock()):
with patch.dict(os.environ, {}, clear=True):
ok, msg = _check_composio_available(api_key="explicit-key")
assert ok is True
assert msg is None
def test_available(self):
with patch("praisonai_tools.tools.composio_tool.util.find_spec", return_value=MagicMock()):
with patch.dict(os.environ, {"COMPOSIO_API_KEY": "test-key"}, clear=True):
ok, msg = _check_composio_available()
assert ok is True
assert msg is None
class TestComposioTools:
def test_get_tools_unavailable_returns_empty(self):
with patch(
"praisonai_tools.tools.composio_tool._check_composio_available",
return_value=(False, "missing"),
):
assert ComposioTools().get_tools(apps=["github"]) == []
def test_get_tools_new_sdk(self):
mock_tool = MagicMock()
mock_client = MagicMock()
mock_client.tools.get.return_value = [mock_tool]
with patch(
"praisonai_tools.tools.composio_tool._check_composio_available",
return_value=(True, None),
):
tools = ComposioTools(api_key="k")
with patch.object(tools, "_get_client", return_value=mock_client):
result = tools.get_tools(apps=["github"])
assert result == [mock_tool]
mock_client.tools.get.assert_called_once_with(user_id="default", toolkits=["github"])
def test_get_tools_new_sdk_explicit_user_id(self):
mock_tool = MagicMock()
mock_client = MagicMock()
mock_client.tools.get.return_value = [mock_tool]
with patch(
"praisonai_tools.tools.composio_tool._check_composio_available",
return_value=(True, None),
):
tools = ComposioTools(api_key="k")
with patch.object(tools, "_get_client", return_value=mock_client):
result = tools.get_tools(apps=["github"], user_id="user-123")
assert result == [mock_tool]
mock_client.tools.get.assert_called_once_with(user_id="user-123", toolkits=["github"])
def test_list_apps(self):
app = MagicMock(key="github")
mock_client = MagicMock()
mock_client.apps.get.return_value = [app]
with patch(
"praisonai_tools.tools.composio_tool._check_composio_available",
return_value=(True, None),
):
tools = ComposioTools(api_key="k")
with patch.object(tools, "_get_client", return_value=mock_client):
assert tools.list_apps() == ["github"]
class TestStandaloneFunctions:
def test_composio_tools_delegates(self):
with patch.object(ComposioTools, "get_tools", return_value=["t1"]) as mock_get:
assert composio_tools(apps=["slack"]) == ["t1"]
mock_get.assert_called_once_with(apps=["slack"], actions=None, tags=None, user_id=None)
def test_composio_list_apps_delegates(self):
with patch.object(ComposioTools, "list_apps", return_value=["github"]) as mock_list:
assert composio_list_apps() == ["github"]
mock_list.assert_called_once_with()
class TestComposioTool:
def test_execute_requires_action_name(self):
assert ComposioTool().execute(action_name="", params={}) == {"error": "action_name is required"}
def test_composio_execute_delegates(self):
with patch.object(ComposioTool, "execute", return_value={"result": "ok"}) as mock_exec:
assert composio_execute("GITHUB_STAR", {"repo": "x"}) == {"result": "ok"}
mock_exec.assert_called_once_with(action_name="GITHUB_STAR", params={"repo": "x"})