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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ Always use absolute imports starting with the full package name. Never use relat
imports (`.` or `..`).

```python
from stackone_ai.tools import StackOneTool # good
from .tools import StackOneTool # bad
from stackone_ai.tools import StackOneTool # good
from .tools import StackOneTool # bad
```

Order: standard library, then third-party, then local.
Expand Down Expand Up @@ -208,14 +208,17 @@ via release-please after a merge to main, never from a developer machine.
## Key Patterns

```python
toolset = StackOneToolSet() # reads STACKONE_API_KEY; accounts are discovered
toolset = StackOneToolSet() # reads STACKONE_API_KEY; accounts are discovered

hits = toolset.search("list recent comments", top_k=3)
hit = hits[0] if hits else None
session_id = hit.get("session_id") if hit else None
account_ids = [hit["account_id"]] if hit else None
toolset.execute(
"linear_list_comments", {"body": {"variables": {"first": 25}}}, account_ids=account_ids, session_id=session_id
"linear_list_comments",
{"body": {"variables": {"first": 25}}},
account_ids=account_ids,
session_id=session_id,
)
toolset.submit_feedback("positive", ["linear_list_comments"], session_id=session_id)

Expand Down
14 changes: 10 additions & 4 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,11 @@ names = toolset.search_action_names("time off requests", top_k=5)

# 3.0
toolset = StackOneToolSet()
hits = toolset.search("list employees", top_k=5) # a list of action dicts, best first
hits = toolset.search("list employees", top_k=5) # a list of action dicts, best first
hit = hits[0]
result = toolset.execute(
hit["action_id"],
{"query": {"page_size": 25}}, # the nested form in hit["input_schema"]
{"query": {"page_size": 25}}, # the nested form in hit["input_schema"]
account_ids=[hit["available_accounts"][0]["id"]],
session_id=hit.get("session_id"),
)
Expand Down Expand Up @@ -193,7 +193,7 @@ import httpx

link = download.execute({"id": "file-id"})["result"]
# {"download_url": ..., "expires_at": ..., "file": {"name", "content_type", "content_length"}}
name = Path(link["file"]["name"] or "download.bin").name # chosen by the provider: keep only the basename
name = Path(link["file"]["name"] or "download.bin").name # chosen by the provider: keep only the basename
Path(name).write_bytes(httpx.get(link["download_url"]).content)
```

Expand Down Expand Up @@ -385,12 +385,15 @@ tool = StackOneTool(
description="Get an employee",
parameters=ToolParameters(type="object", properties={"id": {"type": "string"}}),
_execute_config=ExecuteConfig(
name="get_employee", method="GET", url="https://api.example.com/employees/{id}",
name="get_employee",
method="GET",
url="https://api.example.com/employees/{id}",
parameter_locations={"id": ParameterLocation.PATH},
),
_api_key="...",
)


# 3.0
class GetEmployee(StackOneTool):
def __init__(self) -> None:
Expand All @@ -404,6 +407,7 @@ class GetEmployee(StackOneTool):
args = json.loads(arguments) if isinstance(arguments, str) else dict(arguments or {})
return httpx.get(f"https://api.example.com/employees/{args['id']}").json()


tool = GetEmployee()
```

Expand Down Expand Up @@ -433,10 +437,12 @@ directly:
```python
# 2.x
from stackone_ai.integrations import to_tool_node

node = to_tool_node(tools)

# 3.0
from langgraph.prebuilt import ToolNode

node = ToolNode(tools.to_langchain())
```

Expand Down
15 changes: 7 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ from stackone_ai import StackOneToolSet

toolset = StackOneToolSet()
tools = toolset.fetch_tools(actions=["*_list_*"])
openai_tools = tools.to_openai()[:20] # keep the catalog inside the context window
openai_tools = tools.to_openai()[:20] # keep the catalog inside the context window

client = OpenAI()
messages = [{"role": "user", "content": "Use a tool to list a few records, then summarise them."}]
Expand All @@ -128,7 +128,7 @@ response = client.chat.completions.create(
)

message = response.choices[0].message
messages.append(message.model_dump(exclude_none=True)) # the assistant turn comes first
messages.append(message.model_dump(exclude_none=True)) # the assistant turn comes first
messages.extend(tools.execute_openai_tool_calls(message.tool_calls))
```

Expand Down Expand Up @@ -222,12 +222,11 @@ applied locally to one cached listing — changing a filter never refetches. Cal
```python
toolset = StackOneToolSet()

toolset.fetch_tools() # every tool, every active shared account
toolset.fetch_tools(providers=["linear"]) # one connector
toolset.fetch_tools(actions=["linear_list_*"]) # one connector's list actions
toolset.fetch_tools(actions=["linear_get_issue"]) # exactly one tool
toolset.fetch_tools(providers=["linear"],
actions=["*_get_*"]) # both filters, AND-ed
toolset.fetch_tools() # every tool, every active shared account
toolset.fetch_tools(providers=["linear"]) # one connector
toolset.fetch_tools(actions=["linear_list_*"]) # one connector's list actions
toolset.fetch_tools(actions=["linear_get_issue"]) # exactly one tool
toolset.fetch_tools(providers=["linear"], actions=["*_get_*"]) # both filters, AND-ed
toolset.fetch_tools(account_ids=["acc-123", "acc-456"])
```

Expand Down
2 changes: 1 addition & 1 deletion examples/langchain_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def langchain_integration() -> None:
print(f" - {tool.name}")

# Create model with tools
model = ChatOpenAI(model="gpt-5.4") # ty: ignore[unknown-argument]
model = ChatOpenAI(model="gpt-5.4")
model_with_tools = model.bind_tools(langchain_tools)

result = model_with_tools.invoke("Use one of your tools to list a few records, then summarise them.")
Expand Down
2 changes: 1 addition & 1 deletion examples/langgraph_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def langgraph_integration() -> None:
print(f" - {tool.name}")

# Create a ReAct agent with LangGraph
model = ChatOpenAI(model="gpt-5.4") # ty: ignore[unknown-argument]
model = ChatOpenAI(model="gpt-5.4")
agent = create_agent(model, langchain_tools)

result = agent.invoke(
Expand Down
18 changes: 14 additions & 4 deletions examples/openai_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from __future__ import annotations

import sys
from typing import cast

try:
from dotenv import load_dotenv
Expand All @@ -30,6 +31,11 @@
pass

from openai import OpenAI
from openai.types.chat import (
ChatCompletionFunctionToolParam,
ChatCompletionMessageParam,
ChatCompletionToolMessageParam,
)

from stackone_ai import StackOneToolSet
from stackone_ai.types import StackOneError, ToolsetError
Expand All @@ -49,11 +55,11 @@ def openai_integration() -> None:
"an empty catalog here means the example would prove nothing."
)

openai_tools = tools.to_openai()[:20]
openai_tools = cast("list[ChatCompletionFunctionToolParam]", tools.to_openai()[:20])
names = {t["function"]["name"] for t in openai_tools}
print(f"Loaded {len(openai_tools)} tools, e.g. {sorted(names)[:3]}")

messages: list[dict] = [
messages: list[ChatCompletionMessageParam] = [
{"role": "system", "content": "You answer questions by calling the tools you are given."},
{
"role": "user",
Expand All @@ -72,14 +78,18 @@ def openai_integration() -> None:

print(f"LLM made {len(tool_calls)} tool call(s):")
for tc in tool_calls:
if tc.type != "function":
raise ValueError(f"Expected a function tool call, got {tc.type!r}")
print(f" - {tc.function.name}({tc.function.arguments})")

# The assistant turn must come before its tool results, or OpenAI returns a 400.
messages.append(response.choices[0].message.model_dump(exclude_none=True))
messages.append(
cast("ChatCompletionMessageParam", response.choices[0].message.model_dump(exclude_none=True))
)
tool_messages = tools.execute_openai_tool_calls(tool_calls)
for message in tool_messages:
print(f" Result: {message['content'][:200]}...")
messages.extend(tool_messages)
messages.extend(cast("list[ChatCompletionToolMessageParam]", tool_messages))

final_response = client.chat.completions.create(
model="gpt-5.4", messages=messages, tools=openai_tools, tool_choice="auto"
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ asyncio_default_fixture_loop_scope = "function"
line-length = 110
target-version = "py311"

# These fixtures are a byte-for-byte copy of sdk-conformance; its CI drift check
# owns their formatting. Keep Ruff from rewriting the vendored Markdown examples.
[tool.ruff.format]
exclude = ["tests/vectors/**"]

[tool.ruff.lint]
select = [
"E", # pycodestyle errors
Expand Down
28 changes: 16 additions & 12 deletions stackone_ai/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import threading
import time
from collections import Counter
from collections.abc import AsyncIterator, Callable, Coroutine, Iterable, Sequence
from collections.abc import AsyncGenerator, Callable, Coroutine, Iterable, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
Expand Down Expand Up @@ -632,7 +632,7 @@ def is_rate_limited(exc: BaseException) -> bool:
@asynccontextmanager
async def _mcp_transport(
endpoint: str, headers: dict[str, str], timeout: float, throttle: _Throttle | None = None
) -> AsyncIterator[tuple[Any, Any, Any]]:
) -> AsyncGenerator[tuple[Any, Any, Any]]:
"""Open the streamable-HTTP transport with the caller's timeout on every leg.

The client's own defaults are a 30s connect and a 300s SSE read, and neither was
Expand All @@ -645,7 +645,7 @@ async def _mcp_transport(
than headers and timeouts. It retries a 429, so the MCP client never sees one that
a retry got past.
"""
from mcp.client.streamable_http import streamable_http_client # ty: ignore[unresolved-import]
from mcp.client.streamable_http import streamable_http_client

async with RateLimitRetryingAsyncClient(
throttle=throttle,
Expand All @@ -666,8 +666,8 @@ def fetch_mcp_tools(
``timeout`` bounds the whole exchange, handshake included.
"""
try:
from mcp import types as mcp_types # ty: ignore[unresolved-import]
from mcp.client.session import ClientSession # ty: ignore[unresolved-import]
from mcp import types as mcp_types
from mcp.client.session import ClientSession
except ImportError as exc: # pragma: no cover - depends on optional extra
raise ToolsetConfigError(
"mcp is a core dependency of stackone-ai but could not be imported — reinstall the package."
Expand Down Expand Up @@ -695,7 +695,7 @@ async def _list_within_deadline() -> list[McpToolDefinition]:
cursor: str | None = None
collected: list[McpToolDefinition] = []
while True:
result = await session.list_tools(cursor)
result = await session.list_tools(params=mcp_types.PaginatedRequestParams(cursor=cursor))
for tool in result.tools:
input_schema = tool.inputSchema or {}
collected.append(
Expand Down Expand Up @@ -936,8 +936,8 @@ def call_mcp_tool(

``arguments`` are sent exactly as given; the server maps them onto the action.
"""
from mcp import types as mcp_types # ty: ignore[unresolved-import]
from mcp.client.session import ClientSession # ty: ignore[unresolved-import]
from mcp import types as mcp_types
from mcp.client.session import ClientSession

throttle = _Throttle()

Expand Down Expand Up @@ -1295,10 +1295,14 @@ def to_langchain(self) -> Any:

parent_tool = self

class StackOneLangChainError(ToolException):
status_code: int | None
response_body: Any

class StackOneLangChainTool(BaseTool):
name: str = parent_tool.name
description: str = parent_tool.description
args_schema: dict[str, Any] = args_json_schema # ty: ignore[invalid-assignment]
args_schema: dict[str, Any] = args_json_schema

def _run(self, **kwargs: Any) -> Any:
# Drop unsupplied optionals. pydantic materialises every optional field
Expand All @@ -1320,9 +1324,9 @@ def _run(self, **kwargs: Any) -> Any:
# Without it the agent retries blind, which is the whole point of
# handing the error back.
body = getattr(exc, "response_body", None)
failure = ToolException(f"{exc}: {body}" if body else str(exc))
failure.status_code = getattr(exc, "status_code", None) # type: ignore[attr-defined]
failure.response_body = getattr(exc, "response_body", None) # type: ignore[attr-defined]
failure = StackOneLangChainError(f"{exc}: {body}" if body else str(exc))
failure.status_code = getattr(exc, "status_code", None)
failure.response_body = getattr(exc, "response_body", None)
raise failure from exc

return StackOneLangChainTool()
Expand Down
1 change: 1 addition & 0 deletions stackone_ai/toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,7 @@ def _normalize_schema_properties(self, schema: dict[str, Any]) -> dict[str, Any]
normalized: dict[str, Any] = {}

for name, details in properties.items():
prop: dict[str, Any]
if isinstance(details, dict):
prop = copy.deepcopy(details)
else:
Expand Down
7 changes: 3 additions & 4 deletions tests/test_end_user_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,10 +412,9 @@ def called(self, monkeypatch) -> list[dict[str, Any]]:
seen: list[dict[str, Any]] = []
monkeypatch.setattr(
"stackone_ai.tools.call_mcp_tool",
lambda _endpoint, headers, _name, arguments, **_kwargs: seen.append(
{"headers": headers, "arguments": arguments}
)
or {},
lambda _endpoint, headers, _name, arguments, **_kwargs: (
seen.append({"headers": headers, "arguments": arguments}) or {}
),
)
return seen

Expand Down
Loading
Loading