Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

StackOne AI SDK is a Python library that provides a unified interface for accessing various SaaS tools through AI-friendly APIs. It acts as a bridge between AI applications and multiple SaaS platforms (HRIS, CRM, ATS, LMS, Marketing, etc.) with support for OpenAI, LangChain, CrewAI, and Model Context Protocol (MCP).
StackOne AI SDK is a Python library that provides a unified interface for accessing various SaaS tools through AI-friendly APIs. It acts as a bridge between AI applications and multiple SaaS platforms (HRIS, CRM, ATS, LMS, Marketing, etc.) with support for Agno, OpenAI, LangChain, CrewAI, and Model Context Protocol (MCP).

## Essential Development Commands

Expand Down Expand Up @@ -50,7 +50,7 @@ make mcp-inspector # Run MCP server inspector for debugging
2. **Models** (`stackone_ai/models.py`): Data structures
- `StackOneTool`: Base class with execution logic
- `Tools`: Container for managing multiple tools
- Format converters for different AI frameworks
- Format converters for different AI frameworks (Agno, OpenAI, LangChain)

3. **OpenAPI Parser** (`stackone_ai/specs/parser.py`): Spec conversion
- Converts OpenAPI specs to tool definitions
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ result = execute_tool.call(toolName="hris_list_employees", params={"limit": 10})
- **Glob Pattern Filtering**: Advanced tool filtering with patterns like `"hris_*"` and exclusions `"!hris_delete_*"`
- **Meta Tools** (Beta): Dynamic tool discovery and execution based on natural language queries
- Integration with popular AI frameworks:
- Agno Agents
- OpenAI Functions
- LangChain Tools
- CrewAI Tools
Expand All @@ -72,6 +73,7 @@ For more examples and documentation, visit:

## AI Framework Integration

- [Agno Integration](docs/agno-integration.md)
- [OpenAI Integration](docs/openai-integration.md)
- [LangChain Integration](docs/langchain-integration.md)
- [CrewAI Integration](docs/crewai-integration.md)
Expand Down
119 changes: 119 additions & 0 deletions examples/agno_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""
This example demonstrates how to use StackOne tools with Agno agents.

This example is runnable with the following command:
```bash
uv run examples/agno_integration.py
```

You can find out more about Agno framework at https://docs.agno.com
"""

from agno import Agent
from agno.models.openai import OpenAIChat
from dotenv import load_dotenv

from stackone_ai import StackOneToolSet

load_dotenv()

account_id = "45072196112816593343"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid hard-coding potentially sensitive account and employee identifiers; read them from environment variables or user input instead.

Prompt for AI agents
Address the following comment on examples/agno_integration.py at line 20:

<comment>Avoid hard-coding potentially sensitive account and employee identifiers; read them from environment variables or user input instead.</comment>

<file context>
@@ -0,0 +1,119 @@
+&quot;&quot;&quot;
+This example demonstrates how to use StackOne tools with Agno agents.
+
+This example is runnable with the following command:
+```bash
+uv run examples/agno_integration.py
+```
+
+You can find out more about Agno framework at https://docs.agno.com
</file context>

employee_id = "c28xIQaWQ6MzM5MzczMDA2NzMzMzkwNzIwNA"


def agno_integration() -> None:
"""Demonstrate StackOne tools with Agno agents"""
toolset = StackOneToolSet()

# Filter tools to only the ones we need to avoid context window limits
tools = toolset.get_tools(
[
"hris_get_employee",
"hris_list_employee_employments",
"hris_get_employee_employment",
],
account_id=account_id,
)

# Convert to Agno format
agno_tools = tools.to_agno()

# Create an Agno agent with the tools
agent = Agent(
name="HR Assistant Agent",
role="Helpful HR assistant that can access employee data",
model=OpenAIChat(id="gpt-4o-mini"),
tools=agno_tools,
instructions=[
"You are a helpful HR assistant.",
"Use the provided tools to access employee information.",
"Always be professional and respectful when handling employee data.",
],
show_tool_calls=True,
markdown=True,
)

# Test the agent with a query
query = f"Can you get me information about employee with ID: {employee_id}?"

print(f"Query: {query}")
print("Agent response:")

response = agent.run(query)
print(response.content)

# Verify we got a meaningful response
assert response.content is not None, "Expected response content"
assert len(response.content) > 0, "Expected non-empty response"


def agno_async_integration() -> None:
"""Demonstrate async StackOne tools with Agno agents"""
import asyncio

async def run_async_agent() -> None:
toolset = StackOneToolSet()

# Filter tools to only the ones we need
tools = toolset.get_tools(
["hris_get_employee"],
account_id=account_id,
)

# Convert to Agno format
agno_tools = tools.to_agno()

# Create an Agno agent
agent = Agent(
name="Async HR Agent",
role="Async HR assistant",
model=OpenAIChat(id="gpt-4o-mini"),
tools=agno_tools,
instructions=["You are an async HR assistant."],
)

# Run the agent asynchronously
query = f"Get employee information for ID: {employee_id}"
response = await agent.arun(query)

print(f"Async query: {query}")
print("Async agent response:")
print(response.content)

# Verify response
assert response.content is not None, "Expected async response content"

# Run the async example
asyncio.run(run_async_agent())


if __name__ == "__main__":
print("=== StackOne + Agno Integration Demo ===\n")

print("1. Basic Agno Integration:")
agno_integration()

print("\n2. Async Agno Integration:")
agno_async_integration()

print("\n=== Demo completed successfully! ===")
1 change: 1 addition & 0 deletions examples/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def get_example_files() -> list[str]:

# Map of example files to required optional packages
OPTIONAL_DEPENDENCIES = {
"agno_integration.py": ["agno"],
"openai_integration.py": ["openai"],
"langchain_integration.py": ["langchain_openai"],
"crewai_integration.py": ["crewai"],
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ nav:
- Available Tools: available-tools.md
- File Uploads: file-uploads.md
- Integrations:
- Agno: agno-integration.md
- OpenAI: openai-integration.md
- CrewAI: crewai-integration.md
- LangChain: langchain-integration.md
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ packages = ["stackone_ai"]

[project.optional-dependencies]
examples = [
"agno>=1.7.0",
"crewai>=0.102.0",
"langchain-openai>=0.3.6",
"openai>=1.63.2",
Expand Down
37 changes: 37 additions & 0 deletions stackone_ai/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,35 @@ async def _arun(self, **kwargs: Any) -> Any:

return StackOneLangChainTool()

def to_agno(self) -> Any:
"""Convert this tool to Agno format

Returns:
Tool in Agno format
"""
try:
from agno.tools import Tool as AgnoBaseTool # type: ignore[import-not-found]
except ImportError as e:
raise ImportError(
"Agno is not installed. Please install it with 'pip install agno>=1.7.0' "
"or add 'agno>=1.7.0' to your requirements."
) from e

parent_tool = self

class StackOneAgnoTool(AgnoBaseTool):
def __init__(self) -> None:
super().__init__(
name=parent_tool.name,
description=parent_tool.description,
)

def run(self, **kwargs: Any) -> JsonDict:
"""Run the tool with the provided arguments"""
return parent_tool.execute(kwargs)

return StackOneAgnoTool()

def set_account_id(self, account_id: str | None) -> None:
"""Set the account ID for this tool

Expand Down Expand Up @@ -480,6 +509,14 @@ def to_langchain(self) -> Sequence[BaseTool]:
"""
return [tool.to_langchain() for tool in self.tools]

def to_agno(self) -> list[Any]:
"""Convert all tools to Agno format

Returns:
List of tools in Agno format
"""
return [tool.to_agno() for tool in self.tools]

def meta_tools(self) -> "Tools":
"""Return meta tools for tool discovery and execution

Expand Down
164 changes: 164 additions & 0 deletions tests/test_agno_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
from unittest.mock import MagicMock, patch

import pytest

from stackone_ai.models import (
ExecuteConfig,
StackOneTool,
ToolParameters,
Tools,
)


@pytest.fixture
def mock_tool() -> StackOneTool:
"""Create a mock tool for testing"""
return StackOneTool(
description="Test HRIS tool for getting employee data",
parameters=ToolParameters(
type="object",
properties={
"id": {"type": "string", "description": "Employee ID"},
"include_personal": {"type": "boolean", "description": "Include personal information"},
},
),
_execute_config=ExecuteConfig(
headers={},
method="GET",
url="https://api.stackone.com/unified/hris/employees/{id}",
name="hris_get_employee",
parameter_locations={"id": "path"},
),
_api_key="test_key",
_account_id="test_account",
)


@pytest.fixture
def tools_collection(mock_tool: StackOneTool) -> Tools:
"""Create a Tools collection with mock tools"""
return Tools([mock_tool])


class TestAgnoIntegration:
"""Test Agno integration functionality"""

def test_to_agno_without_agno_installed(self, mock_tool: StackOneTool) -> None:
"""Test that proper error is raised when Agno is not installed"""
with patch.dict('sys.modules', {'agno': None, 'agno.tools': None}):
with pytest.raises(ImportError) as exc_info:
mock_tool.to_agno()

assert "Agno is not installed" in str(exc_info.value)
assert "pip install agno>=1.7.0" in str(exc_info.value)

def test_to_agno_with_mocked_agno(self, mock_tool: StackOneTool) -> None:
"""Test Agno conversion with mocked Agno classes"""
# Mock the Agno Tool class
mock_agno_base_tool = MagicMock()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MagicMock instance used as base class stub—should pass a class, e.g. MagicMock(spec=type) or type('MockTool',(object,),{})

Prompt for AI agents
Address the following comment on tests/test_agno_integration.py at line 58:

<comment>MagicMock instance used as base class stub—should pass a class, e.g. MagicMock(spec=type) or type(&#39;MockTool&#39;,(object,),{})</comment>

<file context>
@@ -0,0 +1,164 @@
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from stackone_ai.models import (
+    ExecuteConfig,
+    StackOneTool,
+    ToolParameters,
+    Tools,
</file context>

mock_agno_module = MagicMock()
mock_agno_module.Tool = mock_agno_base_tool

Check failure on line 61 in tests/test_agno_integration.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (W293)

tests/test_agno_integration.py:61:1: W293 Blank line contains whitespace
with patch.dict('sys.modules', {'agno.tools': mock_agno_module}):
agno_tool = mock_tool.to_agno()

Check failure on line 64 in tests/test_agno_integration.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (W293)

tests/test_agno_integration.py:64:1: W293 Blank line contains whitespace
# Verify an Agno tool instance was created
assert agno_tool is not None

def test_to_agno_tool_execution(self, mock_tool: StackOneTool) -> None:
"""Test that the Agno tool can execute the underlying StackOne tool"""
mock_agno_base_tool = MagicMock()
mock_agno_module = MagicMock()
mock_agno_module.Tool = mock_agno_base_tool

Check failure on line 73 in tests/test_agno_integration.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (W293)

tests/test_agno_integration.py:73:1: W293 Blank line contains whitespace
with patch.dict('sys.modules', {'agno.tools': mock_agno_module}):
agno_tool = mock_tool.to_agno()

Check failure on line 76 in tests/test_agno_integration.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (W293)

tests/test_agno_integration.py:76:1: W293 Blank line contains whitespace
# Verify the tool was created (basic functionality test)
assert agno_tool is not None
assert hasattr(agno_tool, 'run')

def test_tools_to_agno(self, tools_collection: Tools) -> None:
"""Test converting Tools collection to Agno format"""
mock_agno_base_tool = MagicMock()
mock_agno_module = MagicMock()
mock_agno_module.Tool = mock_agno_base_tool

Check failure on line 86 in tests/test_agno_integration.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (W293)

tests/test_agno_integration.py:86:1: W293 Blank line contains whitespace
with patch.dict('sys.modules', {'agno.tools': mock_agno_module}):
agno_tools = tools_collection.to_agno()

Check failure on line 89 in tests/test_agno_integration.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (W293)

tests/test_agno_integration.py:89:1: W293 Blank line contains whitespace
# Verify we got the expected number of tools
assert len(agno_tools) == 1
assert agno_tools[0] is not None

def test_tools_to_agno_multiple_tools(self) -> None:
"""Test converting multiple tools to Agno format"""
# Create multiple mock tools
tool1 = StackOneTool(
description="Test tool 1",
parameters=ToolParameters(type="object", properties={"id": {"type": "string"}}),
_execute_config=ExecuteConfig(
headers={}, method="GET", url="https://api.example.com/test1/{id}", name="test_tool_1"
),
_api_key="test_key",
)
tool2 = StackOneTool(
description="Test tool 2",
parameters=ToolParameters(type="object", properties={"name": {"type": "string"}}),
_execute_config=ExecuteConfig(
headers={}, method="POST", url="https://api.example.com/test2", name="test_tool_2"
),
_api_key="test_key",
)

tools = Tools([tool1, tool2])

Check failure on line 115 in tests/test_agno_integration.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (W293)

tests/test_agno_integration.py:115:1: W293 Blank line contains whitespace
mock_agno_base_tool = MagicMock()
mock_agno_module = MagicMock()
mock_agno_module.Tool = mock_agno_base_tool

Check failure on line 119 in tests/test_agno_integration.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (W293)

tests/test_agno_integration.py:119:1: W293 Blank line contains whitespace
with patch.dict('sys.modules', {'agno.tools': mock_agno_module}):
agno_tools = tools.to_agno()

Check failure on line 122 in tests/test_agno_integration.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (W293)

tests/test_agno_integration.py:122:1: W293 Blank line contains whitespace
assert len(agno_tools) == 2
assert all(tool is not None for tool in agno_tools)

def test_agno_tool_preserves_metadata(self, mock_tool: StackOneTool) -> None:
"""Test that Agno tool conversion preserves important metadata"""
mock_agno_base_tool = MagicMock()
mock_agno_module = MagicMock()
mock_agno_module.Tool = mock_agno_base_tool

Check failure on line 131 in tests/test_agno_integration.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (W293)

tests/test_agno_integration.py:131:1: W293 Blank line contains whitespace
with patch.dict('sys.modules', {'agno.tools': mock_agno_module}):
agno_tool = mock_tool.to_agno()

# Verify the tool was created with expected attributes
assert agno_tool is not None
# For real integration, name and description would be set by the Agno base class
assert hasattr(agno_tool, 'name')
assert hasattr(agno_tool, 'description')


class TestAgnoIntegrationErrors:
"""Test error handling in Agno integration"""

def test_agno_import_error_message(self, mock_tool: StackOneTool) -> None:
"""Test that ImportError contains helpful installation instructions"""
with patch.dict('sys.modules', {'agno': None, 'agno.tools': None}):
with pytest.raises(ImportError) as exc_info:
mock_tool.to_agno()

error_msg = str(exc_info.value)
assert "Agno is not installed" in error_msg
assert "pip install agno>=1.7.0" in error_msg
assert "requirements" in error_msg

def test_tools_to_agno_with_failed_conversion(self) -> None:
"""Test Tools.to_agno() when individual tool conversion fails"""
mock_tool = MagicMock()
mock_tool.to_agno.side_effect = ImportError("Agno not available")

tools = Tools([mock_tool])

with pytest.raises(ImportError):
tools.to_agno()
Loading
Loading