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 @@ -146,7 +146,7 @@ jobs:
env:
# Pinned to a commit on stackone-ai-python's main, not the branch, so a Python change never
# silently changes what this job grades. Bump it deliberately.
PYTHON_SDK_REF: 049abf548c7f5448a89e7999e95336980d23afc3
PYTHON_SDK_REF: d76c26663c9141bf106a19d385252da9a8c7e75d
steps:
- name: Check conformance token is configured
env:
Expand All @@ -166,7 +166,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 @@ -187,8 +187,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

# The SDK's devEngines pins the exact Node version, and the suite runs on it too.
Expand Down
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ for await (const message of result) {

### Search and execute

`search()` asks every linked connector for actions matching a natural-language query and ranks the results together, best first. `execute()` runs one by id, through the connector that serves it.
`search()` makes one call to StackOne's `stackone_search_actions` tool, which searches every linked connector for actions matching a natural-language query and ranks them together, best first. `execute()` runs one by id with one call to `stackone_execute_action`. Each call spans every account in scope, up to 50; more throws `ToolSetConfigError` before any request, so pass `accountIds` to narrow the scope.

```typescript
const actions = await toolset.search('create a time-off request', { topK: 5 });
Expand All @@ -309,17 +309,16 @@ const [best] = actions;
if (!best) throw new Error('No action matched');
// `input_schema` describes what the action accepts; `example_request` shows the shape
const result = await toolset.execute(best.action_id, best.example_request, {
accountIds: [best.account_id],
sessionId: best.session_id,
});
```

- `execute()` takes the nested envelope an action's `example_request` shows: `{ path, query, body }`. (A `fetchTools()` tool takes the keys its own served schema names instead: read them from `tool.parameters.properties`.)
- The connector is the longest one whose name prefixes the action id, so `browser_linkedin_*` actions are not routed to `browser`.
- `action_id` is always the one you pass: an `action_id` inside `args` — for example, one a prompt-injected model put there — is ignored.
- Each hit carries the `session_id` of the search that found it, when the server issued one. Pass it back as `sessionId` to link the calls together.
- 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()` throws `ToolSetConfigError` rather than pick one: pass the hit's account as `{ accountIds: [hit.account_id] }`. Likewise, if an account in scope failed to list and its provider is the action's connector or unknown, `execute()` lists it again at once (asking `GET /accounts` first for any provider it does not know, and throwing its 429 if rate limited; an account that lookup failed to name is neither asked about nor listed early again for 30 seconds), and if it still fails, throws `ToolSetLoadError` rather than run the action on whichever account did list. A failed account on another provider is not listed again early, so it never costs the call a listing timeout, though learning its provider with explicit ids takes one `GET /accounts`; like any account, it is listed again once its 30 seconds are up.
- `topK` (1–50, default 10) caps the whole result, after ranking across every connector. A connector that fails to search is skipped with a warning, unless every connector fails.
- Each hit names its `provider` and the accounts that can run it in `available_accounts`, so an action linked on two accounts is one hit. With one account in scope, `available_accounts` holds just that account's `id`. When the action's connector is linked on more than one account in scope, the server refuses to pick one and `execute()` throws `StackOneAPIError`: pass the account to use as `{ accountIds: [id] }`, one of the hit's `available_accounts`.
- `topK` (1–50, default 10) caps the whole result.
- If the server does not serve the search or execute tool (the project's policy disables search and execute), `search()` and `execute()` throw `ToolSetLoadError`.

[View full example](examples/search-and-execute.ts)

Expand All @@ -346,13 +345,16 @@ await toolset.submitFeedback({
// One tool per action (the server default)
const toolset = new StackOneToolSet();

// Two meta tools per connector instead: `*_search_actions` and `*_execute_action`
// StackOne's search and execute tools instead: `stackone_search_actions` and
// `stackone_execute_action`, spanning every account in scope
const compact = new StackOneToolSet({ toolMode: 'search_execute' });
const metaTools = await compact.fetchTools();
```

`search_execute` keeps the catalog small however many accounts are linked, which suits handing the tools to a model directly. A single call can override the mode with `fetchTools({ mode })`.

The tools have the same names whatever the scope. With several accounts the server also serves `stackone_list_accounts`, and the two tools take a `stackone_account_id` to choose among them. With one account the server serves a pair named for that account (`<provider>_<accountId>_search_actions` and `_execute_action`), and the SDK presents it as `stackone_search_actions` and `stackone_execute_action`, with those names in its descriptions and results. A `stackone_account_id` or `provider` that names the account or its connector is dropped; any other throws `ToolArgumentsError` before any request.

### Filtering Tools with fetchTools()

You can filter tools by account IDs, providers, and action patterns:
Expand Down Expand Up @@ -406,7 +408,7 @@ The catalog is cached per account scope and mode, so changing a filter never ref

### Headers

A tool call's header arguments — the entries of a `headers` object argument, and `headers_<name>` arguments — are forwarded only if the tool's served schema declares them: `<name>` under `properties.headers.properties`, or a `headers_<name>` property. A `headers` property served as an open object — `type: "object"`, no `properties`, and `additionalProperties` not `false` — as on every `*_execute_action`, declares any name, so `toolset.execute(id, { headers: { 'x-custom': '…' } })` forwards it. Anything undeclared is dropped with a warning; every other argument is sent unchanged.
A tool call's header arguments — the entries of a `headers` object argument, and `headers_<name>` arguments — are forwarded only if the tool's served schema declares them: `<name>` under `properties.headers.properties`, or a `headers_<name>` property. A `headers` property served as an open object — `type: "object"`, no `properties`, and `additionalProperties` not `false` — as on `stackone_execute_action`, declares any name, so `toolset.execute(id, { headers: { 'x-custom': '…' } })` forwards it. Anything undeclared is dropped with a warning; every other argument is sent unchanged.

A top-level `headers` argument that isn't a plain object is dropped with a warning, unless the served schema declares `headers` itself as a non-object field (for example `type: "string"`), in which case it's an ordinary argument that happens to be named `headers` and is sent as given. A `headers_<name>` argument is dropped with a warning when its value is an array or object — only a string, number or boolean can be a header value.

Expand Down
16 changes: 10 additions & 6 deletions examples/search-and-execute.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
/**
* Find an action in natural language and run it, without loading a tool catalog.
*
* This is the recommended way to use the SDK: search() asks every linked connector and returns
* ranked actions, 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: search() makes one call across every linked account
* and returns ranked actions, so a catalog of hundreds of tools never has to fit in a model's
* context.
*
* Prerequisites: STACKONE_API_KEY and at least one active linked account. No account id is
* needed — the toolset discovers the ones your key can reach.
Expand Down Expand Up @@ -32,8 +33,10 @@ const printAccounts = (accounts: StackOneAccount[]): void => {
const printRankedMatches = (actions: SearchResult[]): void => {
console.log('\nRanked matches:');
for (const action of actions) {
const accounts = (action.available_accounts ?? []).map((account) => account.id).join(', ');
// oxlint-disable-next-line typescript/no-unnecessary-type-conversion -- a server can send the score as a numeric string
console.log(` ${Number(action.similarity_score ?? 0).toFixed(3)} ${action.action_id}`);
const score = Number(action.similarity_score ?? 0).toFixed(3);
console.log(` ${score} ${action.action_id} [${accounts}]`);
}
};

Expand All @@ -57,13 +60,14 @@ const searchAndExecute = async (): Promise<void> => {
console.log(`\ninput_schema for ${best.action_id}:`);
console.log(JSON.stringify(best.input_schema ?? {}, null, 2).slice(0, 600));

// accountIds pins the call to the account that found the hit; session_id links it to the
// search.
// The hit lists the accounts that can run it. With more than one, accountIds chooses; with one,
// the server picks it. session_id links the call to the search.
const [account] = best.available_accounts ?? [];
const result = await toolset.execute(
best.action_id,
{ query: { limit: 2 } },
{
accountIds: [best.account_id],
accountIds: account === undefined ? null : [account.id],
sessionId: best.session_id,
},
);
Expand Down
Loading
Loading