From ded5ddef85778a8a9985b3a144805905d998b060 Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Wed, 11 Mar 2026 00:09:05 +0000 Subject: [PATCH 01/10] add LLM-driven tool_search and tool_execute --- examples/meta_tools_example.py | 187 ++++++++++++++++++++++ stackone_ai/meta_tools.py | 281 +++++++++++++++++++++++++++++++++ stackone_ai/toolset.py | 51 ++++++ 3 files changed, 519 insertions(+) create mode 100644 examples/meta_tools_example.py create mode 100644 stackone_ai/meta_tools.py diff --git a/examples/meta_tools_example.py b/examples/meta_tools_example.py new file mode 100644 index 0000000..7028c72 --- /dev/null +++ b/examples/meta_tools_example.py @@ -0,0 +1,187 @@ +"""Meta tools example: LLM-driven tool discovery and execution. + +Instead of loading all tools upfront, the LLM autonomously searches for +relevant tools and executes them — keeping token usage minimal. + +Prerequisites: + - STACKONE_API_KEY environment variable + - STACKONE_ACCOUNT_ID environment variable (comma-separated for multiple) + - OPENAI_API_KEY or GOOGLE_API_KEY environment variable + +Run with: + uv run python examples/meta_tools_example.py +""" + +from __future__ import annotations + +import json +import os + +try: + from dotenv import load_dotenv + + load_dotenv() +except ModuleNotFoundError: + pass + +from stackone_ai import StackOneToolSet + +_account_ids = [ + aid.strip() + for aid in os.getenv("STACKONE_ACCOUNT_ID", "").split(",") + if aid.strip() +] + + +def example_openai_meta_tools() -> None: + """Meta tools with OpenAI Chat Completions. + + The LLM receives only tool_search and tool_execute — two small tool + definitions regardless of how many tools exist. It searches for what + it needs and executes. + """ + print("=" * 60) + print("Example 1: Meta tools with OpenAI") + print("=" * 60) + print() + + try: + from openai import OpenAI + except ImportError: + print("Skipped: OpenAI library not installed. Install with: pip install openai") + print() + return + + openai_key = os.getenv("OPENAI_API_KEY") + google_key = os.getenv("GOOGLE_API_KEY") + + if openai_key: + client = OpenAI() + model = "gpt-5.1" + provider = "OpenAI" + elif google_key: + client = OpenAI( + api_key=google_key, + base_url="https://generativelanguage.googleapis.com/v1beta/openai/", + ) + model = "gemini-3-pro-preview" + provider = "Gemini" + else: + print("Skipped: Set OPENAI_API_KEY or GOOGLE_API_KEY to run this example.") + print() + return + + print(f"Using {provider} ({model})") + print() + + toolset = StackOneToolSet(search={"method": "semantic", "top_k": 3}) + + # Get meta tools — returns a Tools collection with tool_search + tool_execute + meta_tools = toolset.get_meta_tools(account_ids=_account_ids or None) + openai_tools = meta_tools.to_openai() + + print(f"Meta tools: {[t.name for t in meta_tools]}") + print() + + messages: list[dict] = [ + { + "role": "system", + "content": ( + "You are a helpful scheduling assistant. " + "Use tool_search to find relevant tools, then tool_execute to run them. " + "If a tool execution fails, try different parameters or a different tool. " + "Do not repeat the same failed call." + ), + }, + { + "role": "user", + "content": "List my upcoming Calendly events for the next week.", + }, + ] + + # Agent loop — let the LLM drive search and execution + max_iterations = 10 + for iteration in range(max_iterations): + print(f"--- Iteration {iteration + 1} ---") + + response = client.chat.completions.create( + model=model, + messages=messages, + tools=openai_tools, + tool_choice="auto", + ) + + choice = response.choices[0] + + if not choice.message.tool_calls: + print(f"\n{provider} final response: {choice.message.content}") + break + + # Add assistant message with tool calls + # Use model_dump with exclude_none to avoid null values that Gemini rejects + messages.append(choice.message.model_dump(exclude_none=True)) + + # Execute each tool call + for tool_call in choice.message.tool_calls: + print(f"LLM called: {tool_call.function.name}({tool_call.function.arguments})") + + tool = meta_tools.get_tool(tool_call.function.name) + if tool is None: + result = {"error": f"Unknown tool: {tool_call.function.name}"} + else: + result = tool.execute(tool_call.function.arguments) + + messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": json.dumps(result), + } + ) + + print() + + +def example_langchain_meta_tools() -> None: + """Meta tools with LangChain. + + The meta tools convert to LangChain format just like any other Tools collection. + """ + print("=" * 60) + print("Example 2: Meta tools with LangChain") + print("=" * 60) + print() + + try: + from langchain_core.tools import BaseTool # noqa: F401 + except ImportError: + print("Skipped: LangChain not installed. Install with: pip install langchain-core") + print() + return + + toolset = StackOneToolSet(search={"method": "semantic", "top_k": 3}) + meta_tools = toolset.get_meta_tools(account_ids=_account_ids or None) + + langchain_tools = meta_tools.to_langchain() + + print(f"Created {len(langchain_tools)} LangChain tools:") + for tool in langchain_tools: + print(f" - {tool.name}: {tool.description}") + print() + print("These tools are ready to use with LangChain agents (AgentExecutor, create_react_agent, etc.)") + print() + + +def main() -> None: + """Run all meta tools examples.""" + api_key = os.getenv("STACKONE_API_KEY") + if not api_key: + print("Set STACKONE_API_KEY to run these examples.") + return + + example_openai_meta_tools() + example_langchain_meta_tools() + + +if __name__ == "__main__": + main() diff --git a/stackone_ai/meta_tools.py b/stackone_ai/meta_tools.py new file mode 100644 index 0000000..66f1162 --- /dev/null +++ b/stackone_ai/meta_tools.py @@ -0,0 +1,281 @@ +"""Meta tools (tool_search + tool_execute) for LLM-driven workflows.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel, Field, field_validator + +from stackone_ai.models import ( + ExecuteConfig, + JsonDict, + ParameterLocation, + StackOneAPIError, + StackOneError, + StackOneTool, + ToolParameters, + Tools, +) + +if TYPE_CHECKING: + from stackone_ai.toolset import SearchMode, StackOneToolSet + + +class MetaToolsOptions(BaseModel): + """Options for get_meta_tools().""" + + account_ids: list[str] | None = None + search: Any | None = Field(default=None, description="Search mode: 'auto', 'semantic', or 'local'") + connector: str | None = None + top_k: int | None = None + min_similarity: float | None = None + + +# --- tool_search --- + + +class SearchInput(BaseModel): + """Input validation for tool_search.""" + + query: str = Field(..., min_length=1) + connector: str | None = None + top_k: int | None = Field(default=None, ge=1, le=50) + + @field_validator("query") + @classmethod + def validate_query(cls, v: str) -> str: + trimmed = v.strip() + if not trimmed: + raise ValueError("query must be a non-empty string") + return trimmed + + +class SearchMetaTool(StackOneTool): + """LLM-callable tool that searches for available StackOne tools.""" + + _toolset: Any = None + _options: MetaToolsOptions = None # type: ignore[assignment] + + def execute( + self, arguments: str | JsonDict | None = None, *, options: JsonDict | None = None + ) -> JsonDict: + try: + if isinstance(arguments, str): + raw_params = json.loads(arguments) + else: + raw_params = arguments or {} + + parsed = SearchInput(**raw_params) + + results = self._toolset.search_tools( + parsed.query, + connector=parsed.connector or self._options.connector, + top_k=parsed.top_k or self._options.top_k or 5, + min_similarity=self._options.min_similarity, + search=self._options.search, + account_ids=self._options.account_ids, + ) + + return { + "tools": [ + { + "name": t.name, + "description": t.description, + "parameters": t.parameters.properties, + } + for t in results + ], + "total": len(results), + "query": parsed.query, + } + except json.JSONDecodeError as exc: + raise StackOneError(f"Invalid JSON in arguments: {exc}") from exc + except Exception as error: + if isinstance(error, StackOneError): + raise + raise StackOneError(f"Error searching tools: {error}") from error + + +# --- tool_execute --- + + +class ExecuteInput(BaseModel): + """Input validation for tool_execute.""" + + tool_name: str = Field(..., min_length=1) + parameters: dict[str, Any] = Field(default_factory=dict) + + @field_validator("tool_name") + @classmethod + def validate_tool_name(cls, v: str) -> str: + trimmed = v.strip() + if not trimmed: + raise ValueError("tool_name must be a non-empty string") + return trimmed + + +class ExecuteMetaTool(StackOneTool): + """LLM-callable tool that executes a StackOne tool by name.""" + + _toolset: Any = None + _options: MetaToolsOptions = None # type: ignore[assignment] + + def execute( + self, arguments: str | JsonDict | None = None, *, options: JsonDict | None = None + ) -> JsonDict: + try: + if isinstance(arguments, str): + raw_params = json.loads(arguments) + else: + raw_params = arguments or {} + + parsed = ExecuteInput(**raw_params) + + all_tools = self._toolset.fetch_tools(account_ids=self._options.account_ids) + target = all_tools.get_tool(parsed.tool_name) + + if target is None: + return { + "error": f'Tool "{parsed.tool_name}" not found. Use tool_search to find available tools.', + } + + return target.execute(parsed.parameters, options=options) + except StackOneAPIError as exc: + # Return API errors to the LLM so it can adjust parameters and retry + return { + "error": str(exc), + "status_code": exc.status_code, + "tool_name": parsed.tool_name if "parsed" in dir() else "unknown", + } + except json.JSONDecodeError as exc: + raise StackOneError(f"Invalid JSON in arguments: {exc}") from exc + except Exception as error: + if isinstance(error, StackOneError): + raise + raise StackOneError(f"Error executing tool: {error}") from error + + +# --- Factory --- + + +def create_meta_tools( + toolset: StackOneToolSet, + options: MetaToolsOptions | None = None, +) -> Tools: + """Create tool_search + tool_execute for LLM-driven workflows. + + Args: + toolset: The StackOneToolSet to delegate search and execution to. + options: Options to scope search and execution. + + Returns: + Tools collection containing tool_search and tool_execute. + """ + opts = options or MetaToolsOptions() + api_key = toolset.api_key + + # tool_search + search_tool = _create_search_tool(api_key, opts) + search_tool._toolset = toolset + search_tool._options = opts + + # tool_execute + execute_tool = _create_execute_tool(api_key, opts) + execute_tool._toolset = toolset + execute_tool._options = opts + + return Tools([search_tool, execute_tool]) + + +def _create_search_tool(api_key: str, opts: MetaToolsOptions) -> SearchMetaTool: + name = "tool_search" + description = ( + "Search for available tools by describing what you need. " + "Returns matching tool names, descriptions, and parameter schemas. " + "Use the returned parameter schemas to know exactly what to pass when calling tool_execute." + ) + parameters = ToolParameters( + type="object", + properties={ + "query": { + "type": "string", + "description": ( + "Natural language description of what you need " + '(e.g. "create an employee", "list time off requests")' + ), + }, + "connector": { + "type": "string", + "description": 'Optional connector filter (e.g. "bamboohr", "hibob")', + }, + "top_k": { + "type": "integer", + "description": "Max results to return (1-50, default 5)", + "minimum": 1, + "maximum": 50, + }, + }, + ) + execute_config = ExecuteConfig( + name=name, + method="POST", + url="local://meta/search", + parameter_locations={ + "query": ParameterLocation.BODY, + "connector": ParameterLocation.BODY, + "top_k": ParameterLocation.BODY, + }, + ) + + tool = SearchMetaTool.__new__(SearchMetaTool) + StackOneTool.__init__( + tool, + description=description, + parameters=parameters, + _execute_config=execute_config, + _api_key=api_key, + ) + return tool + + +def _create_execute_tool(api_key: str, opts: MetaToolsOptions) -> ExecuteMetaTool: + name = "tool_execute" + description = ( + "Execute a tool by name with the given parameters. " + "Use tool_search first to find available tools. " + "The parameters field must match the parameter schema returned by tool_search. " + "Pass parameters as a nested object matching the schema structure." + ) + parameters = ToolParameters( + type="object", + properties={ + "tool_name": { + "type": "string", + "description": "Exact tool name from tool_search results", + }, + "parameters": { + "type": "object", + "description": "Parameters for the tool. Pass an empty object {} if no parameters are needed.", + }, + }, + ) + execute_config = ExecuteConfig( + name=name, + method="POST", + url="local://meta/execute", + parameter_locations={ + "tool_name": ParameterLocation.BODY, + "parameters": ParameterLocation.BODY, + }, + ) + + tool = ExecuteMetaTool.__new__(ExecuteMetaTool) + StackOneTool.__init__( + tool, + description=description, + parameters=parameters, + _execute_config=execute_config, + _api_key=api_key, + ) + return tool diff --git a/stackone_ai/toolset.py b/stackone_ai/toolset.py index 998dbc0..510ff19 100644 --- a/stackone_ai/toolset.py +++ b/stackone_ai/toolset.py @@ -393,6 +393,57 @@ def get_search_tool(self, *, search: SearchMode | None = None) -> SearchTool: return SearchTool(self, config=config) + def get_meta_tools( + self, + *, + account_ids: list[str] | None = None, + search: SearchMode | None = None, + connector: str | None = None, + top_k: int | None = None, + min_similarity: float | None = None, + ) -> Tools: + """Get LLM-callable meta tools (tool_search + tool_execute) for agent-driven workflows. + + Returns a Tools collection that can be passed directly to any LLM framework. + The LLM uses tool_search to discover available tools, then tool_execute to run them. + + Args: + account_ids: Account IDs to scope tool discovery and execution + search: Search mode ('auto', 'semantic', or 'local') + connector: Optional connector filter (e.g. 'bamboohr') + top_k: Maximum number of search results. Defaults to 5. + min_similarity: Minimum similarity score threshold 0-1 + + Returns: + Tools collection containing tool_search and tool_execute + + Example:: + + toolset = StackOneToolSet(account_id="acc-123") + meta_tools = toolset.get_meta_tools() + + # Pass to OpenAI + tools = meta_tools.to_openai() + + # Pass to LangChain + tools = meta_tools.to_langchain() + """ + if self._search_config is None: + raise ToolsetConfigError( + "Search is disabled. Initialize StackOneToolSet with a search config to enable." + ) + + from stackone_ai.meta_tools import MetaToolsOptions, create_meta_tools + + options = MetaToolsOptions( + account_ids=account_ids, + search=search, + connector=connector, + top_k=top_k, + min_similarity=min_similarity, + ) + return create_meta_tools(self, options) + @property def semantic_client(self) -> SemanticSearchClient: """Lazy initialization of semantic search client. From 841c1a0924154db37a880ea98c81fd0245a3da2b Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Wed, 11 Mar 2026 00:18:56 +0000 Subject: [PATCH 02/10] Fix CI --- stackone_ai/meta_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stackone_ai/meta_tools.py b/stackone_ai/meta_tools.py index 66f1162..6151dad 100644 --- a/stackone_ai/meta_tools.py +++ b/stackone_ai/meta_tools.py @@ -256,7 +256,7 @@ def _create_execute_tool(api_key: str, opts: MetaToolsOptions) -> ExecuteMetaToo }, "parameters": { "type": "object", - "description": "Parameters for the tool. Pass an empty object {} if no parameters are needed.", + "description": "Parameters for the tool. Pass {} if none needed.", }, }, ) From 5388c19f8d17cb741e8a753818fd431afa9f3bba Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Wed, 11 Mar 2026 00:54:51 +0000 Subject: [PATCH 03/10] Fix CI and lint issues --- examples/meta_tools_example.py | 6 +----- stackone_ai/meta_tools.py | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/examples/meta_tools_example.py b/examples/meta_tools_example.py index 7028c72..c7af46b 100644 --- a/examples/meta_tools_example.py +++ b/examples/meta_tools_example.py @@ -26,11 +26,7 @@ from stackone_ai import StackOneToolSet -_account_ids = [ - aid.strip() - for aid in os.getenv("STACKONE_ACCOUNT_ID", "").split(",") - if aid.strip() -] +_account_ids = [aid.strip() for aid in os.getenv("STACKONE_ACCOUNT_ID", "").split(",") if aid.strip()] def example_openai_meta_tools() -> None: diff --git a/stackone_ai/meta_tools.py b/stackone_ai/meta_tools.py index 6151dad..5597780 100644 --- a/stackone_ai/meta_tools.py +++ b/stackone_ai/meta_tools.py @@ -19,7 +19,7 @@ ) if TYPE_CHECKING: - from stackone_ai.toolset import SearchMode, StackOneToolSet + from stackone_ai.toolset import StackOneToolSet class MetaToolsOptions(BaseModel): From ba762da822c1ca39766f2f2a97e13521c918ea33 Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Wed, 11 Mar 2026 10:17:28 +0000 Subject: [PATCH 04/10] PR Suggestion from bots --- examples/meta_tools_example.py | 8 +- stackone_ai/meta_tools.py | 67 +++--- stackone_ai/models.py | 4 + tests/test_meta_tools.py | 368 +++++++++++++++++++++++++++++++++ 4 files changed, 412 insertions(+), 35 deletions(-) create mode 100644 tests/test_meta_tools.py diff --git a/examples/meta_tools_example.py b/examples/meta_tools_example.py index c7af46b..0dfccbf 100644 --- a/examples/meta_tools_example.py +++ b/examples/meta_tools_example.py @@ -53,7 +53,7 @@ def example_openai_meta_tools() -> None: if openai_key: client = OpenAI() - model = "gpt-5.1" + model = "gpt-4o" provider = "OpenAI" elif google_key: client = OpenAI( @@ -85,8 +85,10 @@ def example_openai_meta_tools() -> None: "content": ( "You are a helpful scheduling assistant. " "Use tool_search to find relevant tools, then tool_execute to run them. " - "If a tool execution fails, try different parameters or a different tool. " - "Do not repeat the same failed call." + "Always read the parameter schemas from tool_search results carefully. " + "If a tool needs a user URI, first search for and call a " + "'get current user' tool to obtain it. " + "If a tool execution fails, fix the parameters and retry." ), }, { diff --git a/stackone_ai/meta_tools.py b/stackone_ai/meta_tools.py index 5597780..daead8d 100644 --- a/stackone_ai/meta_tools.py +++ b/stackone_ai/meta_tools.py @@ -5,14 +5,13 @@ import json from typing import TYPE_CHECKING, Any -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, PrivateAttr, ValidationError, field_validator from stackone_ai.models import ( ExecuteConfig, JsonDict, ParameterLocation, StackOneAPIError, - StackOneError, StackOneTool, ToolParameters, Tools, @@ -54,8 +53,8 @@ def validate_query(cls, v: str) -> str: class SearchMetaTool(StackOneTool): """LLM-callable tool that searches for available StackOne tools.""" - _toolset: Any = None - _options: MetaToolsOptions = None # type: ignore[assignment] + _toolset: Any = PrivateAttr(default=None) + _options: MetaToolsOptions = PrivateAttr(default=None) # type: ignore[assignment] def execute( self, arguments: str | JsonDict | None = None, *, options: JsonDict | None = None @@ -89,12 +88,8 @@ def execute( "total": len(results), "query": parsed.query, } - except json.JSONDecodeError as exc: - raise StackOneError(f"Invalid JSON in arguments: {exc}") from exc - except Exception as error: - if isinstance(error, StackOneError): - raise - raise StackOneError(f"Error searching tools: {error}") from error + except (json.JSONDecodeError, ValidationError) as exc: + return {"error": f"Invalid input: {exc}", "query": raw_params if "raw_params" in dir() else None} # --- tool_execute --- @@ -118,12 +113,14 @@ def validate_tool_name(cls, v: str) -> str: class ExecuteMetaTool(StackOneTool): """LLM-callable tool that executes a StackOne tool by name.""" - _toolset: Any = None - _options: MetaToolsOptions = None # type: ignore[assignment] + _toolset: Any = PrivateAttr(default=None) + _options: MetaToolsOptions = PrivateAttr(default=None) # type: ignore[assignment] + _cached_tools: Any = PrivateAttr(default=None) def execute( self, arguments: str | JsonDict | None = None, *, options: JsonDict | None = None ) -> JsonDict: + tool_name = "unknown" try: if isinstance(arguments, str): raw_params = json.loads(arguments) @@ -131,29 +128,30 @@ def execute( raw_params = arguments or {} parsed = ExecuteInput(**raw_params) + tool_name = parsed.tool_name - all_tools = self._toolset.fetch_tools(account_ids=self._options.account_ids) - target = all_tools.get_tool(parsed.tool_name) + if self._cached_tools is None: + self._cached_tools = self._toolset.fetch_tools(account_ids=self._options.account_ids) + + target = self._cached_tools.get_tool(parsed.tool_name) if target is None: return { - "error": f'Tool "{parsed.tool_name}" not found. Use tool_search to find available tools.', + "error": ( + f'Tool "{parsed.tool_name}" not found. Use tool_search to find available tools.' + ), } return target.execute(parsed.parameters, options=options) except StackOneAPIError as exc: - # Return API errors to the LLM so it can adjust parameters and retry return { "error": str(exc), "status_code": exc.status_code, - "tool_name": parsed.tool_name if "parsed" in dir() else "unknown", + "response_body": exc.response_body, + "tool_name": tool_name, } - except json.JSONDecodeError as exc: - raise StackOneError(f"Invalid JSON in arguments: {exc}") from exc - except Exception as error: - if isinstance(error, StackOneError): - raise - raise StackOneError(f"Error executing tool: {error}") from error + except (json.JSONDecodeError, ValidationError) as exc: + return {"error": f"Invalid input: {exc}", "tool_name": tool_name} # --- Factory --- @@ -176,24 +174,25 @@ def create_meta_tools( api_key = toolset.api_key # tool_search - search_tool = _create_search_tool(api_key, opts) + search_tool = _create_search_tool(api_key) search_tool._toolset = toolset search_tool._options = opts # tool_execute - execute_tool = _create_execute_tool(api_key, opts) + execute_tool = _create_execute_tool(api_key) execute_tool._toolset = toolset execute_tool._options = opts return Tools([search_tool, execute_tool]) -def _create_search_tool(api_key: str, opts: MetaToolsOptions) -> SearchMetaTool: +def _create_search_tool(api_key: str) -> SearchMetaTool: name = "tool_search" description = ( "Search for available tools by describing what you need. " "Returns matching tool names, descriptions, and parameter schemas. " - "Use the returned parameter schemas to know exactly what to pass when calling tool_execute." + "Use the returned parameter schemas to know exactly what to pass " + "when calling tool_execute." ) parameters = ToolParameters( type="object", @@ -207,13 +206,15 @@ def _create_search_tool(api_key: str, opts: MetaToolsOptions) -> SearchMetaTool: }, "connector": { "type": "string", - "description": 'Optional connector filter (e.g. "bamboohr", "hibob")', + "description": 'Optional connector filter (e.g. "bamboohr")', + "nullable": True, }, "top_k": { "type": "integer", "description": "Max results to return (1-50, default 5)", "minimum": 1, "maximum": 50, + "nullable": True, }, }, ) @@ -239,13 +240,14 @@ def _create_search_tool(api_key: str, opts: MetaToolsOptions) -> SearchMetaTool: return tool -def _create_execute_tool(api_key: str, opts: MetaToolsOptions) -> ExecuteMetaTool: +def _create_execute_tool(api_key: str) -> ExecuteMetaTool: name = "tool_execute" description = ( "Execute a tool by name with the given parameters. " "Use tool_search first to find available tools. " - "The parameters field must match the parameter schema returned by tool_search. " - "Pass parameters as a nested object matching the schema structure." + "The parameters field must match the parameter schema returned " + "by tool_search. Pass parameters as a nested object matching " + "the schema structure." ) parameters = ToolParameters( type="object", @@ -256,7 +258,8 @@ def _create_execute_tool(api_key: str, opts: MetaToolsOptions) -> ExecuteMetaToo }, "parameters": { "type": "object", - "description": "Parameters for the tool. Pass {} if none needed.", + "description": "Parameters for the tool, matching the schema from tool_search.", + "nullable": True, }, }, ) diff --git a/stackone_ai/models.py b/stackone_ai/models.py index aabc802..f38511d 100644 --- a/stackone_ai/models.py +++ b/stackone_ai/models.py @@ -422,6 +422,10 @@ def to_langchain(self) -> BaseTool: python_type = int elif type_str == "boolean": python_type = bool + elif type_str == "object": + python_type = dict + elif type_str == "array": + python_type = list field = Field(description=details.get("description", "")) else: diff --git a/tests/test_meta_tools.py b/tests/test_meta_tools.py new file mode 100644 index 0000000..938ce50 --- /dev/null +++ b/tests/test_meta_tools.py @@ -0,0 +1,368 @@ +"""Tests for meta tools (tool_search + tool_execute).""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +from stackone_ai.meta_tools import ( + ExecuteMetaTool, + MetaToolsOptions, + SearchMetaTool, + create_meta_tools, +) +from stackone_ai.models import ( + ExecuteConfig, + StackOneAPIError, + StackOneTool, + ToolParameters, + Tools, +) + + +def _make_mock_tool(name: str = "test_tool", description: str = "A test tool") -> StackOneTool: + return StackOneTool( + description=description, + parameters=ToolParameters( + type="object", + properties={ + "id": {"type": "string", "description": "The ID"}, + "count": {"type": "integer", "description": "A count"}, + }, + ), + _execute_config=ExecuteConfig( + name=name, + method="GET", + url="http://localhost/test/{id}", + ), + _api_key="test-key", + ) + + +def _make_mock_toolset(tools: list[StackOneTool] | None = None) -> MagicMock: + toolset = MagicMock() + toolset.api_key = "test-key" + + mock_tools = Tools(tools or [_make_mock_tool()]) + toolset.search_tools.return_value = mock_tools + toolset.fetch_tools.return_value = mock_tools + return toolset + + +class TestCreateMetaTools: + def test_returns_tools_collection(self): + toolset = _make_mock_toolset() + result = create_meta_tools(toolset) + + assert isinstance(result, Tools) + assert len(result) == 2 + + def test_tool_names(self): + toolset = _make_mock_toolset() + result = create_meta_tools(toolset) + + names = [t.name for t in result] + assert "tool_search" in names + assert "tool_execute" in names + + def test_search_tool_type(self): + toolset = _make_mock_toolset() + result = create_meta_tools(toolset) + search = result.get_tool("tool_search") + assert isinstance(search, SearchMetaTool) + + def test_execute_tool_type(self): + toolset = _make_mock_toolset() + result = create_meta_tools(toolset) + execute = result.get_tool("tool_execute") + assert isinstance(execute, ExecuteMetaTool) + + def test_options_passed_through(self): + toolset = _make_mock_toolset() + opts = MetaToolsOptions(account_ids=["acc-1"], connector="bamboohr", top_k=3) + result = create_meta_tools(toolset, opts) + + search = result.get_tool("tool_search") + assert search._options.account_ids == ["acc-1"] + assert search._options.connector == "bamboohr" + assert search._options.top_k == 3 + + def test_private_attrs_excluded_from_serialization(self): + toolset = _make_mock_toolset() + result = create_meta_tools(toolset) + search = result.get_tool("tool_search") + + dumped = search.model_dump() + assert "_toolset" not in dumped + assert "_options" not in dumped + + +class TestToolSearch: + def test_delegates_to_search_tools(self): + toolset = _make_mock_toolset() + meta = create_meta_tools(toolset) + search = meta.get_tool("tool_search") + + search.execute({"query": "find employees"}) + + toolset.search_tools.assert_called_once() + call_args = toolset.search_tools.call_args + assert call_args[0][0] == "find employees" + + def test_returns_tool_names_descriptions_and_schemas(self): + mock_tool = _make_mock_tool(name="bamboohr_list_employees", description="List employees") + toolset = _make_mock_toolset([mock_tool]) + meta = create_meta_tools(toolset) + search = meta.get_tool("tool_search") + + result = search.execute({"query": "list employees"}) + + assert result["total"] == 1 + tool_info = result["tools"][0] + assert tool_info["name"] == "bamboohr_list_employees" + assert tool_info["description"] == "List employees" + assert "parameters" in tool_info + assert "id" in tool_info["parameters"] + + def test_passes_connector_from_options(self): + toolset = _make_mock_toolset() + opts = MetaToolsOptions(connector="bamboohr") + meta = create_meta_tools(toolset, opts) + search = meta.get_tool("tool_search") + + search.execute({"query": "employees"}) + + call_kwargs = toolset.search_tools.call_args[1] + assert call_kwargs["connector"] == "bamboohr" + + def test_passes_account_ids_from_options(self): + toolset = _make_mock_toolset() + opts = MetaToolsOptions(account_ids=["acc-1", "acc-2"]) + meta = create_meta_tools(toolset, opts) + search = meta.get_tool("tool_search") + + search.execute({"query": "employees"}) + + call_kwargs = toolset.search_tools.call_args[1] + assert call_kwargs["account_ids"] == ["acc-1", "acc-2"] + + def test_string_arguments(self): + toolset = _make_mock_toolset() + meta = create_meta_tools(toolset) + search = meta.get_tool("tool_search") + + result = search.execute(json.dumps({"query": "employees"})) + + assert "tools" in result + toolset.search_tools.assert_called_once() + + def test_validation_error_returns_error_dict(self): + toolset = _make_mock_toolset() + meta = create_meta_tools(toolset) + search = meta.get_tool("tool_search") + + result = search.execute({"query": ""}) + + assert "error" in result + toolset.search_tools.assert_not_called() + + def test_invalid_json_returns_error_dict(self): + toolset = _make_mock_toolset() + meta = create_meta_tools(toolset) + search = meta.get_tool("tool_search") + + result = search.execute("not valid json") + + assert "error" in result + + def test_missing_query_returns_error_dict(self): + toolset = _make_mock_toolset() + meta = create_meta_tools(toolset) + search = meta.get_tool("tool_search") + + result = search.execute({}) + + assert "error" in result + + +class TestToolExecute: + def test_delegates_to_fetch_and_execute(self): + toolset = MagicMock() + toolset.api_key = "test-key" + + # Create a mock tool that returns a known result + mock_tool = MagicMock() + mock_tool.name = "test_tool" + mock_tools = MagicMock() + mock_tools.get_tool.return_value = mock_tool + mock_tool.execute.return_value = {"result": "ok"} + toolset.fetch_tools.return_value = mock_tools + + meta = create_meta_tools(toolset) + execute = meta.get_tool("tool_execute") + + result = execute.execute({"tool_name": "test_tool", "parameters": {"id": "123"}}) + + mock_tool.execute.assert_called_once() + assert result == {"result": "ok"} + + def test_tool_not_found_returns_error(self): + toolset = MagicMock() + toolset.api_key = "test-key" + mock_tools = MagicMock() + mock_tools.get_tool.return_value = None + toolset.fetch_tools.return_value = mock_tools + + meta = create_meta_tools(toolset) + execute = meta.get_tool("tool_execute") + + result = execute.execute({"tool_name": "nonexistent_tool"}) + + assert "error" in result + assert "not found" in result["error"] + + def test_api_error_returned_as_dict(self): + toolset = MagicMock() + toolset.api_key = "test-key" + + mock_tool = MagicMock() + mock_tool.name = "test_tool" + mock_tool.execute.side_effect = StackOneAPIError( + message="Bad Request", status_code=400, response_body="invalid" + ) + mock_tools = MagicMock() + mock_tools.get_tool.return_value = mock_tool + toolset.fetch_tools.return_value = mock_tools + + meta = create_meta_tools(toolset) + execute = meta.get_tool("tool_execute") + + result = execute.execute({"tool_name": "test_tool", "parameters": {}}) + + assert "error" in result + assert result["status_code"] == 400 + assert result["tool_name"] == "test_tool" + + def test_validation_error_returns_error_dict(self): + toolset = _make_mock_toolset() + meta = create_meta_tools(toolset) + execute = meta.get_tool("tool_execute") + + result = execute.execute({"tool_name": ""}) + + assert "error" in result + + def test_invalid_json_returns_error_dict(self): + toolset = _make_mock_toolset() + meta = create_meta_tools(toolset) + execute = meta.get_tool("tool_execute") + + result = execute.execute("not valid json") + + assert "error" in result + + def test_caches_fetched_tools(self): + toolset = MagicMock() + toolset.api_key = "test-key" + + mock_tool = MagicMock() + mock_tool.name = "test_tool" + mock_tool.execute.return_value = {"ok": True} + mock_tools = MagicMock() + mock_tools.get_tool.return_value = mock_tool + toolset.fetch_tools.return_value = mock_tools + + meta = create_meta_tools(toolset) + execute = meta.get_tool("tool_execute") + + execute.execute({"tool_name": "test_tool"}) + execute.execute({"tool_name": "test_tool"}) + + # fetch_tools should only be called once due to caching + toolset.fetch_tools.assert_called_once() + + def test_passes_account_ids(self): + toolset = MagicMock() + toolset.api_key = "test-key" + + mock_tool = MagicMock() + mock_tool.name = "test_tool" + mock_tool.execute.return_value = {"ok": True} + mock_tools = MagicMock() + mock_tools.get_tool.return_value = mock_tool + toolset.fetch_tools.return_value = mock_tools + + opts = MetaToolsOptions(account_ids=["acc-1"]) + meta = create_meta_tools(toolset, opts) + execute = meta.get_tool("tool_execute") + + execute.execute({"tool_name": "test_tool"}) + + toolset.fetch_tools.assert_called_once_with(account_ids=["acc-1"]) + + def test_string_arguments(self): + toolset = MagicMock() + toolset.api_key = "test-key" + + mock_tool = MagicMock() + mock_tool.name = "test_tool" + mock_tool.execute.return_value = {"ok": True} + mock_tools = MagicMock() + mock_tools.get_tool.return_value = mock_tool + toolset.fetch_tools.return_value = mock_tools + + meta = create_meta_tools(toolset) + execute = meta.get_tool("tool_execute") + + result = execute.execute(json.dumps({"tool_name": "test_tool", "parameters": {}})) + + assert result == {"ok": True} + + +class TestLangChainConversion: + def test_meta_tools_convert_to_langchain(self): + toolset = _make_mock_toolset() + meta = create_meta_tools(toolset) + + langchain_tools = meta.to_langchain() + + assert len(langchain_tools) == 2 + names = [t.name for t in langchain_tools] + assert "tool_search" in names + assert "tool_execute" in names + + def test_execute_tool_parameters_field_is_dict_type(self): + """The 'parameters' field of tool_execute should map to dict, not str.""" + toolset = _make_mock_toolset() + meta = create_meta_tools(toolset) + execute_tool = meta.get_tool("tool_execute") + + langchain_tool = execute_tool.to_langchain() + annotations = langchain_tool.args_schema.__annotations__ + + assert annotations["parameters"] is dict + + +class TestOpenAIConversion: + def test_meta_tools_convert_to_openai(self): + toolset = _make_mock_toolset() + meta = create_meta_tools(toolset) + + openai_tools = meta.to_openai() + + assert len(openai_tools) == 2 + names = [t["function"]["name"] for t in openai_tools] + assert "tool_search" in names + assert "tool_execute" in names + + def test_nullable_fields_not_required(self): + toolset = _make_mock_toolset() + meta = create_meta_tools(toolset) + + openai_tools = meta.to_openai() + search_fn = next(t for t in openai_tools if t["function"]["name"] == "tool_search") + required = search_fn["function"]["parameters"].get("required", []) + + assert "query" in required + assert "connector" not in required + assert "top_k" not in required From 0337e2513811b604a0e811f0eb36b5fe9d17691c Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Thu, 12 Mar 2026 17:04:36 +0000 Subject: [PATCH 05/10] Address the PR comments --- examples/meta_tools_example.py | 177 +++++++++++++++++---------------- stackone_ai/__init__.py | 3 +- stackone_ai/toolset.py | 64 +++++++++++- tests/test_meta_tools.py | 67 ++++++++++++- tests/test_semantic_search.py | 30 +++--- 5 files changed, 233 insertions(+), 108 deletions(-) diff --git a/examples/meta_tools_example.py b/examples/meta_tools_example.py index 0dfccbf..fc8a42c 100644 --- a/examples/meta_tools_example.py +++ b/examples/meta_tools_example.py @@ -1,12 +1,21 @@ """Meta tools example: LLM-driven tool discovery and execution. -Instead of loading all tools upfront, the LLM autonomously searches for -relevant tools and executes them — keeping token usage minimal. +There are two ways to give tools to an LLM: + +1. ``toolset.openai()`` — fetches ALL tools and converts them to OpenAI format. + Token cost scales with the number of tools in your catalog. + +2. ``toolset.openai(mode="search_and_execute")`` — returns just 2 meta tools + (tool_search + tool_execute). The LLM discovers and runs tools on-demand, + keeping token usage constant regardless of catalog size. + +This example demonstrates approach 2 with OpenAI and LangChain clients. Prerequisites: - STACKONE_API_KEY environment variable - - STACKONE_ACCOUNT_ID environment variable (comma-separated for multiple) - - OPENAI_API_KEY or GOOGLE_API_KEY environment variable + - STACKONE_ACCOUNT_ID environment variable + - GOOGLE_API_KEY environment variable (for Gemini) + - OPENAI_API_KEY environment variable (optional, for LangChain example) Run with: uv run python examples/meta_tools_example.py @@ -26,84 +35,55 @@ from stackone_ai import StackOneToolSet -_account_ids = [aid.strip() for aid in os.getenv("STACKONE_ACCOUNT_ID", "").split(",") if aid.strip()] - -def example_openai_meta_tools() -> None: - """Meta tools with OpenAI Chat Completions. +def example_gemini() -> None: + """Complete Gemini integration with meta tools via OpenAI-compatible API. - The LLM receives only tool_search and tool_execute — two small tool - definitions regardless of how many tools exist. It searches for what - it needs and executes. + Shows: init toolset -> get OpenAI tools -> agent loop -> final answer. + Uses gemini-3-pro-preview which handles tool schemas and dates well. """ print("=" * 60) - print("Example 1: Meta tools with OpenAI") + print("Example 1: Gemini client with meta tools") print("=" * 60) print() try: from openai import OpenAI except ImportError: - print("Skipped: OpenAI library not installed. Install with: pip install openai") + print("Skipped: pip install openai") print() return - openai_key = os.getenv("OPENAI_API_KEY") google_key = os.getenv("GOOGLE_API_KEY") - - if openai_key: - client = OpenAI() - model = "gpt-4o" - provider = "OpenAI" - elif google_key: - client = OpenAI( - api_key=google_key, - base_url="https://generativelanguage.googleapis.com/v1beta/openai/", - ) - model = "gemini-3-pro-preview" - provider = "Gemini" - else: - print("Skipped: Set OPENAI_API_KEY or GOOGLE_API_KEY to run this example.") + if not google_key: + print("Skipped: Set GOOGLE_API_KEY to run this example.") print() return - print(f"Using {provider} ({model})") - print() - - toolset = StackOneToolSet(search={"method": "semantic", "top_k": 3}) - - # Get meta tools — returns a Tools collection with tool_search + tool_execute - meta_tools = toolset.get_meta_tools(account_ids=_account_ids or None) - openai_tools = meta_tools.to_openai() - - print(f"Meta tools: {[t.name for t in meta_tools]}") - print() - + # 1. Init toolset + account_id = os.getenv("STACKONE_ACCOUNT_ID") + toolset = StackOneToolSet( + account_id=account_id, + search={"method": "semantic", "top_k": 3}, + execute={"account_ids": [account_id]} if account_id else None, + ) + + # 2. Get meta tools in OpenAI format + meta_tools = toolset.get_meta_tools() + openai_tools = toolset.openai(mode="search_and_execute") + + # 3. Create Gemini client (OpenAI-compatible) and run agent loop + client = OpenAI( + api_key=google_key, + base_url="https://generativelanguage.googleapis.com/v1beta/openai/", + ) messages: list[dict] = [ - { - "role": "system", - "content": ( - "You are a helpful scheduling assistant. " - "Use tool_search to find relevant tools, then tool_execute to run them. " - "Always read the parameter schemas from tool_search results carefully. " - "If a tool needs a user URI, first search for and call a " - "'get current user' tool to obtain it. " - "If a tool execution fails, fix the parameters and retry." - ), - }, - { - "role": "user", - "content": "List my upcoming Calendly events for the next week.", - }, + {"role": "user", "content": "List my upcoming Calendly events for the next week."}, ] - # Agent loop — let the LLM drive search and execution - max_iterations = 10 - for iteration in range(max_iterations): - print(f"--- Iteration {iteration + 1} ---") - + for _step in range(10): response = client.chat.completions.create( - model=model, + model="gemini-3-pro-preview", messages=messages, tools=openai_tools, tool_choice="auto", @@ -111,24 +91,17 @@ def example_openai_meta_tools() -> None: choice = response.choices[0] + # 4. If no tool calls, print final answer and stop if not choice.message.tool_calls: - print(f"\n{provider} final response: {choice.message.content}") + print(f"Answer: {choice.message.content}") break - # Add assistant message with tool calls - # Use model_dump with exclude_none to avoid null values that Gemini rejects + # 5. Execute tool calls and feed results back messages.append(choice.message.model_dump(exclude_none=True)) - - # Execute each tool call for tool_call in choice.message.tool_calls: - print(f"LLM called: {tool_call.function.name}({tool_call.function.arguments})") - + print(f" -> {tool_call.function.name}({tool_call.function.arguments})") tool = meta_tools.get_tool(tool_call.function.name) - if tool is None: - result = {"error": f"Unknown tool: {tool_call.function.name}"} - else: - result = tool.execute(tool_call.function.arguments) - + result = tool.execute(tool_call.function.arguments) if tool else {"error": "Unknown tool"} messages.append( { "role": "tool", @@ -140,33 +113,61 @@ def example_openai_meta_tools() -> None: print() -def example_langchain_meta_tools() -> None: - """Meta tools with LangChain. +def example_langchain() -> None: + """Complete LangChain integration with meta tools. - The meta tools convert to LangChain format just like any other Tools collection. + Shows: init toolset -> bind tools to ChatOpenAI -> agent loop -> final answer. """ print("=" * 60) - print("Example 2: Meta tools with LangChain") + print("Example 2: LangChain client with meta tools") print("=" * 60) print() try: - from langchain_core.tools import BaseTool # noqa: F401 + from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + from langchain_google_genai import ChatGoogleGenerativeAI except ImportError: - print("Skipped: LangChain not installed. Install with: pip install langchain-core") + print("Skipped: pip install langchain-google-genai") + print() + return + + if not os.getenv("GOOGLE_API_KEY"): + print("Skipped: Set GOOGLE_API_KEY to run this example.") print() return - toolset = StackOneToolSet(search={"method": "semantic", "top_k": 3}) - meta_tools = toolset.get_meta_tools(account_ids=_account_ids or None) + # 1. Init toolset + account_id = os.getenv("STACKONE_ACCOUNT_ID") + toolset = StackOneToolSet( + account_id=account_id, + search={"method": "semantic", "top_k": 3}, + execute={"account_ids": [account_id]} if account_id else None, + ) + # 2. Get meta tools in LangChain format and bind to model + meta_tools = toolset.get_meta_tools() langchain_tools = meta_tools.to_langchain() + model = ChatGoogleGenerativeAI(model="gemini-3-pro-preview").bind_tools(langchain_tools) + + # 3. Run agent loop + messages = [HumanMessage(content="List my upcoming Calendly events for the next week.")] + + for _step in range(10): + response: AIMessage = model.invoke(messages) + + # 4. If no tool calls, print final answer and stop + if not response.tool_calls: + print(f"Answer: {response.content}") + break + + # 5. Execute tool calls and feed results back + messages.append(response) + for tool_call in response.tool_calls: + print(f" -> {tool_call['name']}({json.dumps(tool_call['args'])})") + tool = meta_tools.get_tool(tool_call["name"]) + result = tool.execute(tool_call["args"]) if tool else {"error": "Unknown tool"} + messages.append(ToolMessage(content=json.dumps(result), tool_call_id=tool_call["id"])) - print(f"Created {len(langchain_tools)} LangChain tools:") - for tool in langchain_tools: - print(f" - {tool.name}: {tool.description}") - print() - print("These tools are ready to use with LangChain agents (AgentExecutor, create_react_agent, etc.)") print() @@ -177,8 +178,8 @@ def main() -> None: print("Set STACKONE_API_KEY to run these examples.") return - example_openai_meta_tools() - example_langchain_meta_tools() + example_gemini() + example_langchain() if __name__ == "__main__": diff --git a/stackone_ai/__init__.py b/stackone_ai/__init__.py index f8fd6fb..b5ba7fd 100644 --- a/stackone_ai/__init__.py +++ b/stackone_ai/__init__.py @@ -7,12 +7,13 @@ SemanticSearchResponse, SemanticSearchResult, ) -from stackone_ai.toolset import SearchConfig, SearchMode, SearchTool, StackOneToolSet +from stackone_ai.toolset import ExecuteToolsConfig, SearchConfig, SearchMode, SearchTool, StackOneToolSet __all__ = [ "StackOneToolSet", "StackOneTool", "Tools", + "ExecuteToolsConfig", "SearchConfig", "SearchMode", "SearchTool", diff --git a/stackone_ai/toolset.py b/stackone_ai/toolset.py index 510ff19..5721df5 100644 --- a/stackone_ai/toolset.py +++ b/stackone_ai/toolset.py @@ -52,6 +52,20 @@ class SearchConfig(TypedDict, total=False): """Minimum similarity score threshold 0-1.""" +class ExecuteToolsConfig(TypedDict, total=False): + """Execution configuration for the StackOneToolSet constructor. + + Controls default account scoping for tool execution in meta tools. + + When set to ``None`` (default), no account scoping is applied. + When provided, ``account_ids`` flow through to ``get_meta_tools()`` + and ``fetch_tools()`` as defaults. + """ + + account_ids: list[str] + """Account IDs to scope tool discovery and execution.""" + + _SEARCH_DEFAULT: SearchConfig = {"method": "auto"} try: @@ -318,7 +332,8 @@ def __init__( api_key: str | None = None, account_id: str | None = None, base_url: str | None = None, - search: SearchConfig | None = _SEARCH_DEFAULT, + search: SearchConfig | None = None, + execute: ExecuteToolsConfig | None = None, ) -> None: """Initialize StackOne tools with authentication @@ -327,10 +342,14 @@ def __init__( account_id: Optional account ID base_url: Optional base URL override for API requests search: Search configuration. Controls default search behavior. - Omit or pass ``{}`` for defaults (method="auto"). - Pass ``None`` to disable search. + Pass ``None`` (default) to disable search — ``toolset.openai()`` + will return all regular tools. + Pass ``{}`` or ``{"method": "auto"}`` to enable search with defaults. Pass ``{"method": "semantic", "top_k": 5}`` for custom defaults. Per-call options always override these defaults. + execute: Execution configuration. Controls default account scoping + for tool execution. Pass ``{"account_ids": ["acc-1"]}`` to scope + meta tools to specific accounts. Raises: ToolsetConfigError: If no API key is provided or found in environment @@ -347,6 +366,7 @@ def __init__( self._account_ids: list[str] = [] self._semantic_client: SemanticSearchClient | None = None self._search_config: SearchConfig | None = search + self._execute_config: ExecuteToolsConfig | None = execute def set_accounts(self, account_ids: list[str]) -> StackOneToolSet: """Set account IDs for filtering tools @@ -444,6 +464,44 @@ def get_meta_tools( ) return create_meta_tools(self, options) + def openai( + self, + *, + mode: Literal["search_and_execute"] | None = None, + account_ids: list[str] | None = None, + ) -> list[dict[str, Any]]: + """Get tools in OpenAI function calling format. + + Args: + mode: Tool mode. + ``None`` (default): fetch all tools and convert to OpenAI format. + ``"search_and_execute"``: return two meta tools (tool_search + tool_execute) + that let the LLM discover and execute tools on-demand. + account_ids: Account IDs to scope tools. Overrides the ``execute`` + config from the constructor. + + Returns: + List of tool definitions in OpenAI function format. + + Examples:: + + # All tools + toolset = StackOneToolSet() + tools = toolset.openai() + + # Meta tools for agent-driven discovery + toolset = StackOneToolSet() + tools = toolset.openai(mode="search_and_execute") + """ + effective_account_ids = account_ids or ( + self._execute_config.get("account_ids") if self._execute_config else None + ) + + if mode == "search_and_execute": + return self.get_meta_tools(account_ids=effective_account_ids).to_openai() + + return self.fetch_tools(account_ids=effective_account_ids).to_openai() + @property def semantic_client(self) -> SemanticSearchClient: """Lazy initialization of semantic search client. diff --git a/tests/test_meta_tools.py b/tests/test_meta_tools.py index 938ce50..bd80733 100644 --- a/tests/test_meta_tools.py +++ b/tests/test_meta_tools.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from stackone_ai.meta_tools import ( ExecuteMetaTool, @@ -18,6 +18,7 @@ ToolParameters, Tools, ) +from stackone_ai.toolset import StackOneToolSet def _make_mock_tool(name: str = "test_tool", description: str = "A test tool") -> StackOneTool: @@ -366,3 +367,67 @@ def test_nullable_fields_not_required(self): assert "query" in required assert "connector" not in required assert "top_k" not in required + + +class TestToolSetOpenAIMethod: + """Tests for StackOneToolSet.openai() convenience method.""" + + def test_openai_default_fetches_all_tools(self): + toolset = StackOneToolSet(api_key="test-key") + mock_tools = Tools([_make_mock_tool()]) + + with patch.object(toolset, "fetch_tools", return_value=mock_tools) as mock_fetch: + result = toolset.openai() + + mock_fetch.assert_called_once_with(account_ids=None) + assert len(result) == 1 + assert result[0]["function"]["name"] == "test_tool" + + def test_openai_search_and_execute_returns_meta_tools(self): + toolset = StackOneToolSet(api_key="test-key") + mock_meta = Tools([_make_mock_tool(name="tool_search"), _make_mock_tool(name="tool_execute")]) + + with patch.object(toolset, "get_meta_tools", return_value=mock_meta) as mock_get: + result = toolset.openai(mode="search_and_execute") + + mock_get.assert_called_once_with(account_ids=None) + assert len(result) == 2 + names = [t["function"]["name"] for t in result] + assert "tool_search" in names + assert "tool_execute" in names + + def test_openai_passes_account_ids(self): + toolset = StackOneToolSet(api_key="test-key") + mock_tools = Tools([_make_mock_tool()]) + + with patch.object(toolset, "fetch_tools", return_value=mock_tools) as mock_fetch: + toolset.openai(account_ids=["acc-1"]) + + mock_fetch.assert_called_once_with(account_ids=["acc-1"]) + + def test_openai_uses_execute_config_account_ids(self): + toolset = StackOneToolSet(api_key="test-key", execute={"account_ids": ["acc-from-config"]}) + mock_tools = Tools([_make_mock_tool()]) + + with patch.object(toolset, "fetch_tools", return_value=mock_tools) as mock_fetch: + toolset.openai() + + mock_fetch.assert_called_once_with(account_ids=["acc-from-config"]) + + def test_openai_account_ids_overrides_execute_config(self): + toolset = StackOneToolSet(api_key="test-key", execute={"account_ids": ["from-config"]}) + mock_tools = Tools([_make_mock_tool()]) + + with patch.object(toolset, "fetch_tools", return_value=mock_tools) as mock_fetch: + toolset.openai(account_ids=["from-call"]) + + mock_fetch.assert_called_once_with(account_ids=["from-call"]) + + def test_openai_search_and_execute_with_execute_config(self): + toolset = StackOneToolSet(api_key="test-key", execute={"account_ids": ["acc-1"]}) + mock_meta = Tools([_make_mock_tool(name="tool_search"), _make_mock_tool(name="tool_execute")]) + + with patch.object(toolset, "get_meta_tools", return_value=mock_meta) as mock_get: + toolset.openai(mode="search_and_execute") + + mock_get.assert_called_once_with(account_ids=["acc-1"]) diff --git a/tests/test_semantic_search.py b/tests/test_semantic_search.py index 13bef94..8f397fc 100644 --- a/tests/test_semantic_search.py +++ b/tests/test_semantic_search.py @@ -304,7 +304,7 @@ def test_toolset_search_tools( ), ] - toolset = StackOneToolSet(api_key="test-key") + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) tools = toolset.search_tools("create employee", top_k=5) # Should only return tools for available connectors (bamboohr, hibob) @@ -352,7 +352,7 @@ def test_toolset_search_tools_fallback( ), ] - toolset = StackOneToolSet(api_key="test-key") + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) tools = toolset.search_tools("create employee", top_k=5, search="auto") # Should return results from the local BM25+TF-IDF fallback @@ -394,7 +394,7 @@ def test_toolset_search_tools_fallback_respects_connector( ), ] - toolset = StackOneToolSet(api_key="test-key") + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) tools = toolset.search_tools("create employee", connector="bamboohr", search="auto") assert len(tools) > 0 @@ -423,7 +423,7 @@ def test_toolset_search_tools_fallback_disabled( ), ] - toolset = StackOneToolSet(api_key="test-key") + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) with pytest.raises(SemanticSearchError): toolset.search_tools("create employee", search="semantic") @@ -458,7 +458,7 @@ def test_toolset_search_action_names( query="create employee", ) - toolset = StackOneToolSet(api_key="test-key") + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) results = toolset.search_action_names("create employee", min_similarity=0.5) # min_similarity is passed to server; mock returns both results @@ -511,7 +511,7 @@ def test_local_mode_skips_semantic_api( ), ] - toolset = StackOneToolSet(api_key="test-key") + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) tools = toolset.search_tools("create employee", top_k=5, search="local") assert len(tools) > 0 @@ -537,7 +537,7 @@ def test_semantic_mode_raises_on_failure( ), ] - toolset = StackOneToolSet(api_key="test-key") + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) with pytest.raises(SemanticSearchError): toolset.search_tools("create employee", search="semantic") @@ -561,7 +561,7 @@ def test_auto_mode_falls_back_to_local( ), ] - toolset = StackOneToolSet(api_key="test-key") + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) tools = toolset.search_tools("create employee", top_k=5, search="auto") assert len(tools) > 0 @@ -585,7 +585,7 @@ def test_search_tool_passes_search_mode( ), ] - toolset = StackOneToolSet(api_key="test-key") + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) search_tool = toolset.get_search_tool(search="local") tools = search_tool("list employees", top_k=5) @@ -752,7 +752,7 @@ def _search_side_effect( ), ] - toolset = StackOneToolSet(api_key="test-key") + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) results = toolset.search_action_names( "create employee", account_ids=["acc-123"], @@ -776,7 +776,7 @@ def test_search_action_names_returns_empty_on_failure(self, mock_search: MagicMo mock_search.side_effect = SemanticSearchError("API unavailable") - toolset = StackOneToolSet(api_key="test-key") + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) results = toolset.search_action_names("create employee") assert results == [] @@ -808,7 +808,7 @@ def test_searches_all_connectors_in_parallel(self, mock_fetch: MagicMock, mock_s ), ] - toolset = StackOneToolSet(api_key="test-key") + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) toolset.search_action_names( "test", account_ids=["acc-123"], @@ -855,7 +855,7 @@ def test_respects_top_k_after_filtering(self, mock_fetch: MagicMock, mock_search ), ] - toolset = StackOneToolSet(api_key="test-key") + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) results = toolset.search_action_names( "test", account_ids=["acc-123"], @@ -967,7 +967,7 @@ def test_search_tools_deduplicates_versions(self, mock_fetch: MagicMock, mock_se ), ] - toolset = StackOneToolSet(api_key="test-key") + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) tools = toolset.search_tools("list employees", top_k=5) # Should deduplicate: both breathehr versions -> breathehr_list_employees @@ -1002,7 +1002,7 @@ def test_search_action_names_normalizes_versions(self, mock_search: MagicMock) - query="list employees", ) - toolset = StackOneToolSet(api_key="test-key") + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) results = toolset.search_action_names("list employees", top_k=5) # Both results are returned with normalized names (no dedup in global path) From aa955dbc0457cc6e4bab718e54f2c3d2b6b4002e Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Mon, 16 Mar 2026 09:11:01 +0000 Subject: [PATCH 06/10] remove toolset.get_meta_tools() and update to new API --- examples/meta_tools_example.py | 68 ++-------------------------------- stackone_ai/toolset.py | 31 ++++++++++++++++ tests/test_meta_tools.py | 54 +++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 65 deletions(-) diff --git a/examples/meta_tools_example.py b/examples/meta_tools_example.py index fc8a42c..614cd8b 100644 --- a/examples/meta_tools_example.py +++ b/examples/meta_tools_example.py @@ -9,13 +9,12 @@ (tool_search + tool_execute). The LLM discovers and runs tools on-demand, keeping token usage constant regardless of catalog size. -This example demonstrates approach 2 with OpenAI and LangChain clients. +This example demonstrates approach 2 with a Gemini client (OpenAI-compatible). Prerequisites: - STACKONE_API_KEY environment variable - STACKONE_ACCOUNT_ID environment variable - GOOGLE_API_KEY environment variable (for Gemini) - - OPENAI_API_KEY environment variable (optional, for LangChain example) Run with: uv run python examples/meta_tools_example.py @@ -68,8 +67,7 @@ def example_gemini() -> None: execute={"account_ids": [account_id]} if account_id else None, ) - # 2. Get meta tools in OpenAI format - meta_tools = toolset.get_meta_tools() + # 2. Get tools in OpenAI format openai_tools = toolset.openai(mode="search_and_execute") # 3. Create Gemini client (OpenAI-compatible) and run agent loop @@ -100,8 +98,7 @@ def example_gemini() -> None: messages.append(choice.message.model_dump(exclude_none=True)) for tool_call in choice.message.tool_calls: print(f" -> {tool_call.function.name}({tool_call.function.arguments})") - tool = meta_tools.get_tool(tool_call.function.name) - result = tool.execute(tool_call.function.arguments) if tool else {"error": "Unknown tool"} + result = toolset.execute(tool_call.function.name, tool_call.function.arguments) messages.append( { "role": "tool", @@ -113,64 +110,6 @@ def example_gemini() -> None: print() -def example_langchain() -> None: - """Complete LangChain integration with meta tools. - - Shows: init toolset -> bind tools to ChatOpenAI -> agent loop -> final answer. - """ - print("=" * 60) - print("Example 2: LangChain client with meta tools") - print("=" * 60) - print() - - try: - from langchain_core.messages import AIMessage, HumanMessage, ToolMessage - from langchain_google_genai import ChatGoogleGenerativeAI - except ImportError: - print("Skipped: pip install langchain-google-genai") - print() - return - - if not os.getenv("GOOGLE_API_KEY"): - print("Skipped: Set GOOGLE_API_KEY to run this example.") - print() - return - - # 1. Init toolset - account_id = os.getenv("STACKONE_ACCOUNT_ID") - toolset = StackOneToolSet( - account_id=account_id, - search={"method": "semantic", "top_k": 3}, - execute={"account_ids": [account_id]} if account_id else None, - ) - - # 2. Get meta tools in LangChain format and bind to model - meta_tools = toolset.get_meta_tools() - langchain_tools = meta_tools.to_langchain() - model = ChatGoogleGenerativeAI(model="gemini-3-pro-preview").bind_tools(langchain_tools) - - # 3. Run agent loop - messages = [HumanMessage(content="List my upcoming Calendly events for the next week.")] - - for _step in range(10): - response: AIMessage = model.invoke(messages) - - # 4. If no tool calls, print final answer and stop - if not response.tool_calls: - print(f"Answer: {response.content}") - break - - # 5. Execute tool calls and feed results back - messages.append(response) - for tool_call in response.tool_calls: - print(f" -> {tool_call['name']}({json.dumps(tool_call['args'])})") - tool = meta_tools.get_tool(tool_call["name"]) - result = tool.execute(tool_call["args"]) if tool else {"error": "Unknown tool"} - messages.append(ToolMessage(content=json.dumps(result), tool_call_id=tool_call["id"])) - - print() - - def main() -> None: """Run all meta tools examples.""" api_key = os.getenv("STACKONE_API_KEY") @@ -179,7 +118,6 @@ def main() -> None: return example_gemini() - example_langchain() if __name__ == "__main__": diff --git a/stackone_ai/toolset.py b/stackone_ai/toolset.py index 5721df5..4c316d3 100644 --- a/stackone_ai/toolset.py +++ b/stackone_ai/toolset.py @@ -367,6 +367,7 @@ def __init__( self._semantic_client: SemanticSearchClient | None = None self._search_config: SearchConfig | None = search self._execute_config: ExecuteToolsConfig | None = execute + self._meta_tools_cache: Tools | None = None def set_accounts(self, account_ids: list[str]) -> StackOneToolSet: """Set account IDs for filtering tools @@ -502,6 +503,36 @@ def openai( return self.fetch_tools(account_ids=effective_account_ids).to_openai() + + def execute( + self, + tool_name: str, + arguments: str | dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Execute a tool by name. + + Use with ``openai(mode="search_and_execute")`` in manual agent loops — + pass the tool name and arguments from the LLM's tool call directly. + + Meta tools are cached after the first call. + + Args: + tool_name: The tool name from the LLM's tool call + (e.g. ``"tool_search"`` or ``"tool_execute"``). + arguments: The arguments from the LLM's tool call, + as a JSON string or dict. + + Returns: + Tool execution result as a dict. + """ + if self._meta_tools_cache is None: + self._meta_tools_cache = self.get_meta_tools() + + tool = self._meta_tools_cache.get_tool(tool_name) + if tool is None: + return {"error": f'Tool "{tool_name}" not found.'} + return tool.execute(arguments) + @property def semantic_client(self) -> SemanticSearchClient: """Lazy initialization of semantic search client. diff --git a/tests/test_meta_tools.py b/tests/test_meta_tools.py index bd80733..2482aef 100644 --- a/tests/test_meta_tools.py +++ b/tests/test_meta_tools.py @@ -431,3 +431,57 @@ def test_openai_search_and_execute_with_execute_config(self): toolset.openai(mode="search_and_execute") mock_get.assert_called_once_with(account_ids=["acc-1"]) + + +class TestToolSetExecuteMethod: + """Tests for StackOneToolSet.execute() convenience method.""" + + def test_execute_delegates_to_meta_tool(self): + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) + mock_tool = MagicMock() + mock_tool.execute.return_value = {"result": "ok"} + mock_meta = MagicMock() + mock_meta.get_tool.return_value = mock_tool + + with patch.object(toolset, "get_meta_tools", return_value=mock_meta): + result = toolset.execute("tool_search", {"query": "employees"}) + + assert result == {"result": "ok"} + mock_meta.get_tool.assert_called_once_with("tool_search") + mock_tool.execute.assert_called_once_with({"query": "employees"}) + + def test_execute_caches_meta_tools(self): + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) + mock_tool = MagicMock() + mock_tool.execute.return_value = {"ok": True} + mock_meta = MagicMock() + mock_meta.get_tool.return_value = mock_tool + + with patch.object(toolset, "get_meta_tools", return_value=mock_meta) as mock_get: + toolset.execute("tool_search", {"query": "a"}) + toolset.execute("tool_execute", {"tool_name": "b"}) + + mock_get.assert_called_once() + + def test_execute_returns_error_for_unknown_tool(self): + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) + mock_meta = MagicMock() + mock_meta.get_tool.return_value = None + + with patch.object(toolset, "get_meta_tools", return_value=mock_meta): + result = toolset.execute("nonexistent", {}) + + assert "error" in result + + def test_execute_accepts_string_arguments(self): + toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) + mock_tool = MagicMock() + mock_tool.execute.return_value = {"ok": True} + mock_meta = MagicMock() + mock_meta.get_tool.return_value = mock_tool + + with patch.object(toolset, "get_meta_tools", return_value=mock_meta): + result = toolset.execute("tool_search", '{"query": "test"}') + + assert result == {"ok": True} + mock_tool.execute.assert_called_once_with('{"query": "test"}') From 956cd7d7aefa935959a5e46355a52495fc79347e Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Mon, 16 Mar 2026 11:55:36 +0000 Subject: [PATCH 07/10] Update the API --- examples/meta_tools_example.py | 81 +++++++-- stackone_ai/meta_tools.py | 284 ----------------------------- stackone_ai/toolset.py | 318 +++++++++++++++++++++++++++------ tests/test_meta_tools.py | 177 +++++++++++------- 4 files changed, 451 insertions(+), 409 deletions(-) delete mode 100644 stackone_ai/meta_tools.py diff --git a/examples/meta_tools_example.py b/examples/meta_tools_example.py index 614cd8b..b8ce0a2 100644 --- a/examples/meta_tools_example.py +++ b/examples/meta_tools_example.py @@ -1,20 +1,22 @@ -"""Meta tools example: LLM-driven tool discovery and execution. +"""Search and execute example: LLM-driven tool discovery and execution. There are two ways to give tools to an LLM: 1. ``toolset.openai()`` — fetches ALL tools and converts them to OpenAI format. Token cost scales with the number of tools in your catalog. -2. ``toolset.openai(mode="search_and_execute")`` — returns just 2 meta tools +2. ``toolset.openai(mode="search_and_execute")`` — returns just 2 tools (tool_search + tool_execute). The LLM discovers and runs tools on-demand, keeping token usage constant regardless of catalog size. -This example demonstrates approach 2 with a Gemini client (OpenAI-compatible). +This example demonstrates approach 2 with two patterns: +- Raw client (Gemini): manual agent loop with ``toolset.execute()`` +- LangChain: framework handles tool execution automatically Prerequisites: - STACKONE_API_KEY environment variable - STACKONE_ACCOUNT_ID environment variable - - GOOGLE_API_KEY environment variable (for Gemini) + - GOOGLE_API_KEY environment variable (for Gemini/LangChain) Run with: uv run python examples/meta_tools_example.py @@ -36,13 +38,12 @@ def example_gemini() -> None: - """Complete Gemini integration with meta tools via OpenAI-compatible API. + """Raw client: Gemini via OpenAI-compatible API. - Shows: init toolset -> get OpenAI tools -> agent loop -> final answer. - Uses gemini-3-pro-preview which handles tool schemas and dates well. + Shows: init toolset -> get OpenAI tools -> manual agent loop with toolset.execute(). """ print("=" * 60) - print("Example 1: Gemini client with meta tools") + print("Example 1: Raw client (Gemini) — manual execution") print("=" * 60) print() @@ -94,7 +95,7 @@ def example_gemini() -> None: print(f"Answer: {choice.message.content}") break - # 5. Execute tool calls and feed results back + # 5. Execute tool calls manually and feed results back messages.append(choice.message.model_dump(exclude_none=True)) for tool_call in choice.message.tool_calls: print(f" -> {tool_call.function.name}({tool_call.function.arguments})") @@ -110,14 +111,74 @@ def example_gemini() -> None: print() +def example_langchain() -> None: + """Framework: LangChain with auto-execution. + + Shows: init toolset -> get LangChain tools -> bind to model -> framework executes tools. + No toolset.execute() needed — the framework calls _run() on tools automatically. + """ + print("=" * 60) + print("Example 2: LangChain — framework handles execution") + print("=" * 60) + print() + + try: + from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + from langchain_google_genai import ChatGoogleGenerativeAI + except ImportError: + print("Skipped: pip install langchain-google-genai") + print() + return + + if not os.getenv("GOOGLE_API_KEY"): + print("Skipped: Set GOOGLE_API_KEY to run this example.") + print() + return + + # 1. Init toolset + account_id = os.getenv("STACKONE_ACCOUNT_ID") + toolset = StackOneToolSet( + account_id=account_id, + search={"method": "semantic", "top_k": 3}, + execute={"account_ids": [account_id]} if account_id else None, + ) + + # 2. Get tools in LangChain format and bind to model + langchain_tools = toolset.langchain(mode="search_and_execute") + tools_by_name = {tool.name: tool for tool in langchain_tools} + model = ChatGoogleGenerativeAI(model="gemini-3-pro-preview").bind_tools(langchain_tools) + + # 3. Run agent loop + messages = [HumanMessage(content="List my upcoming Calendly events for the next week.")] + + for _step in range(10): + response: AIMessage = model.invoke(messages) + + # 4. If no tool calls, print final answer and stop + if not response.tool_calls: + print(f"Answer: {response.content}") + break + + # 5. Framework-compatible execution — invoke LangChain tools directly + messages.append(response) + for tool_call in response.tool_calls: + print(f" -> {tool_call['name']}({json.dumps(tool_call['args'])})") + tool = tools_by_name[tool_call["name"]] + result = tool.invoke(tool_call["args"]) + messages.append(ToolMessage(content=json.dumps(result), tool_call_id=tool_call["id"])) + + print() + + def main() -> None: - """Run all meta tools examples.""" + """Run all examples.""" api_key = os.getenv("STACKONE_API_KEY") if not api_key: print("Set STACKONE_API_KEY to run these examples.") return example_gemini() + example_langchain() if __name__ == "__main__": diff --git a/stackone_ai/meta_tools.py b/stackone_ai/meta_tools.py deleted file mode 100644 index daead8d..0000000 --- a/stackone_ai/meta_tools.py +++ /dev/null @@ -1,284 +0,0 @@ -"""Meta tools (tool_search + tool_execute) for LLM-driven workflows.""" - -from __future__ import annotations - -import json -from typing import TYPE_CHECKING, Any - -from pydantic import BaseModel, Field, PrivateAttr, ValidationError, field_validator - -from stackone_ai.models import ( - ExecuteConfig, - JsonDict, - ParameterLocation, - StackOneAPIError, - StackOneTool, - ToolParameters, - Tools, -) - -if TYPE_CHECKING: - from stackone_ai.toolset import StackOneToolSet - - -class MetaToolsOptions(BaseModel): - """Options for get_meta_tools().""" - - account_ids: list[str] | None = None - search: Any | None = Field(default=None, description="Search mode: 'auto', 'semantic', or 'local'") - connector: str | None = None - top_k: int | None = None - min_similarity: float | None = None - - -# --- tool_search --- - - -class SearchInput(BaseModel): - """Input validation for tool_search.""" - - query: str = Field(..., min_length=1) - connector: str | None = None - top_k: int | None = Field(default=None, ge=1, le=50) - - @field_validator("query") - @classmethod - def validate_query(cls, v: str) -> str: - trimmed = v.strip() - if not trimmed: - raise ValueError("query must be a non-empty string") - return trimmed - - -class SearchMetaTool(StackOneTool): - """LLM-callable tool that searches for available StackOne tools.""" - - _toolset: Any = PrivateAttr(default=None) - _options: MetaToolsOptions = PrivateAttr(default=None) # type: ignore[assignment] - - def execute( - self, arguments: str | JsonDict | None = None, *, options: JsonDict | None = None - ) -> JsonDict: - try: - if isinstance(arguments, str): - raw_params = json.loads(arguments) - else: - raw_params = arguments or {} - - parsed = SearchInput(**raw_params) - - results = self._toolset.search_tools( - parsed.query, - connector=parsed.connector or self._options.connector, - top_k=parsed.top_k or self._options.top_k or 5, - min_similarity=self._options.min_similarity, - search=self._options.search, - account_ids=self._options.account_ids, - ) - - return { - "tools": [ - { - "name": t.name, - "description": t.description, - "parameters": t.parameters.properties, - } - for t in results - ], - "total": len(results), - "query": parsed.query, - } - except (json.JSONDecodeError, ValidationError) as exc: - return {"error": f"Invalid input: {exc}", "query": raw_params if "raw_params" in dir() else None} - - -# --- tool_execute --- - - -class ExecuteInput(BaseModel): - """Input validation for tool_execute.""" - - tool_name: str = Field(..., min_length=1) - parameters: dict[str, Any] = Field(default_factory=dict) - - @field_validator("tool_name") - @classmethod - def validate_tool_name(cls, v: str) -> str: - trimmed = v.strip() - if not trimmed: - raise ValueError("tool_name must be a non-empty string") - return trimmed - - -class ExecuteMetaTool(StackOneTool): - """LLM-callable tool that executes a StackOne tool by name.""" - - _toolset: Any = PrivateAttr(default=None) - _options: MetaToolsOptions = PrivateAttr(default=None) # type: ignore[assignment] - _cached_tools: Any = PrivateAttr(default=None) - - def execute( - self, arguments: str | JsonDict | None = None, *, options: JsonDict | None = None - ) -> JsonDict: - tool_name = "unknown" - try: - if isinstance(arguments, str): - raw_params = json.loads(arguments) - else: - raw_params = arguments or {} - - parsed = ExecuteInput(**raw_params) - tool_name = parsed.tool_name - - if self._cached_tools is None: - self._cached_tools = self._toolset.fetch_tools(account_ids=self._options.account_ids) - - target = self._cached_tools.get_tool(parsed.tool_name) - - if target is None: - return { - "error": ( - f'Tool "{parsed.tool_name}" not found. Use tool_search to find available tools.' - ), - } - - return target.execute(parsed.parameters, options=options) - except StackOneAPIError as exc: - return { - "error": str(exc), - "status_code": exc.status_code, - "response_body": exc.response_body, - "tool_name": tool_name, - } - except (json.JSONDecodeError, ValidationError) as exc: - return {"error": f"Invalid input: {exc}", "tool_name": tool_name} - - -# --- Factory --- - - -def create_meta_tools( - toolset: StackOneToolSet, - options: MetaToolsOptions | None = None, -) -> Tools: - """Create tool_search + tool_execute for LLM-driven workflows. - - Args: - toolset: The StackOneToolSet to delegate search and execution to. - options: Options to scope search and execution. - - Returns: - Tools collection containing tool_search and tool_execute. - """ - opts = options or MetaToolsOptions() - api_key = toolset.api_key - - # tool_search - search_tool = _create_search_tool(api_key) - search_tool._toolset = toolset - search_tool._options = opts - - # tool_execute - execute_tool = _create_execute_tool(api_key) - execute_tool._toolset = toolset - execute_tool._options = opts - - return Tools([search_tool, execute_tool]) - - -def _create_search_tool(api_key: str) -> SearchMetaTool: - name = "tool_search" - description = ( - "Search for available tools by describing what you need. " - "Returns matching tool names, descriptions, and parameter schemas. " - "Use the returned parameter schemas to know exactly what to pass " - "when calling tool_execute." - ) - parameters = ToolParameters( - type="object", - properties={ - "query": { - "type": "string", - "description": ( - "Natural language description of what you need " - '(e.g. "create an employee", "list time off requests")' - ), - }, - "connector": { - "type": "string", - "description": 'Optional connector filter (e.g. "bamboohr")', - "nullable": True, - }, - "top_k": { - "type": "integer", - "description": "Max results to return (1-50, default 5)", - "minimum": 1, - "maximum": 50, - "nullable": True, - }, - }, - ) - execute_config = ExecuteConfig( - name=name, - method="POST", - url="local://meta/search", - parameter_locations={ - "query": ParameterLocation.BODY, - "connector": ParameterLocation.BODY, - "top_k": ParameterLocation.BODY, - }, - ) - - tool = SearchMetaTool.__new__(SearchMetaTool) - StackOneTool.__init__( - tool, - description=description, - parameters=parameters, - _execute_config=execute_config, - _api_key=api_key, - ) - return tool - - -def _create_execute_tool(api_key: str) -> ExecuteMetaTool: - name = "tool_execute" - description = ( - "Execute a tool by name with the given parameters. " - "Use tool_search first to find available tools. " - "The parameters field must match the parameter schema returned " - "by tool_search. Pass parameters as a nested object matching " - "the schema structure." - ) - parameters = ToolParameters( - type="object", - properties={ - "tool_name": { - "type": "string", - "description": "Exact tool name from tool_search results", - }, - "parameters": { - "type": "object", - "description": "Parameters for the tool, matching the schema from tool_search.", - "nullable": True, - }, - }, - ) - execute_config = ExecuteConfig( - name=name, - method="POST", - url="local://meta/execute", - parameter_locations={ - "tool_name": ParameterLocation.BODY, - "parameters": ParameterLocation.BODY, - }, - ) - - tool = ExecuteMetaTool.__new__(ExecuteMetaTool) - StackOneTool.__init__( - tool, - description=description, - parameters=parameters, - _execute_config=execute_config, - _api_key=api_key, - ) - return tool diff --git a/stackone_ai/toolset.py b/stackone_ai/toolset.py index 4c316d3..b26d1a2 100644 --- a/stackone_ai/toolset.py +++ b/stackone_ai/toolset.py @@ -13,10 +13,14 @@ from importlib import metadata from typing import Any, Literal, TypedDict, TypeVar +from pydantic import BaseModel, Field, PrivateAttr, ValidationError, field_validator + from stackone_ai.constants import DEFAULT_BASE_URL from stackone_ai.models import ( ExecuteConfig, + JsonDict, ParameterLocation, + StackOneAPIError, StackOneTool, ToolParameters, Tools, @@ -58,7 +62,7 @@ class ExecuteToolsConfig(TypedDict, total=False): Controls default account scoping for tool execution in meta tools. When set to ``None`` (default), no account scoping is applied. - When provided, ``account_ids`` flow through to ``get_meta_tools()`` + When provided, ``account_ids`` flow through to ``openai(mode="search_and_execute")`` and ``fetch_tools()`` as defaults. """ @@ -82,6 +86,223 @@ class ExecuteToolsConfig(TypedDict, total=False): _USER_AGENT = f"stackone-ai-python/{_SDK_VERSION}" +# --- Internal tool_search + tool_execute --- + + +class _SearchInput(BaseModel): + """Input validation for tool_search.""" + + query: str = Field(..., min_length=1) + connector: str | None = None + top_k: int | None = Field(default=None, ge=1, le=50) + + @field_validator("query") + @classmethod + def validate_query(cls, v: str) -> str: + trimmed = v.strip() + if not trimmed: + raise ValueError("query must be a non-empty string") + return trimmed + + +class _SearchTool(StackOneTool): + """LLM-callable tool that searches for available StackOne tools.""" + + _toolset: Any = PrivateAttr(default=None) + + def execute( + self, arguments: str | JsonDict | None = None, *, options: JsonDict | None = None + ) -> JsonDict: + try: + if isinstance(arguments, str): + raw_params = json.loads(arguments) + else: + raw_params = arguments or {} + + parsed = _SearchInput(**raw_params) + + search_config = self._toolset._search_config or {} + results = self._toolset.search_tools( + parsed.query, + connector=parsed.connector or search_config.get("connector"), + top_k=parsed.top_k or search_config.get("top_k") or 5, + min_similarity=search_config.get("min_similarity"), + search=search_config.get("method"), + account_ids=self._toolset._account_ids, + ) + + return { + "tools": [ + { + "name": t.name, + "description": t.description, + "parameters": t.parameters.properties, + } + for t in results + ], + "total": len(results), + "query": parsed.query, + } + except (json.JSONDecodeError, ValidationError) as exc: + return {"error": f"Invalid input: {exc}", "query": raw_params if "raw_params" in dir() else None} + + +class _ExecuteInput(BaseModel): + """Input validation for tool_execute.""" + + tool_name: str = Field(..., min_length=1) + parameters: dict[str, Any] = Field(default_factory=dict) + + @field_validator("tool_name") + @classmethod + def validate_tool_name(cls, v: str) -> str: + trimmed = v.strip() + if not trimmed: + raise ValueError("tool_name must be a non-empty string") + return trimmed + + +class _ExecuteTool(StackOneTool): + """LLM-callable tool that executes a StackOne tool by name.""" + + _toolset: Any = PrivateAttr(default=None) + _cached_tools: Any = PrivateAttr(default=None) + + def execute( + self, arguments: str | JsonDict | None = None, *, options: JsonDict | None = None + ) -> JsonDict: + tool_name = "unknown" + try: + if isinstance(arguments, str): + raw_params = json.loads(arguments) + else: + raw_params = arguments or {} + + parsed = _ExecuteInput(**raw_params) + tool_name = parsed.tool_name + + if self._cached_tools is None: + self._cached_tools = self._toolset.fetch_tools(account_ids=self._toolset._account_ids) + + target = self._cached_tools.get_tool(parsed.tool_name) + + if target is None: + return { + "error": ( + f'Tool "{parsed.tool_name}" not found. Use tool_search to find available tools.' + ), + } + + return target.execute(parsed.parameters, options=options) + except StackOneAPIError as exc: + return { + "error": str(exc), + "status_code": exc.status_code, + "response_body": exc.response_body, + "tool_name": tool_name, + } + except (json.JSONDecodeError, ValidationError) as exc: + return {"error": f"Invalid input: {exc}", "tool_name": tool_name} + + +def _create_search_tool(api_key: str) -> _SearchTool: + name = "tool_search" + description = ( + "Search for available tools by describing what you need. " + "Returns matching tool names, descriptions, and parameter schemas. " + "Use the returned parameter schemas to know exactly what to pass " + "when calling tool_execute." + ) + parameters = ToolParameters( + type="object", + properties={ + "query": { + "type": "string", + "description": ( + "Natural language description of what you need " + '(e.g. "create an employee", "list time off requests")' + ), + }, + "connector": { + "type": "string", + "description": 'Optional connector filter (e.g. "bamboohr")', + "nullable": True, + }, + "top_k": { + "type": "integer", + "description": "Max results to return (1-50, default 5)", + "minimum": 1, + "maximum": 50, + "nullable": True, + }, + }, + ) + execute_config = ExecuteConfig( + name=name, + method="POST", + url="local://meta/search", + parameter_locations={ + "query": ParameterLocation.BODY, + "connector": ParameterLocation.BODY, + "top_k": ParameterLocation.BODY, + }, + ) + + tool = _SearchTool.__new__(_SearchTool) + StackOneTool.__init__( + tool, + description=description, + parameters=parameters, + _execute_config=execute_config, + _api_key=api_key, + ) + return tool + + +def _create_execute_tool(api_key: str) -> _ExecuteTool: + name = "tool_execute" + description = ( + "Execute a tool by name with the given parameters. " + "Use tool_search first to find available tools. " + "The parameters field must match the parameter schema returned " + "by tool_search. Pass parameters as a nested object matching " + "the schema structure." + ) + parameters = ToolParameters( + type="object", + properties={ + "tool_name": { + "type": "string", + "description": "Exact tool name from tool_search results", + }, + "parameters": { + "type": "object", + "description": "Parameters for the tool, matching the schema from tool_search.", + "nullable": True, + }, + }, + ) + execute_config = ExecuteConfig( + name=name, + method="POST", + url="local://meta/execute", + parameter_locations={ + "tool_name": ParameterLocation.BODY, + "parameters": ParameterLocation.BODY, + }, + ) + + tool = _ExecuteTool.__new__(_ExecuteTool) + StackOneTool.__init__( + tool, + description=description, + parameters=parameters, + _execute_config=execute_config, + _api_key=api_key, + ) + return tool + + T = TypeVar("T") @@ -367,7 +588,7 @@ def __init__( self._semantic_client: SemanticSearchClient | None = None self._search_config: SearchConfig | None = search self._execute_config: ExecuteToolsConfig | None = execute - self._meta_tools_cache: Tools | None = None + self._tools_cache: Tools | None = None def set_accounts(self, account_ids: list[str]) -> StackOneToolSet: """Set account IDs for filtering tools @@ -414,56 +635,23 @@ def get_search_tool(self, *, search: SearchMode | None = None) -> SearchTool: return SearchTool(self, config=config) - def get_meta_tools( - self, - *, - account_ids: list[str] | None = None, - search: SearchMode | None = None, - connector: str | None = None, - top_k: int | None = None, - min_similarity: float | None = None, - ) -> Tools: - """Get LLM-callable meta tools (tool_search + tool_execute) for agent-driven workflows. - - Returns a Tools collection that can be passed directly to any LLM framework. - The LLM uses tool_search to discover available tools, then tool_execute to run them. - - Args: - account_ids: Account IDs to scope tool discovery and execution - search: Search mode ('auto', 'semantic', or 'local') - connector: Optional connector filter (e.g. 'bamboohr') - top_k: Maximum number of search results. Defaults to 5. - min_similarity: Minimum similarity score threshold 0-1 - - Returns: - Tools collection containing tool_search and tool_execute - - Example:: - - toolset = StackOneToolSet(account_id="acc-123") - meta_tools = toolset.get_meta_tools() - - # Pass to OpenAI - tools = meta_tools.to_openai() - - # Pass to LangChain - tools = meta_tools.to_langchain() - """ + def _build_tools(self, account_ids: list[str] | None = None) -> Tools: + """Build tool_search + tool_execute tools scoped to this toolset.""" if self._search_config is None: raise ToolsetConfigError( "Search is disabled. Initialize StackOneToolSet with a search config to enable." ) - from stackone_ai.meta_tools import MetaToolsOptions, create_meta_tools + if account_ids: + self._account_ids = account_ids - options = MetaToolsOptions( - account_ids=account_ids, - search=search, - connector=connector, - top_k=top_k, - min_similarity=min_similarity, - ) - return create_meta_tools(self, options) + search_tool = _create_search_tool(self.api_key) + search_tool._toolset = self + + execute_tool = _create_execute_tool(self.api_key) + execute_tool._toolset = self + + return Tools([search_tool, execute_tool]) def openai( self, @@ -499,10 +687,38 @@ def openai( ) if mode == "search_and_execute": - return self.get_meta_tools(account_ids=effective_account_ids).to_openai() + return self._build_tools(account_ids=effective_account_ids).to_openai() return self.fetch_tools(account_ids=effective_account_ids).to_openai() + def langchain( + self, + *, + mode: Literal["search_and_execute"] | None = None, + account_ids: list[str] | None = None, + ) -> list[Any]: + """Get tools in LangChain format. + + Args: + mode: Tool mode. + ``None`` (default): fetch all tools and convert to LangChain format. + ``"search_and_execute"``: return two tools (tool_search + tool_execute) + that let the LLM discover and execute tools on-demand. + The framework handles tool execution automatically. + account_ids: Account IDs to scope tools. Overrides the ``execute`` + config from the constructor. + + Returns: + List of LangChain tool objects. + """ + effective_account_ids = account_ids or ( + self._execute_config.get("account_ids") if self._execute_config else None + ) + + if mode == "search_and_execute": + return self._build_tools(account_ids=effective_account_ids).to_langchain() + + return self.fetch_tools(account_ids=effective_account_ids).to_langchain() def execute( self, @@ -514,7 +730,7 @@ def execute( Use with ``openai(mode="search_and_execute")`` in manual agent loops — pass the tool name and arguments from the LLM's tool call directly. - Meta tools are cached after the first call. + Tools are cached after the first call. Args: tool_name: The tool name from the LLM's tool call @@ -525,10 +741,10 @@ def execute( Returns: Tool execution result as a dict. """ - if self._meta_tools_cache is None: - self._meta_tools_cache = self.get_meta_tools() + if self._tools_cache is None: + self._tools_cache = self._build_tools() - tool = self._meta_tools_cache.get_tool(tool_name) + tool = self._tools_cache.get_tool(tool_name) if tool is None: return {"error": f'Tool "{tool_name}" not found.'} return tool.execute(arguments) diff --git a/tests/test_meta_tools.py b/tests/test_meta_tools.py index 2482aef..30de927 100644 --- a/tests/test_meta_tools.py +++ b/tests/test_meta_tools.py @@ -5,12 +5,6 @@ import json from unittest.mock import MagicMock, patch -from stackone_ai.meta_tools import ( - ExecuteMetaTool, - MetaToolsOptions, - SearchMetaTool, - create_meta_tools, -) from stackone_ai.models import ( ExecuteConfig, StackOneAPIError, @@ -18,7 +12,13 @@ ToolParameters, Tools, ) -from stackone_ai.toolset import StackOneToolSet +from stackone_ai.toolset import ( + StackOneToolSet, + _create_execute_tool, + _create_search_tool, + _ExecuteTool, + _SearchTool, +) def _make_mock_tool(name: str = "test_tool", description: str = "A test tool") -> StackOneTool: @@ -40,9 +40,22 @@ def _make_mock_tool(name: str = "test_tool", description: str = "A test tool") - ) +def _make_meta_tools(toolset: MagicMock) -> Tools: + """Build meta tools using the private helpers, wiring in a mock toolset.""" + search_tool = _create_search_tool(toolset.api_key) + search_tool._toolset = toolset + + execute_tool = _create_execute_tool(toolset.api_key) + execute_tool._toolset = toolset + + return Tools([search_tool, execute_tool]) + + def _make_mock_toolset(tools: list[StackOneTool] | None = None) -> MagicMock: toolset = MagicMock() toolset.api_key = "test-key" + toolset._search_config = {"method": "auto"} + toolset._account_ids = [] mock_tools = Tools(tools or [_make_mock_tool()]) toolset.search_tools.return_value = mock_tools @@ -50,17 +63,17 @@ def _make_mock_toolset(tools: list[StackOneTool] | None = None) -> MagicMock: return toolset -class TestCreateMetaTools: +class TestBuildMetaTools: def test_returns_tools_collection(self): toolset = _make_mock_toolset() - result = create_meta_tools(toolset) + result = _make_meta_tools(toolset) assert isinstance(result, Tools) assert len(result) == 2 def test_tool_names(self): toolset = _make_mock_toolset() - result = create_meta_tools(toolset) + result = _make_meta_tools(toolset) names = [t.name for t in result] assert "tool_search" in names @@ -68,40 +81,29 @@ def test_tool_names(self): def test_search_tool_type(self): toolset = _make_mock_toolset() - result = create_meta_tools(toolset) + result = _make_meta_tools(toolset) search = result.get_tool("tool_search") - assert isinstance(search, SearchMetaTool) + assert isinstance(search, _SearchTool) def test_execute_tool_type(self): toolset = _make_mock_toolset() - result = create_meta_tools(toolset) + result = _make_meta_tools(toolset) execute = result.get_tool("tool_execute") - assert isinstance(execute, ExecuteMetaTool) - - def test_options_passed_through(self): - toolset = _make_mock_toolset() - opts = MetaToolsOptions(account_ids=["acc-1"], connector="bamboohr", top_k=3) - result = create_meta_tools(toolset, opts) - - search = result.get_tool("tool_search") - assert search._options.account_ids == ["acc-1"] - assert search._options.connector == "bamboohr" - assert search._options.top_k == 3 + assert isinstance(execute, _ExecuteTool) def test_private_attrs_excluded_from_serialization(self): toolset = _make_mock_toolset() - result = create_meta_tools(toolset) + result = _make_meta_tools(toolset) search = result.get_tool("tool_search") dumped = search.model_dump() assert "_toolset" not in dumped - assert "_options" not in dumped class TestToolSearch: def test_delegates_to_search_tools(self): toolset = _make_mock_toolset() - meta = create_meta_tools(toolset) + meta = _make_meta_tools(toolset) search = meta.get_tool("tool_search") search.execute({"query": "find employees"}) @@ -113,7 +115,7 @@ def test_delegates_to_search_tools(self): def test_returns_tool_names_descriptions_and_schemas(self): mock_tool = _make_mock_tool(name="bamboohr_list_employees", description="List employees") toolset = _make_mock_toolset([mock_tool]) - meta = create_meta_tools(toolset) + meta = _make_meta_tools(toolset) search = meta.get_tool("tool_search") result = search.execute({"query": "list employees"}) @@ -125,21 +127,23 @@ def test_returns_tool_names_descriptions_and_schemas(self): assert "parameters" in tool_info assert "id" in tool_info["parameters"] - def test_passes_connector_from_options(self): + def test_reads_config_from_toolset(self): toolset = _make_mock_toolset() - opts = MetaToolsOptions(connector="bamboohr") - meta = create_meta_tools(toolset, opts) + toolset._search_config = {"method": "semantic", "top_k": 3, "min_similarity": 0.5} + meta = _make_meta_tools(toolset) search = meta.get_tool("tool_search") search.execute({"query": "employees"}) call_kwargs = toolset.search_tools.call_args[1] - assert call_kwargs["connector"] == "bamboohr" + assert call_kwargs["search"] == "semantic" + assert call_kwargs["top_k"] == 3 + assert call_kwargs["min_similarity"] == 0.5 - def test_passes_account_ids_from_options(self): + def test_reads_account_ids_from_toolset(self): toolset = _make_mock_toolset() - opts = MetaToolsOptions(account_ids=["acc-1", "acc-2"]) - meta = create_meta_tools(toolset, opts) + toolset._account_ids = ["acc-1", "acc-2"] + meta = _make_meta_tools(toolset) search = meta.get_tool("tool_search") search.execute({"query": "employees"}) @@ -149,7 +153,7 @@ def test_passes_account_ids_from_options(self): def test_string_arguments(self): toolset = _make_mock_toolset() - meta = create_meta_tools(toolset) + meta = _make_meta_tools(toolset) search = meta.get_tool("tool_search") result = search.execute(json.dumps({"query": "employees"})) @@ -159,7 +163,7 @@ def test_string_arguments(self): def test_validation_error_returns_error_dict(self): toolset = _make_mock_toolset() - meta = create_meta_tools(toolset) + meta = _make_meta_tools(toolset) search = meta.get_tool("tool_search") result = search.execute({"query": ""}) @@ -169,7 +173,7 @@ def test_validation_error_returns_error_dict(self): def test_invalid_json_returns_error_dict(self): toolset = _make_mock_toolset() - meta = create_meta_tools(toolset) + meta = _make_meta_tools(toolset) search = meta.get_tool("tool_search") result = search.execute("not valid json") @@ -178,7 +182,7 @@ def test_invalid_json_returns_error_dict(self): def test_missing_query_returns_error_dict(self): toolset = _make_mock_toolset() - meta = create_meta_tools(toolset) + meta = _make_meta_tools(toolset) search = meta.get_tool("tool_search") result = search.execute({}) @@ -190,8 +194,8 @@ class TestToolExecute: def test_delegates_to_fetch_and_execute(self): toolset = MagicMock() toolset.api_key = "test-key" + toolset._account_ids = [] - # Create a mock tool that returns a known result mock_tool = MagicMock() mock_tool.name = "test_tool" mock_tools = MagicMock() @@ -199,7 +203,7 @@ def test_delegates_to_fetch_and_execute(self): mock_tool.execute.return_value = {"result": "ok"} toolset.fetch_tools.return_value = mock_tools - meta = create_meta_tools(toolset) + meta = _make_meta_tools(toolset) execute = meta.get_tool("tool_execute") result = execute.execute({"tool_name": "test_tool", "parameters": {"id": "123"}}) @@ -210,11 +214,12 @@ def test_delegates_to_fetch_and_execute(self): def test_tool_not_found_returns_error(self): toolset = MagicMock() toolset.api_key = "test-key" + toolset._account_ids = [] mock_tools = MagicMock() mock_tools.get_tool.return_value = None toolset.fetch_tools.return_value = mock_tools - meta = create_meta_tools(toolset) + meta = _make_meta_tools(toolset) execute = meta.get_tool("tool_execute") result = execute.execute({"tool_name": "nonexistent_tool"}) @@ -225,6 +230,7 @@ def test_tool_not_found_returns_error(self): def test_api_error_returned_as_dict(self): toolset = MagicMock() toolset.api_key = "test-key" + toolset._account_ids = [] mock_tool = MagicMock() mock_tool.name = "test_tool" @@ -235,7 +241,7 @@ def test_api_error_returned_as_dict(self): mock_tools.get_tool.return_value = mock_tool toolset.fetch_tools.return_value = mock_tools - meta = create_meta_tools(toolset) + meta = _make_meta_tools(toolset) execute = meta.get_tool("tool_execute") result = execute.execute({"tool_name": "test_tool", "parameters": {}}) @@ -246,7 +252,7 @@ def test_api_error_returned_as_dict(self): def test_validation_error_returns_error_dict(self): toolset = _make_mock_toolset() - meta = create_meta_tools(toolset) + meta = _make_meta_tools(toolset) execute = meta.get_tool("tool_execute") result = execute.execute({"tool_name": ""}) @@ -255,7 +261,7 @@ def test_validation_error_returns_error_dict(self): def test_invalid_json_returns_error_dict(self): toolset = _make_mock_toolset() - meta = create_meta_tools(toolset) + meta = _make_meta_tools(toolset) execute = meta.get_tool("tool_execute") result = execute.execute("not valid json") @@ -265,6 +271,7 @@ def test_invalid_json_returns_error_dict(self): def test_caches_fetched_tools(self): toolset = MagicMock() toolset.api_key = "test-key" + toolset._account_ids = [] mock_tool = MagicMock() mock_tool.name = "test_tool" @@ -273,18 +280,18 @@ def test_caches_fetched_tools(self): mock_tools.get_tool.return_value = mock_tool toolset.fetch_tools.return_value = mock_tools - meta = create_meta_tools(toolset) + meta = _make_meta_tools(toolset) execute = meta.get_tool("tool_execute") execute.execute({"tool_name": "test_tool"}) execute.execute({"tool_name": "test_tool"}) - # fetch_tools should only be called once due to caching toolset.fetch_tools.assert_called_once() - def test_passes_account_ids(self): + def test_passes_account_ids_from_toolset(self): toolset = MagicMock() toolset.api_key = "test-key" + toolset._account_ids = ["acc-1"] mock_tool = MagicMock() mock_tool.name = "test_tool" @@ -293,8 +300,7 @@ def test_passes_account_ids(self): mock_tools.get_tool.return_value = mock_tool toolset.fetch_tools.return_value = mock_tools - opts = MetaToolsOptions(account_ids=["acc-1"]) - meta = create_meta_tools(toolset, opts) + meta = _make_meta_tools(toolset) execute = meta.get_tool("tool_execute") execute.execute({"tool_name": "test_tool"}) @@ -304,6 +310,7 @@ def test_passes_account_ids(self): def test_string_arguments(self): toolset = MagicMock() toolset.api_key = "test-key" + toolset._account_ids = [] mock_tool = MagicMock() mock_tool.name = "test_tool" @@ -312,7 +319,7 @@ def test_string_arguments(self): mock_tools.get_tool.return_value = mock_tool toolset.fetch_tools.return_value = mock_tools - meta = create_meta_tools(toolset) + meta = _make_meta_tools(toolset) execute = meta.get_tool("tool_execute") result = execute.execute(json.dumps({"tool_name": "test_tool", "parameters": {}})) @@ -323,7 +330,7 @@ def test_string_arguments(self): class TestLangChainConversion: def test_meta_tools_convert_to_langchain(self): toolset = _make_mock_toolset() - meta = create_meta_tools(toolset) + meta = _make_meta_tools(toolset) langchain_tools = meta.to_langchain() @@ -335,7 +342,7 @@ def test_meta_tools_convert_to_langchain(self): def test_execute_tool_parameters_field_is_dict_type(self): """The 'parameters' field of tool_execute should map to dict, not str.""" toolset = _make_mock_toolset() - meta = create_meta_tools(toolset) + meta = _make_meta_tools(toolset) execute_tool = meta.get_tool("tool_execute") langchain_tool = execute_tool.to_langchain() @@ -347,7 +354,7 @@ def test_execute_tool_parameters_field_is_dict_type(self): class TestOpenAIConversion: def test_meta_tools_convert_to_openai(self): toolset = _make_mock_toolset() - meta = create_meta_tools(toolset) + meta = _make_meta_tools(toolset) openai_tools = meta.to_openai() @@ -358,7 +365,7 @@ def test_meta_tools_convert_to_openai(self): def test_nullable_fields_not_required(self): toolset = _make_mock_toolset() - meta = create_meta_tools(toolset) + meta = _make_meta_tools(toolset) openai_tools = meta.to_openai() search_fn = next(t for t in openai_tools if t["function"]["name"] == "tool_search") @@ -387,10 +394,10 @@ def test_openai_search_and_execute_returns_meta_tools(self): toolset = StackOneToolSet(api_key="test-key") mock_meta = Tools([_make_mock_tool(name="tool_search"), _make_mock_tool(name="tool_execute")]) - with patch.object(toolset, "get_meta_tools", return_value=mock_meta) as mock_get: + with patch.object(toolset, "_build_tools", return_value=mock_meta) as mock_build: result = toolset.openai(mode="search_and_execute") - mock_get.assert_called_once_with(account_ids=None) + mock_build.assert_called_once_with(account_ids=None) assert len(result) == 2 names = [t["function"]["name"] for t in result] assert "tool_search" in names @@ -427,10 +434,10 @@ def test_openai_search_and_execute_with_execute_config(self): toolset = StackOneToolSet(api_key="test-key", execute={"account_ids": ["acc-1"]}) mock_meta = Tools([_make_mock_tool(name="tool_search"), _make_mock_tool(name="tool_execute")]) - with patch.object(toolset, "get_meta_tools", return_value=mock_meta) as mock_get: + with patch.object(toolset, "_build_tools", return_value=mock_meta) as mock_build: toolset.openai(mode="search_and_execute") - mock_get.assert_called_once_with(account_ids=["acc-1"]) + mock_build.assert_called_once_with(account_ids=["acc-1"]) class TestToolSetExecuteMethod: @@ -443,7 +450,7 @@ def test_execute_delegates_to_meta_tool(self): mock_meta = MagicMock() mock_meta.get_tool.return_value = mock_tool - with patch.object(toolset, "get_meta_tools", return_value=mock_meta): + with patch.object(toolset, "_build_tools", return_value=mock_meta): result = toolset.execute("tool_search", {"query": "employees"}) assert result == {"result": "ok"} @@ -457,18 +464,18 @@ def test_execute_caches_meta_tools(self): mock_meta = MagicMock() mock_meta.get_tool.return_value = mock_tool - with patch.object(toolset, "get_meta_tools", return_value=mock_meta) as mock_get: + with patch.object(toolset, "_build_tools", return_value=mock_meta) as mock_build: toolset.execute("tool_search", {"query": "a"}) toolset.execute("tool_execute", {"tool_name": "b"}) - mock_get.assert_called_once() + mock_build.assert_called_once() def test_execute_returns_error_for_unknown_tool(self): toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) mock_meta = MagicMock() mock_meta.get_tool.return_value = None - with patch.object(toolset, "get_meta_tools", return_value=mock_meta): + with patch.object(toolset, "_build_tools", return_value=mock_meta): result = toolset.execute("nonexistent", {}) assert "error" in result @@ -480,8 +487,50 @@ def test_execute_accepts_string_arguments(self): mock_meta = MagicMock() mock_meta.get_tool.return_value = mock_tool - with patch.object(toolset, "get_meta_tools", return_value=mock_meta): + with patch.object(toolset, "_build_tools", return_value=mock_meta): result = toolset.execute("tool_search", '{"query": "test"}') assert result == {"ok": True} mock_tool.execute.assert_called_once_with('{"query": "test"}') + + +class TestToolSetLangChainMethod: + """Tests for StackOneToolSet.langchain() convenience method.""" + + def test_langchain_default_fetches_all_tools(self): + toolset = StackOneToolSet(api_key="test-key") + mock_tools = Tools([_make_mock_tool()]) + + with patch.object(toolset, "fetch_tools", return_value=mock_tools) as mock_fetch: + result = toolset.langchain() + + mock_fetch.assert_called_once_with(account_ids=None) + assert len(result) == 1 + + def test_langchain_search_and_execute_returns_tools(self): + toolset = StackOneToolSet(api_key="test-key") + mock_tools = Tools([_make_mock_tool(name="tool_search"), _make_mock_tool(name="tool_execute")]) + + with patch.object(toolset, "_build_tools", return_value=mock_tools) as mock_build: + result = toolset.langchain(mode="search_and_execute") + + mock_build.assert_called_once_with(account_ids=None) + assert len(result) == 2 + + def test_langchain_passes_account_ids(self): + toolset = StackOneToolSet(api_key="test-key") + mock_tools = Tools([_make_mock_tool()]) + + with patch.object(toolset, "fetch_tools", return_value=mock_tools) as mock_fetch: + toolset.langchain(account_ids=["acc-1"]) + + mock_fetch.assert_called_once_with(account_ids=["acc-1"]) + + def test_langchain_uses_execute_config_account_ids(self): + toolset = StackOneToolSet(api_key="test-key", execute={"account_ids": ["acc-from-config"]}) + mock_tools = Tools([_make_mock_tool()]) + + with patch.object(toolset, "fetch_tools", return_value=mock_tools) as mock_fetch: + toolset.langchain() + + mock_fetch.assert_called_once_with(account_ids=["acc-from-config"]) From e2df7c6682bcb80a82be421d3bd47ff21879577a Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Mon, 16 Mar 2026 13:54:30 +0000 Subject: [PATCH 08/10] Change tools to Sequence to fix CI --- stackone_ai/toolset.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stackone_ai/toolset.py b/stackone_ai/toolset.py index b26d1a2..cb302c7 100644 --- a/stackone_ai/toolset.py +++ b/stackone_ai/toolset.py @@ -8,7 +8,7 @@ import logging import os import threading -from collections.abc import Coroutine +from collections.abc import Coroutine, Sequence from dataclasses import dataclass from importlib import metadata from typing import Any, Literal, TypedDict, TypeVar @@ -696,7 +696,7 @@ def langchain( *, mode: Literal["search_and_execute"] | None = None, account_ids: list[str] | None = None, - ) -> list[Any]: + ) -> Sequence[Any]: """Get tools in LangChain format. Args: From ac9cfcd246e3f590a5822d1b4df1db7aa4ba660a Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Mon, 16 Mar 2026 15:08:52 +0000 Subject: [PATCH 09/10] Remove all reference to the meta tools --- ..._tools_example.py => agent_tool_search.py} | 2 +- ...test_meta_tools.py => test_agent_tools.py} | 140 +++++++++--------- 2 files changed, 71 insertions(+), 71 deletions(-) rename examples/{meta_tools_example.py => agent_tool_search.py} (99%) rename tests/{test_meta_tools.py => test_agent_tools.py} (83%) diff --git a/examples/meta_tools_example.py b/examples/agent_tool_search.py similarity index 99% rename from examples/meta_tools_example.py rename to examples/agent_tool_search.py index b8ce0a2..bca48f8 100644 --- a/examples/meta_tools_example.py +++ b/examples/agent_tool_search.py @@ -19,7 +19,7 @@ - GOOGLE_API_KEY environment variable (for Gemini/LangChain) Run with: - uv run python examples/meta_tools_example.py + uv run python examples/agent_tool_search.py """ from __future__ import annotations diff --git a/tests/test_meta_tools.py b/tests/test_agent_tools.py similarity index 83% rename from tests/test_meta_tools.py rename to tests/test_agent_tools.py index 30de927..642d6b7 100644 --- a/tests/test_meta_tools.py +++ b/tests/test_agent_tools.py @@ -1,4 +1,4 @@ -"""Tests for meta tools (tool_search + tool_execute).""" +"""Tests for tool_search + tool_execute (agent tool discovery).""" from __future__ import annotations @@ -40,8 +40,8 @@ def _make_mock_tool(name: str = "test_tool", description: str = "A test tool") - ) -def _make_meta_tools(toolset: MagicMock) -> Tools: - """Build meta tools using the private helpers, wiring in a mock toolset.""" +def _make_tools(toolset: MagicMock) -> Tools: + """Build tool_search + tool_execute using the private helpers, wiring in a mock toolset.""" search_tool = _create_search_tool(toolset.api_key) search_tool._toolset = toolset @@ -66,14 +66,14 @@ def _make_mock_toolset(tools: list[StackOneTool] | None = None) -> MagicMock: class TestBuildMetaTools: def test_returns_tools_collection(self): toolset = _make_mock_toolset() - result = _make_meta_tools(toolset) + result = _make_tools(toolset) assert isinstance(result, Tools) assert len(result) == 2 def test_tool_names(self): toolset = _make_mock_toolset() - result = _make_meta_tools(toolset) + result = _make_tools(toolset) names = [t.name for t in result] assert "tool_search" in names @@ -81,19 +81,19 @@ def test_tool_names(self): def test_search_tool_type(self): toolset = _make_mock_toolset() - result = _make_meta_tools(toolset) + result = _make_tools(toolset) search = result.get_tool("tool_search") assert isinstance(search, _SearchTool) def test_execute_tool_type(self): toolset = _make_mock_toolset() - result = _make_meta_tools(toolset) + result = _make_tools(toolset) execute = result.get_tool("tool_execute") assert isinstance(execute, _ExecuteTool) def test_private_attrs_excluded_from_serialization(self): toolset = _make_mock_toolset() - result = _make_meta_tools(toolset) + result = _make_tools(toolset) search = result.get_tool("tool_search") dumped = search.model_dump() @@ -103,8 +103,8 @@ def test_private_attrs_excluded_from_serialization(self): class TestToolSearch: def test_delegates_to_search_tools(self): toolset = _make_mock_toolset() - meta = _make_meta_tools(toolset) - search = meta.get_tool("tool_search") + built = _make_tools(toolset) + search = built.get_tool("tool_search") search.execute({"query": "find employees"}) @@ -115,8 +115,8 @@ def test_delegates_to_search_tools(self): def test_returns_tool_names_descriptions_and_schemas(self): mock_tool = _make_mock_tool(name="bamboohr_list_employees", description="List employees") toolset = _make_mock_toolset([mock_tool]) - meta = _make_meta_tools(toolset) - search = meta.get_tool("tool_search") + built = _make_tools(toolset) + search = built.get_tool("tool_search") result = search.execute({"query": "list employees"}) @@ -130,8 +130,8 @@ def test_returns_tool_names_descriptions_and_schemas(self): def test_reads_config_from_toolset(self): toolset = _make_mock_toolset() toolset._search_config = {"method": "semantic", "top_k": 3, "min_similarity": 0.5} - meta = _make_meta_tools(toolset) - search = meta.get_tool("tool_search") + built = _make_tools(toolset) + search = built.get_tool("tool_search") search.execute({"query": "employees"}) @@ -143,8 +143,8 @@ def test_reads_config_from_toolset(self): def test_reads_account_ids_from_toolset(self): toolset = _make_mock_toolset() toolset._account_ids = ["acc-1", "acc-2"] - meta = _make_meta_tools(toolset) - search = meta.get_tool("tool_search") + built = _make_tools(toolset) + search = built.get_tool("tool_search") search.execute({"query": "employees"}) @@ -153,8 +153,8 @@ def test_reads_account_ids_from_toolset(self): def test_string_arguments(self): toolset = _make_mock_toolset() - meta = _make_meta_tools(toolset) - search = meta.get_tool("tool_search") + built = _make_tools(toolset) + search = built.get_tool("tool_search") result = search.execute(json.dumps({"query": "employees"})) @@ -163,8 +163,8 @@ def test_string_arguments(self): def test_validation_error_returns_error_dict(self): toolset = _make_mock_toolset() - meta = _make_meta_tools(toolset) - search = meta.get_tool("tool_search") + built = _make_tools(toolset) + search = built.get_tool("tool_search") result = search.execute({"query": ""}) @@ -173,8 +173,8 @@ def test_validation_error_returns_error_dict(self): def test_invalid_json_returns_error_dict(self): toolset = _make_mock_toolset() - meta = _make_meta_tools(toolset) - search = meta.get_tool("tool_search") + built = _make_tools(toolset) + search = built.get_tool("tool_search") result = search.execute("not valid json") @@ -182,8 +182,8 @@ def test_invalid_json_returns_error_dict(self): def test_missing_query_returns_error_dict(self): toolset = _make_mock_toolset() - meta = _make_meta_tools(toolset) - search = meta.get_tool("tool_search") + built = _make_tools(toolset) + search = built.get_tool("tool_search") result = search.execute({}) @@ -203,8 +203,8 @@ def test_delegates_to_fetch_and_execute(self): mock_tool.execute.return_value = {"result": "ok"} toolset.fetch_tools.return_value = mock_tools - meta = _make_meta_tools(toolset) - execute = meta.get_tool("tool_execute") + built = _make_tools(toolset) + execute = built.get_tool("tool_execute") result = execute.execute({"tool_name": "test_tool", "parameters": {"id": "123"}}) @@ -219,8 +219,8 @@ def test_tool_not_found_returns_error(self): mock_tools.get_tool.return_value = None toolset.fetch_tools.return_value = mock_tools - meta = _make_meta_tools(toolset) - execute = meta.get_tool("tool_execute") + built = _make_tools(toolset) + execute = built.get_tool("tool_execute") result = execute.execute({"tool_name": "nonexistent_tool"}) @@ -241,8 +241,8 @@ def test_api_error_returned_as_dict(self): mock_tools.get_tool.return_value = mock_tool toolset.fetch_tools.return_value = mock_tools - meta = _make_meta_tools(toolset) - execute = meta.get_tool("tool_execute") + built = _make_tools(toolset) + execute = built.get_tool("tool_execute") result = execute.execute({"tool_name": "test_tool", "parameters": {}}) @@ -252,8 +252,8 @@ def test_api_error_returned_as_dict(self): def test_validation_error_returns_error_dict(self): toolset = _make_mock_toolset() - meta = _make_meta_tools(toolset) - execute = meta.get_tool("tool_execute") + built = _make_tools(toolset) + execute = built.get_tool("tool_execute") result = execute.execute({"tool_name": ""}) @@ -261,8 +261,8 @@ def test_validation_error_returns_error_dict(self): def test_invalid_json_returns_error_dict(self): toolset = _make_mock_toolset() - meta = _make_meta_tools(toolset) - execute = meta.get_tool("tool_execute") + built = _make_tools(toolset) + execute = built.get_tool("tool_execute") result = execute.execute("not valid json") @@ -280,8 +280,8 @@ def test_caches_fetched_tools(self): mock_tools.get_tool.return_value = mock_tool toolset.fetch_tools.return_value = mock_tools - meta = _make_meta_tools(toolset) - execute = meta.get_tool("tool_execute") + built = _make_tools(toolset) + execute = built.get_tool("tool_execute") execute.execute({"tool_name": "test_tool"}) execute.execute({"tool_name": "test_tool"}) @@ -300,8 +300,8 @@ def test_passes_account_ids_from_toolset(self): mock_tools.get_tool.return_value = mock_tool toolset.fetch_tools.return_value = mock_tools - meta = _make_meta_tools(toolset) - execute = meta.get_tool("tool_execute") + built = _make_tools(toolset) + execute = built.get_tool("tool_execute") execute.execute({"tool_name": "test_tool"}) @@ -319,8 +319,8 @@ def test_string_arguments(self): mock_tools.get_tool.return_value = mock_tool toolset.fetch_tools.return_value = mock_tools - meta = _make_meta_tools(toolset) - execute = meta.get_tool("tool_execute") + built = _make_tools(toolset) + execute = built.get_tool("tool_execute") result = execute.execute(json.dumps({"tool_name": "test_tool", "parameters": {}})) @@ -328,11 +328,11 @@ def test_string_arguments(self): class TestLangChainConversion: - def test_meta_tools_convert_to_langchain(self): + def test_tools_convert_to_langchain(self): toolset = _make_mock_toolset() - meta = _make_meta_tools(toolset) + built = _make_tools(toolset) - langchain_tools = meta.to_langchain() + langchain_tools = built.to_langchain() assert len(langchain_tools) == 2 names = [t.name for t in langchain_tools] @@ -342,8 +342,8 @@ def test_meta_tools_convert_to_langchain(self): def test_execute_tool_parameters_field_is_dict_type(self): """The 'parameters' field of tool_execute should map to dict, not str.""" toolset = _make_mock_toolset() - meta = _make_meta_tools(toolset) - execute_tool = meta.get_tool("tool_execute") + built = _make_tools(toolset) + execute_tool = built.get_tool("tool_execute") langchain_tool = execute_tool.to_langchain() annotations = langchain_tool.args_schema.__annotations__ @@ -352,11 +352,11 @@ def test_execute_tool_parameters_field_is_dict_type(self): class TestOpenAIConversion: - def test_meta_tools_convert_to_openai(self): + def test_tools_convert_to_openai(self): toolset = _make_mock_toolset() - meta = _make_meta_tools(toolset) + built = _make_tools(toolset) - openai_tools = meta.to_openai() + openai_tools = built.to_openai() assert len(openai_tools) == 2 names = [t["function"]["name"] for t in openai_tools] @@ -365,9 +365,9 @@ def test_meta_tools_convert_to_openai(self): def test_nullable_fields_not_required(self): toolset = _make_mock_toolset() - meta = _make_meta_tools(toolset) + built = _make_tools(toolset) - openai_tools = meta.to_openai() + openai_tools = built.to_openai() search_fn = next(t for t in openai_tools if t["function"]["name"] == "tool_search") required = search_fn["function"]["parameters"].get("required", []) @@ -390,11 +390,11 @@ def test_openai_default_fetches_all_tools(self): assert len(result) == 1 assert result[0]["function"]["name"] == "test_tool" - def test_openai_search_and_execute_returns_meta_tools(self): + def test_openai_search_and_execute_returns_tools(self): toolset = StackOneToolSet(api_key="test-key") - mock_meta = Tools([_make_mock_tool(name="tool_search"), _make_mock_tool(name="tool_execute")]) + mock_built = Tools([_make_mock_tool(name="tool_search"), _make_mock_tool(name="tool_execute")]) - with patch.object(toolset, "_build_tools", return_value=mock_meta) as mock_build: + with patch.object(toolset, "_build_tools", return_value=mock_built) as mock_build: result = toolset.openai(mode="search_and_execute") mock_build.assert_called_once_with(account_ids=None) @@ -432,9 +432,9 @@ def test_openai_account_ids_overrides_execute_config(self): def test_openai_search_and_execute_with_execute_config(self): toolset = StackOneToolSet(api_key="test-key", execute={"account_ids": ["acc-1"]}) - mock_meta = Tools([_make_mock_tool(name="tool_search"), _make_mock_tool(name="tool_execute")]) + mock_built = Tools([_make_mock_tool(name="tool_search"), _make_mock_tool(name="tool_execute")]) - with patch.object(toolset, "_build_tools", return_value=mock_meta) as mock_build: + with patch.object(toolset, "_build_tools", return_value=mock_built) as mock_build: toolset.openai(mode="search_and_execute") mock_build.assert_called_once_with(account_ids=["acc-1"]) @@ -443,28 +443,28 @@ def test_openai_search_and_execute_with_execute_config(self): class TestToolSetExecuteMethod: """Tests for StackOneToolSet.execute() convenience method.""" - def test_execute_delegates_to_meta_tool(self): + def test_execute_delegates_to_tool(self): toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) mock_tool = MagicMock() mock_tool.execute.return_value = {"result": "ok"} - mock_meta = MagicMock() - mock_meta.get_tool.return_value = mock_tool + mock_built = MagicMock() + mock_built.get_tool.return_value = mock_tool - with patch.object(toolset, "_build_tools", return_value=mock_meta): + with patch.object(toolset, "_build_tools", return_value=mock_built): result = toolset.execute("tool_search", {"query": "employees"}) assert result == {"result": "ok"} - mock_meta.get_tool.assert_called_once_with("tool_search") + mock_built.get_tool.assert_called_once_with("tool_search") mock_tool.execute.assert_called_once_with({"query": "employees"}) - def test_execute_caches_meta_tools(self): + def test_execute_caches_tools(self): toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) mock_tool = MagicMock() mock_tool.execute.return_value = {"ok": True} - mock_meta = MagicMock() - mock_meta.get_tool.return_value = mock_tool + mock_built = MagicMock() + mock_built.get_tool.return_value = mock_tool - with patch.object(toolset, "_build_tools", return_value=mock_meta) as mock_build: + with patch.object(toolset, "_build_tools", return_value=mock_built) as mock_build: toolset.execute("tool_search", {"query": "a"}) toolset.execute("tool_execute", {"tool_name": "b"}) @@ -472,10 +472,10 @@ def test_execute_caches_meta_tools(self): def test_execute_returns_error_for_unknown_tool(self): toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) - mock_meta = MagicMock() - mock_meta.get_tool.return_value = None + mock_built = MagicMock() + mock_built.get_tool.return_value = None - with patch.object(toolset, "_build_tools", return_value=mock_meta): + with patch.object(toolset, "_build_tools", return_value=mock_built): result = toolset.execute("nonexistent", {}) assert "error" in result @@ -484,10 +484,10 @@ def test_execute_accepts_string_arguments(self): toolset = StackOneToolSet(api_key="test-key", search={"method": "auto"}) mock_tool = MagicMock() mock_tool.execute.return_value = {"ok": True} - mock_meta = MagicMock() - mock_meta.get_tool.return_value = mock_tool + mock_built = MagicMock() + mock_built.get_tool.return_value = mock_tool - with patch.object(toolset, "_build_tools", return_value=mock_meta): + with patch.object(toolset, "_build_tools", return_value=mock_built): result = toolset.execute("tool_search", '{"query": "test"}') assert result == {"ok": True} From 97f59f41a3d23924359c01830df13a646fca7aa9 Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Mon, 16 Mar 2026 15:41:38 +0000 Subject: [PATCH 10/10] Fix doc strings --- stackone_ai/toolset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stackone_ai/toolset.py b/stackone_ai/toolset.py index cb302c7..815a06b 100644 --- a/stackone_ai/toolset.py +++ b/stackone_ai/toolset.py @@ -59,7 +59,7 @@ class SearchConfig(TypedDict, total=False): class ExecuteToolsConfig(TypedDict, total=False): """Execution configuration for the StackOneToolSet constructor. - Controls default account scoping for tool execution in meta tools. + Controls default account scoping for tool execution. When set to ``None`` (default), no account scoping is applied. When provided, ``account_ids`` flow through to ``openai(mode="search_and_execute")``