diff --git a/CLAUDE.md b/CLAUDE.md index 1bf16e7..1df7c25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 1c5105e..a724a8a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -72,10 +73,11 @@ For more examples and documentation, visit: ## AI Framework Integration -- [OpenAI Integration](docs/openai-integration.md) -- [LangChain Integration](docs/langchain-integration.md) -- [CrewAI Integration](docs/crewai-integration.md) -- [LangGraph Tool Node](docs/langgraph-tool-node.md) +- [Agno Integration](docs/agno_integration.md) +- [OpenAI Integration](docs/openai_integration.md) +- [LangChain Integration](docs/langchain_integration.md) +- [CrewAI Integration](docs/crewai_integration.md) +- [LangGraph Tool Node](docs/langgraph_tool_node.md) ## License diff --git a/examples/agno_integration.py b/examples/agno_integration.py new file mode 100644 index 0000000..14971e7 --- /dev/null +++ b/examples/agno_integration.py @@ -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.agent import Agent +from agno.models.openai import OpenAIChat +from dotenv import load_dotenv + +from stackone_ai import StackOneToolSet + +load_dotenv() + +account_id = "45072196112816593343" +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! ===") diff --git a/examples/test_examples.py b/examples/test_examples.py index bc71332..bd39ab4 100644 --- a/examples/test_examples.py +++ b/examples/test_examples.py @@ -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"], @@ -60,11 +61,11 @@ def test_run_example(example_file: str) -> None: "id": "test-employee-1", "first_name": "John", "last_name": "Doe", - "email": "john.doe@example.com" + "email": "john.doe@example.com", } ] }, - status=200 + status=200, ) # Mock document upload endpoint @@ -72,7 +73,7 @@ def test_run_example(example_file: str) -> None: responses.POST, "https://api.stackone.com/unified/hris/employees/c28xIQaWQ6MzM5MzczMDA2NzMzMzkwNzIwNA/documents/upload", json={"success": True, "document_id": "test-doc-123"}, - status=200 + status=200, ) example_path = Path(__file__).parent / example_file diff --git a/mkdocs.yml b/mkdocs.yml index 2ecdebc..6fc99d5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -46,6 +46,7 @@ nav: - Available Tools: available-tools.md - File Uploads: file-uploads.md - Integrations: - - OpenAI: openai-integration.md - - CrewAI: crewai-integration.md - - LangChain: langchain-integration.md + - Agno: agno_integration.md + - OpenAI: openai_integration.md + - CrewAI: crewai_integration.md + - LangChain: langchain_integration.md diff --git a/pyproject.toml b/pyproject.toml index 31b5d14..4876975 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", @@ -110,3 +111,15 @@ warn_unreachable = true [[tool.mypy.overrides]] module = "bm25s" ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "agno.tools" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "agno.agent" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "agno.models.openai" +ignore_missing_imports = true diff --git a/stackone_ai/models.py b/stackone_ai/models.py index 21425fa..23bdb97 100644 --- a/stackone_ai/models.py +++ b/stackone_ai/models.py @@ -397,6 +397,34 @@ async def _arun(self, **kwargs: Any) -> Any: return StackOneLangChainTool() + def to_agno(self) -> Any: + """Convert this tool to Agno format + + Returns: + Function callable in Agno format + """ + try: + from agno.tools import tool + 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 + + def agno_tool_function(**kwargs: Any) -> JsonDict: + """Execute the StackOne tool with the provided arguments""" + return parent_tool.execute(kwargs) + + # Apply Agno tool decorator with metadata + decorated_tool = tool( + name=parent_tool.name, + description=parent_tool.description, + )(agno_tool_function) + + return decorated_tool + def set_account_id(self, account_id: str | None) -> None: """Set the account ID for this tool @@ -480,6 +508,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 diff --git a/tests/test_agno_integration.py b/tests/test_agno_integration.py new file mode 100644 index 0000000..56075a0 --- /dev/null +++ b/tests/test_agno_integration.py @@ -0,0 +1,185 @@ +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 decorator""" + # Mock the Agno tool decorator + mock_tool_decorator = MagicMock() + mock_agno_module = MagicMock() + mock_agno_module.tool = mock_tool_decorator + + # Configure the decorator to return the decorated function + mock_tool_decorator.return_value = lambda func: func + + with patch.dict("sys.modules", {"agno.tools": mock_agno_module}): + agno_tool = mock_tool.to_agno() + + # Verify an Agno tool function was created + assert agno_tool is not None + assert callable(agno_tool) + + def test_to_agno_tool_execution(self, mock_tool: StackOneTool) -> None: + """Test that the Agno tool can execute the underlying StackOne tool""" + mock_tool_decorator = MagicMock() + mock_agno_module = MagicMock() + mock_agno_module.tool = mock_tool_decorator + + # Configure the decorator to return the decorated function + mock_tool_decorator.return_value = lambda func: func + + with patch.dict("sys.modules", {"agno.tools": mock_agno_module}): + agno_tool = mock_tool.to_agno() + + # Verify the tool was created and can be called + assert agno_tool is not None + assert callable(agno_tool) + + def test_tools_to_agno(self, tools_collection: Tools) -> None: + """Test converting Tools collection to Agno format""" + mock_tool_decorator = MagicMock() + mock_agno_module = MagicMock() + mock_agno_module.tool = mock_tool_decorator + + # Configure the decorator to return the decorated function + mock_tool_decorator.return_value = lambda func: func + + with patch.dict("sys.modules", {"agno.tools": mock_agno_module}): + agno_tools = tools_collection.to_agno() + + # Verify we got the expected number of tools + assert len(agno_tools) == 1 + assert agno_tools[0] is not None + assert callable(agno_tools[0]) + + 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]) + + mock_tool_decorator = MagicMock() + mock_agno_module = MagicMock() + mock_agno_module.tool = mock_tool_decorator + + # Configure the decorator to return the decorated function + mock_tool_decorator.return_value = lambda func: func + + with patch.dict("sys.modules", {"agno.tools": mock_agno_module}): + agno_tools = tools.to_agno() + + assert len(agno_tools) == 2 + assert all(tool is not None and callable(tool) 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_tool_decorator = MagicMock() + mock_agno_module = MagicMock() + mock_agno_module.tool = mock_tool_decorator + + # Configure the decorator to return the decorated function + mock_tool_decorator.return_value = lambda func: func + + with patch.dict("sys.modules", {"agno.tools": mock_agno_module}): + agno_tool = mock_tool.to_agno() + + # Verify the tool was created + assert agno_tool is not None + assert callable(agno_tool) + + # Verify the decorator was called with the correct metadata + mock_tool_decorator.assert_called_once_with( + name=mock_tool.name, + description=mock_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() diff --git a/uv.lock b/uv.lock index 3f983ac..20838ac 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,30 @@ resolution-markers = [ "python_full_version < '3.13'", ] +[[package]] +name = "agno" +version = "1.7.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docstring-parser" }, + { name = "gitpython" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "tomli" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/2d/9c191f9e14bcf3461abdef1e5d604b82dc8e96883772fe5d05d02bdd7496/agno-1.7.11.tar.gz", hash = "sha256:3891b062a1a623b3e75f61712058565ecf6e397574a03dc67193c90cd0e767df", size = 745361, upload-time = "2025-08-14T12:32:21.581Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/b8/048ab6555ed482ffec39699fa6008f9116a4fc7cbaa630fb76ddee8ecec8/agno-1.7.11-py3-none-any.whl", hash = "sha256:0e6364450395558dea52a0c64ec4ded0c77e93769c24076143d6eb138db7740e", size = 986160, upload-time = "2025-08-14T12:32:19.162Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -791,6 +815,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/c8/dd58967d119baab745caec2f9d853297cec1989ec1d63f677d3880632b88/gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c", size = 215076, upload-time = "2025-07-24T03:45:54.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" }, +] + [[package]] name = "google-auth" version = "2.40.3" @@ -3304,6 +3352,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -3359,6 +3416,7 @@ docs = [ { name = "pymdown-extensions" }, ] examples = [ + { name = "agno" }, { name = "crewai" }, { name = "langchain-openai" }, { name = "openai" }, @@ -3381,6 +3439,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "agno", marker = "extra == 'examples'", specifier = ">=1.7.0" }, { name = "bm25s", specifier = ">=0.2.2" }, { name = "crewai", marker = "extra == 'examples'", specifier = ">=0.102.0" }, { name = "langchain-core", specifier = ">=0.1.0" },