Skip to content
Merged
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
7 changes: 4 additions & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ jobs:
env:
# Pinned to a commit on stackone-ai-node's main, not the branch, so a Node change never
# silently changes what this job grades. Bump it deliberately.
NODE_SDK_REF: c41b1009b10287057ac72070f69ee147fad3be6b
NODE_SDK_REF: 099fd1e3405f8a56125f9691e20ccd1333dda12f
steps:
- name: Check conformance token is configured
env:
Expand All @@ -134,7 +134,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: StackOneHQ/sdk-conformance
ref: 5f60cc0dfdf4d1a25afad93a68eeefe30e89568d
ref: 4ead1c25bb443ef1fd1986d5f053b2d1475d0e90
token: ${{ secrets.CONFORMANCE_REPO_TOKEN }}
path: sdk-conformance
persist-credentials: false
Expand All @@ -161,8 +161,9 @@ jobs:

- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
# pnpm from sdk-conformance's packageManager; in each SDK checkout pnpm switches to that
# repo's own packageManager version, so one setup serves all three installs.
with:
version: 10.26.0
package_json_file: sdk-conformance/package.json

# 24, not 22: the Node SDK's devEngines pins Node ^24.11.0, and the suite runs on it too.
Expand Down
39 changes: 25 additions & 14 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ Some names were public on `main` for a while before 3.0 but never in a release:

Client-side search is gone: the BM25/TF-IDF index, the semantic search client, the
`search=` constructor argument, and the `tool_search`/`tool_execute` meta tools. The
server's `*_search_actions` tool now does the search, and `toolset.execute()` runs an
action by id through the server's `*_execute_action` tool.
server's `stackone_search_actions` tool now does the search, and `toolset.execute()`
runs an action by id through the server's `stackone_execute_action` tool. Each is one
call spanning every account in scope.

```python
# 2.x
Expand All @@ -100,26 +101,29 @@ hit = hits[0]
result = toolset.execute(
hit["action_id"],
{"query": {"page_size": 25}}, # the nested form in hit["input_schema"]
account_ids=[hit["account_id"]],
account_ids=[hit["available_accounts"][0]["id"]],
session_id=hit.get("session_id"),
)
```

`search()` returns dicts carrying `action_id` and the `account_id` that found it, plus
`description`, `similarity_score`, `input_schema`, `example_request` and `session_id`
when the server sends them. `top_k` caps the whole result, as in 2.x, after ranking
across every connector; the same action linked on two accounts is two hits. When an
action's connector is linked on more than one account, `execute()` raises
`ToolsetConfigError` unless `account_ids` picks one, such as `[hit["account_id"]]`.
`search()` returns the server's hits: dicts carrying `action_id`, `provider` and
`available_accounts` (each `{"id", "name"}`), plus `description`, `similarity_score`,
`input_schema` and `example_request` when the server sends them, and the search's
`session_id`. `top_k` caps the whole result, as in 2.x; the same action linked on two
accounts is one hit naming both. When an action's connector is linked on more than one
account in scope, the server refuses to pick one and `execute()` raises
`StackOneAPIError` unless `account_ids` names one, such as
`[hit["available_accounts"][0]["id"]]`.

`toolset.execute()` has a new signature. It used to take a meta tool name, such as
`"tool_execute"`, with arguments as a JSON string or a dict. It now takes
`(action_id, arguments=None, *, account_ids=None, session_id=None)`, and `arguments`
must be a dict. It raises instead of returning `{"error": ...}`: `ToolsetConfigError`
before any request when `action_id`, `arguments` or `session_id` is malformed,
`ToolArgumentsError` when the arguments cannot be encoded as JSON, `ToolsetLoadError` when
no linked connector matches the action, and `StackOneAPIError` when the action fails,
including when the server rejects the arguments.
the server did not serve `stackone_execute_action` (the project's policy disables
search_execute), and `StackOneAPIError` when the action fails, including when the server
rejects the arguments or the action id.

To give a model the search and execute tools, set the tool mode on the toolset.
`openai()`, `langchain()` and `pydantic_ai()` no longer take `mode=`.
Expand All @@ -131,14 +135,21 @@ openai_tools = toolset.openai(mode="search_and_execute")
# ...for each tool call the model makes:
toolset.execute(call.function.name, call.function.arguments)

# 3.0: two tools per connector, served by the server
# 3.0: stackone_search_actions and stackone_execute_action, served by the server and
# spanning every account in scope (at most 50), with stackone_list_accounts for several
toolset = StackOneToolSet(tool_mode="search_execute")
tools = toolset.fetch_tools()
openai_tools = tools.to_openai()
# ...then run the model's tool calls and get the messages to send back:
messages.extend(tools.execute_openai_tool_calls(message.tool_calls))
```

For a single account the server serves that account's own pair,
`<provider>_<account>_search_actions` and `_execute_action`; the SDK presents them as
`stackone_search_actions` and `stackone_execute_action`, so code and prompts use the same
names for one account or many. A scope of more than 50 accounts raises
`ToolsetConfigError` before any request.

`get_search_tool()` and `semantic_client` are removed. Call `toolset.search()` instead.

## Results
Expand Down Expand Up @@ -223,7 +234,7 @@ an ordinary argument that happens to be named `headers`: it skips header filteri
sent as given, subject to the same JSON-value check as every other argument. A
`headers_<name>` argument is dropped with a warning when its value is a list or dict.

`*_execute_action` serves an open `headers` object, so `toolset.execute()` passes your
`stackone_execute_action` serves an open `headers` object, so `toolset.execute()` passes your
own headers on to the action, with the exception of those four:

```python
Expand Down Expand Up @@ -316,7 +327,7 @@ tool.execute({"id": "1"}, options={"feedback_session_id": "chat-42"})

# 3.0
hit = toolset.search("list workers")[0]
toolset.execute(hit["action_id"], account_ids=[hit["account_id"]], session_id=hit.get("session_id"))
toolset.execute(hit["action_id"], session_id=hit.get("session_id"))
toolset.submit_feedback(
"positive",
[hit["action_id"]],
Expand Down
35 changes: 24 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,14 @@ hits = toolset.search("list recent comments", top_k=3)
# "example_request": {"action_id": "linear_list_comments"},
# "input_schema": {"type": "object", "properties": {"body": {...}}},
# "session_id": "3f9c...",
# "account_id": "acc-123"}, ...]
# "available_accounts": [{"id": "acc-123", "name": "Acme Linear"}]}, ...]
hit = hits[0]

# 2. Run it. Build the arguments from input_schema.
result = toolset.execute(
hit["action_id"],
{"body": {"variables": {"first": 25}}},
account_ids=[hit["account_id"]],
account_ids=[hit["available_accounts"][0]["id"]],
session_id=hit.get("session_id"),
)
result["result"]["data"] # as the server wrote it: {"isError": false, "result": ...}; a failure raises
Expand All @@ -69,15 +69,28 @@ result["result"]["data"] # as the server wrote it: {"isError": false, "result":
toolset.submit_feedback("positive", [hit["action_id"]], session_id=hit.get("session_id"))
```

`search()` asks every linked connector and returns actions ranked by
`similarity_score`, so a catalog of hundreds of tools never has to fit in a
model's context. This is the recommended way to use the SDK. `top_k` (1–50, default
10) caps the whole result, after ranking across every connector.

Each hit carries the `account_id` of the account that found it, and the same action
linked on two accounts is two hits. When an action's connector is linked on more than
one account, `execute()` raises `ToolsetConfigError` rather than pick one: pass the
hit's account as `account_ids=[hit["account_id"]]`.
`search()` makes one call to StackOne's `stackone_search_actions` tool, which searches
every linked connector and returns actions ranked by `similarity_score`, so a catalog
of hundreds of tools never has to fit in a model's context. This is the recommended
way to use the SDK. `top_k` (1–50, default 10) caps the whole result.

Each hit names the accounts that can run it in `available_accounts`, so an action
linked on two accounts is one hit. `execute()` makes one call to
`stackone_execute_action`. When the action's connector is linked on more than one
account in scope, the server refuses to pick one and `execute()` raises
`StackOneAPIError`: pass the account to use as `account_ids=[...]`, one of the hit's
`available_accounts`.

To hand a model the same two tools, build the toolset with
`tool_mode="search_execute"`: `fetch_tools()` then returns `stackone_search_actions`
and `stackone_execute_action`, spanning every account in scope in one request, up to
50 accounts; more raise `ToolsetConfigError`, so pass `account_ids` to narrow the scope.
Several accounts also get `stackone_list_accounts`. For one account the server serves
that account's own search and execute pair, which the SDK presents under the same two
names: their descriptions, schemas and results read in those names, hits gain
`available_accounts` and `provider`, and a `stackone_account_id` or `provider` argument
naming that account or its connector is dropped, while any other raises
`ToolArgumentsError` before a request is made.

Each hit carries the `session_id` of the search that found it, when the server
issued one, so read it with `hit.get("session_id")`. Passing it to
Expand Down
45 changes: 33 additions & 12 deletions examples/pydantic_ai_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,25 +28,46 @@
from stackone_ai import StackOneToolSet


def pydantic_ai_integration() -> None:
for var in ["STACKONE_API_KEY", "STACKONE_ACCOUNT_ID", "OPENAI_API_KEY"]:
if not os.getenv(var):
print(f"Set {var} to run this example.")
return

toolset = StackOneToolSet()
tools = toolset.fetch_tools(
actions=["*_list_*"],
account_ids=[os.environ["STACKONE_ACCOUNT_ID"]],
).to_pydantic_ai()
def individual_tools(account_id: str) -> None:
"""One Pydantic AI tool per action: suits a handful of actions."""
tools = StackOneToolSet().fetch_tools(actions=["*_list_*"], account_ids=[account_id]).to_pydantic_ai()

agent = Agent(
"openai:gpt-5.4",
system_prompt="You are a helpful assistant that answers by calling tools.",
tools=tools,
)
result = agent.run_sync("Use one of your tools to list a few records, then summarise them.")
print(f"Result:\n{result.output}")
print(f"Individual tools:\n{result.output}\n")


def search_and_execute(account_id: str) -> None:
"""StackOne's search and execute tools: the model finds an action, then runs it."""
toolset = StackOneToolSet(tool_mode="search_execute")
tools = toolset.fetch_tools(account_ids=[account_id]).to_pydantic_ai()

agent = Agent(
"openai:gpt-5.4",
system_prompt=(
"You answer by calling tools. First call stackone_search_actions to find an action, "
"then call stackone_execute_action with its action_id and arguments built from its "
"example_request."
),
tools=tools,
)
result = agent.run_sync("List a few records from any of my connected apps, then summarise them.")
print(f"Search and execute:\n{result.output}")


def pydantic_ai_integration() -> None:
for var in ["STACKONE_API_KEY", "STACKONE_ACCOUNT_ID", "OPENAI_API_KEY"]:
if not os.getenv(var):
print(f"Set {var} to run this example.")
return

account_id = os.environ["STACKONE_ACCOUNT_ID"]
individual_tools(account_id)
search_and_execute(account_id)


if __name__ == "__main__":
Expand Down
11 changes: 5 additions & 6 deletions examples/search_and_execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,12 @@ def search_and_execute() -> None:
print(f"\narguments: {json.dumps(arguments)}")

# session_id links this call, and the feedback below, to the search that found
# the action. It is optional: leave it out and each call stands alone. account_id
# runs it on the account that found it: with the same provider linked on two
# accounts, execute() refuses to pick one.
# the action. It is optional: leave it out and each call stands alone.
# available_accounts names the accounts that can run it: with the same provider
# linked on two accounts, the server refuses to pick one, so name the first.
session_id = best.get("session_id")
result = toolset.execute(
best["action_id"], arguments, account_ids=[best["account_id"]], session_id=session_id
)
account_id = best["available_accounts"][0]["id"]
result = toolset.execute(best["action_id"], arguments, account_ids=[account_id], session_id=session_id)
print(f"\nresult: {json.dumps(result, default=str)[:300]}...")

try:
Expand Down
Loading
Loading