diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d48357c6..42e9c1e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - name: Set up Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: '20' + node-version: '22' - name: Bootstrap run: ./scripts/bootstrap @@ -55,7 +55,7 @@ jobs: - name: Set up Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: '20' + node-version: '22' - name: Bootstrap run: ./scripts/bootstrap @@ -95,7 +95,7 @@ jobs: - name: Set up Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: '20' + node-version: '22' - name: Bootstrap run: ./scripts/bootstrap @@ -129,7 +129,7 @@ jobs: - name: Set up Node uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: yarn install diff --git a/.release-please-manifest.json b/.release-please-manifest.json index e284d65c..ff827d8d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,7 +1,7 @@ { - ".": "0.96.0", - "packages/vertex-sdk": "0.16.0", - "packages/bedrock-sdk": "0.29.1", + ".": "0.97.0", + "packages/vertex-sdk": "0.16.1", + "packages/bedrock-sdk": "0.29.2", "packages/foundry-sdk": "0.2.3", "packages/aws-sdk": "0.3.0" } diff --git a/.stats.yml b/.stats.yml index 0997c882..c59f1a4b 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 97 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/anthropic/anthropic-9bc52c052beb11ccfff68e9d96335774c8377f914bcf36278e5774c68aa84e69.yml -openapi_spec_hash: 3a5f6e11b9fda1c165c6f9edbdee7d90 -config_hash: ed200254fa6776c7b124706c91c80475 +configured_endpoints: 106 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/anthropic/anthropic-56896df83861385b03a49bdf36623a811f5dedd599fdacf5680c73f9e73e1546.yml +openapi_spec_hash: e1812c6c53a1029d12b5d83ca50f4b78 +config_hash: 45b88a8e434814b9e6f4258be5804047 diff --git a/CHANGELOG.md b/CHANGELOG.md index 092553bd..9508a464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 0.97.0 (2026-05-19) + +Full Changelog: [sdk-v0.96.0...sdk-v0.97.0](https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.96.0...sdk-v0.97.0) + +### Features + +* **client:** Add support for self-hosted sandboxes in CMA with sandbox helpers ([659a343](https://github.com/anthropics/anthropic-sdk-typescript/commit/659a343c820e316229715466b64e420428ee762b)) + + +### Bug Fixes + +* **typescript:** upgrade tsc-multi so that it works with Node 26 ([623f71c](https://github.com/anthropics/anthropic-sdk-typescript/commit/623f71c848ce9b3b88eb08e009b6b3d08a6e5c1c)) + + +### Chores + +* **tests:** remove redundant File import ([cf821fc](https://github.com/anthropics/anthropic-sdk-typescript/commit/cf821fcc06f84cb2150cc0ed4ddb862b5d67f633)) + ## 0.96.0 (2026-05-13) Full Changelog: [sdk-v0.95.2...sdk-v0.96.0](https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.95.2...sdk-v0.96.0) diff --git a/MIGRATION.md b/MIGRATION.md index 1aa57cdd..cb71a154 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -52,8 +52,15 @@ client.parents.children.retrieve('p_123', 'c_456'); client.parents.children.retrieve('c_456', { parent_id: 'p_123' }); ``` -This affects the following methods: +
+ +This affects the following methods +- `client.beta.environments.work.retrieve()` +- `client.beta.environments.work.update()` +- `client.beta.environments.work.ack()` +- `client.beta.environments.work.heartbeat()` +- `client.beta.environments.work.stop()` - `client.beta.sessions.resources.retrieve()` - `client.beta.sessions.resources.update()` - `client.beta.sessions.resources.delete()` @@ -73,6 +80,9 @@ This affects the following methods: - `client.beta.memoryStores.memoryVersions.redact()` - `client.beta.skills.versions.retrieve()` - `client.beta.skills.versions.delete()` +- `client.beta.skills.versions.download()` + +
### URI encoded path parameters @@ -123,6 +133,9 @@ client.example.list(undefined, { headers: { ... } }); - `client.beta.environments.list()` - `client.beta.environments.delete()` - `client.beta.environments.archive()` +- `client.beta.environments.work.list()` +- `client.beta.environments.work.poll()` +- `client.beta.environments.work.stats()` - `client.beta.sessions.retrieve()` - `client.beta.sessions.list()` - `client.beta.sessions.delete()` diff --git a/api.md b/api.md index c9203782..b712525a 100644 --- a/api.md +++ b/api.md @@ -532,7 +532,13 @@ Types: - BetaManagedAgentsAgentToolsetDefaultConfig - BetaManagedAgentsAgentToolsetDefaultConfigParams - BetaManagedAgentsAgentToolset20260401 +- BetaManagedAgentsAgentToolset20260401BashInput +- BetaManagedAgentsAgentToolset20260401EditInput +- BetaManagedAgentsAgentToolset20260401GlobInput +- BetaManagedAgentsAgentToolset20260401GrepInput - BetaManagedAgentsAgentToolset20260401Params +- BetaManagedAgentsAgentToolset20260401ReadInput +- BetaManagedAgentsAgentToolset20260401WriteInput - BetaManagedAgentsAlwaysAllowPolicy - BetaManagedAgentsAlwaysAskPolicy - BetaManagedAgentsAnthropicSkill @@ -555,6 +561,7 @@ Types: - BetaManagedAgentsMultiagentCoordinator - BetaManagedAgentsMultiagentCoordinatorParams - BetaManagedAgentsMultiagentSelfParams +- BetaManagedAgentsSessionThreadAgent - BetaManagedAgentsSkillParams - BetaManagedAgentsURLMCPServerParams @@ -576,24 +583,49 @@ Methods: Types: -- BetaCloudConfig -- BetaCloudConfigParams -- BetaEnvironment -- BetaEnvironmentDeleteResponse -- BetaLimitedNetwork -- BetaLimitedNetworkParams -- BetaPackages -- BetaPackagesParams -- BetaUnrestrictedNetwork +- BetaCloudConfig +- BetaCloudConfigParams +- BetaEnvironment +- BetaEnvironmentDeleteResponse +- BetaLimitedNetwork +- BetaLimitedNetworkParams +- BetaPackages +- BetaPackagesParams +- BetaSelfHostedConfig +- BetaSelfHostedConfigParams +- BetaUnrestrictedNetwork Methods: -- client.beta.environments.create({ ...params }) -> BetaEnvironment -- client.beta.environments.retrieve(environmentID, { ...params }) -> BetaEnvironment -- client.beta.environments.update(environmentID, { ...params }) -> BetaEnvironment -- client.beta.environments.list({ ...params }) -> BetaEnvironmentsPageCursor -- client.beta.environments.delete(environmentID, { ...params }) -> BetaEnvironmentDeleteResponse -- client.beta.environments.archive(environmentID, { ...params }) -> BetaEnvironment +- client.beta.environments.create({ ...params }) -> BetaEnvironment +- client.beta.environments.retrieve(environmentID, { ...params }) -> BetaEnvironment +- client.beta.environments.update(environmentID, { ...params }) -> BetaEnvironment +- client.beta.environments.list({ ...params }) -> BetaEnvironmentsPageCursor +- client.beta.environments.delete(environmentID, { ...params }) -> BetaEnvironmentDeleteResponse +- client.beta.environments.archive(environmentID, { ...params }) -> BetaEnvironment + +### Work + +Types: + +- BetaSelfHostedWork +- BetaSelfHostedWorkHeartbeatResponse +- BetaSelfHostedWorkListResponse +- BetaSelfHostedWorkQueueStats +- BetaSelfHostedWorkStopRequest +- BetaSelfHostedWorkUpdateRequest +- BetaSessionWorkData + +Methods: + +- client.beta.environments.work.retrieve(workID, { ...params }) -> BetaSelfHostedWork +- client.beta.environments.work.update(workID, { ...params }) -> BetaSelfHostedWork +- client.beta.environments.work.list(environmentID, { ...params }) -> BetaSelfHostedWorksPageCursor +- client.beta.environments.work.ack(workID, { ...params }) -> BetaSelfHostedWork +- client.beta.environments.work.heartbeat(workID, { ...params }) -> BetaSelfHostedWorkHeartbeatResponse +- client.beta.environments.work.poll(environmentID, { ...params }) -> BetaSelfHostedWork | null +- client.beta.environments.work.stats(environmentID, { ...params }) -> BetaSelfHostedWorkQueueStats +- client.beta.environments.work.stop(workID, { ...params }) -> BetaSelfHostedWork ## Sessions @@ -613,9 +645,12 @@ Types: - BetaManagedAgentsOutcomeEvaluationResource - BetaManagedAgentsSession - BetaManagedAgentsSessionAgent +- BetaManagedAgentsSessionAgentUpdate - BetaManagedAgentsSessionMultiagentCoordinator - BetaManagedAgentsSessionStats +- BetaManagedAgentsSessionUpdatedEvent - BetaManagedAgentsSessionUsage +- BetaManagedAgentsUserToolResultEvent Methods: @@ -701,6 +736,7 @@ Types: - BetaManagedAgentsUserMessageEventParams - BetaManagedAgentsUserToolConfirmationEvent - BetaManagedAgentsUserToolConfirmationEventParams +- BetaManagedAgentsUserToolResultEventParams Methods: @@ -733,7 +769,6 @@ Methods: Types: - BetaManagedAgentsSessionThread -- BetaManagedAgentsSessionThreadAgent - BetaManagedAgentsSessionThreadStats - BetaManagedAgentsSessionThreadStatus - BetaManagedAgentsSessionThreadUsage @@ -911,6 +946,7 @@ Methods: - client.beta.skills.versions.retrieve(version, { ...params }) -> VersionRetrieveResponse - client.beta.skills.versions.list(skillID, { ...params }) -> VersionListResponsesPageCursor - client.beta.skills.versions.delete(version, { ...params }) -> VersionDeleteResponse +- client.beta.skills.versions.download(version, { ...params }) -> Response ## Webhooks diff --git a/bin/migration-config.json b/bin/migration-config.json index 9d443b46..3b800638 100644 --- a/bin/migration-config.json +++ b/bin/migration-config.json @@ -4,6 +4,191 @@ "clientClass": "Anthropic", "baseClientClass": "BaseAnthropic", "methods": [ + { + "base": "beta.environments.work", + "name": "retrieve", + "params": [ + { + "type": "param", + "key": "work_id", + "location": "path" + }, + { + "type": "params", + "maybeOverload": false + }, + { + "type": "options" + } + ], + "oldParams": [ + { + "type": "param", + "key": "environment_id", + "location": "path" + }, + { + "type": "param", + "key": "work_id", + "location": "path" + }, + { + "type": "params", + "maybeOverload": true + }, + { + "type": "options" + } + ] + }, + { + "base": "beta.environments.work", + "name": "update", + "params": [ + { + "type": "param", + "key": "work_id", + "location": "path" + }, + { + "type": "params", + "maybeOverload": false + }, + { + "type": "options" + } + ], + "oldParams": [ + { + "type": "param", + "key": "environment_id", + "location": "path" + }, + { + "type": "param", + "key": "work_id", + "location": "path" + }, + { + "type": "params", + "maybeOverload": false + }, + { + "type": "options" + } + ] + }, + { + "base": "beta.environments.work", + "name": "ack", + "params": [ + { + "type": "param", + "key": "work_id", + "location": "path" + }, + { + "type": "params", + "maybeOverload": false + }, + { + "type": "options" + } + ], + "oldParams": [ + { + "type": "param", + "key": "environment_id", + "location": "path" + }, + { + "type": "param", + "key": "work_id", + "location": "path" + }, + { + "type": "params", + "maybeOverload": true + }, + { + "type": "options" + } + ] + }, + { + "base": "beta.environments.work", + "name": "heartbeat", + "params": [ + { + "type": "param", + "key": "work_id", + "location": "path" + }, + { + "type": "params", + "maybeOverload": false + }, + { + "type": "options" + } + ], + "oldParams": [ + { + "type": "param", + "key": "environment_id", + "location": "path" + }, + { + "type": "param", + "key": "work_id", + "location": "path" + }, + { + "type": "params", + "maybeOverload": true + }, + { + "type": "options" + } + ] + }, + { + "base": "beta.environments.work", + "name": "stop", + "params": [ + { + "type": "param", + "key": "work_id", + "location": "path" + }, + { + "type": "params", + "maybeOverload": false + }, + { + "type": "options" + } + ], + "oldParams": [ + { + "type": "param", + "key": "environment_id", + "location": "path" + }, + { + "type": "param", + "key": "work_id", + "location": "path" + }, + { + "type": "params", + "maybeOverload": false + }, + { + "type": "options" + } + ] + }, { "base": "beta.sessions.resources", "name": "retrieve", @@ -706,6 +891,43 @@ "type": "options" } ] + }, + { + "base": "beta.skills.versions", + "name": "download", + "params": [ + { + "type": "param", + "key": "version", + "location": "path" + }, + { + "type": "params", + "maybeOverload": false + }, + { + "type": "options" + } + ], + "oldParams": [ + { + "type": "param", + "key": "skill_id", + "location": "path" + }, + { + "type": "param", + "key": "version", + "location": "path" + }, + { + "type": "params", + "maybeOverload": true + }, + { + "type": "options" + } + ] } ] } diff --git a/examples/agents-comprehensive.ts b/examples/agents-comprehensive.ts index 14df092c..d437fcd7 100644 --- a/examples/agents-comprehensive.ts +++ b/examples/agents-comprehensive.ts @@ -102,12 +102,12 @@ async function main() { const stream = await client.beta.sessions.events.stream(session.id); for await (const event of stream) { console.log(JSON.stringify(event, null, 2)); - if (event.type === 'agent.custom_tool_use' && event.name === 'get_weather') { + if (event.type === 'agent.tool_use' && event.name === 'get_weather') { await client.beta.sessions.events.send(session.id, { events: [ { - type: 'user.custom_tool_result', - custom_tool_use_id: event.id, + type: 'user.tool_result', + tool_use_id: event.id, content: [{ type: 'text', text: '{"temperature_c": 14}' }], }, ], diff --git a/examples/managed-agents-observe-tool-calls.ts b/examples/managed-agents-observe-tool-calls.ts new file mode 100644 index 00000000..c74fa0ab --- /dev/null +++ b/examples/managed-agents-observe-tool-calls.ts @@ -0,0 +1,269 @@ +#!/usr/bin/env -S npm run tsn -T + +// Self-hosted runner, "observe every tool call" flavor: the low-level +// `client.beta.sessions.events.toolRunner(...)` path. It is an async iterable +// that dispatches a session's `agent.tool_use` / `agent.custom_tool_use` events +// to your local tools, posts each result back, and yields one +// `DispatchedToolCall` per completed call — so you can watch every dispatch +// (name, input, error flag, whether the result posted). Unlike +// `EnvironmentWorker`, it does NOT poll for work and does NOT manage a +// work-item lease. +// +// Two scenarios, two functions in this file: +// +// main() — PRIMARY. A session you created and drive yourself: no work queue, +// no lease. `toolRunner` just dispatches tools against the session's +// events, so it works the same whether or not the session's environment is +// self-hosted. Reach for this when you want per-call visibility on a +// session you own. +// +// observeAsSelfHostedWorker() — SECONDARY (not called by default). If you ARE +// a self-hosted worker but want per-call visibility, you have to compose the +// pieces `EnvironmentWorker` would otherwise compose for you: the work +// poller, the per-session agent tool context, AND your own lease heartbeat +// running in parallel with the `toolRunner` loop. Reach for +// `EnvironmentWorker` instead unless you specifically need to see each call. +// +// Requires: +// ANTHROPIC_API_KEY - your API key (read by the SDK client) +// ANTHROPIC_ENVIRONMENT_ID - the environment the session runs in +// ANTHROPIC_ENVIRONMENT_KEY - the environment key, the runner's single +// credential (only the secondary scenario needs it) +// +// Security model: the tools execute bash and file operations directly on the +// host. Run inside a container or other isolation boundary you control. + +import Anthropic from '@anthropic-ai/sdk'; +import { betaZodTool } from '@anthropic-ai/sdk/helpers/beta/zod'; +import type { DispatchedToolCall } from '@anthropic-ai/sdk/helpers/beta/environments'; +import { + betaAgentToolset20260401, + setupSkills, + type AgentToolContext, +} from '@anthropic-ai/sdk/tools/agent-toolset/node'; +import { z } from 'zod/v4'; + +// Required for sessions to accept a self-hosted environment_id. +const MANAGED_AGENTS_BETA = 'managed-agents-2026-04-01'; + +const client = new Anthropic(); + +const environmentId = requireEnv('ANTHROPIC_ENVIRONMENT_ID'); +const workdir = process.env['ANTHROPIC_WORKDIR'] ?? '.'; + +// A custom tool built with the `betaZodTool` helper — the same +// `BetaRunnableTool` shape `client.beta.messages.toolRunner` accepts, with the +// input schema derived from the Zod schema and `run`'s args validated against +// it. `toolRunner` dispatches it alongside the defaults whenever the model +// emits a matching `agent.tool_use` event. +const CURRENT_TIME_DESCRIPTION = 'Get the current time in ISO 8601 format.'; +const currentTime = betaZodTool({ + name: 'current_time', + description: CURRENT_TIME_DESCRIPTION, + inputSchema: z.object({}), + run: async () => new Date().toISOString(), +}); + +// ===== PRIMARY: observe a session you created and drive yourself ===== + +async function main() { + // 1. Create an agent that exposes both the default toolset and our custom + // tool, then a session and the initial prompt. + const agent = await client.beta.agents.create({ + name: 'observe-tool-calls-example', + model: 'claude-haiku-4-5', + system: 'You are running in a sandbox. Use the available tools to answer.', + tools: [ + { type: 'agent_toolset_20260401' }, + { + type: 'custom', + name: currentTime.name, + description: CURRENT_TIME_DESCRIPTION, + input_schema: { type: 'object', properties: {} }, + }, + ], + }); + console.log('created agent', agent.id); + + const session = await client.beta.sessions.create({ + agent: agent.id, + environment_id: environmentId, + title: 'observe-tool-calls-example', + betas: [MANAGED_AGENTS_BETA], + }); + console.log('created session', session.id); + + // 2. Build the per-session agent tool context: the workdir the file tools + // confine to, plus the session id `setupSkills` uses to download the + // agent's skills into `{workdir}/skills/`. `cleanupSkills` removes them. + const ctx: AgentToolContext = { workdir, client, sessionId: session.id }; + const cleanupSkills = await setupSkills(ctx); + + try { + await client.beta.sessions.events.send(session.id, { + events: [ + { + type: 'user.message', + content: [ + { + type: 'text', + text: 'What is the current time? Also run `pwd` to show me the working directory.', + }, + ], + }, + ], + betas: [MANAGED_AGENTS_BETA], + }); + + // 3. Iterate `toolRunner`: it attaches to the session, runs each tool call + // locally, posts the result back, and yields one `DispatchedToolCall` per + // completed call. `tools` is the standard set bound to `ctx` plus our + // custom tool. The runner stops on its own once the session goes idle + // (`maxIdleMs` after an `end_turn`); the timeout signal is just a hard + // cap for the demo. `toolRunner` does NOT touch any work-item lease. + console.log('\n--- tool calls ---'); + for await (const call of client.beta.sessions.events.toolRunner(session.id, { + tools: [...betaAgentToolset20260401(ctx), currentTime], + maxIdleMs: 10_000, + signal: AbortSignal.timeout(120_000), + })) { + printCall(call); + } + } finally { + // 4. Clean up the downloaded skills, the session, and the agent. + await cleanupSkills().catch(() => {}); + await client.beta.sessions.delete(session.id, { betas: [MANAGED_AGENTS_BETA] }).catch(() => {}); + await client.beta.agents.archive(agent.id).catch(() => {}); + } +} + +// ===== SECONDARY: observe each call while ALSO being a self-hosted worker ===== + +// NOT called by `main()`. This is the shape you reach for only if you are a +// self-hosted worker AND you want per-call visibility. +// +// IMPORTANT: `toolRunner` does NOT manage the work-item lease — `EnvironmentWorker` +// is what normally does. `EnvironmentWorker` polls for work, runs the equivalent +// of the `toolRunner` loop, AND heartbeats the lease (force-stopping on exit), +// all composed together. Drop down to `toolRunner` for per-call visibility and +// you give up that lease management — so you have to roll it back yourself: the +// heartbeat task below runs in parallel with the `toolRunner` loop for exactly +// that reason. It is a SIMPLIFIED shape (fixed interval, minimal error +// handling); `EnvironmentWorker`'s internal heartbeat loop is the careful +// reference — it adapts the interval to the server's `ttl_seconds` and tolerates +// transient failures with backoff. Rolling your own heartbeat is the cost of +// getting per-call visibility AND lease management together. +async function observeAsSelfHostedWorker(): Promise { + const environmentKey = requireEnv('ANTHROPIC_ENVIRONMENT_KEY'); + + // Every per-session call (the `toolRunner` event stream, the lease heartbeat, + // the force-stop) authenticates with the environment key. `apiKey: null` + // clears the parent client's `X-Api-Key` — without it, both `X-Api-Key` + // AND `Authorization: Bearer …` would land on the wire and the server + // rejects the dual auth on the events stream with 401. + const sessionClient = client.withOptions({ + apiKey: null, + authToken: environmentKey, + credentials: undefined, + }); + + // The work poller claims items and yields them; it ack's each one and posts + // `work.stop` after the loop body returns. + for await (const work of client.beta.environments.work.poller({ environmentId, environmentKey })) { + const sessionId = work.data.id; + console.log('claimed work', work.id, 'for session', sessionId); + + // Per-session agent tool context + skills, same as the primary scenario. + const ctx: AgentToolContext = { workdir, client, sessionId }; + const cleanupSkills = await setupSkills(ctx); + + // A controller shared by the heartbeat task and the `toolRunner` loop: + // whichever finishes first aborts the other. + const ctrl = new AbortController(); + const heartbeatTask = heartbeatLease(sessionClient, work, ctrl.signal).finally(() => ctrl.abort()); + + try { + for await (const call of sessionClient.beta.sessions.events.toolRunner(sessionId, { + tools: [...betaAgentToolset20260401(ctx), currentTime], + signal: ctrl.signal, + })) { + printCall(call); + } + } finally { + ctrl.abort(); + await heartbeatTask; + await cleanupSkills().catch(() => {}); + // Force-stop the work item — `toolRunner` will not do it for you. + await sessionClient.beta.environments.work + .stop(work.id, { environment_id: work.environment_id, force: true }) + .catch(() => {}); + } + } +} + +/** + * A SIMPLIFIED lease heartbeat — see the comment block on + * {@link observeAsSelfHostedWorker}. Beats on a fixed 30s interval; the first + * beat uses the `NO_HEARTBEAT` sentinel, each later one echoes the server's + * previous `last_heartbeat`. Returns (letting the shared controller abort the + * `toolRunner` loop) as soon as the control plane reports the work is + * stopping/stopped or the lease was not extended. + */ +async function heartbeatLease( + client: Anthropic, + work: { id: string; environment_id: string }, + signal: AbortSignal, +): Promise { + let expectedLastHeartbeat = 'NO_HEARTBEAT'; + while (!signal.aborted) { + const resp = await client.beta.environments.work.heartbeat(work.id, { + environment_id: work.environment_id, + expected_last_heartbeat: expectedLastHeartbeat, + }); + expectedLastHeartbeat = resp.last_heartbeat; + if (resp.state === 'stopping' || resp.state === 'stopped' || !resp.lease_extended) return; + await delay(30_000, signal); + } +} + +/** Print one observed tool call: name, input, error flag, and whether the result posted. */ +function printCall(call: DispatchedToolCall): void { + const input = truncate(JSON.stringify(call.event.input)); + const status = call.isError ? 'error' : 'ok'; + const posted = call.posted ? '' : ' [result post failed]'; + console.log(`tool ${call.name}(${input}) -> ${status}${posted}`); +} + +function requireEnv(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`${name} is required`); + return v; +} + +/** Resolve after `ms`, or early when `signal` aborts. */ +function delay(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve(); + return; + } + const timer = setTimeout(() => resolve(), ms); + signal.addEventListener( + 'abort', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); +} + +function truncate(s: string, n = 120): string { + return s.length <= n ? s : s.slice(0, n) + '…'; +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/managed-agents-private-sandbox-worker.ts b/examples/managed-agents-private-sandbox-worker.ts new file mode 100644 index 00000000..2e925f05 --- /dev/null +++ b/examples/managed-agents-private-sandbox-worker.ts @@ -0,0 +1,154 @@ +#!/usr/bin/env -S npm run tsn -T + +// Self-contained demo: create an agent and session that target a self-hosted +// environment, then run an `EnvironmentWorker` locally to serve the default +// agent_toolset_20260401 tools plus one custom tool. +// +// Requires: +// ANTHROPIC_API_KEY - your API key (read by the SDK client) +// ANTHROPIC_ENVIRONMENT_ID - a self-hosted environment to poll +// ANTHROPIC_ENVIRONMENT_KEY - the environment key (the runner's single credential) +// +// Security model: the worker executes bash and file operations directly on the +// host. Run inside a container or other isolation boundary you control. + +import Anthropic from '@anthropic-ai/sdk'; +import type { BetaRunnableTool } from '@anthropic-ai/sdk/lib/tools/BetaRunnableTool'; +import { betaAgentToolset20260401 } from '@anthropic-ai/sdk/tools/agent-toolset/node'; +import type { BetaManagedAgentsSessionEvent } from '@anthropic-ai/sdk/resources/beta/sessions/events'; + +// Required for sessions to accept a self-hosted environment_id. +const MANAGED_AGENTS_BETA = 'managed-agents-2026-04-01'; + +const client = new Anthropic(); + +const environmentId = requireEnv('ANTHROPIC_ENVIRONMENT_ID'); +const environmentKey = requireEnv('ANTHROPIC_ENVIRONMENT_KEY'); +const workdir = process.env['ANTHROPIC_WORKDIR'] ?? '.'; + +// A custom tool, in the same BetaRunnableTool shape that +// `client.beta.messages.toolRunner` accepts. The worker will execute it +// alongside the defaults whenever the model emits a matching +// `agent.tool_use` event. +const CURRENT_TIME_DESCRIPTION = 'Get the current time in ISO 8601 format.'; +const currentTime: BetaRunnableTool> = { + type: 'custom', + name: 'current_time', + description: CURRENT_TIME_DESCRIPTION, + input_schema: { type: 'object', properties: {} }, + parse: (x) => x as Record, + run: async () => new Date().toISOString(), +}; + +async function main() { + // 1. Create an agent that exposes both the default toolset and our custom + // tool. The custom tool definition here tells the model the tool exists; + // the implementation lives in `currentTime` above. + const agent = await client.beta.agents.create({ + name: 'self-hosted-runner-example', + model: 'claude-haiku-4-5', + system: 'You are running in a self-hosted sandbox. Use the available tools to answer.', + tools: [ + { type: 'agent_toolset_20260401' }, + { + type: 'custom', + name: currentTime.name, + description: CURRENT_TIME_DESCRIPTION, + input_schema: { type: 'object', properties: {} }, + }, + ], + }); + console.log('created agent', agent.id); + + // 2. Create a session against the self-hosted environment. + const session = await client.beta.sessions.create({ + agent: agent.id, + environment_id: environmentId, + title: 'self-hosted-runner-example', + betas: [MANAGED_AGENTS_BETA], + }); + console.log('created session', session.id); + + try { + // 3. Send the initial prompt. + await client.beta.sessions.events.send(session.id, { + events: [ + { + type: 'user.message', + content: [ + { + type: 'text', + text: 'What is the current time? Also run `pwd` to show me the working directory.', + }, + ], + }, + ], + betas: [MANAGED_AGENTS_BETA], + }); + + // 4. Run the environment worker. It polls for work, and for each claimed + // session sets up the workdir + downloads the agent's skills, then runs + // the local tools against the session's `agent.tool_use` events while + // heartbeating the work-item lease, force-stopping on exit. `tools` is a + // factory so `betaAgentToolset20260401` is bound to each session's + // workdir/id. The 60s deadline ends the demo; `run()` returns when it + // fires. + // + // If you already hold a single claimed work item, use `handleItem()` + // instead of `run()` — it runs the per-item flow once and returns. + // Called with no arguments, `handleItem()` reads the work item from the + // `ANTHROPIC_WORK_ID` / `ANTHROPIC_ENVIRONMENT_ID` / `ANTHROPIC_SESSION_ID` / + // `ANTHROPIC_ENVIRONMENT_KEY` environment variables, so `environmentId` / + // `environmentKey` aren't needed: + // await client.beta.environments.work.worker({ workdir, tools }).handleItem(); + await client.beta.environments.work + .worker({ + environmentId, + environmentKey, + workdir, + tools: (ctx) => [...betaAgentToolset20260401(ctx), currentTime], + }) + .run(AbortSignal.timeout(60_000)); + + // 5. Print the resulting transcript. + console.log('\n--- transcript ---'); + for await (const ev of client.beta.sessions.events.list(session.id, { betas: [MANAGED_AGENTS_BETA] })) { + console.log(summarise(ev)); + } + } finally { + // 6. Clean up. + await client.beta.sessions.delete(session.id, { betas: [MANAGED_AGENTS_BETA] }).catch(() => {}); + await client.beta.agents.archive(agent.id).catch(() => {}); + } +} + +function requireEnv(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`${name} is required`); + return v; +} + +function summarise(ev: BetaManagedAgentsSessionEvent): string { + switch (ev.type) { + case 'user.message': + case 'agent.message': + return `${ev.type}: ${truncate(ev.content.map((b) => (b.type === 'text' ? b.text : '')).join(''))}`; + case 'agent.tool_use': + return `${ev.type}: ${ev.name} ${truncate(JSON.stringify(ev.input))}`; + case 'user.tool_result': { + const text = ev.content?.map((b) => (b.type === 'text' ? b.text : '')).join('') ?? ''; + return `${ev.type}: ${truncate(text)}${ev.is_error ? ' [error]' : ''}`; + } + default: + return ev.type; + } +} + +function truncate(s: string, n = 120): string { + return s.length <= n ? s : s.slice(0, n) + '…'; +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/managed-agents-worker-dispatch.ts b/examples/managed-agents-worker-dispatch.ts new file mode 100644 index 00000000..10349d2e --- /dev/null +++ b/examples/managed-agents-worker-dispatch.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env -S npm run tsn -T + +// Self-hosted runner, "worker-dispatch" flavor: this process was handed ONE +// already-claimed work item by an upstream poller/orchestrator — e.g. an +// `ant worker poll --on-work ` loop, or your own dispatcher that +// spawns a fresh sandbox per work item. It does NOT create an agent or session, +// and it does NOT poll for work: something else did that and claimed the item. +// +// `EnvironmentWorker.handleItem()` with no arguments reads the claimed item's +// identity from the environment variables the upstream poller sets: +// ANTHROPIC_WORK_ID - the claimed work item to serve +// ANTHROPIC_ENVIRONMENT_ID - the self-hosted environment it belongs to +// ANTHROPIC_SESSION_ID - the session to run tools for +// ANTHROPIC_ENVIRONMENT_KEY - the environment key (the runner's single credential) +// It then runs the session's tools while heartbeating the work-item lease, +// force-stops the item on exit, and returns — one item, then this process exits. +// +// Also required: +// ANTHROPIC_API_KEY - your API key (read by the SDK client) +// +// Security model: the worker executes bash and file operations directly on the +// host. This is the "sandbox process" shape — the upstream orchestrator is +// expected to have spawned it inside a container or other isolation boundary. + +import Anthropic from '@anthropic-ai/sdk'; +import type { BetaRunnableTool } from '@anthropic-ai/sdk/lib/tools/BetaRunnableTool'; +import { betaAgentToolset20260401 } from '@anthropic-ai/sdk/tools/agent-toolset/node'; + +const client = new Anthropic(); + +// Base directory for the per-session AgentToolContext. An orchestrator typically +// points this at the sandbox's scratch space. +const workdir = process.env['ANTHROPIC_WORKDIR'] ?? '.'; + +// A custom tool, in the same BetaRunnableTool shape that +// `client.beta.messages.toolRunner` accepts. The worker executes it alongside +// the defaults whenever the model emits a matching `agent.tool_use` event. +const CURRENT_TIME_DESCRIPTION = 'Get the current time in ISO 8601 format.'; +const currentTime: BetaRunnableTool> = { + type: 'custom', + name: 'current_time', + description: CURRENT_TIME_DESCRIPTION, + input_schema: { type: 'object', properties: {} }, + parse: (x) => x as Record, + run: async () => new Date().toISOString(), +}; + +async function main() { + // Build the worker with a `tools` factory — the standard agent_toolset_20260401 + // set plus our one custom tool — and nothing else. No `environmentId` / + // `environmentKey` here: `handleItem()` resolves the work item (and the + // environment key) from the `ANTHROPIC_*` env vars the upstream poller set. + const worker = client.beta.environments.work.worker({ + workdir, + tools: (ctx) => [...betaAgentToolset20260401(ctx), currentTime], + }); + + // Service the single claimed item to completion: set up the workdir + download + // the session agent's skills, run the local tools against the session's + // `agent.tool_use` events while heartbeating the lease, then force-stop the + // work item. Returns when the session is done — then this process exits. + await worker.handleItem(); + console.log('work item handled'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/helpers.md b/helpers.md index 7d3acaed..66f36d23 100644 --- a/helpers.md +++ b/helpers.md @@ -108,7 +108,7 @@ The SDK provides helpers for parsing structured JSON outputs from Claude using J ```ts import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'; import Anthropic from '@anthropic-ai/sdk'; -import { z } from 'zod/v4'; +import { z } from 'zod'; const client = new Anthropic(); @@ -183,7 +183,7 @@ The SDK provides helper functions to create runnable tools that can be automatic ```ts import { betaZodTool } from '@anthropic-ai/sdk/helpers/beta/zod'; -import { z } from 'zod/v4'; +import { z } from 'zod'; const weatherTool = betaZodTool({ name: 'get_weather', @@ -505,3 +505,99 @@ See the following example files for more usage patterns: - [`examples/tools-helpers-zod.ts`](examples/tools-helpers-zod.ts) - Zod-based tools - [`examples/tools-helpers-json-schema.ts`](examples/tools-helpers-json-schema.ts) - JSON Schema tools - [`examples/tools.ts`](examples/tools.ts) - Basic tool usage + +# Self-Hosted Environment Runner + +The SDK exposes a few building blocks for serving managed-agents sessions from a self-hosted environment: + +- `client.beta.environments.work.worker({ ... })` (returns an `EnvironmentWorker`, also exported from `@anthropic-ai/sdk/helpers/beta/environments`) — the full worker: polls for work, and for each claimed session sets up the workdir + downloads the agent's skills, runs the local tools against the session's `agent.tool_use` events while heartbeating the work-item lease, force-stops the work on exit, cleans up the downloaded skills, and loops. `worker.handleItem(...)` runs that same per-item flow for a single work item you've already claimed; with no arguments it reads the work id / environment id / session id from `ANTHROPIC_WORK_ID` / `ANTHROPIC_ENVIRONMENT_ID` / `ANTHROPIC_SESSION_ID` (the env vars `ant worker poll --on-work` sets) and the environment key from `ANTHROPIC_ENVIRONMENT_KEY`. `environmentId` / `environmentKey` are only needed by `run()`'s poll loop — `handleItem()` works without them. Composed from the two pieces below. +- `client.beta.environments.work.poller(...)` — control-plane only (a `WorkPoller`): claims work items from an environment, ack's each one before yielding it, and posts `stop` automatically when the consumer's loop body returns or the iteration ends. +- `client.beta.sessions.events.toolRunner(...)` — the sessions-side counterpart to `client.beta.messages.toolRunner` (a `SessionToolRunner`): for each `agent.tool_use` event the agent emits during a session, runs the matching tool from your registry, posts the result back, and yields a `DispatchedToolCall` so you can observe what happened. Internally drives event-stream reconnect and result posting; it does not touch the work-item lease. + +The tool implementations themselves live in a separate Node-only module — `@anthropic-ai/sdk/tools/agent-toolset/node` — alongside `@anthropic-ai/sdk/tools/memory/node`. `betaAgentToolset20260401(ctx)` returns the standard `agent_toolset_20260401` set (`bash`, `read`, `write`, `edit`, `glob`, `grep`) as `BetaRunnableTool` objects, the same shape `client.beta.messages.toolRunner` accepts. The individual factories — `betaBashTool`, `betaReadTool`, `betaWriteTool`, `betaEditTool`, `betaGlobTool`, `betaGrepTool` — are exported too. + +> **Node 22+ required.** The agent toolset uses the native `fs.glob` (added in Node 22) for its `glob` tool, so `@anthropic-ai/sdk/tools/agent-toolset/node` requires Node 22 or newer. The rest of the SDK still supports Node 18+. + +```ts +import Anthropic from '@anthropic-ai/sdk'; +import { betaAgentToolset20260401 } from '@anthropic-ai/sdk/tools/agent-toolset/node'; + +const client = new Anthropic(); + +// One-stop worker: poll → run the toolset for each session → force-stop → loop. +// `tools` is a factory so `betaAgentToolset20260401` is bound to each session's workdir/id. +// `environmentKey` is the runner's single credential — it authenticates both the +// work-poll calls and every per-session call (event stream, heartbeat, force-stop). +await client.beta.environments.work + .worker({ + environmentId: process.env.ANTHROPIC_ENVIRONMENT_ID!, + environmentKey: process.env.ANTHROPIC_ENVIRONMENT_KEY!, + workdir: '/workspace', + tools: (ctx) => [...betaAgentToolset20260401(ctx), myCustomTool], + }) + .run(AbortSignal.timeout(60 * 60_000)); +``` + +If you already hold a claimed work item — e.g. an `ant worker poll --on-work` script handed one to a fresh process — call `handleItem` to run just the per-item flow (build the workdir + skills, run the session's tools while heartbeating the lease, force-stop on exit). Inside that command the work id / environment id / session id / environment key are already in the environment, so the sandbox case is just: + +```ts +await client.beta.environments.work.worker({ workdir: '/workspace', tools }).handleItem(); +``` + +Pass the values explicitly when you have the objects in hand (e.g. you iterate the poller yourself): + +```ts +await client.beta.environments.work.worker({ workdir: '/workspace', tools }).handleItem({ + workId: work.id, + environmentId: work.environment_id, + sessionId: work.data.id, + environmentKey: process.env.ANTHROPIC_ENVIRONMENT_KEY!, +}); +``` + +`betaAgentToolset20260401(ctx)` returns a plain array — filter or extend it to customise: + +```ts +const tools = betaAgentToolset20260401(ctx).filter((t) => t.name !== 'grep'); // remove +const tools = [...betaAgentToolset20260401(ctx), myCustomTool]; // extend with any BetaRunnableTool +``` + +If you want the pieces separately — e.g. to observe each tool call, or to manage the work lifecycle yourself — drive the poller and the session tool runner directly: + +```ts +import { + betaAgentToolset20260401, + setupSkills, + type AgentToolContext, +} from '@anthropic-ai/sdk/tools/agent-toolset/node'; + +const environmentKey = process.env.ANTHROPIC_ENVIRONMENT_KEY!; +// The environment key authenticates the per-session calls too — scope a client to it. +const sessionClient = client.withOptions({ authToken: environmentKey }); + +for await (const work of client.beta.environments.work.poller({ + environmentId: process.env.ANTHROPIC_ENVIRONMENT_ID!, + environmentKey, +})) { + if (work.data.type !== 'session') continue; + // Setting `client` + `sessionId` makes `setupSkills` fetch the session's + // resolved agent and download each of its skills into `{workdir}/skills//` + // (via `client.beta.skills.versions.download`). Call it before the tool runner; + // it returns a cleanup function to call once the work item is done. + const ctx: AgentToolContext = { workdir: '/workspace', client, sessionId: work.data.id }; + const cleanupSkills = await setupSkills(ctx); + try { + for await (const call of sessionClient.beta.sessions.events.toolRunner(work.data.id, { + tools: betaAgentToolset20260401(ctx), + })) { + console.log(`${call.name} -> ${call.isError ? 'error' : 'ok'}`); + } + } finally { + await cleanupSkills(); + } +} +``` + +The toolset executes shell and file operations directly on the host. Run it inside a container or other isolation boundary you control. + +See [`examples/managed-agents-private-sandbox-worker.ts`](examples/managed-agents-private-sandbox-worker.ts) for a complete example. diff --git a/package.json b/package.json index 9cb4484f..a0a17fe3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@anthropic-ai/sdk", - "version": "0.96.0", + "version": "0.97.0", "description": "The official TypeScript library for the Anthropic API", "author": "Anthropic ", "types": "dist/index.d.ts", @@ -57,7 +57,7 @@ "publint": "^0.2.12", "ts-jest": "^29.1.0", "ts-node": "^10.5.0", - "tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz", + "tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.11/tsc-multi.tgz", "tsconfig-paths": "^4.0.0", "tslib": "^2.8.1", "typescript": "5.8.3", diff --git a/packages/bedrock-sdk/CHANGELOG.md b/packages/bedrock-sdk/CHANGELOG.md index 093b32f1..bb70e40d 100644 --- a/packages/bedrock-sdk/CHANGELOG.md +++ b/packages/bedrock-sdk/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.29.2 (2026-05-19) + +Full Changelog: [bedrock-sdk-v0.29.1...bedrock-sdk-v0.29.2](https://github.com/anthropics/anthropic-sdk-typescript/compare/bedrock-sdk-v0.29.1...bedrock-sdk-v0.29.2) + +### Bug Fixes + +* align @types/node in sub-packages to fix CI build ([#1017](https://github.com/anthropics/anthropic-sdk-typescript/issues/1017)) ([9888c76](https://github.com/anthropics/anthropic-sdk-typescript/commit/9888c7691913de036fa85d25cd6707036c167a99)) + ## 0.29.1 (2026-04-30) Full Changelog: [bedrock-sdk-v0.29.0...bedrock-sdk-v0.29.1](https://github.com/anthropics/anthropic-sdk-typescript/compare/bedrock-sdk-v0.29.0...bedrock-sdk-v0.29.1) diff --git a/packages/bedrock-sdk/package.json b/packages/bedrock-sdk/package.json index 0050c3ac..cfc4c513 100644 --- a/packages/bedrock-sdk/package.json +++ b/packages/bedrock-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@anthropic-ai/bedrock-sdk", - "version": "0.29.1", + "version": "0.29.2", "description": "The official TypeScript library for the Anthropic Bedrock API", "author": "Anthropic ", "types": "dist/index.d.ts", diff --git a/packages/bedrock-sdk/yarn.lock b/packages/bedrock-sdk/yarn.lock index c1a1aa6c..0be36acb 100644 --- a/packages/bedrock-sdk/yarn.lock +++ b/packages/bedrock-sdk/yarn.lock @@ -16,9 +16,10 @@ "@jridgewell/trace-mapping" "^0.3.9" "@anthropic-ai/sdk@file:../../dist": - version "0.86.0" + version "0.96.0" dependencies: json-schema-to-ts "^3.1.1" + standardwebhooks "^1.0.0" "@aws-crypto/crc32@3.0.0": version "3.0.0" @@ -1990,6 +1991,11 @@ "@smithy/util-buffer-from" "^4.0.0" tslib "^2.6.2" +"@stablelib/base64@^1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@stablelib/base64/-/base64-1.0.1.tgz#bdfc1c6d3a62d7a3b7bbc65b6cce1bb4561641be" + integrity sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ== + "@swc/core-darwin-arm64@1.11.24": version "1.11.24" resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.24.tgz#c9fcc9c4bad0511fed26210449556d2b33fb2d9a" @@ -2190,11 +2196,11 @@ undici-types "~5.26.4" "@types/node@^20.17.6": - version "20.17.24" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.24.tgz#2325476954e6fc8c2f11b9c61e26ba6eb7d3f5b6" - integrity sha512-d7fGCyB96w9BnWQrOsJtpyiSaBcAYYr75bnK6ZRjDbql2cGLj/3GsL5OYmLPNq76l7Gf2q4Rv9J2o6h5CrD9sA== + version "20.19.41" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.41.tgz#bb266a1e0aaa2f4537d14ae8ebf238dd9ca73ce6" + integrity sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ== dependencies: - undici-types "~6.19.2" + undici-types "~6.21.0" "@types/semver@^7.5.0": version "7.5.6" @@ -2966,6 +2972,11 @@ fast-levenshtein@^2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-sha256@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/fast-sha256/-/fast-sha256-1.3.0.tgz#7916ba2054eeb255982608cccd0f6660c79b7ae6" + integrity sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ== + fast-xml-parser@4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz#86dbf3f18edf8739326447bcaac31b4ae7f6514f" @@ -4257,6 +4268,14 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" +standardwebhooks@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/standardwebhooks/-/standardwebhooks-1.0.0.tgz#5faa23ceacbf9accd344361101d9e3033b64324f" + integrity sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg== + dependencies: + "@stablelib/base64" "^1.0.0" + fast-sha256 "^1.3.0" + string-length@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" @@ -4507,10 +4526,10 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== -undici-types@~6.19.2: - version "6.19.8" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" - integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== +undici-types@~6.21.0: + version "6.21.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" + integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== update-browserslist-db@^1.0.13: version "1.0.13" diff --git a/packages/vertex-sdk/CHANGELOG.md b/packages/vertex-sdk/CHANGELOG.md index e70b6397..9505073f 100644 --- a/packages/vertex-sdk/CHANGELOG.md +++ b/packages/vertex-sdk/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.16.1 (2026-05-19) + +Full Changelog: [vertex-sdk-v0.16.0...vertex-sdk-v0.16.1](https://github.com/anthropics/anthropic-sdk-typescript/compare/vertex-sdk-v0.16.0...vertex-sdk-v0.16.1) + +### Bug Fixes + +* align @types/node in sub-packages to fix CI build ([#1017](https://github.com/anthropics/anthropic-sdk-typescript/issues/1017)) ([9888c76](https://github.com/anthropics/anthropic-sdk-typescript/commit/9888c7691913de036fa85d25cd6707036c167a99)) + ## 0.16.0 (2026-04-10) Full Changelog: [vertex-sdk-v0.15.0...vertex-sdk-v0.16.0](https://github.com/anthropics/anthropic-sdk-typescript/compare/vertex-sdk-v0.15.0...vertex-sdk-v0.16.0) diff --git a/packages/vertex-sdk/package.json b/packages/vertex-sdk/package.json index d7209c35..c72bfc22 100644 --- a/packages/vertex-sdk/package.json +++ b/packages/vertex-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@anthropic-ai/vertex-sdk", - "version": "0.16.0", + "version": "0.16.1", "description": "The official TypeScript library for the Anthropic Vertex API", "author": "Anthropic ", "types": "dist/index.d.ts", diff --git a/packages/vertex-sdk/yarn.lock b/packages/vertex-sdk/yarn.lock index a4ed3490..df624c40 100644 --- a/packages/vertex-sdk/yarn.lock +++ b/packages/vertex-sdk/yarn.lock @@ -16,11 +16,10 @@ "@jridgewell/trace-mapping" "^0.3.9" "@anthropic-ai/sdk@file:../../dist": - # x-release-please-start-version version "0.96.0" - # x-release-please-end-version dependencies: json-schema-to-ts "^3.1.1" + standardwebhooks "^1.0.0" "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.22.13", "@babel/code-frame@^7.23.5": version "7.23.5" @@ -665,6 +664,11 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@stablelib/base64@^1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@stablelib/base64/-/base64-1.0.1.tgz#bdfc1c6d3a62d7a3b7bbc65b6cce1bb4561641be" + integrity sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ== + "@ts-morph/common@~0.20.0": version "0.20.0" resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.20.0.tgz#3f161996b085ba4519731e4d24c35f6cba5b80af" @@ -775,11 +779,11 @@ undici-types "~5.26.4" "@types/node@^20.17.6": - version "20.17.24" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.24.tgz#2325476954e6fc8c2f11b9c61e26ba6eb7d3f5b6" - integrity sha512-d7fGCyB96w9BnWQrOsJtpyiSaBcAYYr75bnK6ZRjDbql2cGLj/3GsL5OYmLPNq76l7Gf2q4Rv9J2o6h5CrD9sA== + version "20.19.41" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.41.tgz#bb266a1e0aaa2f4537d14ae8ebf238dd9ca73ce6" + integrity sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ== dependencies: - undici-types "~6.19.2" + undici-types "~6.21.0" "@types/semver@^7.5.0": version "7.5.6" @@ -1575,6 +1579,11 @@ fast-levenshtein@^2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-sha256@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/fast-sha256/-/fast-sha256-1.3.0.tgz#7916ba2054eeb255982608cccd0f6660c79b7ae6" + integrity sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ== + fastq@^1.6.0: version "1.16.0" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.16.0.tgz#83b9a9375692db77a822df081edb6a9cf6839320" @@ -2931,6 +2940,14 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" +standardwebhooks@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/standardwebhooks/-/standardwebhooks-1.0.0.tgz#5faa23ceacbf9accd344361101d9e3033b64324f" + integrity sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg== + dependencies: + "@stablelib/base64" "^1.0.0" + fast-sha256 "^1.3.0" + string-length@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" @@ -3181,10 +3198,10 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== -undici-types@~6.19.2: - version "6.19.8" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" - integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== +undici-types@~6.21.0: + version "6.21.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" + integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== update-browserslist-db@^1.0.13: version "1.0.13" diff --git a/scripts/detect-breaking-changes b/scripts/detect-breaking-changes index c13daedd..5a97d3b3 100755 --- a/scripts/detect-breaking-changes +++ b/scripts/detect-breaking-changes @@ -17,7 +17,8 @@ TEST_PATHS=( tests/api-resources/beta/messages/batches.test.ts tests/api-resources/beta/agents/agents.test.ts tests/api-resources/beta/agents/versions.test.ts - tests/api-resources/beta/environments.test.ts + tests/api-resources/beta/environments/environments.test.ts + tests/api-resources/beta/environments/work.test.ts tests/api-resources/beta/sessions/sessions.test.ts tests/api-resources/beta/sessions/events.test.ts tests/api-resources/beta/sessions/resources.test.ts diff --git a/src/core/streaming.ts b/src/core/streaming.ts index e8821db3..febbcb3d 100644 --- a/src/core/streaming.ts +++ b/src/core/streaming.ts @@ -71,6 +71,7 @@ export class Stream implements AsyncIterable { sse.event === 'user.interrupt' || sse.event === 'user.tool_confirmation' || sse.event === 'user.custom_tool_result' || + sse.event === 'user.tool_result' || sse.event === 'agent.message' || sse.event === 'agent.thinking' || sse.event === 'agent.tool_use' || @@ -85,6 +86,7 @@ export class Stream implements AsyncIterable { sse.event === 'session.status_terminated' || sse.event === 'session.error' || sse.event === 'session.deleted' || + sse.event === 'session.updated' || sse.event === 'span.model_request_start' || sse.event === 'span.model_request_end' || sse.event === 'span.outcome_evaluation_start' || diff --git a/src/helpers/beta/environments.ts b/src/helpers/beta/environments.ts new file mode 100644 index 00000000..c9d7bbfd --- /dev/null +++ b/src/helpers/beta/environments.ts @@ -0,0 +1,33 @@ +/** + * Self-hosted environment runner helpers. + * + * - {@link WorkPoller} (`client.beta.environments.work.poller`) — control-plane + * only: claims work items and yields each one. + * - {@link SessionToolRunner} (`client.beta.sessions.events.toolRunner`) — the + * sessions-side counterpart to `client.beta.messages.toolRunner`: dispatches + * local tools against a session's `agent.tool_use` events. + * - {@link EnvironmentWorker} (`client.beta.environments.work.worker`) — the full + * composition: poll → set up the workdir + skills → run a + * {@link SessionToolRunner} while heartbeating the work-item lease → + * force-stop on exit → loop. Use `handleItem()` for the per-item flow when you + * already hold a claimed work item — with no arguments it reads the + * `ANTHROPIC_*` env vars that `ant worker poll --on-work` sets. The class is + * also exported here if you prefer `new EnvironmentWorker({ client, ... })`. + * + * The tool implementations themselves (`betaAgentToolset20260401` and the + * per-tool factories) live in their own Node-only module — import them directly + * from `@anthropic-ai/sdk/tools/agent-toolset/node`. + */ +export { + WorkPoller, + EnvironmentWorker, + SessionToolRunner, + MANAGED_AGENTS_BETA, + DEFAULT_MAX_IDLE_MS, + type DispatchedToolCall, + type WorkPollerOptions, + type EnvironmentWorkerOptions, + type EnvironmentWorkerTools, + type HandleItemOptions, + type SessionToolRunnerOptions, +} from '../../lib/environments/index'; diff --git a/src/helpers/beta/json-schema.ts b/src/helpers/beta/json-schema.ts index edcc7027..ca475082 100644 --- a/src/helpers/beta/json-schema.ts +++ b/src/helpers/beta/json-schema.ts @@ -20,6 +20,12 @@ export function betaTool & { t args: NoInfer>, context?: BetaToolRunContext, ) => Promisable>; + /** + * Optional cleanup hook for tools that hold process-level resources (e.g. a + * persistent shell). `client.beta.sessions.events.toolRunner` calls it once + * when iteration ends. + */ + close?: () => void | Promise; }): BetaRunnableTool>> { if (options.inputSchema.type !== 'object') { throw new Error( @@ -34,6 +40,7 @@ export function betaTool & { t description: options.description, run: options.run, parse: (content: unknown) => content as FromSchema, + ...(options.close ? { close: options.close } : {}), } as any; } diff --git a/src/helpers/beta/zod.ts b/src/helpers/beta/zod.ts index c1acf524..1872445a 100644 --- a/src/helpers/beta/zod.ts +++ b/src/helpers/beta/zod.ts @@ -53,6 +53,12 @@ export function betaZodTool(options: { args: z.infer, context?: BetaToolRunContext, ) => Promisable>; + /** + * Optional cleanup hook for tools that hold process-level resources (e.g. a + * persistent shell). `client.beta.sessions.events.toolRunner` calls it once + * when iteration ends. + */ + close?: () => void | Promise; }): BetaRunnableTool> { const jsonSchema = z.toJSONSchema(options.inputSchema, { reused: 'ref' }); @@ -70,5 +76,6 @@ export function betaZodTool(options: { description: options.description, run: options.run, parse: (args: unknown) => options.inputSchema.parse(args) as z.infer, + ...(options.close ? { close: options.close } : {}), }; } diff --git a/src/internal/utils/abort.ts b/src/internal/utils/abort.ts new file mode 100644 index 00000000..851be716 --- /dev/null +++ b/src/internal/utils/abort.ts @@ -0,0 +1,21 @@ +/** + * Chain an external {@link AbortSignal} into a local {@link AbortController}: + * the controller aborts whenever `external` aborts (synchronously if it is + * already aborted). + * + * Returns a cleanup function that detaches the listener. Callers MUST invoke it + * on their normal teardown path — `{ once: true }` only removes the listener if + * abort actually fires, so a long-lived `external` signal (e.g. a daemon-wide + * signal reused across many short-lived controllers) would otherwise leak one + * listener per controller. + */ +export function linkAbort(external: AbortSignal | null | undefined, controller: AbortController): () => void { + if (!external) return () => {}; + if (external.aborted) { + controller.abort(); + return () => {}; + } + const onAbort = () => controller.abort(); + external.addEventListener('abort', onAbort); + return () => external.removeEventListener('abort', onAbort); +} diff --git a/src/internal/utils/async-queue.ts b/src/internal/utils/async-queue.ts new file mode 100644 index 00000000..f84536b4 --- /dev/null +++ b/src/internal/utils/async-queue.ts @@ -0,0 +1,66 @@ +export type AsyncQueueResult = { done: false; value: T } | { done: true; value: undefined }; + +/** + * Single-consumer async queue that bridges background producers to an + * `AsyncIterator`-style reader. Producers `push()` items; the consumer awaits + * `next()`. `close()` is idempotent and wakes any pending `next()` with + * `done: true`. `tryShift()` synchronously drains remaining items after + * iteration has been signalled to stop. + */ +export class AsyncQueue { + #items: T[] = []; + #waiters: Array<(r: AsyncQueueResult) => void> = []; + #closed = false; + + /** Enqueue an item, or hand it directly to a waiting reader. Returns `false` once closed. */ + push(item: T): boolean { + if (this.#closed) return false; + const w = this.#waiters.shift(); + if (w) w({ done: false, value: item }); + else this.#items.push(item); + return true; + } + + /** Mark the queue done. Idempotent; wakes every pending reader with `done: true`. */ + close(): void { + if (this.#closed) return; + this.#closed = true; + while (this.#waiters.length > 0) { + const w = this.#waiters.shift()!; + w({ done: true, value: undefined }); + } + } + + /** + * Resolve with the next item, or `done: true` once the queue is closed and + * drained. When `signal` is supplied, aborting it resolves a pending read + * with `done: true` (cancellation is pushed down here rather than handled by + * an outer `Promise.race`). + */ + next(signal?: AbortSignal): Promise> { + if (this.#items.length > 0) { + return Promise.resolve({ done: false, value: this.#items.shift()! }); + } + if (this.#closed || signal?.aborted) { + return Promise.resolve({ done: true, value: undefined }); + } + return new Promise>((resolve) => { + const waiter = (r: AsyncQueueResult) => { + signal?.removeEventListener('abort', onAbort); + resolve(r); + }; + const onAbort = () => { + const idx = this.#waiters.indexOf(waiter); + if (idx >= 0) this.#waiters.splice(idx, 1); + resolve({ done: true, value: undefined }); + }; + this.#waiters.push(waiter); + signal?.addEventListener('abort', onAbort, { once: true }); + }); + } + + /** Synchronously remove and return the next buffered item, or `undefined` if empty. */ + tryShift(): T | undefined { + return this.#items.shift(); + } +} diff --git a/src/internal/utils/backoff.ts b/src/internal/utils/backoff.ts new file mode 100644 index 00000000..9bd50e43 --- /dev/null +++ b/src/internal/utils/backoff.ts @@ -0,0 +1,41 @@ +import { APIError } from '../../core/error'; + +/** True when `e` is an {@link APIError} whose HTTP status equals `code`. */ +export function isStatus(e: unknown, code: number): boolean { + return e instanceof APIError && e.status === code; +} + +/** True when `e` is an {@link APIError} with a 4xx status. */ +export function is4xx(e: unknown): boolean { + return e instanceof APIError && typeof e.status === 'number' && e.status >= 400 && e.status < 500; +} + +/** + * True for a 4xx that the core client's retry policy would *not* retry, i.e. a + * permanent client error. 408 (request timeout), 409 (lock timeout) and 429 + * (rate limit) are retryable for the base client (`Anthropic.shouldRetry`), so + * they are not treated as fatal here — keeping helper retry behaviour aligned + * with the rest of the SDK. + */ +export function isFatal4xx(e: unknown): boolean { + return is4xx(e) && !isStatus(e, 408) && !isStatus(e, 409) && !isStatus(e, 429); +} + +/** Exponential backoff: `baseMs * 2 ** attempt`, clamped to `capMs`. */ +export function backoff(attempt: number, baseMs: number, capMs: number): number { + return Math.min(baseMs * 2 ** attempt, capMs); +} + +/** Uniform random delay in the half-open interval `[lowMs, highMs)`. */ +export function jitter(lowMs: number, highMs: number): number { + return lowMs + Math.random() * (highMs - lowMs); +} + +/** + * Trim up to 25% off `ms` at random so a fleet of clients backing off after a + * shared outage does not retry in lockstep — mirrors the jitter the core client + * applies to its own retry timeout. + */ +export function applyJitter(ms: number): number { + return ms * (1 - Math.random() * 0.25); +} diff --git a/src/internal/utils/promise.ts b/src/internal/utils/promise.ts new file mode 100644 index 00000000..e5b79f6c --- /dev/null +++ b/src/internal/utils/promise.ts @@ -0,0 +1,18 @@ +/** + * A deferred: a `Promise` together with its `resolve` / `reject` functions. + * This is `Promise.withResolvers()`, which is not available in all supported + * runtimes. + */ +export function promiseWithResolvers(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} diff --git a/src/internal/utils/sleep.ts b/src/internal/utils/sleep.ts index 65e52962..01e9bf99 100644 --- a/src/internal/utils/sleep.ts +++ b/src/internal/utils/sleep.ts @@ -1,3 +1,26 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +/** + * Resolve after `ms`, or immediately when `signal` aborts. + * + * When a `signal` is passed the abort listener is always removed so repeated + * calls do not accumulate listeners on a long-lived signal. Resolves (rather + * than rejects) on abort — callers treat abort as "wake up early," not as a + * failure; callers that want to unwind should check the signal themselves. + */ +export const sleep = (ms: number, signal?: AbortSignal): Promise => + new Promise((resolve) => { + if (signal?.aborted) return resolve(); -export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + + // `{ once: true }` auto-removes the listener if abort fires first, + // so we only need an explicit remove on the timer-wins path above. + signal?.addEventListener('abort', onAbort, { once: true }); + }); diff --git a/src/lib/environments/index.ts b/src/lib/environments/index.ts new file mode 100644 index 00000000..277215a5 --- /dev/null +++ b/src/lib/environments/index.ts @@ -0,0 +1,23 @@ +export { + WorkPoller, + type WorkPollerOptions, + POLL_BLOCK_MS, + backoff, + jitter, + isStatus, + is4xx, + isFatal4xx, +} from './poller'; +export { + EnvironmentWorker, + type EnvironmentWorkerOptions, + type EnvironmentWorkerTools, + type HandleItemOptions, +} from './worker'; +export { + SessionToolRunner, + type SessionToolRunnerOptions, + type DispatchedToolCall, + MANAGED_AGENTS_BETA, + DEFAULT_MAX_IDLE_MS, +} from '../tools/SessionToolRunner'; diff --git a/src/lib/environments/poller.ts b/src/lib/environments/poller.ts new file mode 100644 index 00000000..2ee166e7 --- /dev/null +++ b/src/lib/environments/poller.ts @@ -0,0 +1,253 @@ +import { AnthropicError } from '../../core/error'; +import type { Anthropic } from '../../client'; +import type { BetaSelfHostedWork } from '../../resources/beta/environments/work'; +import { loggerFor } from '../../internal/utils/log'; +import { sleep } from '../../internal/utils/sleep'; +import { uuid4 } from '../../internal/utils/uuid'; +import { linkAbort } from '../../internal/utils/abort'; +import { buildHeaders } from '../../internal/headers'; +import type { BetaToolRunnerRequestOptions } from '../tools/BetaToolRunner'; +import { + applyJitter, + backoff as expBackoff, + isFatal4xx, + isStatus, + jitter, +} from '../../internal/utils/backoff'; +import { copyClientForHelper } from '../helper-client'; + +export { is4xx, isFatal4xx, isStatus, jitter } from '../../internal/utils/backoff'; + +// API caps block_ms at 999; rely on client-side jitter between empty polls. +export const POLL_BLOCK_MS = 999; +const POLL_BACKOFF_BASE_MS = 1000; +const POLL_BACKOFF_CAP_MS = 60_000; + +export interface WorkPollerOptions { + client: Anthropic; + environmentId: string; + /** + * The environment key — the single credential for the self-hosted runner. It + * authenticates the work-poll calls here and every per-session call the + * consumer makes afterwards. + */ + environmentKey: string; + workerId?: string; + /** External abort signal. Aborting it ends the iteration. */ + signal?: AbortSignal; + /** + * Whether the poller posts `work.stop` itself after the consumer's loop body + * returns. Defaults to `true`. Set `false` when the consumer already owns the + * stop (e.g. {@link EnvironmentWorker} force-stops every item) so the work + * item is not stopped twice. + * + * Orthogonal to {@link WorkPollerOptions.drain}: `autoStop` is a per-item + * lifecycle flag (does the poller `work.stop` each item), `drain` controls + * loop termination (does the poller return when the queue is empty). They are + * not two names for the same thing — `EnvironmentWorker.run` uses + * `autoStop: false` with `drain` defaulting `false`. + */ + autoStop?: boolean; + /** + * When `true`, the poller returns (ends iteration) as soon as the work queue + * is empty instead of long-polling forever. Defaults to `false` (long-poll + * until aborted). Pair with `blockMs: null` for a single non-blocking pass + * over whatever is already queued. + */ + drain?: boolean; + /** + * Block timeout in milliseconds passed through to `work.poll` — the server + * long-polls up to this long for an item before returning empty. Defaults to + * {@link POLL_BLOCK_MS} (the API cap, 999). Pass `null` to omit it entirely + * for a non-blocking single poll (useful with {@link WorkPollerOptions.drain}). + */ + blockMs?: number | null; + /** + * Reclaim unacknowledged work items older than this many milliseconds, passed + * through to `work.poll`'s `reclaim_older_than_ms`. Defaults to `undefined` + * (omitted — the server applies its own default). + */ + reclaimOlderThanMs?: number | null; + /** + * Extra per-request options merged into the poll/ack/stop calls. Custom + * `headers` (e.g. a proxy's auth/routing headers) are layered on top of the + * environment-key auth + helper telemetry headers; the poller owns the abort + * signal, so a `signal` here is ignored. + */ + requestOptions?: BetaToolRunnerRequestOptions; +} + +/** + * Async-iterable that long-polls a self-hosted environment for work, ack's + * each item, yields the {@link BetaSelfHostedWork} item, and posts `stop` after + * the consumer's loop body returns (or when the consumer `break`s). + * + * @example + * ```ts + * for await (const work of client.beta.environments.work.poller({ + * environmentId, + * environmentKey, + * })) { + * // ...service the work... + * } + * ``` + */ +export class WorkPoller implements AsyncIterable { + readonly client: Anthropic; + readonly environmentId: string; + readonly environmentKey: string; + readonly workerId: string; + + // Sub-client scoped to the environment key. Every poll / ack / stop call + // is routed through this so the parent's `X-Api-Key` never lands on the + // wire alongside the bearer credential. The helper-telemetry header is + // attached as a default on this client; per-call plumbing is unnecessary. + readonly #runnerClient: Anthropic; + #consumed = false; + readonly #controller: AbortController; + readonly #detachExternal: () => void; + readonly #autoStop: boolean; + readonly #drain: boolean; + readonly #blockMs: number | null; + readonly #reclaimOlderThanMs: number | null; + readonly #requestOpts: BetaToolRunnerRequestOptions | undefined; + + constructor(opts: WorkPollerOptions) { + this.client = opts.client; + this.environmentId = opts.environmentId; + this.environmentKey = opts.environmentKey; + this.workerId = opts.workerId ?? defaultWorkerId(); + this.#runnerClient = copyClientForHelper(opts.client, { + authToken: opts.environmentKey, + helper: 'environments-work-poller', + }); + this.#autoStop = opts.autoStop ?? true; + this.#drain = opts.drain ?? false; + // `undefined` => default to the API cap; an explicit `null` => omit + // `block_ms` for a non-blocking poll. + this.#blockMs = opts.blockMs === undefined ? POLL_BLOCK_MS : opts.blockMs; + this.#reclaimOlderThanMs = opts.reclaimOlderThanMs ?? null; + this.#requestOpts = opts.requestOptions; + this.#controller = new AbortController(); + this.#detachExternal = linkAbort(opts.signal, this.#controller); + } + + /** Read-only view of this iterator's abort signal. */ + get signal(): AbortSignal { + return this.#controller.signal; + } + + /** Abort the iterator. The current `for await` will exit cleanly. */ + abort(): void { + this.#controller.abort(); + } + + async *[Symbol.asyncIterator](): AsyncIterator { + if (this.#consumed) { + throw new AnthropicError('Cannot iterate over a consumed WorkPoller'); + } + this.#consumed = true; + const log = loggerFor(this.client); + log.info('poller starting', { + component: 'work-poller', + environment_id: this.environmentId, + }); + + try { + let attempt = 0; + while (!this.#controller.signal.aborted) { + let work: BetaSelfHostedWork | null; + try { + work = await this.#runnerClient.beta.environments.work.poll( + this.environmentId, + { + 'Anthropic-Worker-ID': this.workerId, + ...(this.#blockMs !== null ? { block_ms: this.#blockMs } : {}), + ...(this.#reclaimOlderThanMs !== null ? + { reclaim_older_than_ms: this.#reclaimOlderThanMs } + : {}), + }, + { headers: buildHeaders([this.#requestOpts?.headers]), signal: this.#controller.signal }, + ); + } catch (e) { + if (this.#controller.signal.aborted) return; + // A bad environment key / missing environment never recovers — surface + // it instead of spinning forever at the backoff cap. + if (isFatal4xx(e)) { + log.error('poll failed permanently, stopping poller', { error: String(e) }); + throw e; + } + // Jittered exponential backoff so a fleet of pollers doesn't retry in + // lockstep after a shared outage. + const wait = applyJitter(backoff(attempt)); + log.warn('poll failed, backing off', { error: String(e), backoff_ms: wait }); + attempt++; + await sleep(wait, this.#controller.signal); + continue; + } + attempt = 0; + if (work == null) { + // Queue empty: either return now (drain) or wait and poll again. + if (this.#drain) return; + await sleep(jitter(1000, 3000), this.#controller.signal); + continue; + } + log.info('claimed work', { + component: 'work-poller', + environment_id: this.environmentId, + work_id: work.id, + work_type: work.data.type, + }); + + try { + await this.#runnerClient.beta.environments.work.ack( + work.id, + { environment_id: work.environment_id }, + { headers: buildHeaders([this.#requestOpts?.headers]), signal: this.#controller.signal }, + ); + } catch (e) { + log.error('ack failed', { work_id: work.id, error: String(e) }); + continue; + } + + try { + yield work; + } finally { + // Post-handler stop. Runs whether the consumer body returned + // normally, threw, or `break`d out of the loop — unless the consumer + // owns the stop itself (`autoStop: false`). + if (this.#autoStop) { + try { + await this.#runnerClient.beta.environments.work.stop( + work.id, + { environment_id: work.environment_id }, + { headers: buildHeaders([this.#requestOpts?.headers]) }, + ); + } catch (e) { + if (!isStatus(e, 409)) log.warn('stop failed', { work_id: work.id, error: String(e) }); + } + } + } + } + } finally { + // Detach from the external signal so the consumer can drop their + // signal reference without leaking this iterator instance. + this.#detachExternal(); + } + } +} + +/** Exponential poll backoff: 1s, 2s, 4s … clamped to a 60s cap. */ +export function backoff(attempt: number): number { + return expBackoff(attempt, POLL_BACKOFF_BASE_MS, POLL_BACKOFF_CAP_MS); +} + +function defaultWorkerId(): string { + // The API documents the worker id as a *unique* identifier for Redis consumer + // groups, so the fallback must be unique even when several pollers share a + // host. Prefix with the hostname when one is exposed for readability, but rely + // on the uuid for uniqueness. + const env = (globalThis as { process?: { env?: Record } }).process?.env; + const host = env?.['HOSTNAME']; + return host ? `${host}-${uuid4()}` : uuid4(); +} diff --git a/src/lib/environments/worker.ts b/src/lib/environments/worker.ts new file mode 100644 index 00000000..7ec7c2d7 --- /dev/null +++ b/src/lib/environments/worker.ts @@ -0,0 +1,395 @@ +import { AnthropicError } from '../../core/error'; +import type { Anthropic } from '../../client'; +import type { BetaSelfHostedWork } from '../../resources/beta/environments/work'; +import { loggerFor, type Logger } from '../../internal/utils/log'; +import { readEnv } from '../../internal/utils/env'; +import { sleep } from '../../internal/utils/sleep'; +import { isFatal4xx, isStatus } from '../../internal/utils/backoff'; +import { linkAbort } from '../../internal/utils/abort'; +import { buildHeaders } from '../../internal/headers'; +import type { BetaRunnableTool } from '../tools/BetaRunnableTool'; +import type { BetaToolRunnerRequestOptions } from '../tools/BetaToolRunner'; +import { SessionToolRunner } from '../tools/SessionToolRunner'; +import { WorkPoller } from './poller'; +import { copyClientForHelper } from '../helper-client'; +// `tools/agent-toolset/node` is Node-only (node:child_process, node:fs, …). +// Only the type is imported statically (erased at build); the module's values +// (`setupSkills`, `betaAgentToolset20260401`) are loaded lazily inside the +// per-item handler. That keeps this file free of Node-only deps in the static +// import graph, which is what lets `client.beta.environments.work.worker()` +// exist as a resource method without pulling Node built-ins into the SDK core. +import type { AgentToolContext } from '../../tools/agent-toolset/node'; + +const HEARTBEAT_DEFAULT_MS = 30_000; +const NO_HEARTBEAT_SENTINEL = 'NO_HEARTBEAT'; + +/** + * Either a fixed tool array or a factory invoked once per claimed session with + * that session's {@link AgentToolContext} — use the factory form to bind + * `betaAgentToolset20260401` (or any tool that needs the workdir / session + * id) to the right session. + */ +export type EnvironmentWorkerTools = + | Array + | ((ctx: AgentToolContext) => Array); + +export interface EnvironmentWorkerOptions { + client: Anthropic; + /** + * The self-hosted environment to poll for work. Required by + * {@link EnvironmentWorker.run}; not used by {@link EnvironmentWorker.handleItem}. + */ + environmentId?: string; + /** + * The environment key — the single credential for the runner. It authenticates + * the work-poll calls and every per-session call (event stream, lease + * heartbeat, force-stop). Required by {@link EnvironmentWorker.run}; falls back + * to `ANTHROPIC_ENVIRONMENT_KEY` in {@link EnvironmentWorker.handleItem}. + */ + environmentKey?: string; + /** + * Tools to expose to each claimed session. Defaults to + * `betaAgentToolset20260401(ctx)` (the standard `agent_toolset_20260401` set + * bound to the per-session {@link AgentToolContext}). + */ + tools?: EnvironmentWorkerTools; + /** Base directory for the per-session {@link AgentToolContext}. Defaults to `process.cwd()`. */ + workdir?: string; + /** Forwarded to the per-session {@link AgentToolContext}. */ + unrestrictedPaths?: boolean; + /** Forwarded to {@link SessionToolRunner} (`maxIdleMs`). */ + maxIdleMs?: number; + /** Forwarded to the {@link WorkPoller}. */ + workerId?: string; + /** External abort signal; aborting it ends the run. */ + signal?: AbortSignal; + /** + * Extra per-request options merged into every call this worker issues — the + * work poll/ack/heartbeat/stop control-plane calls and the per-session + * SessionToolRunner's stream/list/send. Mirrors what + * `client.beta.messages.toolRunner` accepts: custom `headers` (e.g. a proxy's + * auth/routing headers) reach all of them. The worker owns the abort signals, + * so a `signal` here is ignored — use {@link EnvironmentWorkerOptions.signal}. + */ + requestOptions?: BetaToolRunnerRequestOptions; +} + +/** + * Options for {@link EnvironmentWorker.handleItem}. Every field falls back to the + * matching `ANTHROPIC_*` environment variable — the ones the + * `ant worker poll --on-work` command sets for the process it spawns — when not + * passed explicitly. + */ +export interface HandleItemOptions { + /** Work item id. Falls back to `ANTHROPIC_WORK_ID`. */ + workId?: string; + /** Self-hosted environment id. Falls back to `ANTHROPIC_ENVIRONMENT_ID`. */ + environmentId?: string; + /** Session id. Falls back to `ANTHROPIC_SESSION_ID`. */ + sessionId?: string; + /** + * The environment key used to authenticate every per-session call. Resolution + * order: this option, then the worker's own `environmentKey`, then + * `ANTHROPIC_ENVIRONMENT_KEY`. + */ + environmentKey?: string; + /** External abort signal; aborting it ends the run. Defaults to the constructor's signal. */ + signal?: AbortSignal; +} + +/** The fields of {@link BetaSelfHostedWork} the per-item flow reads. */ +type ClaimedWork = Pick; + +/** + * The self-hosted environment runner, composed from the control-plane + * {@link WorkPoller} and the per-session {@link SessionToolRunner}. + * + * For each claimed `session` work item it: builds the per-session + * {@link AgentToolContext}, downloads the session agent's skills + * (`setupSkills`), then runs a {@link SessionToolRunner} for the session + * *while* heartbeating the work-item lease in parallel; on exit it force-stops + * the work item, cleans up the downloaded skills, and loops to the next one. The + * lease heartbeat reports `state === "stopping"` / a lost lease back into the run + * by aborting the session runner. + * + * Use {@link EnvironmentWorker.handleItem} if you already hold a claimed work + * item (e.g. a `worker poll --on-work` script handed one to a fresh process) and + * just want the per-item flow without the poll loop — with no arguments it reads + * the `ANTHROPIC_*` env vars that command sets. + * + * Construct it via `client.beta.environments.work.worker({ ... })` (or + * `new EnvironmentWorker({ client, ... })` directly). + * + * @example + * ```ts + * // Long-running daemon: poll for work, serve each session, loop. + * await client.beta.environments.work + * .worker({ environmentId, environmentKey, workdir: '/workspace' }) + * .run(AbortSignal.timeout(60 * 60_000)); + * + * // Already-claimed item (e.g. inside `ant worker poll --on-work ...`): + * await client.beta.environments.work.worker({ workdir: '/workspace' }).handleItem(); + * ``` + */ +export class EnvironmentWorker { + readonly client: Anthropic; + readonly environmentId: string | undefined; + readonly environmentKey: string | undefined; + readonly tools: EnvironmentWorkerTools | undefined; + readonly workdir: string; + readonly unrestrictedPaths: boolean | undefined; + readonly maxIdleMs: number | undefined; + readonly workerId: string | undefined; + readonly requestOptions: BetaToolRunnerRequestOptions | undefined; + readonly #signal: AbortSignal | undefined; + + constructor(opts: EnvironmentWorkerOptions) { + this.client = opts.client; + this.environmentId = opts.environmentId; + this.environmentKey = opts.environmentKey; + this.tools = opts.tools; + this.workdir = opts.workdir ?? process.cwd(); + this.unrestrictedPaths = opts.unrestrictedPaths; + this.maxIdleMs = opts.maxIdleMs; + this.workerId = opts.workerId; + this.requestOptions = opts.requestOptions; + this.#signal = opts.signal; + } + + /** + * Poll the environment and service each claimed session until the supplied + * signal (or the one passed to the constructor) aborts. Throws if + * `environmentId` / `environmentKey` were not provided to the constructor. + */ + async run(signal?: AbortSignal): Promise { + const { environmentId, environmentKey } = this; + if (environmentId === undefined || environmentKey === undefined) { + throw new AnthropicError( + 'EnvironmentWorker.run: environmentId and environmentKey are required to poll for work', + ); + } + const externalSignal = signal ?? this.#signal; + const poller = new WorkPoller({ + client: this.client, + environmentId, + environmentKey, + ...(this.workerId !== undefined ? { workerId: this.workerId } : {}), + ...(externalSignal ? { signal: externalSignal } : {}), + ...(this.requestOptions !== undefined ? { requestOptions: this.requestOptions } : {}), + // The per-item handler force-stops every work item on exit; let it be the + // single owner of `work.stop` rather than double-posting from the poller. + autoStop: false, + }); + + for await (const work of poller) { + await this.#handleItem(work, environmentKey, poller.signal); + } + } + + /** + * Service a single, already-claimed work item without the poll loop: build the + * per-session {@link AgentToolContext} (workdir from this worker's options), + * download the session agent's skills (`setupSkills`), run a + * {@link SessionToolRunner} for the session while heartbeating the work-item + * lease in parallel, and force-stop the work item on exit (whether the runner + * finishes normally, throws, or the heartbeat loop signals shutdown). + * + * Use this when something else does the claiming — e.g. a `worker poll + * --on-work` script that hands an already-claimed item to a fresh process. The + * work id / environment id / session id each fall back to `ANTHROPIC_WORK_ID` / + * `ANTHROPIC_ENVIRONMENT_ID` / `ANTHROPIC_SESSION_ID` (the env vars that + * command sets) when not passed; the environment key resolves from this + * option, then the worker's own `environmentKey`, then + * `ANTHROPIC_ENVIRONMENT_KEY`. With no arguments inside that command it just + * works. Throws a clear error naming the first of the four required values + * still missing after resolution. + */ + async handleItem(opts?: HandleItemOptions): Promise { + const workId = opts?.workId ?? readEnv('ANTHROPIC_WORK_ID'); + const environmentId = opts?.environmentId ?? readEnv('ANTHROPIC_ENVIRONMENT_ID'); + const sessionId = opts?.sessionId ?? readEnv('ANTHROPIC_SESSION_ID'); + const environmentKey = + opts?.environmentKey ?? this.environmentKey ?? readEnv('ANTHROPIC_ENVIRONMENT_KEY'); + + if (!workId) { + throw new AnthropicError('handleItem: workId is required — pass it or set ANTHROPIC_WORK_ID'); + } + if (!environmentId) { + throw new AnthropicError( + 'handleItem: environmentId is required — pass it or set ANTHROPIC_ENVIRONMENT_ID', + ); + } + if (!sessionId) { + throw new AnthropicError('handleItem: sessionId is required — pass it or set ANTHROPIC_SESSION_ID'); + } + if (!environmentKey) { + throw new AnthropicError( + 'handleItem: environmentKey is required — pass it, construct the worker with it, or set ANTHROPIC_ENVIRONMENT_KEY', + ); + } + + const work: ClaimedWork = { + id: workId, + environment_id: environmentId, + data: { type: 'session', id: sessionId }, + }; + await this.#handleItem(work, environmentKey, opts?.signal ?? this.#signal); + } + + /** + * The per-item body shared by {@link EnvironmentWorker.run}'s poll loop and + * {@link EnvironmentWorker.handleItem}: run a {@link SessionToolRunner} for the + * work item's session while heartbeating its lease, force-stopping on exit. + * Non-session work items are ignored. + */ + async #handleItem( + work: ClaimedWork, + environmentKey: string, + externalSignal: AbortSignal | undefined, + ): Promise { + const log = loggerFor(this.client); + // Every per-session call — the SessionToolRunner event stream/list/send, the + // lease heartbeat, and the work force-stop — authenticates with the + // environment key. Scope a client to it once and thread that through. + // `copyClientForHelper` also clears the parent's `apiKey`, so the sub-client + // emits *only* the bearer credential on the wire (a plain + // `withOptions({authToken})` would leave `X-Api-Key` set as well). + const sessionClient = copyClientForHelper(this.client, { + authToken: environmentKey, + helper: 'environments-worker', + }); + + // The poller runs with `autoStop: false`, so the per-item handler is the + // single owner of `work.stop` for every claimed item. + const sessionId = work.data.id; + + const ctx: AgentToolContext = { + workdir: this.workdir, + client: this.client, + sessionId, + ...(this.unrestrictedPaths !== undefined ? { unrestrictedPaths: this.unrestrictedPaths } : {}), + }; + // Lazily load the Node-only toolset module — see the import note at the top. + const agentToolset = await import('../../tools/agent-toolset/node'); + let cleanupSkills: () => Promise = async () => {}; + try { + cleanupSkills = await agentToolset.setupSkills(ctx); + } catch (e) { + log.warn('skill setup failed', { session_id: sessionId, work_id: work.id, error: String(e) }); + } + const tools = + typeof this.tools === 'function' ? + this.tools(ctx) + : this.tools ?? agentToolset.betaAgentToolset20260401(ctx); + + // A per-session controller: aborts when the supplied signal aborts, when the + // session runner finishes, or when the lease heartbeat says to stop. + const ctrl = new AbortController(); + const detachExternal = linkAbort(externalSignal, ctrl); + + const heartbeatPromise = heartbeatLoop(sessionClient, work, ctrl, log, this.requestOptions).catch((e) => { + if (!ctrl.signal.aborted) log.error('heartbeat loop failed', { work_id: work.id, error: String(e) }); + ctrl.abort(); + }); + + try { + const runner = new SessionToolRunner(sessionId, { + client: sessionClient, + tools, + ...(this.maxIdleMs !== undefined ? { maxIdleMs: this.maxIdleMs } : {}), + ...(this.requestOptions !== undefined ? { requestOptions: this.requestOptions } : {}), + signal: ctrl.signal, + }); + for await (const _ of runner) { + // Drive the runner to completion; per-call observability is not part + // of this composition's surface — use `SessionToolRunner` directly + // (via `client.beta.sessions.events.toolRunner`) if you want it. + } + } finally { + ctrl.abort(); + detachExternal(); + await heartbeatPromise; + await cleanupSkills().catch((e) => { + log.warn('skill cleanup failed', { session_id: sessionId, work_id: work.id, error: String(e) }); + }); + await forceStop(sessionClient, work, log, this.requestOptions); + } + } +} + +/** Force-stop a claimed work item, swallowing the 409 that means it's already stopped. */ +async function forceStop( + client: Anthropic, + work: Pick, + log: Logger, + requestOptions?: BetaToolRunnerRequestOptions, +): Promise { + try { + await client.beta.environments.work.stop( + work.id, + { environment_id: work.environment_id, force: true }, + // Caller's headers pass through; the helper-tag header is on the scoped + // sub-client's default_headers via copyClientForHelper, so no per-call + // re-stamping needed. + { ...requestOptions, headers: buildHeaders([requestOptions?.headers]) }, + ); + } catch (e) { + if (!isStatus(e, 409)) { + log.error('force-stop on exit failed', { work_id: work.id, error: String(e) }); + } + } +} + +/** + * Keep the work-item lease alive while a session is being served. Aborts `ctrl` + * when the control plane reports the work is `stopping`/`stopped`, when the + * lease is no longer extended, or on a permanent heartbeat failure. + */ +async function heartbeatLoop( + client: Anthropic, + work: Pick, + ctrl: AbortController, + logger: Logger, + requestOptions?: BetaToolRunnerRequestOptions, +): Promise { + let intervalMs = HEARTBEAT_DEFAULT_MS; + let last = NO_HEARTBEAT_SENTINEL; + const beat = async (): Promise => { + try { + const resp = await client.beta.environments.work.heartbeat( + work.id, + { environment_id: work.environment_id, expected_last_heartbeat: last }, + { ...requestOptions, headers: buildHeaders([requestOptions?.headers]), signal: ctrl.signal }, + ); + last = resp.last_heartbeat; + if (resp.ttl_seconds > 0) { + intervalMs = Math.max(1_000, Math.min((resp.ttl_seconds * 1000) / 2, HEARTBEAT_DEFAULT_MS)); + } + if (resp.state === 'stopping' || resp.state === 'stopped') { + logger.info('heartbeat signals shutdown', { work_id: work.id, state: resp.state }); + ctrl.abort(); + } + if (!resp.lease_extended) { + logger.warn('lease not extended, shutting down', { work_id: work.id }); + ctrl.abort(); + } + } catch (e) { + // An abort throws to unwind the caller (the `heartbeatLoop(...).catch` + // in `#handleItem`) rather than returning early. + ctrl.signal.throwIfAborted(); + if (isFatal4xx(e)) { + logger.error('permanent heartbeat failure', { work_id: work.id, error: String(e) }); + ctrl.abort(); + throw e; + } + logger.warn('transient heartbeat failure', { work_id: work.id, error: String(e) }); + } + }; + + await beat(); + while (!ctrl.signal.aborted) { + await sleep(intervalMs, ctrl.signal); + ctrl.signal.throwIfAborted(); + await beat(); + } +} diff --git a/src/lib/helper-client.ts b/src/lib/helper-client.ts new file mode 100644 index 00000000..4fa69cfc --- /dev/null +++ b/src/lib/helper-client.ts @@ -0,0 +1,89 @@ +import { AnthropicError } from '../core/error'; +import type { Anthropic } from '../client'; +import { buildHeaders, type HeadersLike, type NullableHeaders } from '../internal/headers'; + +/** + * Shared util for building a runner-helper-bound sub-client. + * + * The work poller, the environment worker, and the session tool runner each + * need to issue requests authenticated by a per-helper credential (a + * self-hosted environment key, today) rather than the parent client's own + * `X-Api-Key`, *and* tagged with their own `x-stainless-helper` telemetry + * value. Each wants to inherit the parent's full configuration — `timeout`, + * `maxRetries`, `fetch`, `fetchOptions`, custom `defaultHeaders`, + * `defaultQuery` — and override only the auth + telemetry bits. + * + * {@link copyClientForHelper} is the one shared construction. + */ + +/** + * The closed set of `x-stainless-helper` telemetry tags the runner helpers + * stamp on outgoing requests. Constrained as a string union so a typo at any + * call site is a type error rather than silently mistagged telemetry. + */ +export type HelperTag = 'environments-work-poller' | 'environments-worker' | 'session-tool-runner'; + +interface ClientInternalAccess { + _options: { defaultHeaders?: HeadersLike }; + _authState?: { extraHeaders?: Record }; +} + +/** + * Return a `withOptions()` clone of `client` set up for use *by* one of the + * runner helpers: authenticated with `authToken` as Bearer credentials, with + * the parent's `X-Api-Key` cleared, and tagged with the helper's + * `x-stainless-helper` value on every outgoing request. + * + * The returned sub-client inherits the parent's full configuration + * (`baseURL`, `timeout`, `maxRetries`, `fetch`, `fetchOptions`, custom + * `defaultHeaders`, `defaultQuery`). Overrides applied: + * + * - `authToken: authToken` — the new credential. + * - `apiKey: null` — the parent's `X-Api-Key` is cleared. `withOptions` + * inherits the parent's `apiKey` by default; without this, both + * `X-Api-Key` *and* `Authorization: Bearer …` would land on the wire. + * `client.ts` only triggers the env-var fallback when `apiKey === undefined`, + * so explicit `null` is honored. + * - `credentials: undefined` — opts the clone out of any inherited + * credentials/config/profile so the explicit bearer is the unambiguous auth. + * - `baseURL: client.baseURL` — pins the parent's resolved host (auth override otherwise resets it). + * - `defaultHeaders` is rebuilt as `parent._authState.extraHeaders ⊕ parent.defaultHeaders ⊕ + * {'x-stainless-helper': helper}`. `withOptions` *replaces* (does not + * merge) `defaultHeaders`, so we merge here so any custom headers the + * caller set on the parent client survive on the sub-client. + */ +export function copyClientForHelper( + client: T, + { authToken, helper }: { authToken: string; helper: HelperTag }, +): T { + if (!authToken) { + throw new AnthropicError( + `copyClientForHelper: expected a non-empty authToken but received ${JSON.stringify(authToken)}`, + ); + } + const internal = client as unknown as ClientInternalAccess; + const parentDefaults = internal._options.defaultHeaders; + // Carry the parent's credential/profile headers; strip the auth ones (we re-auth below). + const parentAuthExtraHeaders = internal._authState?.extraHeaders; + const inheritedAuthExtraHeaders: Record | undefined = + parentAuthExtraHeaders ? + Object.fromEntries( + Object.entries(parentAuthExtraHeaders).filter(([name]) => { + const lower = name.toLowerCase(); + return lower !== 'authorization' && lower !== 'x-api-key'; + }), + ) + : undefined; + const defaultHeaders: NullableHeaders = buildHeaders([ + inheritedAuthExtraHeaders, + parentDefaults, + { 'x-stainless-helper': helper }, + ]); + return client.withOptions({ + apiKey: null, + authToken, + baseURL: client.baseURL, + credentials: undefined, + defaultHeaders, + }) as T; +} diff --git a/src/lib/tools/BetaRunnableTool.ts b/src/lib/tools/BetaRunnableTool.ts index dd8f3e26..54a481d9 100644 --- a/src/lib/tools/BetaRunnableTool.ts +++ b/src/lib/tools/BetaRunnableTool.ts @@ -11,8 +11,14 @@ import { BetaToolTextEditor20250124, BetaToolTextEditor20250429, BetaToolTextEditor20250728, + BetaToolUnion, BetaToolUseBlock, } from '../../resources/beta'; +import type { + BetaManagedAgentsAgentCustomToolUseEvent, + BetaManagedAgentsAgentToolUseEvent, +} from '../../resources/beta/sessions/events'; +import { ToolError } from './ToolError'; export type Promisable = T | Promise; @@ -33,8 +39,32 @@ export type BetaClientRunnableToolType = | BetaToolTextEditor20250429 | BetaToolTextEditor20250728; +/** + * The tool-use that triggered a {@link BetaRunnableTool.run}: + * + * - from `client.beta.messages.toolRunner`, a Messages `tool_use` content block; + * - from `client.beta.sessions.events.toolRunner`, the `agent.tool_use` / + * `agent.custom_tool_use` session event. + * + * The shapes overlap on the common fields (`id`, `name`, `input`), so code that + * only reads those works without narrowing; narrow on the shape (e.g. `'type' in + * x`) when you need surface-specific properties. + */ +export type BetaToolUse = + | BetaToolUseBlock + | BetaManagedAgentsAgentToolUseEvent + | BetaManagedAgentsAgentCustomToolUseEvent; + export type BetaToolRunContext = { - toolUseBlock: BetaToolUseBlock; + /** The tool-use that triggered this run. See {@link BetaToolUse}. */ + toolUse: BetaToolUse; + /** + * @deprecated Renamed to `toolUse`. Also note that for + * `client.beta.sessions.events.toolRunner` this is the `agent.tool_use` / + * `agent.custom_tool_use` *event*, not a Messages content block, despite the + * name — which is why it was renamed. + */ + toolUseBlock: BetaToolUse; signal?: AbortSignal | null | undefined; }; @@ -46,4 +76,55 @@ export type BetaRunnableTool = BetaClientRunnableToolType & { context?: BetaToolRunContext, ) => Promisable>; parse: (content: unknown) => Input; + /** + * Optional cleanup hook for tools that hold process-level resources (e.g. a + * persistent shell). `SessionToolRunner` (`client.beta.sessions.events.toolRunner`) + * calls it once when iteration ends. + */ + close?: () => Promisable; }; + +/** + * Resolve the registry key for a tool — the name the model addresses it by. + * MCP toolsets are keyed on `mcp_server_name`; every other tool on `name`. + * Shared so the tool-name lookup is identical across `toolRunner()` surfaces. + */ +export function toolName(tool: BetaToolUnion | BetaRunnableTool): string { + return 'name' in tool ? tool.name : tool.mcp_server_name; +} + +/** + * Format a thrown value into tool-result content: a {@link ToolError} carries + * its own structured content, anything else becomes an `Error: ` + * string. Shared so every `toolRunner()` surface reports tool failures the + * same way to the model. + */ +export function toolErrorContent(e: unknown): string | Array { + return e instanceof ToolError ? e.content : `Error: ${e instanceof Error ? e.message : String(e)}`; +} + +/** Outcome of {@link runRunnableTool}: the content to post back and whether it is an error. */ +export interface RunnableToolOutcome { + content: string | Array; + isError: boolean; +} + +/** + * Run a {@link BetaRunnableTool} end-to-end: parse the raw input, invoke `run`, + * and format any thrown value via {@link toolErrorContent}. Shared so the + * parse → run → catch → format pipeline is identical across `toolRunner()` + * surfaces. + */ +export async function runRunnableTool( + tool: BetaRunnableTool, + rawInput: unknown, + context: BetaToolRunContext, +): Promise { + try { + const input = tool.parse ? tool.parse(rawInput) : rawInput; + const content = await tool.run(input, context); + return { content, isError: false }; + } catch (e) { + return { content: toolErrorContent(e), isError: true }; + } +} diff --git a/src/lib/tools/BetaToolRunner.ts b/src/lib/tools/BetaToolRunner.ts index 3ca54ac1..0b45863e 100644 --- a/src/lib/tools/BetaToolRunner.ts +++ b/src/lib/tools/BetaToolRunner.ts @@ -6,26 +6,10 @@ import { BetaMessage, BetaMessageParam, BetaToolUnion, MessageCreateParams } fro import { BetaMessageStream } from '../BetaMessageStream'; import { RequestOptions } from '../../internal/request-options'; import { buildHeaders } from '../../internal/headers'; +import { promiseWithResolvers } from '../../internal/utils/promise'; import { CompactionControl, DEFAULT_SUMMARY_PROMPT, DEFAULT_TOKEN_THRESHOLD } from './CompactionControl'; import { collectStainlessHelpers } from '../stainless-helper-header'; -/** - * Just Promise.withResolvers(), which is not available in all environments. - */ -function promiseWithResolvers(): { - promise: Promise; - resolve: (value: T) => void; - reject: (reason?: any) => void; -} { - let resolve: (value: T) => void; - let reject: (reason?: any) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve: resolve!, reject: reject! }; -} - /** * A ToolRunner handles the automatic conversation loop between the assistant and tools. * @@ -484,6 +468,7 @@ async function generateToolResponse( } const result = await tool.run(input, { + toolUse: toolUse, toolUseBlock: toolUse, signal: requestOptions?.signal, }); diff --git a/src/lib/tools/SessionToolRunner.ts b/src/lib/tools/SessionToolRunner.ts new file mode 100644 index 00000000..b233c748 --- /dev/null +++ b/src/lib/tools/SessionToolRunner.ts @@ -0,0 +1,591 @@ +import { AnthropicError } from '../../core/error'; +import type { Anthropic } from '../../client'; +import type { + BetaManagedAgentsAgentCustomToolUseEvent, + BetaManagedAgentsAgentToolUseEvent, + BetaManagedAgentsSessionEvent, + BetaManagedAgentsStreamSessionEvents, + BetaManagedAgentsUserCustomToolResultEventParams, + BetaManagedAgentsUserToolResultEventParams, +} from '../../resources/beta/sessions/events'; +import type { BetaToolResultContentBlockParam } from '../../resources/beta'; +import { loggerFor, type Logger } from '../../internal/utils/log'; +import { sleep } from '../../internal/utils/sleep'; +import { isFatal4xx } from '../../internal/utils/backoff'; +import { linkAbort } from '../../internal/utils/abort'; +import { AsyncQueue } from '../../internal/utils/async-queue'; +import { buildHeaders } from '../../internal/headers'; +import type { RequestOptions } from '../../internal/request-options'; +import { runRunnableTool, toolName, type BetaRunnableTool } from './BetaRunnableTool'; +import type { BetaToolRunnerRequestOptions } from './BetaToolRunner'; + +/** Beta header for the managed-agents API. */ +export const MANAGED_AGENTS_BETA = 'managed-agents-2026-04-01'; + +/** `x-stainless-helper` value identifying this helper in SDK telemetry. */ +const HELPER_NAME = 'SessionToolRunner'; + +const STREAM_BACKOFF_START_MS = 500; +const STREAM_BACKOFF_CAP_MS = 10_000; +const TOOL_TIMEOUT_MS = 120_000; +const DRAIN_TIMEOUT_MS = 30_000; +const SEND_RETRIES = 3; + +/** Block type accepted in a `user.tool_result` event's content — codegen'd, stays in sync with the API. */ +type SessionContentBlock = NonNullable[number]; + +/** + * A tool-call event the runner dispatches against the local registry: either a + * builtin `agent.tool_use` (answered with `user.tool_result`) or a custom + * `agent.custom_tool_use` (answered with `user.custom_tool_result`). Server-side + * `agent.mcp_tool_use` calls are intentionally excluded — the runner does not + * handle them. + */ +type DispatchedToolUseEvent = BetaManagedAgentsAgentToolUseEvent | BetaManagedAgentsAgentCustomToolUseEvent; + +/** + * The result-event params paired with a {@link DispatchedToolUseEvent}: a + * `user.tool_result` answers an `agent.tool_use`, a `user.custom_tool_result` + * answers an `agent.custom_tool_use`. The two pairs must be matched exactly. + */ +type DispatchedToolResultParams = + | BetaManagedAgentsUserToolResultEventParams + | BetaManagedAgentsUserCustomToolResultEventParams; + +export interface SessionToolRunnerOptions { + client: Anthropic; + /** + * Tools to expose to the session, in the same {@link BetaRunnableTool} shape + * `client.beta.messages.toolRunner` accepts. Use + * `betaAgentToolset20260401({ workdir })` from + * `@anthropic-ai/sdk/tools/agent-toolset/node` for the standard + * `agent_toolset_20260401` set; filter or extend the array to customise. + */ + tools: Array; + /** + * Once the session goes idle with `stop_reason.type === "end_turn"`, the + * runner keeps running for this many milliseconds before stopping; any new + * event resets the countdown and it re-arms on the next `end_turn` idle. + * Defaults to {@link DEFAULT_MAX_IDLE_MS} (60s). `0` (or negative) disables + * it — the runner then only stops on session termination or the consumer + * breaking out / aborting. + */ + maxIdleMs?: number; + /** External abort signal. Aborting it ends the iteration. */ + signal?: AbortSignal; + /** + * Extra per-request options merged into every call this runner issues + * (event stream / list / send). Mirrors what `client.beta.messages.toolRunner` + * accepts: custom `headers` (e.g. a proxy's auth/routing headers) reach the + * poll/heartbeat/stop/stream/list/send calls. The runner always owns the abort + * signal, so a `signal` here is ignored — pass {@link SessionToolRunnerOptions.signal} + * to abort externally. + */ + requestOptions?: BetaToolRunnerRequestOptions; +} + +/** Default {@link SessionToolRunnerOptions.maxIdleMs}: 60 seconds. */ +export const DEFAULT_MAX_IDLE_MS = 60_000; + +/** + * Outcome of a single tool execution dispatched by {@link SessionToolRunner}. + * + * Yielded after the tool ran (or failed) and after the result was posted back + * to the session as a `user.tool_result` event. Consumers can read either the + * embedded {@link DispatchedToolCall.event} / {@link DispatchedToolCall.result} + * blocks or the flat top-level convenience fields. + */ +export interface DispatchedToolCall { + /** + * The `agent.tool_use` or `agent.custom_tool_use` event that triggered this + * dispatch. Read `event.input` for the raw tool input and `event.name` for the + * tool name; `event.type` distinguishes a builtin tool call from a custom one. + */ + readonly event: DispatchedToolUseEvent; + /** + * The result event posted (or attempted) back to the session for this call: a + * `user.tool_result` for an `agent.tool_use`, a `user.custom_tool_result` for + * an `agent.custom_tool_use`. Read `result.content` for the tool's output + * blocks and `result.is_error` for the error flag. + */ + readonly result: DispatchedToolResultParams; + /** + * Flat convenience for `event.id` — the id of the tool-use event this result + * answers (echoed back as `tool_use_id` / `custom_tool_use_id` on the result). + */ + readonly toolUseId: string; + /** Flat convenience for `event.name` — the dispatched tool's name. */ + readonly name: string; + /** + * Flat convenience for `result.is_error` — `true` when the tool threw or was + * not found, `false` on success. + */ + readonly isError: boolean; + /** + * Whether the `user.tool_result` was successfully posted back to the session. + * `false` when the post itself failed — typically a permanent 4xx or + * send-retry exhaustion. + */ + readonly posted: boolean; +} + +/** Returns true if `ev` is a `session.status_idle` with `stop_reason` `end_turn`. */ +function isEndTurnIdle(ev: { type?: string; stop_reason?: { type?: string } }): boolean { + return ev.type === 'session.status_idle' && ev.stop_reason?.type === 'end_turn'; +} + +/** + * The sessions-side counterpart to `client.beta.messages.toolRunner`: an + * async-iterable that attaches to a managed-agents session, executes every + * incoming `agent.tool_use` and `agent.custom_tool_use` event against a local + * tool registry, posts the matching result back (`user.tool_result` for the + * former, `user.custom_tool_result` for the latter), and yields one + * {@link DispatchedToolCall} per completed call. Server-side `agent.mcp_tool_use` + * calls are not dispatched. Internally drives event-stream reconnect and result + * posting. + * + * Iteration ends when the session terminates (`session.status_terminated` / + * `session.deleted`), when the consumer `break`s out of the loop or aborts the + * supplied signal, or — once the session has gone idle with + * `stop_reason.type === "end_turn"` — when `maxIdleMs` elapses with no new + * event (any new event resets that countdown; it re-arms on the next `end_turn` + * idle; `maxIdleMs <= 0` disables it). The `finally` branch drains any in-flight + * tool calls and runs each tool's `close()` cleanup hook. It does *not* touch + * the work-item lease — wrap it in an `EnvironmentWorker` if you need + * heartbeating / force-stop. + * + * @example + * ```ts + * import { betaAgentToolset20260401 } from '@anthropic-ai/sdk/tools/agent-toolset/node'; + * + * for await (const call of client.beta.sessions.events.toolRunner(work.data.id, { + * tools: [...betaAgentToolset20260401({ workdir }), myTool], + * })) { + * console.log(`${call.name} -> ${call.isError ? 'error' : 'ok'}`); + * } + * ``` + */ +export class SessionToolRunner implements AsyncIterable { + readonly client: Anthropic; + readonly sessionId: string; + readonly tools: ReadonlyArray; + readonly maxIdleMs: number; + + #consumed = false; + readonly #controller: AbortController; + readonly #detachExternal: () => void; + readonly #requestOpts: BetaToolRunnerRequestOptions | undefined; + readonly #toolByName: Map; + readonly #logger: Logger; + readonly #seen = new Set(); + readonly #answered = new Set(); + readonly #results = new AsyncQueue(); + #inFlightCount = 0; + #onIdle: (() => void) | null = null; + // When the session is idle past an `end_turn`, the pending stop timer; cleared + // by any new event. Event-driven — there is no polling watchdog. + #idleTimer: ReturnType | undefined; + + constructor(sessionId: string, opts: SessionToolRunnerOptions) { + this.client = opts.client; + this.sessionId = sessionId; + this.tools = opts.tools; + this.maxIdleMs = opts.maxIdleMs ?? DEFAULT_MAX_IDLE_MS; + this.#logger = loggerFor(opts.client); + this.#toolByName = new Map(opts.tools.map((t) => [toolName(t), t])); + this.#controller = new AbortController(); + this.#detachExternal = linkAbort(opts.signal, this.#controller); + this.#requestOpts = opts.requestOptions; + } + + /** Read-only view of this runner's abort signal. */ + get signal(): AbortSignal { + return this.#controller.signal; + } + + /** Abort the runner. Background tasks will wind down and `for await` will exit cleanly. */ + abort(): void { + this.#controller.abort(); + } + + async *[Symbol.asyncIterator](): AsyncIterator { + if (this.#consumed) { + throw new AnthropicError('Cannot iterate over a consumed SessionToolRunner'); + } + this.#consumed = true; + this.#logger.info('session tool runner starting', { + component: 'session-tool-runner', + session_id: this.sessionId, + }); + + // The one background promise: drives the event stream and dispatches tools. + // Its `.catch` aborts the controller so the main loop unwinds. + const streamPromise = this.#streamLoop().catch((e) => { + if (!this.#controller.signal.aborted) { + this.#logger.error('stream loop failed', { error: String(e) }); + } + this.#controller.abort(); + }); + + try { + // Phase 1: yield results as they arrive. `next(signal)` resolves + // `done: true` when the controller aborts — cancellation is handled in + // the queue read, no outer `Promise.race` needed. + while (true) { + const next = await this.#results.next(this.#controller.signal); + if (next.done) break; + yield next.value; + } + + // Phase 2: let the stream loop settle (and push any final results), then + // drain whatever is still queued before closing. + await streamPromise; + let pending: DispatchedToolCall | undefined; + while ((pending = this.#results.tryShift()) !== undefined) { + yield pending; + } + } finally { + this.#controller.abort(); + this.#disarmIdleTimer(); + // Re-await defensively in case the consumer broke out of phase 1 before + // phase 2 ran — a no-op if it already settled. + await streamPromise; + try { + await this.#drain(); + } catch (e) { + this.#logger.warn('drain failed', { error: String(e) }); + } + this.#results.close(); + for (const t of this.tools) { + try { + // `close` is typed `() => Promisable`, so a single `await` + // covers both the sync and async return. + await t.close?.(); + } catch (e) { + this.#logger.warn('tool.close failed', { tool: toolName(t), error: String(e) }); + } + } + // Detach from the external signal so the consumer can drop their signal + // reference without leaking this iterator instance. + this.#detachExternal(); + } + } + + // ===== request options ===== + + /** + * Request options for every helper-issued call: the caller's `requestOptions` + * (custom proxy headers etc.) with the helper telemetry header stamped on and + * the runner's own abort signal forced last so it always owns cancellation. + */ + #requestOptions(): RequestOptions { + return { + ...this.#requestOpts, + headers: buildHeaders([{ 'x-stainless-helper': HELPER_NAME }, this.#requestOpts?.headers]), + signal: this.#controller.signal, + }; + } + + // ===== event stream ===== + + async #streamLoop(): Promise { + const ctrl = this.#controller; + let backoff = STREAM_BACKOFF_START_MS; + while (!ctrl.signal.aborted) { + try { + // Establish the event stream *before* reconciling history, so an event + // emitted in the gap between listing and attaching is buffered on the + // stream rather than lost. `seen`/`answered` dedup any event that shows + // up both in the reconcile pass and on the live stream. + const stream = await this.client.beta.sessions.events.stream( + this.sessionId, + {}, + this.#requestOptions(), + ); + await this.#reconcile(); + for await (const ev of stream) { + backoff = STREAM_BACKOFF_START_MS; + if (await this.#handleStreamEvent(ev)) return; + } + } catch (e) { + // An abort throws to unwind the caller (the iterator's `streamPromise` + // `.catch`) rather than returning early and letting it carry on. + ctrl.signal.throwIfAborted(); + if (isFatal4xx(e)) { + this.#logger.error('permanent stream failure, shutting down', { error: String(e) }); + ctrl.abort(); + throw e; + } + this.#logger.warn('stream disconnected, reconnecting', { + error: String(e), + backoff_ms: backoff, + }); + } + ctrl.signal.throwIfAborted(); + await sleep(backoff, ctrl.signal); + backoff = Math.min(backoff * 2, STREAM_BACKOFF_CAP_MS); + } + } + + /** + * Read full history before dispatching so a `tool_use` whose result appears + * later in the same history is not re-executed. Runs after the live stream is + * already attached (see {@link SessionToolRunner.#streamLoop}). + */ + async #reconcile(): Promise { + const ctrl = this.#controller; + const pending: DispatchedToolUseEvent[] = []; + let lastWasEndTurn = false; + try { + for await (const ev of this.client.beta.sessions.events.list( + this.sessionId, + { limit: 1000 }, + this.#requestOptions(), + )) { + this.#ingestHistory(ev, pending); + lastWasEndTurn = isEndTurnIdle(ev); + } + } catch (e) { + // An abort throws to unwind the caller; a real list failure is + // non-fatal — undo the speculative `seen` entries and let `#streamLoop` + // carry on with the live stream. + ctrl.signal.throwIfAborted(); + this.#logger.warn('reconcile list failed', { error: String(e) }); + // If list itself failed, undo the speculative `seen` entries so the next + // reconcile pass (or the live stream) can pick them up. Leave the idle + // timer untouched — the history we read may be incomplete. + for (const ev of pending) this.#seen.delete(ev.id); + return; + } + const unanswered = pending.filter((ev) => !this.#answered.has(ev.id)); + // If the most recent event in history is an `end_turn` idle and there's no + // outstanding tool work, the session is done — arm the idle timer so the + // runner stops even if that `end_turn` arrived during a disconnect. + if (lastWasEndTurn && unanswered.length === 0) this.#armIdleTimer(); + else this.#disarmIdleTimer(); + for (const ev of unanswered) await this.#execute(ev); + } + + #ingestHistory(ev: BetaManagedAgentsSessionEvent, pending: DispatchedToolUseEvent[]): void { + if (ev.type === 'agent.tool_use' || ev.type === 'agent.custom_tool_use') { + // Mark the event seen so a replay on the live stream is not dispatched + // twice, but decide whether it still needs executing from `answered`, not + // `seen`: a call whose result post failed is seen-but-unanswered, and must + // be retried on the next reconcile pass rather than silently dropped. + this.#seen.add(ev.id); + if (!this.#answered.has(ev.id)) pending.push(ev); + } else if (ev.type === 'user.tool_result') { + this.#answered.add(ev.tool_use_id); + } else if (ev.type === 'user.custom_tool_result') { + this.#answered.add(ev.custom_tool_use_id); + } + } + + /** Returns true when the runner should exit. */ + async #handleStreamEvent(ev: BetaManagedAgentsStreamSessionEvents): Promise { + // Arm/disarm the idle timer: an `end_turn` idle starts the grace countdown; + // any other event cancels it. + if (isEndTurnIdle(ev)) this.#armIdleTimer(); + else this.#disarmIdleTimer(); + switch (ev.type) { + case 'agent.tool_use': + case 'agent.custom_tool_use': + if (!this.#seen.has(ev.id)) { + this.#seen.add(ev.id); + await this.#execute(ev); + } + return false; + case 'user.tool_result': + this.#answered.add(ev.tool_use_id); + return false; + case 'user.custom_tool_result': + this.#answered.add(ev.custom_tool_use_id); + return false; + case 'session.status_terminated': + case 'session.deleted': + this.#logger.info('session terminated', { + component: 'session-tool-runner', + session_id: this.sessionId, + }); + this.#controller.abort(); + return true; + default: + return false; + } + } + + // ===== idle timer ===== + + /** (Re)start the grace countdown that stops the runner after `maxIdleMs` of idle. */ + #armIdleTimer(): void { + this.#disarmIdleTimer(); + if (this.maxIdleMs <= 0) return; + this.#idleTimer = setTimeout(() => { + this.#logger.info('session idle after end_turn; stopping', { + component: 'session-tool-runner', + session_id: this.sessionId, + max_idle_ms: this.maxIdleMs, + }); + this.#controller.abort(); + }, this.maxIdleMs); + } + + /** Cancel a pending idle countdown, if any. */ + #disarmIdleTimer(): void { + if (this.#idleTimer !== undefined) { + clearTimeout(this.#idleTimer); + this.#idleTimer = undefined; + } + } + + // ===== tool execution ===== + + async #execute(ev: DispatchedToolUseEvent): Promise { + if (this.#answered.has(ev.id)) return; + this.#logger.info('executing tool', { + component: 'session-tool-runner', + session_id: this.sessionId, + tool: ev.name, + tool_use_id: ev.id, + }); + this.#inFlightCount++; + try { + const tool = this.#toolByName.get(ev.name); + let content: string | Array; + let isError: boolean; + if (!tool) { + // Match `BetaToolRunner`'s wording — the string lands in the model's + // context, so the two `toolRunner()` surfaces should agree. + content = `Error: Tool '${ev.name}' not found`; + isError = true; + } else { + // Per-tool controller: aborts on the runner's own signal *or* the + // per-tool timeout, so an in-flight tool stops promptly when the runner + // is aborted instead of running until the timeout. + const toolCtrl = new AbortController(); + const detachTool = linkAbort(this.#controller.signal, toolCtrl); + const timer = setTimeout(() => toolCtrl.abort(), TOOL_TIMEOUT_MS); + try { + // Pass the source `agent.tool_use` / `agent.custom_tool_use` event + // straight through as the run context's `toolUse` — it is a union + // member of `BetaToolUse`, no Messages-block adapter needed. + const outcome = await runRunnableTool(tool, ev.input, { + toolUse: ev, + toolUseBlock: ev, + signal: toolCtrl.signal, + }); + content = outcome.content; + isError = outcome.isError; + } finally { + clearTimeout(timer); + detachTool(); + } + } + // Answer with the result event that matches the call kind: a + // `user.tool_result` for an `agent.tool_use`, a `user.custom_tool_result` + // for an `agent.custom_tool_use`. Posting the wrong one leaves the call + // unanswered and the session stuck. + const result = buildResultEvent(ev, isError, toSessionContent(content)); + const posted = await this.#sendResult(result, ev.id); + this.#results.push({ + event: ev, + result, + toolUseId: ev.id, + name: ev.name, + isError, + posted, + }); + } finally { + this.#inFlightCount--; + if (this.#inFlightCount === 0) this.#onIdle?.(); + } + } + + async #sendResult(result: DispatchedToolResultParams, toolUseId: string): Promise { + const ctrl = this.#controller; + let lastErr: unknown; + for (let i = 0; i < SEND_RETRIES; i++) { + // An abort throws to unwind the caller rather than returning a + // `posted: false` result the iterator would carry on past. + ctrl.signal.throwIfAborted(); + try { + await this.client.beta.sessions.events.send( + this.sessionId, + { events: [result] }, + this.#requestOptions(), + ); + this.#answered.add(toolUseId); + return true; + } catch (e) { + lastErr = e; + // Only short-circuit on a permanent 4xx; 408/409/429 deserve the + // remaining retries (aligned with the core client's retry policy). + if (isFatal4xx(e)) break; + // Back off only *between* attempts — never after the final one, since + // there is no further try left to wait for. + if (i < SEND_RETRIES - 1) await sleep((i + 1) * 1000, ctrl.signal); + } + } + this.#logger.error('failed to send tool result', { + tool_use_id: toolUseId, + error: String(lastErr), + }); + return false; + } + + /** Wait (bounded) for in-flight tool executions to finish during teardown. */ + async #drain(): Promise { + if (this.#inFlightCount === 0) return; + await Promise.race([new Promise((r) => (this.#onIdle = r)), sleep(DRAIN_TIMEOUT_MS)]); + this.#onIdle = null; + if (this.#inFlightCount > 0) { + this.#logger.warn('drain timeout exceeded'); + } + } +} + +/** + * Build the result event that answers `ev`: a `user.tool_result` for a builtin + * `agent.tool_use`, a `user.custom_tool_result` for a custom + * `agent.custom_tool_use`. The two `(use, result)` pairs are distinct API event + * types and must be matched exactly — a `user.tool_result` does not answer a + * custom tool call. + */ +function buildResultEvent( + ev: DispatchedToolUseEvent, + isError: boolean, + content: SessionContentBlock[], +): DispatchedToolResultParams { + if (ev.type === 'agent.custom_tool_use') { + return { type: 'user.custom_tool_result', custom_tool_use_id: ev.id, is_error: isError, content }; + } + return { type: 'user.tool_result', tool_use_id: ev.id, is_error: isError, content }; +} + +// The Messages-API tool-result block union is wider than the Sessions-API +// tool_result content union; pass through text/image/document and stringify +// anything else so a BetaRunnableTool authored for toolRunner still works here. +function toSessionContent(content: string | Array): SessionContentBlock[] { + if (typeof content === 'string') return [{ type: 'text', text: content || '(no output)' }]; + const out = content.map((b): SessionContentBlock => { + if (b.type === 'text') return { type: 'text', text: b.text || '(no output)' }; + if (b.type === 'image' || b.type === 'document') return b as SessionContentBlock; + if (b.type === 'search_result') { + // The Messages `search_result` block param maps field-for-field onto the + // Sessions `BetaManagedAgentsSearchResultBlock`; map it explicitly rather + // than letting it fall through to the JSON.stringify branch (which would + // bury a structured result inside a text block). `citations` is required + // on the Sessions side and optional on the Messages side — default the + // flag to `false` when the producer left it unset. + return { + type: 'search_result', + source: b.source, + title: b.title, + content: b.content.map((c) => ({ type: 'text', text: c.text })), + citations: { enabled: b.citations?.enabled ?? false }, + }; + } + return { type: 'text', text: JSON.stringify(b) }; + }); + return out.length > 0 ? out : [{ type: 'text', text: '(no output)' }]; +} diff --git a/src/resources/beta/agents/agents.ts b/src/resources/beta/agents/agents.ts index 3bb35a06..888ad2f4 100644 --- a/src/resources/beta/agents/agents.ts +++ b/src/resources/beta/agents/agents.ts @@ -292,6 +292,92 @@ export interface BetaManagedAgentsAgentToolset20260401 { type: 'agent_toolset_20260401'; } +/** + * Input payload for the `bash` tool of the `agent_toolset_20260401` toolset. All + * fields are optional; a normal invocation supplies `command`, while + * `restart=true` (with no `command`) reboots the runner-side bash session. + */ +export interface BetaManagedAgentsAgentToolset20260401BashInput { + /** + * Shell command to execute. Omit only when `restart` is true. + */ + command?: string; + + /** + * When true, restart the persistent bash session instead of running a command. + * Subsequent calls without `restart` will run against the fresh session. + */ + restart?: boolean; + + /** + * Per-call timeout in milliseconds. Defaults to the runner-wide tool timeout when + * omitted or zero. + */ + timeout_ms?: number; +} + +/** + * Input payload for the `edit` tool. Performs a string replacement in the named + * file; by default `old_string` must occur exactly once. + */ +export interface BetaManagedAgentsAgentToolset20260401EditInput { + /** + * Path of the file to edit. + */ + file_path: string; + + /** + * Replacement text. + */ + new_string: string; + + /** + * Substring to find and replace. + */ + old_string: string; + + /** + * When true, replace every occurrence of `old_string` instead of requiring a + * unique match. + */ + replace_all?: boolean; +} + +/** + * Input payload for the `glob` tool. Returns paths matching a doublestar glob + * pattern, newest first. + */ +export interface BetaManagedAgentsAgentToolset20260401GlobInput { + /** + * Doublestar glob pattern (e.g. `** /*.go`). Absolute patterns are only permitted + * when the runner is configured to allow them. + */ + pattern: string; + + /** + * Optional directory root to search under. Defaults to the runner's working + * directory. + */ + path?: string; +} + +/** + * Input payload for the `grep` tool. Searches file contents for a regular + * expression, returning matching lines. + */ +export interface BetaManagedAgentsAgentToolset20260401GrepInput { + /** + * Regular expression to search for. + */ + pattern: string; + + /** + * Optional directory root to search under. Defaults to the runner's working + * directory. + */ + path?: string; +} + /** * Configuration for built-in agent tools. Use this to enable or disable groups of * tools available to the agent. @@ -310,6 +396,39 @@ export interface BetaManagedAgentsAgentToolset20260401Params { default_config?: BetaManagedAgentsAgentToolsetDefaultConfigParams | null; } +/** + * Input payload for the `read` tool. Reads file contents relative to the runner's + * working directory (or absolute when the runner permits). + */ +export interface BetaManagedAgentsAgentToolset20260401ReadInput { + /** + * Path of the file to read. + */ + file_path: string; + + /** + * Optional `[start_line, end_line]` 1-indexed inclusive range. When omitted the + * entire file is returned. `end_line` of 0 or negative means "to end of file". + */ + view_range?: Array; +} + +/** + * Input payload for the `write` tool. Writes (overwriting) the entire file + * contents. + */ +export interface BetaManagedAgentsAgentToolset20260401WriteInput { + /** + * Full file contents to write. + */ + content: string; + + /** + * Path of the file to write. + */ + file_path: string; +} + /** * Tool calls are automatically approved without user confirmation. */ @@ -645,6 +764,38 @@ export interface BetaManagedAgentsMultiagentSelfParams { type: 'self'; } +/** + * Resolved `agent` definition for a single `session_thread`. Snapshot of the agent + * at thread creation time. The multiagent roster is not repeated here; read it + * from `Session.agent`. + */ +export interface BetaManagedAgentsSessionThreadAgent { + id: string; + + description: string | null; + + mcp_servers: Array; + + /** + * Model identifier and configuration. + */ + model: BetaManagedAgentsModelConfig; + + name: string; + + skills: Array; + + system: string | null; + + tools: Array< + BetaManagedAgentsAgentToolset20260401 | BetaManagedAgentsMCPToolset | BetaManagedAgentsCustomTool + >; + + type: 'agent'; + + version: number; +} + /** * Skill to load in the session container. */ @@ -864,7 +1015,13 @@ export declare namespace Agents { type BetaManagedAgentsAgentToolsetDefaultConfig as BetaManagedAgentsAgentToolsetDefaultConfig, type BetaManagedAgentsAgentToolsetDefaultConfigParams as BetaManagedAgentsAgentToolsetDefaultConfigParams, type BetaManagedAgentsAgentToolset20260401 as BetaManagedAgentsAgentToolset20260401, + type BetaManagedAgentsAgentToolset20260401BashInput as BetaManagedAgentsAgentToolset20260401BashInput, + type BetaManagedAgentsAgentToolset20260401EditInput as BetaManagedAgentsAgentToolset20260401EditInput, + type BetaManagedAgentsAgentToolset20260401GlobInput as BetaManagedAgentsAgentToolset20260401GlobInput, + type BetaManagedAgentsAgentToolset20260401GrepInput as BetaManagedAgentsAgentToolset20260401GrepInput, type BetaManagedAgentsAgentToolset20260401Params as BetaManagedAgentsAgentToolset20260401Params, + type BetaManagedAgentsAgentToolset20260401ReadInput as BetaManagedAgentsAgentToolset20260401ReadInput, + type BetaManagedAgentsAgentToolset20260401WriteInput as BetaManagedAgentsAgentToolset20260401WriteInput, type BetaManagedAgentsAlwaysAllowPolicy as BetaManagedAgentsAlwaysAllowPolicy, type BetaManagedAgentsAlwaysAskPolicy as BetaManagedAgentsAlwaysAskPolicy, type BetaManagedAgentsAnthropicSkill as BetaManagedAgentsAnthropicSkill, @@ -887,6 +1044,7 @@ export declare namespace Agents { type BetaManagedAgentsMultiagentCoordinator as BetaManagedAgentsMultiagentCoordinator, type BetaManagedAgentsMultiagentCoordinatorParams as BetaManagedAgentsMultiagentCoordinatorParams, type BetaManagedAgentsMultiagentSelfParams as BetaManagedAgentsMultiagentSelfParams, + type BetaManagedAgentsSessionThreadAgent as BetaManagedAgentsSessionThreadAgent, type BetaManagedAgentsSkillParams as BetaManagedAgentsSkillParams, type BetaManagedAgentsURLMCPServerParams as BetaManagedAgentsURLMCPServerParams, type BetaManagedAgentsAgentsPageCursor as BetaManagedAgentsAgentsPageCursor, diff --git a/src/resources/beta/agents/index.ts b/src/resources/beta/agents/index.ts index 64f2d8b4..1de34d80 100644 --- a/src/resources/beta/agents/index.ts +++ b/src/resources/beta/agents/index.ts @@ -9,7 +9,13 @@ export { type BetaManagedAgentsAgentToolsetDefaultConfig, type BetaManagedAgentsAgentToolsetDefaultConfigParams, type BetaManagedAgentsAgentToolset20260401, + type BetaManagedAgentsAgentToolset20260401BashInput, + type BetaManagedAgentsAgentToolset20260401EditInput, + type BetaManagedAgentsAgentToolset20260401GlobInput, + type BetaManagedAgentsAgentToolset20260401GrepInput, type BetaManagedAgentsAgentToolset20260401Params, + type BetaManagedAgentsAgentToolset20260401ReadInput, + type BetaManagedAgentsAgentToolset20260401WriteInput, type BetaManagedAgentsAlwaysAllowPolicy, type BetaManagedAgentsAlwaysAskPolicy, type BetaManagedAgentsAnthropicSkill, @@ -32,6 +38,7 @@ export { type BetaManagedAgentsMultiagentCoordinator, type BetaManagedAgentsMultiagentCoordinatorParams, type BetaManagedAgentsMultiagentSelfParams, + type BetaManagedAgentsSessionThreadAgent, type BetaManagedAgentsSkillParams, type BetaManagedAgentsURLMCPServerParams, type AgentCreateParams, diff --git a/src/resources/beta/beta.ts b/src/resources/beta/beta.ts index 2fbe6c42..4440f4b2 100644 --- a/src/resources/beta/beta.ts +++ b/src/resources/beta/beta.ts @@ -1,26 +1,6 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. import { APIResource } from '../../core/resource'; -import * as EnvironmentsAPI from './environments'; -import { - BetaCloudConfig, - BetaCloudConfigParams, - BetaEnvironment, - BetaEnvironmentDeleteResponse, - BetaEnvironmentsPageCursor, - BetaLimitedNetwork, - BetaLimitedNetworkParams, - BetaPackages, - BetaPackagesParams, - BetaUnrestrictedNetwork, - EnvironmentArchiveParams, - EnvironmentCreateParams, - EnvironmentDeleteParams, - EnvironmentListParams, - EnvironmentRetrieveParams, - EnvironmentUpdateParams, - Environments, -} from './environments'; import * as FilesAPI from './files'; import { BetaFileScope, @@ -103,7 +83,13 @@ import { BetaManagedAgentsAgentToolConfig, BetaManagedAgentsAgentToolConfigParams, BetaManagedAgentsAgentToolset20260401, + BetaManagedAgentsAgentToolset20260401BashInput, + BetaManagedAgentsAgentToolset20260401EditInput, + BetaManagedAgentsAgentToolset20260401GlobInput, + BetaManagedAgentsAgentToolset20260401GrepInput, BetaManagedAgentsAgentToolset20260401Params, + BetaManagedAgentsAgentToolset20260401ReadInput, + BetaManagedAgentsAgentToolset20260401WriteInput, BetaManagedAgentsAgentToolsetDefaultConfig, BetaManagedAgentsAgentToolsetDefaultConfigParams, BetaManagedAgentsAgentsPageCursor, @@ -129,9 +115,32 @@ import { BetaManagedAgentsMultiagentCoordinator, BetaManagedAgentsMultiagentCoordinatorParams, BetaManagedAgentsMultiagentSelfParams, + BetaManagedAgentsSessionThreadAgent, BetaManagedAgentsSkillParams, BetaManagedAgentsURLMCPServerParams, } from './agents/agents'; +import * as EnvironmentsAPI from './environments/environments'; +import { + BetaCloudConfig, + BetaCloudConfigParams, + BetaEnvironment, + BetaEnvironmentDeleteResponse, + BetaEnvironmentsPageCursor, + BetaLimitedNetwork, + BetaLimitedNetworkParams, + BetaPackages, + BetaPackagesParams, + BetaSelfHostedConfig, + BetaSelfHostedConfigParams, + BetaUnrestrictedNetwork, + EnvironmentArchiveParams, + EnvironmentCreateParams, + EnvironmentDeleteParams, + EnvironmentListParams, + EnvironmentRetrieveParams, + EnvironmentUpdateParams, + Environments, +} from './environments/environments'; import * as MemoryStoresAPI from './memory-stores/memory-stores'; import { BetaManagedAgentsDeletedMemoryStore, @@ -378,10 +387,13 @@ import { BetaManagedAgentsOutcomeEvaluationResource, BetaManagedAgentsSession, BetaManagedAgentsSessionAgent, + BetaManagedAgentsSessionAgentUpdate, BetaManagedAgentsSessionMultiagentCoordinator, BetaManagedAgentsSessionStats, + BetaManagedAgentsSessionUpdatedEvent, BetaManagedAgentsSessionUsage, BetaManagedAgentsSessionsPageCursor, + BetaManagedAgentsUserToolResultEvent, SessionArchiveParams, SessionCreateParams, SessionDeleteParams, @@ -800,7 +812,13 @@ export declare namespace Beta { type BetaManagedAgentsAgentToolsetDefaultConfig as BetaManagedAgentsAgentToolsetDefaultConfig, type BetaManagedAgentsAgentToolsetDefaultConfigParams as BetaManagedAgentsAgentToolsetDefaultConfigParams, type BetaManagedAgentsAgentToolset20260401 as BetaManagedAgentsAgentToolset20260401, + type BetaManagedAgentsAgentToolset20260401BashInput as BetaManagedAgentsAgentToolset20260401BashInput, + type BetaManagedAgentsAgentToolset20260401EditInput as BetaManagedAgentsAgentToolset20260401EditInput, + type BetaManagedAgentsAgentToolset20260401GlobInput as BetaManagedAgentsAgentToolset20260401GlobInput, + type BetaManagedAgentsAgentToolset20260401GrepInput as BetaManagedAgentsAgentToolset20260401GrepInput, type BetaManagedAgentsAgentToolset20260401Params as BetaManagedAgentsAgentToolset20260401Params, + type BetaManagedAgentsAgentToolset20260401ReadInput as BetaManagedAgentsAgentToolset20260401ReadInput, + type BetaManagedAgentsAgentToolset20260401WriteInput as BetaManagedAgentsAgentToolset20260401WriteInput, type BetaManagedAgentsAlwaysAllowPolicy as BetaManagedAgentsAlwaysAllowPolicy, type BetaManagedAgentsAlwaysAskPolicy as BetaManagedAgentsAlwaysAskPolicy, type BetaManagedAgentsAnthropicSkill as BetaManagedAgentsAnthropicSkill, @@ -823,6 +841,7 @@ export declare namespace Beta { type BetaManagedAgentsMultiagentCoordinator as BetaManagedAgentsMultiagentCoordinator, type BetaManagedAgentsMultiagentCoordinatorParams as BetaManagedAgentsMultiagentCoordinatorParams, type BetaManagedAgentsMultiagentSelfParams as BetaManagedAgentsMultiagentSelfParams, + type BetaManagedAgentsSessionThreadAgent as BetaManagedAgentsSessionThreadAgent, type BetaManagedAgentsSkillParams as BetaManagedAgentsSkillParams, type BetaManagedAgentsURLMCPServerParams as BetaManagedAgentsURLMCPServerParams, type BetaManagedAgentsAgentsPageCursor as BetaManagedAgentsAgentsPageCursor, @@ -843,6 +862,8 @@ export declare namespace Beta { type BetaLimitedNetworkParams as BetaLimitedNetworkParams, type BetaPackages as BetaPackages, type BetaPackagesParams as BetaPackagesParams, + type BetaSelfHostedConfig as BetaSelfHostedConfig, + type BetaSelfHostedConfigParams as BetaSelfHostedConfigParams, type BetaUnrestrictedNetwork as BetaUnrestrictedNetwork, type BetaEnvironmentsPageCursor as BetaEnvironmentsPageCursor, type EnvironmentCreateParams as EnvironmentCreateParams, @@ -869,9 +890,12 @@ export declare namespace Beta { type BetaManagedAgentsOutcomeEvaluationResource as BetaManagedAgentsOutcomeEvaluationResource, type BetaManagedAgentsSession as BetaManagedAgentsSession, type BetaManagedAgentsSessionAgent as BetaManagedAgentsSessionAgent, + type BetaManagedAgentsSessionAgentUpdate as BetaManagedAgentsSessionAgentUpdate, type BetaManagedAgentsSessionMultiagentCoordinator as BetaManagedAgentsSessionMultiagentCoordinator, type BetaManagedAgentsSessionStats as BetaManagedAgentsSessionStats, + type BetaManagedAgentsSessionUpdatedEvent as BetaManagedAgentsSessionUpdatedEvent, type BetaManagedAgentsSessionUsage as BetaManagedAgentsSessionUsage, + type BetaManagedAgentsUserToolResultEvent as BetaManagedAgentsUserToolResultEvent, type BetaManagedAgentsSessionsPageCursor as BetaManagedAgentsSessionsPageCursor, type SessionCreateParams as SessionCreateParams, type SessionRetrieveParams as SessionRetrieveParams, diff --git a/src/resources/beta/environments.ts b/src/resources/beta/environments.ts index 0e83df6b..85fdba4f 100644 --- a/src/resources/beta/environments.ts +++ b/src/resources/beta/environments.ts @@ -1,542 +1,3 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -import { APIResource } from '../../core/resource'; -import * as BetaAPI from './beta'; -import { APIPromise } from '../../core/api-promise'; -import { PageCursor, type PageCursorParams, PagePromise } from '../../core/pagination'; -import { buildHeaders } from '../../internal/headers'; -import { RequestOptions } from '../../internal/request-options'; -import { path } from '../../internal/utils/path'; - -export class Environments extends APIResource { - /** - * Create a new environment with the specified configuration. - * - * @example - * ```ts - * const betaEnvironment = - * await client.beta.environments.create({ - * name: 'python-data-analysis', - * }); - * ``` - */ - create(params: EnvironmentCreateParams, options?: RequestOptions): APIPromise { - const { betas, ...body } = params; - return this._client.post('/v1/environments?beta=true', { - body, - ...options, - headers: buildHeaders([ - { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, - options?.headers, - ]), - }); - } - - /** - * Retrieve a specific environment by ID. - * - * @example - * ```ts - * const betaEnvironment = - * await client.beta.environments.retrieve( - * 'env_011CZkZ9X2dpNyB7HsEFoRfW', - * ); - * ``` - */ - retrieve( - environmentID: string, - params: EnvironmentRetrieveParams | null | undefined = {}, - options?: RequestOptions, - ): APIPromise { - const { betas } = params ?? {}; - return this._client.get(path`/v1/environments/${environmentID}?beta=true`, { - ...options, - headers: buildHeaders([ - { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, - options?.headers, - ]), - }); - } - - /** - * Update an existing environment's configuration. - * - * @example - * ```ts - * const betaEnvironment = - * await client.beta.environments.update( - * 'env_011CZkZ9X2dpNyB7HsEFoRfW', - * ); - * ``` - */ - update( - environmentID: string, - params: EnvironmentUpdateParams, - options?: RequestOptions, - ): APIPromise { - const { betas, ...body } = params; - return this._client.post(path`/v1/environments/${environmentID}?beta=true`, { - body, - ...options, - headers: buildHeaders([ - { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, - options?.headers, - ]), - }); - } - - /** - * List environments with pagination support. - * - * @example - * ```ts - * // Automatically fetches more pages as needed. - * for await (const betaEnvironment of client.beta.environments.list()) { - * // ... - * } - * ``` - */ - list( - params: EnvironmentListParams | null | undefined = {}, - options?: RequestOptions, - ): PagePromise { - const { betas, ...query } = params ?? {}; - return this._client.getAPIList('/v1/environments?beta=true', PageCursor, { - query, - ...options, - headers: buildHeaders([ - { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, - options?.headers, - ]), - }); - } - - /** - * Delete an environment by ID. Returns a confirmation of the deletion. - * - * @example - * ```ts - * const betaEnvironmentDeleteResponse = - * await client.beta.environments.delete( - * 'env_011CZkZ9X2dpNyB7HsEFoRfW', - * ); - * ``` - */ - delete( - environmentID: string, - params: EnvironmentDeleteParams | null | undefined = {}, - options?: RequestOptions, - ): APIPromise { - const { betas } = params ?? {}; - return this._client.delete(path`/v1/environments/${environmentID}?beta=true`, { - ...options, - headers: buildHeaders([ - { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, - options?.headers, - ]), - }); - } - - /** - * Archive an environment by ID. Archived environments cannot be used to create new - * sessions. - * - * @example - * ```ts - * const betaEnvironment = - * await client.beta.environments.archive( - * 'env_011CZkZ9X2dpNyB7HsEFoRfW', - * ); - * ``` - */ - archive( - environmentID: string, - params: EnvironmentArchiveParams | null | undefined = {}, - options?: RequestOptions, - ): APIPromise { - const { betas } = params ?? {}; - return this._client.post(path`/v1/environments/${environmentID}/archive?beta=true`, { - ...options, - headers: buildHeaders([ - { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, - options?.headers, - ]), - }); - } -} - -export type BetaEnvironmentsPageCursor = PageCursor; - -/** - * `cloud` environment configuration. - */ -export interface BetaCloudConfig { - /** - * Network configuration policy. - */ - networking: BetaUnrestrictedNetwork | BetaLimitedNetwork; - - /** - * Package manager configuration. - */ - packages: BetaPackages; - - /** - * Environment type - */ - type: 'cloud'; -} - -/** - * Request params for `cloud` environment configuration. - * - * Fields default to null; on update, omitted fields preserve the existing value. - */ -export interface BetaCloudConfigParams { - /** - * Environment type - */ - type: 'cloud'; - - /** - * Network configuration policy. Omit on update to preserve the existing value. - */ - networking?: BetaUnrestrictedNetwork | BetaLimitedNetworkParams | null; - - /** - * Specify packages (and optionally their versions) available in this environment. - * - * When versioning, use the version semantics relevant for the package manager, - * e.g. for `pip` use `package==1.0.0`. You are responsible for validating the - * package and version exist. Unversioned installs the latest. - */ - packages?: BetaPackagesParams | null; -} - -/** - * Unified Environment resource for both cloud and self-hosted environments. - */ -export interface BetaEnvironment { - /** - * Environment identifier (e.g., 'env\_...') - */ - id: string; - - /** - * RFC 3339 timestamp when environment was archived, or null if not archived - */ - archived_at: string | null; - - /** - * `cloud` environment configuration. - */ - config: BetaCloudConfig; - - /** - * RFC 3339 timestamp when environment was created - */ - created_at: string; - - /** - * User-provided description for the environment - */ - description: string; - - /** - * User-provided metadata key-value pairs - */ - metadata: { [key: string]: string }; - - /** - * Human-readable name for the environment - */ - name: string; - - /** - * The type of object (always 'environment') - */ - type: 'environment'; - - /** - * RFC 3339 timestamp when environment was last updated - */ - updated_at: string; -} - -/** - * Response after deleting an environment. - */ -export interface BetaEnvironmentDeleteResponse { - /** - * Environment identifier - */ - id: string; - - /** - * The type of response - */ - type: 'environment_deleted'; -} - -/** - * Limited network access. - */ -export interface BetaLimitedNetwork { - /** - * Permits outbound access to MCP server endpoints configured on the agent, beyond - * those listed in the `allowed_hosts` array. - */ - allow_mcp_servers: boolean; - - /** - * Permits outbound access to public package registries (PyPI, npm, etc.) beyond - * those listed in the `allowed_hosts` array. - */ - allow_package_managers: boolean; - - /** - * Specifies domains the container can reach. - */ - allowed_hosts: Array; - - /** - * Network policy type - */ - type: 'limited'; -} - -/** - * Limited network request params. - * - * Fields default to null; on update, omitted fields preserve the existing value. - */ -export interface BetaLimitedNetworkParams { - /** - * Network policy type - */ - type: 'limited'; - - /** - * Permits outbound access to MCP server endpoints configured on the agent, beyond - * those listed in the `allowed_hosts` array. Defaults to `false`. - */ - allow_mcp_servers?: boolean | null; - - /** - * Permits outbound access to public package registries (PyPI, npm, etc.) beyond - * those listed in the `allowed_hosts` array. Defaults to `false`. - */ - allow_package_managers?: boolean | null; - - /** - * Specifies domains the container can reach. - */ - allowed_hosts?: Array | null; -} - -/** - * Packages (and their versions) available in this environment. - */ -export interface BetaPackages { - /** - * Ubuntu/Debian packages to install - */ - apt: Array; - - /** - * Rust packages to install - */ - cargo: Array; - - /** - * Ruby packages to install - */ - gem: Array; - - /** - * Go packages to install - */ - go: Array; - - /** - * Node.js packages to install - */ - npm: Array; - - /** - * Python packages to install - */ - pip: Array; - - /** - * Package configuration type - */ - type?: 'packages'; -} - -/** - * Specify packages (and optionally their versions) available in this environment. - * - * When versioning, use the version semantics relevant for the package manager, - * e.g. for `pip` use `package==1.0.0`. You are responsible for validating the - * package and version exist. Unversioned installs the latest. - */ -export interface BetaPackagesParams { - /** - * Ubuntu/Debian packages to install - */ - apt?: Array | null; - - /** - * Rust packages to install - */ - cargo?: Array | null; - - /** - * Ruby packages to install - */ - gem?: Array | null; - - /** - * Go packages to install - */ - go?: Array | null; - - /** - * Node.js packages to install - */ - npm?: Array | null; - - /** - * Python packages to install - */ - pip?: Array | null; - - /** - * Package configuration type - */ - type?: 'packages'; -} - -/** - * Unrestricted network access. - */ -export interface BetaUnrestrictedNetwork { - /** - * Network policy type - */ - type: 'unrestricted'; -} - -export interface EnvironmentCreateParams { - /** - * Body param: Human-readable name for the environment - */ - name: string; - - /** - * Body param: Request params for `cloud` environment configuration. - * - * Fields default to null; on update, omitted fields preserve the existing value. - */ - config?: BetaCloudConfigParams | null; - - /** - * Body param: Optional description of the environment - */ - description?: string | null; - - /** - * Body param: User-provided metadata key-value pairs - */ - metadata?: { [key: string]: string }; - - /** - * Header param: Optional header to specify the beta version(s) you want to use. - */ - betas?: Array; -} - -export interface EnvironmentRetrieveParams { - /** - * Optional header to specify the beta version(s) you want to use. - */ - betas?: Array; -} - -export interface EnvironmentUpdateParams { - /** - * Body param: Request params for `cloud` environment configuration. - * - * Fields default to null; on update, omitted fields preserve the existing value. - */ - config?: BetaCloudConfigParams | null; - - /** - * Body param: Updated description of the environment - */ - description?: string | null; - - /** - * Body param: User-provided metadata key-value pairs. Set a value to null or empty - * string to delete the key. - */ - metadata?: { [key: string]: string | null }; - - /** - * Body param: Updated name for the environment - */ - name?: string | null; - - /** - * Header param: Optional header to specify the beta version(s) you want to use. - */ - betas?: Array; -} - -export interface EnvironmentListParams extends PageCursorParams { - /** - * Query param: Include archived environments in the response - */ - include_archived?: boolean; - - /** - * Header param: Optional header to specify the beta version(s) you want to use. - */ - betas?: Array; -} - -export interface EnvironmentDeleteParams { - /** - * Optional header to specify the beta version(s) you want to use. - */ - betas?: Array; -} - -export interface EnvironmentArchiveParams { - /** - * Optional header to specify the beta version(s) you want to use. - */ - betas?: Array; -} - -export declare namespace Environments { - export { - type BetaCloudConfig as BetaCloudConfig, - type BetaCloudConfigParams as BetaCloudConfigParams, - type BetaEnvironment as BetaEnvironment, - type BetaEnvironmentDeleteResponse as BetaEnvironmentDeleteResponse, - type BetaLimitedNetwork as BetaLimitedNetwork, - type BetaLimitedNetworkParams as BetaLimitedNetworkParams, - type BetaPackages as BetaPackages, - type BetaPackagesParams as BetaPackagesParams, - type BetaUnrestrictedNetwork as BetaUnrestrictedNetwork, - type BetaEnvironmentsPageCursor as BetaEnvironmentsPageCursor, - type EnvironmentCreateParams as EnvironmentCreateParams, - type EnvironmentRetrieveParams as EnvironmentRetrieveParams, - type EnvironmentUpdateParams as EnvironmentUpdateParams, - type EnvironmentListParams as EnvironmentListParams, - type EnvironmentDeleteParams as EnvironmentDeleteParams, - type EnvironmentArchiveParams as EnvironmentArchiveParams, - }; -} +export * from './environments/index'; diff --git a/src/resources/beta/environments/environments.ts b/src/resources/beta/environments/environments.ts new file mode 100644 index 00000000..87ca636e --- /dev/null +++ b/src/resources/beta/environments/environments.ts @@ -0,0 +1,625 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../../core/resource'; +import * as BetaAPI from '../beta'; +import * as WorkAPI from './work'; +import { + BetaSelfHostedWork, + BetaSelfHostedWorkHeartbeatResponse, + BetaSelfHostedWorkListResponse, + BetaSelfHostedWorkQueueStats, + BetaSelfHostedWorkStopRequest, + BetaSelfHostedWorkUpdateRequest, + BetaSelfHostedWorksPageCursor, + BetaSessionWorkData, + Work, + WorkAckParams, + WorkHeartbeatParams, + WorkListParams, + WorkPollParams, + WorkRetrieveParams, + WorkStatsParams, + WorkStopParams, + WorkUpdateParams, +} from './work'; +import { APIPromise } from '../../../core/api-promise'; +import { PageCursor, type PageCursorParams, PagePromise } from '../../../core/pagination'; +import { buildHeaders } from '../../../internal/headers'; +import { RequestOptions } from '../../../internal/request-options'; +import { path } from '../../../internal/utils/path'; + +export class Environments extends APIResource { + work: WorkAPI.Work = new WorkAPI.Work(this._client); + + /** + * Create a new environment with the specified configuration. + * + * @example + * ```ts + * const betaEnvironment = + * await client.beta.environments.create({ + * name: 'python-data-analysis', + * }); + * ``` + */ + create(params: EnvironmentCreateParams, options?: RequestOptions): APIPromise { + const { betas, ...body } = params; + return this._client.post('/v1/environments?beta=true', { + body, + ...options, + headers: buildHeaders([ + { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, + options?.headers, + ]), + }); + } + + /** + * Retrieve a specific environment by ID. + * + * @example + * ```ts + * const betaEnvironment = + * await client.beta.environments.retrieve( + * 'env_011CZkZ9X2dpNyB7HsEFoRfW', + * ); + * ``` + */ + retrieve( + environmentID: string, + params: EnvironmentRetrieveParams | null | undefined = {}, + options?: RequestOptions, + ): APIPromise { + const { betas } = params ?? {}; + return this._client.get(path`/v1/environments/${environmentID}?beta=true`, { + ...options, + headers: buildHeaders([ + { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, + options?.headers, + ]), + }); + } + + /** + * Update an existing environment's configuration. + * + * @example + * ```ts + * const betaEnvironment = + * await client.beta.environments.update( + * 'env_011CZkZ9X2dpNyB7HsEFoRfW', + * ); + * ``` + */ + update( + environmentID: string, + params: EnvironmentUpdateParams, + options?: RequestOptions, + ): APIPromise { + const { betas, ...body } = params; + return this._client.post(path`/v1/environments/${environmentID}?beta=true`, { + body, + ...options, + headers: buildHeaders([ + { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, + options?.headers, + ]), + }); + } + + /** + * List environments with pagination support. + * + * @example + * ```ts + * // Automatically fetches more pages as needed. + * for await (const betaEnvironment of client.beta.environments.list()) { + * // ... + * } + * ``` + */ + list( + params: EnvironmentListParams | null | undefined = {}, + options?: RequestOptions, + ): PagePromise { + const { betas, ...query } = params ?? {}; + return this._client.getAPIList('/v1/environments?beta=true', PageCursor, { + query, + ...options, + headers: buildHeaders([ + { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, + options?.headers, + ]), + }); + } + + /** + * Delete an environment by ID. Returns a confirmation of the deletion. + * + * @example + * ```ts + * const betaEnvironmentDeleteResponse = + * await client.beta.environments.delete( + * 'env_011CZkZ9X2dpNyB7HsEFoRfW', + * ); + * ``` + */ + delete( + environmentID: string, + params: EnvironmentDeleteParams | null | undefined = {}, + options?: RequestOptions, + ): APIPromise { + const { betas } = params ?? {}; + return this._client.delete(path`/v1/environments/${environmentID}?beta=true`, { + ...options, + headers: buildHeaders([ + { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, + options?.headers, + ]), + }); + } + + /** + * Archive an environment by ID. Archived environments cannot be used to create new + * sessions. + * + * @example + * ```ts + * const betaEnvironment = + * await client.beta.environments.archive( + * 'env_011CZkZ9X2dpNyB7HsEFoRfW', + * ); + * ``` + */ + archive( + environmentID: string, + params: EnvironmentArchiveParams | null | undefined = {}, + options?: RequestOptions, + ): APIPromise { + const { betas } = params ?? {}; + return this._client.post(path`/v1/environments/${environmentID}/archive?beta=true`, { + ...options, + headers: buildHeaders([ + { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, + options?.headers, + ]), + }); + } +} + +export type BetaEnvironmentsPageCursor = PageCursor; + +/** + * `cloud` environment configuration. + */ +export interface BetaCloudConfig { + /** + * Network configuration policy. + */ + networking: BetaUnrestrictedNetwork | BetaLimitedNetwork; + + /** + * Package manager configuration. + */ + packages: BetaPackages; + + /** + * Environment type + */ + type: 'cloud'; +} + +/** + * Request params for `cloud` environment configuration. + * + * Fields default to null; on update, omitted fields preserve the existing value. + */ +export interface BetaCloudConfigParams { + /** + * Environment type + */ + type: 'cloud'; + + /** + * Network configuration policy. Omit on update to preserve the existing value. + */ + networking?: BetaUnrestrictedNetwork | BetaLimitedNetworkParams | null; + + /** + * Specify packages (and optionally their versions) available in this environment. + * + * When versioning, use the version semantics relevant for the package manager, + * e.g. for `pip` use `package==1.0.0`. You are responsible for validating the + * package and version exist. Unversioned installs the latest. + */ + packages?: BetaPackagesParams | null; +} + +/** + * Unified Environment resource for both cloud and self-hosted environments. + */ +export interface BetaEnvironment { + /** + * Environment identifier (e.g., 'env\_...') + */ + id: string; + + /** + * RFC 3339 timestamp when environment was archived, or null if not archived + */ + archived_at: string | null; + + /** + * Environment configuration (either Anthropic Cloud or self-hosted) + */ + config: BetaCloudConfig | BetaSelfHostedConfig; + + /** + * RFC 3339 timestamp when environment was created + */ + created_at: string; + + /** + * User-provided description for the environment + */ + description: string; + + /** + * User-provided metadata key-value pairs + */ + metadata: { [key: string]: string }; + + /** + * Human-readable name for the environment + */ + name: string; + + /** + * The type of object (always 'environment') + */ + type: 'environment'; + + /** + * RFC 3339 timestamp when environment was last updated + */ + updated_at: string; + + /** + * The visibility scope for this environment. 'organization' means visible to all + * accounts. 'account' means visible only to the owning account. + */ + scope?: 'organization' | 'account'; +} + +/** + * Response after deleting an environment. + */ +export interface BetaEnvironmentDeleteResponse { + /** + * Environment identifier + */ + id: string; + + /** + * The type of response + */ + type: 'environment_deleted'; +} + +/** + * Limited network access. + */ +export interface BetaLimitedNetwork { + /** + * Permits outbound access to MCP server endpoints configured on the agent, beyond + * those listed in the `allowed_hosts` array. + */ + allow_mcp_servers: boolean; + + /** + * Permits outbound access to public package registries (PyPI, npm, etc.) beyond + * those listed in the `allowed_hosts` array. + */ + allow_package_managers: boolean; + + /** + * Specifies domains the container can reach. + */ + allowed_hosts: Array; + + /** + * Network policy type + */ + type: 'limited'; +} + +/** + * Limited network request params. + * + * Fields default to null; on update, omitted fields preserve the existing value. + */ +export interface BetaLimitedNetworkParams { + /** + * Network policy type + */ + type: 'limited'; + + /** + * Permits outbound access to MCP server endpoints configured on the agent, beyond + * those listed in the `allowed_hosts` array. Defaults to `false`. + */ + allow_mcp_servers?: boolean | null; + + /** + * Permits outbound access to public package registries (PyPI, npm, etc.) beyond + * those listed in the `allowed_hosts` array. Defaults to `false`. + */ + allow_package_managers?: boolean | null; + + /** + * Specifies domains the container can reach. + */ + allowed_hosts?: Array | null; +} + +/** + * Packages (and their versions) available in this environment. + */ +export interface BetaPackages { + /** + * Ubuntu/Debian packages to install + */ + apt: Array; + + /** + * Rust packages to install + */ + cargo: Array; + + /** + * Ruby packages to install + */ + gem: Array; + + /** + * Go packages to install + */ + go: Array; + + /** + * Node.js packages to install + */ + npm: Array; + + /** + * Python packages to install + */ + pip: Array; + + /** + * Package configuration type + */ + type?: 'packages'; +} + +/** + * Specify packages (and optionally their versions) available in this environment. + * + * When versioning, use the version semantics relevant for the package manager, + * e.g. for `pip` use `package==1.0.0`. You are responsible for validating the + * package and version exist. Unversioned installs the latest. + */ +export interface BetaPackagesParams { + /** + * Ubuntu/Debian packages to install + */ + apt?: Array | null; + + /** + * Rust packages to install + */ + cargo?: Array | null; + + /** + * Ruby packages to install + */ + gem?: Array | null; + + /** + * Go packages to install + */ + go?: Array | null; + + /** + * Node.js packages to install + */ + npm?: Array | null; + + /** + * Python packages to install + */ + pip?: Array | null; + + /** + * Package configuration type + */ + type?: 'packages'; +} + +/** + * Configuration for self-hosted environments. + */ +export interface BetaSelfHostedConfig { + /** + * Environment type + */ + type: 'self_hosted'; +} + +/** + * Request params for `self_hosted` environment configuration. + */ +export interface BetaSelfHostedConfigParams { + /** + * Environment type + */ + type: 'self_hosted'; +} + +/** + * Unrestricted network access. + */ +export interface BetaUnrestrictedNetwork { + /** + * Network policy type + */ + type: 'unrestricted'; +} + +export interface EnvironmentCreateParams { + /** + * Body param: Human-readable name for the environment + */ + name: string; + + /** + * Body param: Environment configuration + */ + config?: BetaCloudConfigParams | BetaSelfHostedConfigParams | null; + + /** + * Body param: Optional description of the environment + */ + description?: string | null; + + /** + * Body param: User-provided metadata key-value pairs + */ + metadata?: { [key: string]: string }; + + /** + * Body param: The visibility scope for this environment. 'organization' makes the + * environment visible to all accounts. 'account' restricts visibility to the + * owning account only. Only applicable for self-hosted environments. If not + * specified, defaults based on organization type. + */ + scope?: 'organization' | 'account' | null; + + /** + * Header param: Optional header to specify the beta version(s) you want to use. + */ + betas?: Array; +} + +export interface EnvironmentRetrieveParams { + /** + * Optional header to specify the beta version(s) you want to use. + */ + betas?: Array; +} + +export interface EnvironmentUpdateParams { + /** + * Body param: Updated environment configuration + */ + config?: BetaCloudConfigParams | BetaSelfHostedConfigParams | null; + + /** + * Body param: Updated description of the environment + */ + description?: string | null; + + /** + * Body param: User-provided metadata key-value pairs. Set a value to null or empty + * string to delete the key. + */ + metadata?: { [key: string]: string | null }; + + /** + * Body param: Updated name for the environment + */ + name?: string | null; + + /** + * Body param: The visibility scope for this environment. 'organization' makes the + * environment visible to all accounts. 'account' restricts visibility to the + * owning account only. + */ + scope?: 'organization' | 'account' | null; + + /** + * Header param: Optional header to specify the beta version(s) you want to use. + */ + betas?: Array; +} + +export interface EnvironmentListParams extends PageCursorParams { + /** + * Query param: Include archived environments in the response + */ + include_archived?: boolean; + + /** + * Header param: Optional header to specify the beta version(s) you want to use. + */ + betas?: Array; +} + +export interface EnvironmentDeleteParams { + /** + * Optional header to specify the beta version(s) you want to use. + */ + betas?: Array; +} + +export interface EnvironmentArchiveParams { + /** + * Optional header to specify the beta version(s) you want to use. + */ + betas?: Array; +} + +Environments.Work = Work; + +export declare namespace Environments { + export { + type BetaCloudConfig as BetaCloudConfig, + type BetaCloudConfigParams as BetaCloudConfigParams, + type BetaEnvironment as BetaEnvironment, + type BetaEnvironmentDeleteResponse as BetaEnvironmentDeleteResponse, + type BetaLimitedNetwork as BetaLimitedNetwork, + type BetaLimitedNetworkParams as BetaLimitedNetworkParams, + type BetaPackages as BetaPackages, + type BetaPackagesParams as BetaPackagesParams, + type BetaSelfHostedConfig as BetaSelfHostedConfig, + type BetaSelfHostedConfigParams as BetaSelfHostedConfigParams, + type BetaUnrestrictedNetwork as BetaUnrestrictedNetwork, + type BetaEnvironmentsPageCursor as BetaEnvironmentsPageCursor, + type EnvironmentCreateParams as EnvironmentCreateParams, + type EnvironmentRetrieveParams as EnvironmentRetrieveParams, + type EnvironmentUpdateParams as EnvironmentUpdateParams, + type EnvironmentListParams as EnvironmentListParams, + type EnvironmentDeleteParams as EnvironmentDeleteParams, + type EnvironmentArchiveParams as EnvironmentArchiveParams, + }; + + export { + Work as Work, + type BetaSelfHostedWork as BetaSelfHostedWork, + type BetaSelfHostedWorkHeartbeatResponse as BetaSelfHostedWorkHeartbeatResponse, + type BetaSelfHostedWorkListResponse as BetaSelfHostedWorkListResponse, + type BetaSelfHostedWorkQueueStats as BetaSelfHostedWorkQueueStats, + type BetaSelfHostedWorkStopRequest as BetaSelfHostedWorkStopRequest, + type BetaSelfHostedWorkUpdateRequest as BetaSelfHostedWorkUpdateRequest, + type BetaSessionWorkData as BetaSessionWorkData, + type BetaSelfHostedWorksPageCursor as BetaSelfHostedWorksPageCursor, + type WorkRetrieveParams as WorkRetrieveParams, + type WorkUpdateParams as WorkUpdateParams, + type WorkListParams as WorkListParams, + type WorkAckParams as WorkAckParams, + type WorkHeartbeatParams as WorkHeartbeatParams, + type WorkPollParams as WorkPollParams, + type WorkStatsParams as WorkStatsParams, + type WorkStopParams as WorkStopParams, + }; +} diff --git a/src/resources/beta/environments/index.ts b/src/resources/beta/environments/index.ts new file mode 100644 index 00000000..e73271b1 --- /dev/null +++ b/src/resources/beta/environments/index.ts @@ -0,0 +1,42 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export { + Environments, + type BetaCloudConfig, + type BetaCloudConfigParams, + type BetaEnvironment, + type BetaEnvironmentDeleteResponse, + type BetaLimitedNetwork, + type BetaLimitedNetworkParams, + type BetaPackages, + type BetaPackagesParams, + type BetaSelfHostedConfig, + type BetaSelfHostedConfigParams, + type BetaUnrestrictedNetwork, + type EnvironmentCreateParams, + type EnvironmentRetrieveParams, + type EnvironmentUpdateParams, + type EnvironmentListParams, + type EnvironmentDeleteParams, + type EnvironmentArchiveParams, + type BetaEnvironmentsPageCursor, +} from './environments'; +export { + Work, + type BetaSelfHostedWork, + type BetaSelfHostedWorkHeartbeatResponse, + type BetaSelfHostedWorkListResponse, + type BetaSelfHostedWorkQueueStats, + type BetaSelfHostedWorkStopRequest, + type BetaSelfHostedWorkUpdateRequest, + type BetaSessionWorkData, + type WorkRetrieveParams, + type WorkUpdateParams, + type WorkListParams, + type WorkAckParams, + type WorkHeartbeatParams, + type WorkPollParams, + type WorkStatsParams, + type WorkStopParams, + type BetaSelfHostedWorksPageCursor, +} from './work'; diff --git a/src/resources/beta/environments/work.ts b/src/resources/beta/environments/work.ts new file mode 100644 index 00000000..58cf6bae --- /dev/null +++ b/src/resources/beta/environments/work.ts @@ -0,0 +1,697 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import type { Anthropic } from '../../../client'; +import { APIResource } from '../../../core/resource'; +import * as BetaAPI from '../beta'; +import { APIPromise } from '../../../core/api-promise'; +import { PageCursor, type PageCursorParams, PagePromise } from '../../../core/pagination'; +import { buildHeaders } from '../../../internal/headers'; +import { RequestOptions } from '../../../internal/request-options'; +import { path } from '../../../internal/utils/path'; +import { + WorkPoller, + type WorkPollerOptions as RunnerWorkPollerOptions, +} from '../../../lib/environments/poller'; +import { + EnvironmentWorker, + type EnvironmentWorkerOptions as RunnerEnvironmentWorkerOptions, +} from '../../../lib/environments/worker'; + +export class Work extends APIResource { + /** + * Note: these endpoints are called automatically by the pre-built environment + * worker provided in the SDKs and CLI, for orchestrating sessions with self-hosted + * sandbox environments. They are included here as a reference; you do not need to + * invoke them directly. + * + * Retrieve detailed information about a specific work item. + * + * @example + * ```ts + * const betaSelfHostedWork = + * await client.beta.environments.work.retrieve('work_id', { + * environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW', + * }); + * ``` + */ + retrieve( + workID: string, + params: WorkRetrieveParams, + options?: RequestOptions, + ): APIPromise { + const { environment_id, betas } = params; + return this._client.get(path`/v1/environments/${environment_id}/work/${workID}?beta=true`, { + ...options, + headers: buildHeaders([ + { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, + options?.headers, + ]), + }); + } + + /** + * Note: these endpoints are called automatically by the pre-built environment + * worker provided in the SDKs and CLI, for orchestrating sessions with self-hosted + * sandbox environments. They are included here as a reference; you do not need to + * invoke them directly. + * + * Update work item metadata with merge semantics. + * + * @example + * ```ts + * const betaSelfHostedWork = + * await client.beta.environments.work.update('work_id', { + * environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW', + * metadata: { foo: 'string' }, + * }); + * ``` + */ + update(workID: string, params: WorkUpdateParams, options?: RequestOptions): APIPromise { + const { environment_id, betas, ...body } = params; + return this._client.post(path`/v1/environments/${environment_id}/work/${workID}?beta=true`, { + body, + ...options, + headers: buildHeaders([ + { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, + options?.headers, + ]), + }); + } + + /** + * Note: these endpoints are called automatically by the pre-built environment + * worker provided in the SDKs and CLI, for orchestrating sessions with self-hosted + * sandbox environments. They are included here as a reference; you do not need to + * invoke them directly. + * + * List work items in an environment. + * + * @example + * ```ts + * // Automatically fetches more pages as needed. + * for await (const betaSelfHostedWork of client.beta.environments.work.list( + * 'env_011CZkZ9X2dpNyB7HsEFoRfW', + * )) { + * // ... + * } + * ``` + */ + list( + environmentID: string, + params: WorkListParams | null | undefined = {}, + options?: RequestOptions, + ): PagePromise { + const { betas, ...query } = params ?? {}; + return this._client.getAPIList( + path`/v1/environments/${environmentID}/work?beta=true`, + PageCursor, + { + query, + ...options, + headers: buildHeaders([ + { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, + options?.headers, + ]), + }, + ); + } + + /** + * Note: these endpoints are called automatically by the pre-built environment + * worker provided in the SDKs and CLI, for orchestrating sessions with self-hosted + * sandbox environments. They are included here as a reference; you do not need to + * invoke them directly. + * + * Acknowledge receipt of a work item, transitioning it from 'queued' to 'starting' + * and removing it from the queue. + * + * @example + * ```ts + * const betaSelfHostedWork = + * await client.beta.environments.work.ack('work_id', { + * environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW', + * }); + * ``` + */ + ack(workID: string, params: WorkAckParams, options?: RequestOptions): APIPromise { + const { environment_id, betas } = params; + return this._client.post(path`/v1/environments/${environment_id}/work/${workID}/ack?beta=true`, { + ...options, + headers: buildHeaders([ + { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, + options?.headers, + ]), + }); + } + + /** + * Note: these endpoints are called automatically by the pre-built environment + * worker provided in the SDKs and CLI, for orchestrating sessions with self-hosted + * sandbox environments. They are included here as a reference; you do not need to + * invoke them directly. + * + * Record a heartbeat for a work item to maintain the lease. + * + * @example + * ```ts + * const betaSelfHostedWorkHeartbeatResponse = + * await client.beta.environments.work.heartbeat('work_id', { + * environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW', + * }); + * ``` + */ + heartbeat( + workID: string, + params: WorkHeartbeatParams, + options?: RequestOptions, + ): APIPromise { + const { environment_id, desired_ttl_seconds, expected_last_heartbeat, betas } = params; + return this._client.post(path`/v1/environments/${environment_id}/work/${workID}/heartbeat?beta=true`, { + query: { desired_ttl_seconds, expected_last_heartbeat }, + ...options, + headers: buildHeaders([ + { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, + options?.headers, + ]), + }); + } + + /** + * Note: these endpoints are called automatically by the pre-built environment + * worker provided in the SDKs and CLI, for orchestrating sessions with self-hosted + * sandbox environments. They are included here as a reference; you do not need to + * invoke them directly. + * + * Long poll for work items in the queue. + * + * @example + * ```ts + * const betaSelfHostedWork = + * await client.beta.environments.work.poll( + * 'env_011CZkZ9X2dpNyB7HsEFoRfW', + * ); + * ``` + */ + poll( + environmentID: string, + params: WorkPollParams | null | undefined = {}, + options?: RequestOptions, + ): APIPromise { + const { betas, 'Anthropic-Worker-ID': anthropicWorkerID, ...query } = params ?? {}; + return this._client.get(path`/v1/environments/${environmentID}/work/poll?beta=true`, { + query, + ...options, + headers: buildHeaders([ + { + 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString(), + ...(anthropicWorkerID != null ? { 'Anthropic-Worker-ID': anthropicWorkerID } : undefined), + }, + options?.headers, + ]), + }); + } + + /** + * Get statistics about the work queue for an environment. + * + * @example + * ```ts + * const betaSelfHostedWorkQueueStats = + * await client.beta.environments.work.stats( + * 'env_011CZkZ9X2dpNyB7HsEFoRfW', + * ); + * ``` + */ + stats( + environmentID: string, + params: WorkStatsParams | null | undefined = {}, + options?: RequestOptions, + ): APIPromise { + const { betas } = params ?? {}; + return this._client.get(path`/v1/environments/${environmentID}/work/stats?beta=true`, { + ...options, + headers: buildHeaders([ + { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, + options?.headers, + ]), + }); + } + + /** + * Note: these endpoints are called automatically by the pre-built environment + * worker provided in the SDKs and CLI, for orchestrating sessions with self-hosted + * sandbox environments. They are included here as a reference; you do not need to + * invoke them directly. + * + * Stop a work item, initiating graceful or forced shutdown. + * + * @example + * ```ts + * const betaSelfHostedWork = + * await client.beta.environments.work.stop('work_id', { + * environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW', + * }); + * ``` + */ + stop(workID: string, params: WorkStopParams, options?: RequestOptions): APIPromise { + const { environment_id, betas, ...body } = params; + return this._client.post(path`/v1/environments/${environment_id}/work/${workID}/stop?beta=true`, { + body, + ...options, + headers: buildHeaders([ + { 'anthropic-beta': [...(betas ?? []), 'managed-agents-2026-04-01'].toString() }, + options?.headers, + ]), + }); + } + + /** + * Continuously claim work from a self-hosted environment, ack each item, + * and yield it. Posts `stop` automatically when the consumer's loop body + * returns or when iteration ends. + * + * @example + * ```ts + * for await (const work of client.beta.environments.work.poller({ + * environmentId, + * environmentKey, + * })) { + * if (work.data.type !== 'session') continue; + * // ...service the work... + * } + * ``` + */ + poller(opts: Omit): WorkPoller { + return new WorkPoller({ ...opts, client: this._client as Anthropic }); + } + + /** + * The self-hosted environment runner: poll for work, and for each claimed + * session set up the workdir, download the agent's skills, run the tools while + * heartbeating the lease, and force-stop on exit. + * + * @example + * ```ts + * // Long-running daemon — poll, serve each session, loop: + * await client.beta.environments.work + * .worker({ environmentId, environmentKey, workdir: '/workspace' }) + * .run(); + * + * // Or service one already-claimed work item (e.g. inside a sandbox spawned + * // by `ant worker poll --on-work`) — handleItem() reads the ANTHROPIC_* env vars: + * await client.beta.environments.work.worker({ workdir: '/workspace' }).handleItem(); + * ``` + */ + worker(opts: Omit): EnvironmentWorker { + return new EnvironmentWorker({ ...opts, client: this._client as Anthropic }); + } +} + +export type BetaSelfHostedWorksPageCursor = PageCursor; + +/** + * Work data for environment health checks. + * + * This resource type is used for assessing the health of containers where work + * occurs. The data is opaque to users; the runner handles the health check by + * probing connectivity to required services. + */ +export interface BetaHealthCheckWorkData { + /** + * Health check identifier + */ + id: string; + + /** + * Type of work data + */ + type?: 'healthcheck'; +} + +/** + * Work resource representing a unit of work in a self-hosted environment. + * + * Work items are queued when sessions are created or when long-dormant sessions + * receive new messages. The Environment Manager polls for work items and executes + * them on customer-hosted infrastructure. + */ +export interface BetaSelfHostedWork { + /** + * Work identifier (e.g., 'work\_...') + */ + id: string; + + /** + * RFC 3339 timestamp when work was acknowledged by Environment Manager + */ + acknowledged_at: string | null; + + /** + * RFC 3339 timestamp when work was created + */ + created_at: string; + + /** + * The actual work to be performed (session or health check) + */ + data: BetaSessionWorkData | BetaHealthCheckWorkData; + + /** + * Environment identifier this work belongs to (e.g., `env_...`) + */ + environment_id: string; + + /** + * RFC 3339 timestamp of the most recent heartbeat + */ + latest_heartbeat_at: string | null; + + /** + * User-provided metadata key-value pairs associated with this work item + */ + metadata: { [key: string]: string }; + + /** + * Session instance JWT secret (only included in certain retrieval paths) + */ + secret: string | null; + + /** + * RFC 3339 timestamp when work execution started + */ + started_at: string | null; + + /** + * Current state of the work item + */ + state: 'queued' | 'starting' | 'active' | 'stopping' | 'stopped'; + + /** + * RFC 3339 timestamp when stop was requested + */ + stop_requested_at: string | null; + + /** + * RFC 3339 timestamp when work execution stopped + */ + stopped_at: string | null; + + /** + * The type of object (always 'work') + */ + type: 'work'; +} + +/** + * Response after recording a heartbeat for a work item. + */ +export interface BetaSelfHostedWorkHeartbeatResponse { + /** + * RFC 3339 timestamp of the actual heartbeat from DB + */ + last_heartbeat: string; + + /** + * Whether the heartbeat succeeded in extending the lease + */ + lease_extended: boolean; + + /** + * Current state of the work item (active/stopping/stopped) + */ + state: 'queued' | 'starting' | 'active' | 'stopping' | 'stopped'; + + /** + * Effective TTL applied to the lease + */ + ttl_seconds: number; + + /** + * The type of response + */ + type: 'work_heartbeat'; +} + +/** + * Response when listing work items with cursor-based pagination. + */ +export interface BetaSelfHostedWorkListResponse { + /** + * List of work items + */ + data: Array; + + /** + * Opaque cursor for fetching the next page of results + */ + next_page: string | null; +} + +/** + * Statistics about the work queue for an environment. + * + * Uses Redis Stream consumer group metrics for O(1) queries. + */ +export interface BetaSelfHostedWorkQueueStats { + /** + * Number of work items waiting to be picked up (lag from consumer group) + */ + depth: number; + + /** + * RFC 3339 timestamp of oldest item in the work stream (includes both queued and + * pending items), null if stream empty + */ + oldest_queued_at: string | null; + + /** + * Number of work items being processed (polled but not acknowledged) + */ + pending: number; + + /** + * The type of object + */ + type: 'work_queue_stats'; + + /** + * Number of workers that have polled for work in the last 30 seconds. Requires + * worker_id to be sent with poll requests. + */ + workers_polling: number | null; +} + +/** + * Request to stop a work item. + */ +export interface BetaSelfHostedWorkStopRequest { + /** + * If true, immediately stop work without graceful shutdown + */ + force?: boolean; +} + +/** + * Request to update work item metadata. + */ +export interface BetaSelfHostedWorkUpdateRequest { + /** + * Metadata patch. Set a key to a string to upsert it, or to null to delete it. + * Omit the field to preserve existing metadata. + */ + metadata: { [key: string]: string | null }; +} + +/** + * Work data for session work items. + * + * This resource type is used when work represents a session that needs to be + * executed in a self-hosted environment. + */ +export interface BetaSessionWorkData { + /** + * Session identifier (e.g., 'session\_...') + */ + id: string; + + /** + * Type of work data + */ + type: 'session'; +} + +/** + * Decoded payload of the `secret` field on a self-hosted work item. The wire value + * of `secret` is a base64url-encoded JSON document matching this schema. SDKs that + * ship a runner helper for self-hosted environments use this type as the return + * value of their secret-decoding utility. + */ +export interface BetaWorkSecret { + /** + * Bearer credential the runner uses for all per-session downstream calls + * (heartbeat, ack, event stream, send, stop). Format: `sk-ant-req-...`. + */ + sessions_token: string; + + /** + * API base URL the runner should use for downstream calls. When absent the runner + * falls back to its default Anthropic endpoint. + */ + api_base_url?: string; +} + +export interface WorkRetrieveParams { + /** + * Path param + */ + environment_id: string; + + /** + * Header param: Optional header to specify the beta version(s) you want to use. + */ + betas?: Array; +} + +export interface WorkUpdateParams { + /** + * Path param + */ + environment_id: string; + + /** + * Body param: Metadata patch. Set a key to a string to upsert it, or to null to + * delete it. Omit the field to preserve existing metadata. + */ + metadata: { [key: string]: string | null }; + + /** + * Header param: Optional header to specify the beta version(s) you want to use. + */ + betas?: Array; +} + +export interface WorkListParams extends PageCursorParams { + /** + * Header param: Optional header to specify the beta version(s) you want to use. + */ + betas?: Array; +} + +export interface WorkAckParams { + /** + * Path param + */ + environment_id: string; + + /** + * Header param: Optional header to specify the beta version(s) you want to use. + */ + betas?: Array; +} + +export interface WorkHeartbeatParams { + /** + * Path param + */ + environment_id: string; + + /** + * Query param: Desired TTL in seconds + */ + desired_ttl_seconds?: number | null; + + /** + * Query param: Expected last_heartbeat for conditional update (optimistic + * concurrency). Use literal 'NO_HEARTBEAT' to claim an unclaimed lease (first + * heartbeat). For subsequent heartbeats, echo the server's previous last_heartbeat + * value exactly. Returns 412 Precondition Failed if the actual value doesn't + * match. + */ + expected_last_heartbeat?: string | null; + + /** + * Header param: Optional header to specify the beta version(s) you want to use. + */ + betas?: Array; +} + +export interface WorkPollParams { + /** + * Query param: How long to wait for work to arrive before returning. Must be 1-999 + * in milliseconds. Defaults to non-blocking (returns immediately if no work is + * available). + */ + block_ms?: number | null; + + /** + * Query param: Reclaim unacknowledged work items older than this many + * milliseconds. If omitted, uses the default (5000ms). + */ + reclaim_older_than_ms?: number | null; + + /** + * Header param: Optional header to specify the beta version(s) you want to use. + */ + betas?: Array; + + /** + * Header param: Unique identifier for the specific worker polling, used to track + * aggregated environment-level work metrics in Console + */ + 'Anthropic-Worker-ID'?: string; +} + +export interface WorkStatsParams { + /** + * Optional header to specify the beta version(s) you want to use. + */ + betas?: Array; +} + +export interface WorkStopParams { + /** + * Path param + */ + environment_id: string; + + /** + * Body param: If true, immediately stop work without graceful shutdown + */ + force?: boolean; + + /** + * Header param: Optional header to specify the beta version(s) you want to use. + */ + betas?: Array; +} + +export { WorkPoller, type WorkPollerOptions } from '../../../lib/environments/poller'; +export { EnvironmentWorker, type EnvironmentWorkerOptions } from '../../../lib/environments/worker'; + +Work.WorkPoller = WorkPoller; +Work.EnvironmentWorker = EnvironmentWorker; + +export declare namespace Work { + export { WorkPoller, EnvironmentWorker }; + + export { + type BetaHealthCheckWorkData as BetaHealthCheckWorkData, + type BetaSelfHostedWork as BetaSelfHostedWork, + type BetaSelfHostedWorkHeartbeatResponse as BetaSelfHostedWorkHeartbeatResponse, + type BetaSelfHostedWorkListResponse as BetaSelfHostedWorkListResponse, + type BetaSelfHostedWorkQueueStats as BetaSelfHostedWorkQueueStats, + type BetaSelfHostedWorkStopRequest as BetaSelfHostedWorkStopRequest, + type BetaSelfHostedWorkUpdateRequest as BetaSelfHostedWorkUpdateRequest, + type BetaSessionWorkData as BetaSessionWorkData, + type BetaWorkSecret as BetaWorkSecret, + type BetaSelfHostedWorksPageCursor as BetaSelfHostedWorksPageCursor, + type WorkRetrieveParams as WorkRetrieveParams, + type WorkUpdateParams as WorkUpdateParams, + type WorkListParams as WorkListParams, + type WorkAckParams as WorkAckParams, + type WorkHeartbeatParams as WorkHeartbeatParams, + type WorkPollParams as WorkPollParams, + type WorkStatsParams as WorkStatsParams, + type WorkStopParams as WorkStopParams, + }; +} diff --git a/src/resources/beta/index.ts b/src/resources/beta/index.ts index b842ecaa..d3813fa7 100644 --- a/src/resources/beta/index.ts +++ b/src/resources/beta/index.ts @@ -9,7 +9,13 @@ export { type BetaManagedAgentsAgentToolsetDefaultConfig, type BetaManagedAgentsAgentToolsetDefaultConfigParams, type BetaManagedAgentsAgentToolset20260401, + type BetaManagedAgentsAgentToolset20260401BashInput, + type BetaManagedAgentsAgentToolset20260401EditInput, + type BetaManagedAgentsAgentToolset20260401GlobInput, + type BetaManagedAgentsAgentToolset20260401GrepInput, type BetaManagedAgentsAgentToolset20260401Params, + type BetaManagedAgentsAgentToolset20260401ReadInput, + type BetaManagedAgentsAgentToolset20260401WriteInput, type BetaManagedAgentsAlwaysAllowPolicy, type BetaManagedAgentsAlwaysAskPolicy, type BetaManagedAgentsAnthropicSkill, @@ -32,6 +38,7 @@ export { type BetaManagedAgentsMultiagentCoordinator, type BetaManagedAgentsMultiagentCoordinatorParams, type BetaManagedAgentsMultiagentSelfParams, + type BetaManagedAgentsSessionThreadAgent, type BetaManagedAgentsSkillParams, type BetaManagedAgentsURLMCPServerParams, type AgentCreateParams, @@ -66,6 +73,8 @@ export { type BetaLimitedNetworkParams, type BetaPackages, type BetaPackagesParams, + type BetaSelfHostedConfig, + type BetaSelfHostedConfigParams, type BetaUnrestrictedNetwork, type EnvironmentCreateParams, type EnvironmentRetrieveParams, @@ -74,7 +83,7 @@ export { type EnvironmentDeleteParams, type EnvironmentArchiveParams, type BetaEnvironmentsPageCursor, -} from './environments'; +} from './environments/index'; export { Files, type BetaFileScope, @@ -354,9 +363,12 @@ export { type BetaManagedAgentsOutcomeEvaluationResource, type BetaManagedAgentsSession, type BetaManagedAgentsSessionAgent, + type BetaManagedAgentsSessionAgentUpdate, type BetaManagedAgentsSessionMultiagentCoordinator, type BetaManagedAgentsSessionStats, + type BetaManagedAgentsSessionUpdatedEvent, type BetaManagedAgentsSessionUsage, + type BetaManagedAgentsUserToolResultEvent, type SessionCreateParams, type SessionRetrieveParams, type SessionUpdateParams, diff --git a/src/resources/beta/messages/messages.ts b/src/resources/beta/messages/messages.ts index b5687648..919d4762 100644 --- a/src/resources/beta/messages/messages.ts +++ b/src/resources/beta/messages/messages.ts @@ -1179,11 +1179,6 @@ export interface BetaCompactionBlock { * treats these as no-ops. Empty string content is not allowed. */ export interface BetaCompactionBlockParam { - /** - * Summary of previously compacted content, or null if compaction failed - */ - content: string | null; - type: 'compaction'; /** @@ -1191,6 +1186,11 @@ export interface BetaCompactionBlockParam { */ cache_control?: BetaCacheControlEphemeral | null; + /** + * Summary of previously compacted content, or null if compaction failed + */ + content?: string | null; + /** * Opaque metadata from prior compaction, to be round-tripped verbatim */ diff --git a/src/resources/beta/sessions/events.ts b/src/resources/beta/sessions/events.ts index bd7490df..27be9967 100644 --- a/src/resources/beta/sessions/events.ts +++ b/src/resources/beta/sessions/events.ts @@ -1,13 +1,19 @@ // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +import type { Anthropic } from '../../../client'; import { APIResource } from '../../../core/resource'; import * as BetaAPI from '../beta'; +import * as SessionsAPI from './sessions'; import { APIPromise } from '../../../core/api-promise'; import { PageCursor, type PageCursorParams, PagePromise } from '../../../core/pagination'; import { Stream } from '../../../core/streaming'; import { buildHeaders } from '../../../internal/headers'; import { RequestOptions } from '../../../internal/request-options'; import { path } from '../../../internal/utils/path'; +import { + SessionToolRunner, + type SessionToolRunnerOptions as RunnerSessionToolRunnerOptions, +} from '../../../lib/tools/SessionToolRunner'; export class Events extends APIResource { /** @@ -109,6 +115,29 @@ export class Events extends APIResource { stream: true, }) as APIPromise>; } + + /** + * Attach to a session and dispatch every incoming `agent.tool_use` and + * `agent.custom_tool_use` event to a local tool registry, sending the matching + * result back (`user.tool_result` / `user.custom_tool_result`). The + * sessions-side counterpart to `client.beta.messages.toolRunner`: yields one + * entry per completed tool call so callers can observe each dispatch (and + * `break` to abort cleanly). + * + * @example + * ```ts + * import { betaAgentToolset20260401 } from '@anthropic-ai/sdk/tools/agent-toolset/node'; + * + * for await (const call of client.beta.sessions.events.toolRunner(work.data.id, { + * tools: [...betaAgentToolset20260401({ workdir }), myTool], + * })) { + * console.log(`${call.name} -> ${call.isError ? 'error' : 'ok'}`); + * } + * ``` + */ + toolRunner(sessionID: string, opts: Omit): SessionToolRunner { + return new SessionToolRunner(sessionID, { ...opts, client: this._client as Anthropic }); + } } export type BetaManagedAgentsSessionEventsPageCursor = PageCursor; @@ -524,7 +553,8 @@ export type BetaManagedAgentsEventParams = | BetaManagedAgentsUserInterruptEventParams | BetaManagedAgentsUserToolConfirmationEventParams | BetaManagedAgentsUserCustomToolResultEventParams - | BetaManagedAgentsUserDefineOutcomeEventParams; + | BetaManagedAgentsUserDefineOutcomeEventParams + | BetaManagedAgentsUserToolResultEventParams; /** * Document referenced by file ID. @@ -804,6 +834,7 @@ export interface BetaManagedAgentsSendSessionEvents { | BetaManagedAgentsUserToolConfirmationEvent | BetaManagedAgentsUserCustomToolResultEvent | BetaManagedAgentsUserDefineOutcomeEvent + | SessionsAPI.BetaManagedAgentsUserToolResultEvent >; } @@ -897,7 +928,9 @@ export type BetaManagedAgentsSessionEvent = | BetaManagedAgentsSessionThreadStatusRunningEvent | BetaManagedAgentsSessionThreadStatusIdleEvent | BetaManagedAgentsSessionThreadStatusTerminatedEvent - | BetaManagedAgentsSessionThreadStatusRescheduledEvent; + | SessionsAPI.BetaManagedAgentsUserToolResultEvent + | BetaManagedAgentsSessionThreadStatusRescheduledEvent + | SessionsAPI.BetaManagedAgentsSessionUpdatedEvent; /** * The agent is idle waiting on one or more blocking user-input events (tool @@ -1380,7 +1413,9 @@ export type BetaManagedAgentsStreamSessionEvents = | BetaManagedAgentsSessionThreadStatusRunningEvent | BetaManagedAgentsSessionThreadStatusIdleEvent | BetaManagedAgentsSessionThreadStatusTerminatedEvent - | BetaManagedAgentsSessionThreadStatusRescheduledEvent; + | SessionsAPI.BetaManagedAgentsUserToolResultEvent + | BetaManagedAgentsSessionThreadStatusRescheduledEvent + | SessionsAPI.BetaManagedAgentsSessionUpdatedEvent; /** * Regular text content. @@ -1744,6 +1779,38 @@ export interface BetaManagedAgentsUserToolConfirmationEventParams { deny_message?: string | null; } +/** + * Parameters for providing the result of an agent-toolset tool execution. Only + * valid on `self_hosted` environments, where sandbox-routed tools are executed by + * the client rather than the server. + */ +export interface BetaManagedAgentsUserToolResultEventParams { + /** + * The id of the `agent.tool_use` event this result corresponds to, which can be + * found in the last `session.status_idle` + * [event's](https://platform.claude.com/docs/en/api/beta/sessions/events/list#beta_managed_agents_session_requires_action.event_ids) + * `stop_reason.event_ids` field. + */ + tool_use_id: string; + + type: 'user.tool_result'; + + /** + * The result content returned by the tool. + */ + content?: Array< + | BetaManagedAgentsTextBlock + | BetaManagedAgentsImageBlock + | BetaManagedAgentsDocumentBlock + | BetaManagedAgentsSearchResultBlock + >; + + /** + * Whether the tool execution resulted in an error. + */ + is_error?: boolean | null; +} + export interface EventListParams extends PageCursorParams { /** * Query param: Return events created after this time (exclusive). @@ -1803,7 +1870,13 @@ export interface EventStreamParams { betas?: Array; } +export { SessionToolRunner, type SessionToolRunnerOptions } from '../../../lib/tools/SessionToolRunner'; + +Events.SessionToolRunner = SessionToolRunner; + export declare namespace Events { + export { SessionToolRunner }; + export { type BetaManagedAgentsAgentCustomToolUseEvent as BetaManagedAgentsAgentCustomToolUseEvent, type BetaManagedAgentsAgentMCPToolResultEvent as BetaManagedAgentsAgentMCPToolResultEvent, @@ -1876,6 +1949,7 @@ export declare namespace Events { type BetaManagedAgentsUserMessageEventParams as BetaManagedAgentsUserMessageEventParams, type BetaManagedAgentsUserToolConfirmationEvent as BetaManagedAgentsUserToolConfirmationEvent, type BetaManagedAgentsUserToolConfirmationEventParams as BetaManagedAgentsUserToolConfirmationEventParams, + type BetaManagedAgentsUserToolResultEventParams as BetaManagedAgentsUserToolResultEventParams, type BetaManagedAgentsSessionEventsPageCursor as BetaManagedAgentsSessionEventsPageCursor, type EventListParams as EventListParams, type EventSendParams as EventSendParams, diff --git a/src/resources/beta/sessions/index.ts b/src/resources/beta/sessions/index.ts index 49cf7e52..b6cdb336 100644 --- a/src/resources/beta/sessions/index.ts +++ b/src/resources/beta/sessions/index.ts @@ -73,6 +73,7 @@ export { type BetaManagedAgentsUserMessageEventParams, type BetaManagedAgentsUserToolConfirmationEvent, type BetaManagedAgentsUserToolConfirmationEventParams, + type BetaManagedAgentsUserToolResultEventParams, type EventListParams, type EventSendParams, type EventStreamParams, @@ -110,9 +111,12 @@ export { type BetaManagedAgentsOutcomeEvaluationResource, type BetaManagedAgentsSession, type BetaManagedAgentsSessionAgent, + type BetaManagedAgentsSessionAgentUpdate, type BetaManagedAgentsSessionMultiagentCoordinator, type BetaManagedAgentsSessionStats, + type BetaManagedAgentsSessionUpdatedEvent, type BetaManagedAgentsSessionUsage, + type BetaManagedAgentsUserToolResultEvent, type SessionCreateParams, type SessionRetrieveParams, type SessionUpdateParams, @@ -124,7 +128,6 @@ export { export { Threads, type BetaManagedAgentsSessionThread, - type BetaManagedAgentsSessionThreadAgent, type BetaManagedAgentsSessionThreadStats, type BetaManagedAgentsSessionThreadStatus, type BetaManagedAgentsSessionThreadUsage, diff --git a/src/resources/beta/sessions/sessions.ts b/src/resources/beta/sessions/sessions.ts index bd257bc0..077d3a08 100644 --- a/src/resources/beta/sessions/sessions.ts +++ b/src/resources/beta/sessions/sessions.ts @@ -77,6 +77,7 @@ import { BetaManagedAgentsUserMessageEventParams, BetaManagedAgentsUserToolConfirmationEvent, BetaManagedAgentsUserToolConfirmationEventParams, + BetaManagedAgentsUserToolResultEventParams, EventListParams, EventSendParams, EventStreamParams, @@ -102,7 +103,6 @@ import { import * as ThreadsAPI from './threads/threads'; import { BetaManagedAgentsSessionThread, - BetaManagedAgentsSessionThreadAgent, BetaManagedAgentsSessionThreadStats, BetaManagedAgentsSessionThreadStatus, BetaManagedAgentsSessionThreadUsage, @@ -596,6 +596,29 @@ export interface BetaManagedAgentsSessionAgent { version: number; } +/** + * Mid-session agent configuration update. Only `tools` and `mcp_servers` are + * updatable. Full replacement: the provided array becomes the new value. To + * preserve existing entries, GET the session, modify the array, and POST it back. + */ +export interface BetaManagedAgentsSessionAgentUpdate { + /** + * Replacement MCP server list. Full replacement: the provided array becomes the + * new value. Send an empty array to clear; omit to preserve. + */ + mcp_servers?: Array; + + /** + * Replacement tool list. Full replacement: the provided array becomes the new + * value. Send an empty array to clear; omit to preserve. + */ + tools?: Array< + | AgentsAPI.BetaManagedAgentsAgentToolset20260401Params + | AgentsAPI.BetaManagedAgentsMCPToolsetParams + | AgentsAPI.BetaManagedAgentsCustomToolParams + >; +} + /** * Resolved coordinator topology with full agent definitions for each roster * member. @@ -604,7 +627,7 @@ export interface BetaManagedAgentsSessionMultiagentCoordinator { /** * Full `agent` definitions the coordinator may spawn as session threads. */ - agents: Array; + agents: Array; type: 'coordinator'; } @@ -626,6 +649,42 @@ export interface BetaManagedAgentsSessionStats { duration_seconds?: number; } +/** + * Emitted when an UpdateSession request changed at least one field. Carries only + * the fields that changed; absent fields were not part of the update. The new + * configuration applies from the next turn. + */ +export interface BetaManagedAgentsSessionUpdatedEvent { + /** + * Unique identifier for this event. + */ + id: string; + + /** + * A timestamp in RFC 3339 format + */ + processed_at: string; + + type: 'session.updated'; + + /** + * Resolved `agent` definition for a `session`. Snapshot of the `agent` at + * `session` creation time. + */ + agent?: BetaManagedAgentsSessionAgent | null; + + /** + * The session's full metadata bag after the update. Present when the update set + * non-empty metadata; absent when metadata was unchanged or cleared to empty. + */ + metadata?: { [key: string]: string }; + + /** + * The session's new title. Present only when the update changed it. + */ + title?: string | null; +} + /** * Cumulative token usage for a session across all turns. */ @@ -651,6 +710,54 @@ export interface BetaManagedAgentsSessionUsage { output_tokens?: number; } +/** + * Event sent by the client providing the result of an agent-toolset tool + * execution. Only valid on `self_hosted` environments, where sandbox-routed tools + * are executed by the client rather than the server. + */ +export interface BetaManagedAgentsUserToolResultEvent { + /** + * Unique identifier for this event. + */ + id: string; + + /** + * The id of the `agent.tool_use` event this result corresponds to, which can be + * found in the last `session.status_idle` + * [event's](https://platform.claude.com/docs/en/api/beta/sessions/events/list#beta_managed_agents_session_requires_action.event_ids) + * `stop_reason.event_ids` field. + */ + tool_use_id: string; + + type: 'user.tool_result'; + + /** + * The result content returned by the tool. + */ + content?: Array< + | EventsAPI.BetaManagedAgentsTextBlock + | EventsAPI.BetaManagedAgentsImageBlock + | EventsAPI.BetaManagedAgentsDocumentBlock + | EventsAPI.BetaManagedAgentsSearchResultBlock + >; + + /** + * Whether the tool execution resulted in an error. + */ + is_error?: boolean | null; + + /** + * A timestamp in RFC 3339 format + */ + processed_at?: string | null; + + /** + * Routes this result to a subagent thread. Copy from the `agent.tool_use` event's + * `session_thread_id`. + */ + session_thread_id?: string | null; +} + export interface SessionCreateParams { /** * Body param: Agent identifier. Accepts the `agent` ID string, which pins the @@ -706,6 +813,14 @@ export interface SessionRetrieveParams { } export interface SessionUpdateParams { + /** + * Body param: Mid-session agent configuration update. Only `tools` and + * `mcp_servers` are updatable. Full replacement: the provided array becomes the + * new value. To preserve existing entries, GET the session, modify the array, and + * POST it back. + */ + agent?: BetaManagedAgentsSessionAgentUpdate; + /** * Body param: Metadata patch. Set a key to a string to upsert it, or to null to * delete it. Omit the field to preserve. @@ -824,9 +939,12 @@ export declare namespace Sessions { type BetaManagedAgentsOutcomeEvaluationResource as BetaManagedAgentsOutcomeEvaluationResource, type BetaManagedAgentsSession as BetaManagedAgentsSession, type BetaManagedAgentsSessionAgent as BetaManagedAgentsSessionAgent, + type BetaManagedAgentsSessionAgentUpdate as BetaManagedAgentsSessionAgentUpdate, type BetaManagedAgentsSessionMultiagentCoordinator as BetaManagedAgentsSessionMultiagentCoordinator, type BetaManagedAgentsSessionStats as BetaManagedAgentsSessionStats, + type BetaManagedAgentsSessionUpdatedEvent as BetaManagedAgentsSessionUpdatedEvent, type BetaManagedAgentsSessionUsage as BetaManagedAgentsSessionUsage, + type BetaManagedAgentsUserToolResultEvent as BetaManagedAgentsUserToolResultEvent, type BetaManagedAgentsSessionsPageCursor as BetaManagedAgentsSessionsPageCursor, type SessionCreateParams as SessionCreateParams, type SessionRetrieveParams as SessionRetrieveParams, @@ -909,6 +1027,7 @@ export declare namespace Sessions { type BetaManagedAgentsUserMessageEventParams as BetaManagedAgentsUserMessageEventParams, type BetaManagedAgentsUserToolConfirmationEvent as BetaManagedAgentsUserToolConfirmationEvent, type BetaManagedAgentsUserToolConfirmationEventParams as BetaManagedAgentsUserToolConfirmationEventParams, + type BetaManagedAgentsUserToolResultEventParams as BetaManagedAgentsUserToolResultEventParams, type BetaManagedAgentsSessionEventsPageCursor as BetaManagedAgentsSessionEventsPageCursor, type EventListParams as EventListParams, type EventSendParams as EventSendParams, @@ -935,7 +1054,6 @@ export declare namespace Sessions { export { Threads as Threads, type BetaManagedAgentsSessionThread as BetaManagedAgentsSessionThread, - type BetaManagedAgentsSessionThreadAgent as BetaManagedAgentsSessionThreadAgent, type BetaManagedAgentsSessionThreadStats as BetaManagedAgentsSessionThreadStats, type BetaManagedAgentsSessionThreadStatus as BetaManagedAgentsSessionThreadStatus, type BetaManagedAgentsSessionThreadUsage as BetaManagedAgentsSessionThreadUsage, diff --git a/src/resources/beta/sessions/threads/index.ts b/src/resources/beta/sessions/threads/index.ts index ba1ec109..a103e291 100644 --- a/src/resources/beta/sessions/threads/index.ts +++ b/src/resources/beta/sessions/threads/index.ts @@ -4,7 +4,6 @@ export { Events, type EventListParams, type EventStreamParams } from './events'; export { Threads, type BetaManagedAgentsSessionThread, - type BetaManagedAgentsSessionThreadAgent, type BetaManagedAgentsSessionThreadStats, type BetaManagedAgentsSessionThreadStatus, type BetaManagedAgentsSessionThreadUsage, diff --git a/src/resources/beta/sessions/threads/threads.ts b/src/resources/beta/sessions/threads/threads.ts index 5a9f8ad7..d304247d 100644 --- a/src/resources/beta/sessions/threads/threads.ts +++ b/src/resources/beta/sessions/threads/threads.ts @@ -121,7 +121,7 @@ export interface BetaManagedAgentsSessionThread { * at thread creation time. The multiagent roster is not repeated here; read it * from `Session.agent`. */ - agent: BetaManagedAgentsSessionThreadAgent; + agent: AgentsAPI.BetaManagedAgentsSessionThreadAgent; /** * A timestamp in RFC 3339 format @@ -166,40 +166,6 @@ export interface BetaManagedAgentsSessionThread { usage: BetaManagedAgentsSessionThreadUsage | null; } -/** - * Resolved `agent` definition for a single `session_thread`. Snapshot of the agent - * at thread creation time. The multiagent roster is not repeated here; read it - * from `Session.agent`. - */ -export interface BetaManagedAgentsSessionThreadAgent { - id: string; - - description: string | null; - - mcp_servers: Array; - - /** - * Model identifier and configuration. - */ - model: AgentsAPI.BetaManagedAgentsModelConfig; - - name: string; - - skills: Array; - - system: string | null; - - tools: Array< - | AgentsAPI.BetaManagedAgentsAgentToolset20260401 - | AgentsAPI.BetaManagedAgentsMCPToolset - | AgentsAPI.BetaManagedAgentsCustomTool - >; - - type: 'agent'; - - version: number; -} - /** * Timing statistics for a session thread. */ @@ -287,7 +253,9 @@ export type BetaManagedAgentsStreamSessionThreadEvents = | EventsAPI.BetaManagedAgentsSessionThreadStatusRunningEvent | EventsAPI.BetaManagedAgentsSessionThreadStatusIdleEvent | EventsAPI.BetaManagedAgentsSessionThreadStatusTerminatedEvent - | EventsAPI.BetaManagedAgentsSessionThreadStatusRescheduledEvent; + | SessionsAPI.BetaManagedAgentsUserToolResultEvent + | EventsAPI.BetaManagedAgentsSessionThreadStatusRescheduledEvent + | SessionsAPI.BetaManagedAgentsSessionUpdatedEvent; export interface ThreadRetrieveParams { /** @@ -325,7 +293,6 @@ Threads.Events = Events; export declare namespace Threads { export { type BetaManagedAgentsSessionThread as BetaManagedAgentsSessionThread, - type BetaManagedAgentsSessionThreadAgent as BetaManagedAgentsSessionThreadAgent, type BetaManagedAgentsSessionThreadStats as BetaManagedAgentsSessionThreadStats, type BetaManagedAgentsSessionThreadStatus as BetaManagedAgentsSessionThreadStatus, type BetaManagedAgentsSessionThreadUsage as BetaManagedAgentsSessionThreadUsage, diff --git a/src/resources/beta/skills/index.ts b/src/resources/beta/skills/index.ts index ca00aaec..9ad90e43 100644 --- a/src/resources/beta/skills/index.ts +++ b/src/resources/beta/skills/index.ts @@ -22,5 +22,6 @@ export { type VersionRetrieveParams, type VersionListParams, type VersionDeleteParams, + type VersionDownloadParams, type VersionListResponsesPageCursor, } from './versions'; diff --git a/src/resources/beta/skills/skills.ts b/src/resources/beta/skills/skills.ts index 13737de2..02d7f4e1 100644 --- a/src/resources/beta/skills/skills.ts +++ b/src/resources/beta/skills/skills.ts @@ -8,6 +8,7 @@ import { VersionCreateResponse, VersionDeleteParams, VersionDeleteResponse, + VersionDownloadParams, VersionListParams, VersionListResponse, VersionListResponsesPageCursor, @@ -380,5 +381,6 @@ export declare namespace Skills { type VersionRetrieveParams as VersionRetrieveParams, type VersionListParams as VersionListParams, type VersionDeleteParams as VersionDeleteParams, + type VersionDownloadParams as VersionDownloadParams, }; } diff --git a/src/resources/beta/skills/versions.ts b/src/resources/beta/skills/versions.ts index 1d259d3d..dc164656 100644 --- a/src/resources/beta/skills/versions.ts +++ b/src/resources/beta/skills/versions.ts @@ -127,6 +127,35 @@ export class Versions extends APIResource { ]), }); } + + /** + * Download a skill version's content as a zip archive. + * + * @example + * ```ts + * const response = await client.beta.skills.versions.download( + * 'version', + * { skill_id: 'skill_id' }, + * ); + * + * const content = await response.blob(); + * console.log(content); + * ``` + */ + download(version: string, params: VersionDownloadParams, options?: RequestOptions): APIPromise { + const { skill_id, betas } = params; + return this._client.get(path`/v1/skills/${skill_id}/versions/${version}/content?beta=true`, { + ...options, + headers: buildHeaders([ + { + 'anthropic-beta': [...(betas ?? []), 'skills-2025-10-02'].toString(), + Accept: 'application/binary', + }, + options?.headers, + ]), + __binaryResponse: true, + }); + } } export type VersionListResponsesPageCursor = PageCursor; @@ -359,6 +388,20 @@ export interface VersionDeleteParams { betas?: Array; } +export interface VersionDownloadParams { + /** + * Path param: Unique identifier for the skill. + * + * The format and length of IDs may change over time. + */ + skill_id: string; + + /** + * Header param: Optional header to specify the beta version(s) you want to use. + */ + betas?: Array; +} + export declare namespace Versions { export { type VersionCreateResponse as VersionCreateResponse, @@ -370,5 +413,6 @@ export declare namespace Versions { type VersionRetrieveParams as VersionRetrieveParams, type VersionListParams as VersionListParams, type VersionDeleteParams as VersionDeleteParams, + type VersionDownloadParams as VersionDownloadParams, }; } diff --git a/src/tools/agent-toolset/fs-util.ts b/src/tools/agent-toolset/fs-util.ts new file mode 100644 index 00000000..10eb5a91 --- /dev/null +++ b/src/tools/agent-toolset/fs-util.ts @@ -0,0 +1,154 @@ +/** + * Shared, Node-only filesystem helpers for the agent toolset's file tools: + * path confinement (symlink-aware), an atomic write, and language-independent + * error messages. Kept out of `node.ts` so the tool implementations stay focused + * and these helpers can be reused by every file tool. + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { ToolError } from '../../lib/tools/ToolError'; + +/** Mode for directories the file tools create — not world-writable under a 0 umask. */ +export const DIR_CREATE_MODE = 0o755; +/** Mode for files the file tools create. */ +export const FILE_CREATE_MODE = 0o644; + +/** `realpath` `p`, or return `p` unchanged when it cannot be resolved. */ +async function realpathOrSelf(p: string): Promise { + try { + return await fs.realpath(p); + } catch { + return p; + } +} + +/** + * Fully resolve `abs`: `realpath` the longest existing ancestor and re-append + * the rest, but never re-append a component that is itself a symlink — read the + * link and continue from its target instead. This handles paths being created + * (write/edit) without letting a symlink leaf (e.g. a dangling one pointing + * outside a confinement root) slip through unresolved. + */ +export async function canonicalize(abs: string): Promise { + const tail: string[] = []; + let prefix = abs; + for (;;) { + let real: string; + try { + real = await fs.realpath(prefix); + } catch { + let isLink = false; + try { + isLink = (await fs.lstat(prefix)).isSymbolicLink(); + } catch { + /* prefix truly doesn't exist (ENOENT) — fall through and walk up */ + } + if (isLink) { + // Resolve the symlink ourselves and retry; `tail` (the part below it) + // still applies to the link's target. + prefix = path.resolve(path.dirname(prefix), await fs.readlink(prefix)); + continue; + } + const parent = path.dirname(prefix); + if (parent === prefix) return abs; // walked past the FS root without a hit + tail.push(path.basename(prefix)); + prefix = parent; + continue; + } + return tail.length ? path.join(real, ...tail.reverse()) : real; + } +} + +/** + * Resolve `p` and confine it to `root`. + * + * Unless `allowOutside` is set, absolute inputs are rejected and the + * **canonical** path is returned — every symlink in `p` (including the leaf, + * even a dangling one) is resolved before the confinement check, and the + * resolved path is what the caller then operates on, so a symlink inside `root` + * that points outside it can neither pass the check nor be followed afterwards. + * + * Residual TOCTOU: a component could still be swapped for a symlink between this + * call and the eventual `fs` operation. Closing that fully needs per-component + * `O_NOFOLLOW`/`openat`, which Node does not expose ergonomically; this is why a + * sandbox is still recommended for the toolset as a whole. + */ +export async function confineToRoot( + root: string, + p: string, + opts?: { allowOutside?: boolean }, +): Promise { + const allowOutside = opts?.allowOutside ?? false; + if (path.isAbsolute(p)) { + if (!allowOutside) { + throw new ToolError(`absolute path ${JSON.stringify(p)} not permitted`); + } + return path.resolve(p); + } + const realRoot = await realpathOrSelf(path.resolve(root)); + const abs = path.resolve(realRoot, p); + if (allowOutside) return abs; + const real = await canonicalize(abs); + const rootSep = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep; + if (real !== realRoot && !real.startsWith(rootSep)) { + throw new ToolError(`path ${JSON.stringify(p)} escapes workdir`); + } + return real; +} + +/** + * Atomically write `content` to `targetPath`: write a sibling temp file, fsync + * it, then rename over the target. The rename is atomic on most filesystems, so + * a crash mid-write never leaves the target half-written. + */ +export async function atomicWriteFile(targetPath: string, content: string): Promise { + const dir = path.dirname(targetPath); + const tempPath = path.join(dir, `.tmp-${process.pid}-${randomUUID()}`); + let handle: fs.FileHandle | undefined; + try { + handle = await fs.open(tempPath, 'wx', FILE_CREATE_MODE); + await handle.writeFile(content, 'utf-8'); + await handle.sync(); + await handle.close(); + handle = undefined; + await fs.rename(tempPath, targetPath); + } catch (err) { + if (handle) await handle.close().catch(() => {}); + await fs.unlink(tempPath).catch(() => {}); + throw err; + } +} + +/** + * Map a thrown filesystem error to a consistent, language-independent message, + * so the model sees the same wording regardless of the runtime (Node's raw + * `ENOENT: no such file...` text would otherwise leak through). Falls back to + * the raw error message for codes we don't special-case. + */ +export function fsErrorMessage(err: unknown, file: string): string { + const code = (err as { code?: string } | null)?.code; + switch (code) { + case 'ENOENT': + return `${file}: no such file or directory`; + case 'EACCES': + case 'EPERM': + return `${file}: permission denied`; + case 'ENOTDIR': + return `${file}: not a directory`; + case 'EISDIR': + return `${file}: is a directory`; + case 'ELOOP': + return `${file}: too many levels of symbolic links`; + case 'ENAMETOOLONG': + return `${file}: file name too long`; + case 'ENOSPC': + return `${file}: no space left on device`; + case 'EMFILE': + case 'ENFILE': + return `${file}: too many open files`; + default: + return `${file}: ${err instanceof Error ? err.message : String(err)}`; + } +} diff --git a/src/tools/agent-toolset/node.ts b/src/tools/agent-toolset/node.ts new file mode 100644 index 00000000..e41c38b3 --- /dev/null +++ b/src/tools/agent-toolset/node.ts @@ -0,0 +1,787 @@ +/** + * Node implementation of the `agent_toolset_20260401` tools — `bash`, `read`, + * `write`, `edit`, `glob`, `grep` — plus the workdir/skills + * {@link AgentToolContext}. + * + * This mirrors `@anthropic-ai/sdk/tools/memory/node`: it is the explicit, + * Node-only entry point for these implementations. Importing it pulls in + * `node:child_process`, `node:fs`, etc., so it is kept separate from the rest of + * the SDK — depending on it is an opt-in. + * + * **Node 22+ is required** for this module: the `glob` tool uses the native + * `fs.glob`, added in Node 22. The rest of the SDK still supports Node 18+; only + * the agent toolset has this requirement. + * + * The result of {@link betaAgentToolset20260401} is a plain `BetaRunnableTool[]`; + * hand it to any tool runner — `client.beta.messages.toolRunner({ …, tools })` + * for the Messages API, or `client.beta.sessions.events.toolRunner({ …, tools })` + * for a managed-agents session: + * + * ```ts + * import { betaAgentToolset20260401 } from '@anthropic-ai/sdk/tools/agent-toolset/node'; + * + * const tools = betaAgentToolset20260401({ workdir: '/work' }); + * const tools2 = betaAgentToolset20260401({ workdir: '/work' }).filter((t) => t.name !== 'bash'); + * ``` + * + * Trust model: the file tools confine to `workdir` (symlink-aware) and are safe + * without a sandbox; `bash` is unrestricted and should run inside one. See + * {@link AgentToolContext}. + */ + +import * as fs from 'node:fs/promises'; +import * as fssync from 'node:fs'; +import * as path from 'node:path'; +import * as cp from 'node:child_process'; +import * as crypto from 'node:crypto'; +import * as readline from 'node:readline'; +import type { Anthropic } from '../../client'; +import { AnthropicError } from '../../core/error'; +import type { BetaRunnableTool } from '../../lib/tools/BetaRunnableTool'; +import { ToolError } from '../../lib/tools/ToolError'; +import { betaTool } from '../../helpers/beta/json-schema'; +import { promiseWithResolvers } from '../../internal/utils/promise'; +import { atomicWriteFile, confineToRoot, DIR_CREATE_MODE, fsErrorMessage } from './fs-util'; + +export { setupSkills, resolveSkillVersion, extractSkillArchive } from './skills'; + +const BASH_OUTPUT_LIMIT = 100 * 1024; +const BASH_DEFAULT_TIMEOUT_MS = 120_000; +const READ_MAX_BYTES = 256 * 1024; +// `edit` reads the whole file before rewriting it, so it carries the same +// OOM/FIFO exposure as `read`. Kept as a symmetric alias for now; the cap value +// (and reject-vs-truncate behaviour) is intentionally a separate knob pending +// validation with the CMA team. +const EDIT_MAX_BYTES = READ_MAX_BYTES; +const GREP_OUTPUT_LIMIT = 100 * 1024; +const GREP_MAX_LINE_LENGTH = 2000; +const GLOB_RESULT_LIMIT = 200; + +const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g; + +// `fs.glob` is Node 22+. `@types/node` may still target an older line, so the +// surface this module uses is typed locally rather than relying on the ambient +// declaration. At runtime this module requires Node 22 (see the file header). +type GlobFn = ( + pattern: string, + options: { + cwd?: string; + withFileTypes?: boolean; + exclude?: (entry: fssync.Dirent) => boolean; + }, +) => AsyncIterable; +const fsGlob = (fs as unknown as { glob: GlobFn }).glob; + +/** + * Workdir + path-policy for the agent toolset. + * + * Trust model — two tiers: + * + * - The file tools ({@link betaReadTool}, {@link betaWriteTool}, + * {@link betaEditTool}, {@link betaGlobTool}, {@link betaGrepTool}) confine to + * `workdir` unless `unrestrictedPaths` is set. {@link resolvePath} + * canonicalizes the target (resolving every symlink, including the leaf) + * before the check *and* returns that canonical path for the operation, so a + * symlink inside the workdir that points outside it neither passes the check + * nor gets followed afterwards — this is a real boundary, not a lexical hint + * (modulo the residual TOCTOU noted on {@link resolvePath}). + * - {@link betaBashTool} runs an unrestricted `/bin/bash` and cannot be + * confined. Run it — and, for defense in depth, the whole toolset — inside a + * sandbox the host controls (e.g. a self-hosted environment runner). + */ +export interface AgentToolContext { + /** Base directory for resolving relative tool paths. */ + workdir: string; + /** + * When `false` (default), the file tools reject absolute paths and paths + * that escape `workdir` (symlinks resolved). Does **not** constrain + * {@link betaBashTool}. + */ + unrestrictedPaths?: boolean; + /** + * Anthropic client. Optional — the bare toolset needs no client; it is only + * used by `setupSkills`, which (together with {@link AgentToolContext.sessionId}) + * fetches the session's resolved agent and downloads each of its skills into + * `{workdir}/skills//`. + */ + client?: Anthropic; + /** Session whose agent's skills `setupSkills` should download. */ + sessionId?: string; + /** + * Optional environment for the bash subprocess. When unset, the bash tool + * inherits the process environment with the runner's `ANTHROPIC_*` + * credentials scrubbed. When provided, it FULLY REPLACES that default + * environment — the mapping is used verbatim and is NOT merged with or added + * to the scrubbed process environment. To keep the defaults plus extra vars, + * build the combined mapping yourself before passing it. + */ + env?: NodeJS.ProcessEnv; +} + +/** + * Returns the `agent_toolset_20260401` implementations bound to `ctx`. The + * result is a plain array of `BetaRunnableTool`; filter or extend it before + * handing it to a tool runner: + * + * ```ts + * const tools = [...betaAgentToolset20260401(ctx), myCustomTool]; + * const tools = betaAgentToolset20260401(ctx).filter((t) => t.name !== 'grep'); + * ``` + * + * Concurrency note: `client.beta.sessions.events.toolRunner` dispatches a + * session's tool calls serially (the sessions API delivers one `agent.tool_use` + * at a time). `client.beta.messages.toolRunner` runs a turn's `tool.run` calls + * via `Promise.all`. The toolset below is safe under either model — + * {@link betaBashTool} serializes its persistent shell internally and the FS + * tools are independent per call — but {@link betaEditTool}/{@link betaWriteTool} + * cannot synchronize concurrent writes to the *same* file across processes, so a + * multi-edit turn touching one path is still subject to inherent FS lost-update + * races. Custom tools that close over mutable state should do their own queueing. + */ +export function betaAgentToolset20260401(ctx: AgentToolContext): BetaRunnableTool[] { + return [ + betaBashTool(ctx), + betaReadTool(ctx), + betaWriteTool(ctx), + betaEditTool(ctx), + betaGlobTool(ctx), + betaGrepTool(ctx), + ]; +} + +/** + * Resolve `p` relative to `ctx.workdir`. Unless `unrestrictedPaths` is set, + * absolute inputs are rejected and the **canonical** path is returned — every + * symlink in `p` (including the leaf, even a dangling one) is resolved before + * the workdir check, and the resolved path is what the tool then operates on, so + * a symlink inside the workdir that points outside it can neither pass the check + * nor be followed afterwards. See the trust model on {@link AgentToolContext}. + * + * Residual TOCTOU: a component could still be swapped for a symlink between this + * call and the eventual `fs` operation. Closing that fully needs per-component + * `O_NOFOLLOW`/`openat`, which Node does not expose ergonomically; the same + * residual exposure exists in `tools/memory/node` and is why a sandbox is still + * recommended for the toolset as a whole. + */ +export function resolvePath(ctx: AgentToolContext, p: string): Promise { + return confineToRoot(ctx.workdir, p, { allowOutside: ctx.unrestrictedPaths ?? false }); +} + +// ---- bash ---------------------------------------------------------------- + +/** + * Build the environment for the spawned bash shell. The runner process holds + * Anthropic credentials in `ANTHROPIC_*` env vars — the API key, the auth token, + * and the per-work session token among them. `bash` runs an unrestricted shell, + * so any command the agent runs could read those straight out of `process.env`; + * strip the whole `ANTHROPIC_*` namespace from the child's environment. + * Everything else (PATH, HOME, locale, …) is passed through unchanged. + * + * Passing an explicit `env` to {@link AgentToolContext} does NOT add to this + * default — it FULLY REPLACES it. The provided mapping becomes the entire bash + * environment verbatim; nothing here is merged in, so callers who want the + * scrubbed process environment plus extras must build that mapping themselves. + */ +function scrubbedShellEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith('ANTHROPIC_')) continue; + env[key] = value; + } + return env; +} + +/** + * A persistent /bin/bash process. State (cwd, env, background jobs) survives + * across exec() calls. Uses pipes rather than a PTY so input is never echoed. + */ +export class BashSession { + #proc: cp.ChildProcessWithoutNullStreams; + #buf = ''; + #truncated = false; + #closed = false; + // While a command is in flight, the resolver to fire once its sentinel lands + // in `#buf` (or once the shell dies). Event-driven: no polling loop. + #waiting: { sentinel: string; resolve: () => void } | null = null; + + constructor(dir: string, env: NodeJS.ProcessEnv = scrubbedShellEnv()) { + this.#proc = cp.spawn('/bin/bash', ['--noprofile', '--norc'], { + cwd: dir, + // `env` is the full base environment (the scrubbed process env by + // default, or the verbatim replacement from `AgentToolContext.env`). + // PS1/PS2/TERM are shell-control settings BashSession always applies so + // the pipe-based sentinel exec parsing works — not part of the + // user-facing environment. + env: { ...env, PS1: '', PS2: '', TERM: 'dumb' }, + stdio: ['pipe', 'pipe', 'pipe'], + detached: true, + }); + this.#proc.stdout.setEncoding('utf8'); + this.#proc.stderr.setEncoding('utf8'); + this.#proc.stdout.on('data', (d: string) => this.#append(d)); + this.#proc.stderr.on('data', (d: string) => this.#append(d)); + this.#proc.once('close', () => { + this.#closed = true; + // Wake any in-flight exec so it fails fast instead of waiting for its deadline. + const w = this.#waiting; + this.#waiting = null; + w?.resolve(); + }); + } + + /** Whether the underlying shell process has exited. */ + get closed(): boolean { + return this.#closed; + } + + // Cap the buffer during accumulation so a command that streams unboundedly + // can't OOM the runner. Keeps the tail so the sentinel stays detectable. + // Also resolves the in-flight exec the instant its sentinel is buffered. + #append(d: string): void { + this.#buf += d; + if (this.#buf.length > BASH_OUTPUT_LIMIT) { + this.#buf = this.#buf.slice(this.#buf.length - BASH_OUTPUT_LIMIT); + this.#truncated = true; + } + if (this.#waiting && this.#buf.indexOf(this.#waiting.sentinel) >= 0) { + const w = this.#waiting; + this.#waiting = null; + w.resolve(); + } + } + + async exec( + command: string, + opts: { timeoutMs?: number; signal?: AbortSignal | null | undefined } = {}, + ): Promise<{ output: string; exitCode: number }> { + if (this.#closed) { + throw new AnthropicError('bash session terminated'); + } + const timeoutMs = opts.timeoutMs ?? BASH_DEFAULT_TIMEOUT_MS; + const signal = opts.signal; + if (signal?.aborted) { + throw new AnthropicError('bash command aborted'); + } + this.#buf = ''; + this.#truncated = false; + // Per-call nonce so a command that prints a fixed marker can't spoof the + // exit-code framing. The `''` split keeps the literal out of what we write + // to stdin — only the shell's printf reassembles it. + const sentinel = `__ANT_CMD_${crypto.randomUUID()}_DONE__`; + const sentinelSplit = `${sentinel.slice(0, 8)}''${sentinel.slice(8)}`; + // &1; printf '\\n${sentinelSplit}%d\\n' $?\n`; + this.#proc.stdin.write(wrapped); + + if (this.#buf.indexOf(sentinel) < 0) { + // Park until the sentinel lands, the deadline passes, the caller aborts, + // or the shell dies — whichever comes first. `#append` (and the `close` + // handler) resolve `sentinelSeen`; the deadline / abort reject. + const { promise: sentinelSeen, resolve } = promiseWithResolvers(); + this.#waiting = { sentinel, resolve }; + let timer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + try { + await Promise.race([ + sentinelSeen, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new AnthropicError(`bash command timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + }), + new Promise((_, reject) => { + if (!signal) return; + onAbort = () => reject(new AnthropicError('bash command aborted')); + signal.addEventListener('abort', onAbort, { once: true }); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + if (onAbort && signal) signal.removeEventListener('abort', onAbort); + this.#waiting = null; + } + } + + const idx = this.#buf.indexOf(sentinel); + if (idx < 0) { + // The shell closed (or was killed) before emitting the sentinel. + throw new AnthropicError('bash session terminated'); + } + const tail = this.#buf.slice(idx + sentinel.length); + const m = tail.match(/^(-?\d+)/); + const exitCode = m ? parseInt(m[1]!, 10) : -1; + let out = this.#buf.slice(0, idx).replace(ANSI_RE, '').replace(/\n+$/, ''); + if (this.#truncated) { + out = `[output truncated]\n${out}`; + } + return { output: out, exitCode }; + } + + close(): void { + if (this.#closed) return; + this.#closed = true; + const w = this.#waiting; + this.#waiting = null; + w?.resolve(); + this.#proc.stdout.destroy(); + this.#proc.stderr.destroy(); + this.#proc.stdin.destroy(); + try { + // Negative PID targets the process group so foreground jobs (e.g. a + // hung sleep) die with the shell. + process.kill(-this.#proc.pid!, 'SIGKILL'); + } catch { + this.#proc.kill('SIGKILL'); + } + this.#proc.unref(); + } +} + +export function betaBashTool(ctx: AgentToolContext): BetaRunnableTool { + let session: BashSession | undefined; + // Concurrent run() callers chain onto this promise so writes to the shared + // shell's stdin can't interleave (which would corrupt the sentinel-match + // exit-code parsing in BashSession.exec). Each call replaces `tail` with a + // promise that resolves only after its own exec settles. + let tail: Promise = Promise.resolve(); + return betaTool({ + name: 'bash', + description: 'Run a bash command in a persistent shell. State (cwd, env vars) persists across calls.', + inputSchema: { + type: 'object', + properties: { + command: { type: 'string', description: 'The command to run' }, + restart: { type: 'boolean', description: 'Restart the persistent shell before running' }, + timeout_ms: { type: 'integer', description: 'Per-call timeout in milliseconds' }, + }, + }, + run: async ({ command, restart, timeout_ms }, context) => { + const prev = tail; + const gate = promiseWithResolvers(); + tail = gate.promise; + // Swallow prior rejections — earlier callers got their own error path; + // we just need to wait for the shell to be free. + try { + await prev; + } catch { + // ignore + } + try { + if (restart) { + session?.close(); + session = undefined; + } + if (!command) { + if (restart) return 'bash session restarted'; + throw new ToolError('bash: command is required'); + } + session ??= new BashSession(ctx.workdir, ctx.env); + try { + const { output, exitCode } = await session.exec(command, { + timeoutMs: timeout_ms ?? BASH_DEFAULT_TIMEOUT_MS, + signal: context?.signal, + }); + if (exitCode !== 0) throw new ToolError(output || `exit ${exitCode}`); + return output; + } catch (e) { + if (e instanceof ToolError) throw e; + // Timeout, abort, or terminated: the still-running command will emit + // a stale sentinel, so discard this session and let the next call + // start fresh. + session.close(); + session = undefined; + throw new ToolError(`bash: ${e instanceof Error ? e.message : String(e)}`); + } + } finally { + gate.resolve(); + } + }, + close: () => { + session?.close(); + session = undefined; + }, + }); +} + +// ---- fs ------------------------------------------------------------------ + +export function betaReadTool(ctx: AgentToolContext): BetaRunnableTool { + return betaTool({ + name: 'read', + description: 'Read a UTF-8 text file relative to the workdir.', + inputSchema: { + type: 'object', + properties: { + file_path: { type: 'string' }, + view_range: { + type: 'array', + items: { type: 'integer' }, + description: '[start_line, end_line] 1-indexed inclusive', + }, + }, + required: ['file_path'], + }, + run: async ({ file_path, view_range }) => { + if (!file_path) throw new ToolError('read: file_path is required'); + const abs = await resolvePath(ctx, file_path); + let data: string; + try { + // stat() before any open(): the size cap stops a multi-GB file from + // OOM'ing the runner, and isFile() rejects FIFOs/devices/dirs without + // opening them (open() on an unconnected FIFO blocks indefinitely). + const st = await fs.stat(abs); + if (!st.isFile()) { + throw new ToolError(`read: ${file_path} is not a regular file`); + } + if (st.size > READ_MAX_BYTES) { + throw new ToolError( + `read: ${file_path} is ${st.size} bytes, exceeds ${READ_MAX_BYTES}-byte limit. ` + + 'Use bash (head/tail/sed) to read a slice.', + ); + } + data = await fs.readFile(abs, 'utf8'); + } catch (e) { + if (e instanceof ToolError) throw e; + throw new ToolError(`read: ${fsErrorMessage(e, file_path)}`); + } + if (!view_range) return data; + if (view_range.length !== 2) throw new ToolError('read: view_range must be [start_line, end_line]'); + const [startLine, endLine] = view_range as [number, number]; + const lines = data.split('\n'); + const start = Math.max(0, startLine - 1); + const end = endLine > 0 ? endLine : lines.length; + return lines.slice(start, end).join('\n'); + }, + }); +} + +export function betaWriteTool(ctx: AgentToolContext): BetaRunnableTool { + return betaTool({ + name: 'write', + description: 'Write a UTF-8 text file relative to the workdir, creating parent directories as needed.', + inputSchema: { + type: 'object', + properties: { file_path: { type: 'string' }, content: { type: 'string' } }, + required: ['file_path', 'content'], + }, + run: async ({ file_path, content }) => { + if (!file_path) throw new ToolError('write: file_path is required'); + const abs = await resolvePath(ctx, file_path); + try { + await fs.mkdir(path.dirname(abs), { recursive: true, mode: DIR_CREATE_MODE }); + await atomicWriteFile(abs, content ?? ''); + } catch (e) { + throw new ToolError(`write: ${fsErrorMessage(e, file_path)}`); + } + return `wrote ${Buffer.byteLength(content ?? '')} bytes to ${file_path}`; + }, + }); +} + +export function betaEditTool(ctx: AgentToolContext): BetaRunnableTool { + return betaTool({ + name: 'edit', + description: + 'Replace old_string with new_string in a file. old_string must be unique unless replace_all.', + inputSchema: { + type: 'object', + properties: { + file_path: { type: 'string' }, + old_string: { type: 'string' }, + new_string: { type: 'string' }, + replace_all: { type: 'boolean' }, + }, + required: ['file_path', 'old_string', 'new_string'], + }, + run: async ({ file_path, old_string, new_string, replace_all }) => { + if (!file_path) throw new ToolError('edit: file_path is required'); + if (!old_string) throw new ToolError('edit: old_string is required'); + const abs = await resolvePath(ctx, file_path); + let data: string; + try { + // stat() before any open() — same guard as `read`: the size cap stops a + // multi-GB file from OOM'ing the runner, and isFile() rejects + // FIFOs/devices/dirs without opening them (open() on an unconnected FIFO + // blocks indefinitely). The edit path is model-controlled, so it needs + // the same bound `read` already has. + const st = await fs.stat(abs); + if (!st.isFile()) { + throw new ToolError(`edit: ${file_path} is not a regular file`); + } + if (st.size > EDIT_MAX_BYTES) { + throw new ToolError( + `edit: ${file_path} is ${st.size} bytes, exceeds ${EDIT_MAX_BYTES}-byte limit. ` + + 'Use bash (sed/awk) to edit a large file.', + ); + } + data = await fs.readFile(abs, 'utf8'); + } catch (e) { + if (e instanceof ToolError) throw e; + throw new ToolError(`edit: ${fsErrorMessage(e, file_path)}`); + } + const count = data.split(old_string).length - 1; + if (count === 0) throw new ToolError(`edit: old_string not found in ${file_path}`); + let updated: string; + if (replace_all) { + updated = data.split(old_string).join(new_string); + } else { + if (count > 1) + throw new ToolError(`edit: old_string appears ${count} times in ${file_path} (must be unique)`); + // Callback form so `$&`/`$1`/`` $` `` in new_string are inserted + // literally instead of expanded as replacement patterns. + updated = data.replace(old_string, () => new_string); + } + try { + await atomicWriteFile(abs, updated); + } catch (e) { + throw new ToolError(`edit: write: ${fsErrorMessage(e, file_path)}`); + } + return `edited ${file_path} (${replace_all ? count : 1} replacement(s))`; + }, + }); +} + +// ---- search -------------------------------------------------------------- + +export function betaGlobTool(ctx: AgentToolContext): BetaRunnableTool { + return betaTool({ + name: 'glob', + description: + 'Match files under the workdir against a glob pattern. Results are mtime-sorted, newest first.', + inputSchema: { + type: 'object', + properties: { + pattern: { type: 'string' }, + path: { type: 'string', description: 'Directory to search in. Defaults to the workdir.' }, + }, + required: ['pattern'], + }, + run: async ({ pattern, path: searchPath }) => { + if (!pattern) throw new ToolError('glob: pattern is required'); + let root = path.resolve(ctx.workdir); + let pat = pattern; + if (path.isAbsolute(pattern)) { + if (!ctx.unrestrictedPaths) throw new ToolError('glob: absolute pattern not permitted'); + root = path.parse(pattern).root; + pat = path.relative(root, pattern); + } else if (searchPath) { + root = await resolvePath(ctx, searchPath); + } + // A `..` in the *pattern itself* (e.g. `../../*`) walks `fs.glob` out of + // the search root — this is separate from the `searchPath` confinement + // above, which only covers the path argument. Reject it outright when the + // toolset is confined. + if (!ctx.unrestrictedPaths && pat.split(/[\\/]/).includes('..')) { + throw new ToolError('glob: ".." is not permitted in the pattern'); + } + const matches: { path: string; mtime: number }[] = []; + try { + // Native `fs.glob` (Node 22+). `exclude` prunes the noisy dirs the + // legacy walker skipped; only regular files are collected. + for await (const entry of fsGlob(pat, { + cwd: root, + withFileTypes: true, + exclude: (d) => d.name === '.git' || d.name === 'node_modules', + })) { + if (!entry.isFile()) continue; + const full = path.join(entry.parentPath, entry.name); + // Defense in depth: drop any match that resolved outside the search + // root (e.g. via a symlinked directory in the tree) when confined. + if (!ctx.unrestrictedPaths && !isWithin(root, full)) continue; + let mtime = 0; + try { + mtime = (await fs.stat(full)).mtimeMs; + } catch { + // unreadable — keep it in the list with mtime 0 + } + matches.push({ path: full, mtime }); + } + } catch (e) { + throw new ToolError(`glob: ${e instanceof Error ? e.message : String(e)}`); + } + if (matches.length === 0) return 'no matches'; + matches.sort((a, b) => b.mtime - a.mtime); + return matches + .slice(0, GLOB_RESULT_LIMIT) + .map((m) => m.path) + .join('\n'); + }, + }); +} + +export function betaGrepTool(ctx: AgentToolContext): BetaRunnableTool { + return betaTool({ + name: 'grep', + description: 'Search file contents for a regex. Uses ripgrep if available, otherwise a built-in walker.', + inputSchema: { + type: 'object', + properties: { pattern: { type: 'string' }, path: { type: 'string' } }, + required: ['pattern'], + }, + run: async ({ pattern, path: p }, context) => { + if (!pattern) throw new ToolError('grep: pattern is required'); + let searchPath = path.resolve(ctx.workdir); + if (p) searchPath = await resolvePath(ctx, p); + const rg = await findRg(); + return rg ? + runRipgrep(rg, pattern, searchPath, context?.signal) + : runWalkGrep(pattern, searchPath, context?.signal); + }, + }); +} + +function runRipgrep( + rg: string, + pattern: string, + searchPath: string, + signal?: AbortSignal | null | undefined, +): Promise { + return new Promise((resolve, reject) => { + const proc = cp.spawn(rg, ['-n', '--no-heading', '-e', pattern, '--', searchPath], { + ...(signal ? { signal } : {}), + }); + let out = ''; + let errOut = ''; + let truncated = false; + proc.stdout.on('data', (d) => { + if (truncated) return; + out += d; + if (out.length > GREP_OUTPUT_LIMIT) { + truncated = true; + out = out.slice(0, GREP_OUTPUT_LIMIT); + proc.kill('SIGKILL'); + } + }); + proc.stderr.on('data', (d) => (errOut += d)); + proc.on('close', (code) => { + if (signal?.aborted) return reject(new ToolError('grep: aborted')); + if (truncated) return resolve(out + `\n[output truncated at ${GREP_OUTPUT_LIMIT} bytes]`); + if (code === 0) return resolve(out); + if (code === 1) return resolve('no matches'); + reject(new ToolError(`grep: rg failed: ${errOut || `exit ${code}`}`)); + }); + proc.on('error', (e) => { + if (signal?.aborted) return reject(new ToolError('grep: aborted')); + reject(new ToolError(`grep: rg failed: ${e.message}`)); + }); + }); +} + +async function runWalkGrep( + pattern: string, + root: string, + signal?: AbortSignal | null | undefined, +): Promise { + let re: RegExp; + try { + re = new RegExp(pattern); + } catch (e) { + throw new ToolError(`grep: invalid regex: ${e instanceof Error ? e.message : String(e)}`); + } + const hits: string[] = []; + let budget = GREP_OUTPUT_LIMIT; + const push = (line: string): boolean => { + budget -= line.length + 1; + if (budget < 0) { + hits.push(`[output truncated at ${GREP_OUTPUT_LIMIT} bytes]`); + return false; + } + hits.push(line); + return true; + }; + const stat = await fs.stat(root).catch(() => null); + if (stat?.isFile()) { + await grepFile(root, re, push); + } else { + await walk(root, '', (rel) => grepFile(path.join(root, rel), re, push), signal); + } + if (signal?.aborted) throw new ToolError('grep: aborted'); + if (hits.length === 0) return 'no matches'; + return hits.join('\n'); +} + +async function grepFile(file: string, re: RegExp, push: (line: string) => boolean): Promise { + const stream = fssync.createReadStream(file, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + let i = 0; + try { + for await (const line of rl) { + i++; + // Cap line length: `pattern` is model-supplied and JS regexes backtrack, + // so a pathological pattern against a very long line is a ReDoS. + if (line.length > GREP_MAX_LINE_LENGTH) continue; + if (re.test(line) && !push(`${file}:${i}:${line}`)) return false; + } + } catch { + // unreadable / binary + } finally { + stream.destroy(); + } + return true; +} + +// ---- utils --------------------------------------------------------------- + +/** True when `p` is `root` itself or lexically contained within it. */ +function isWithin(root: string, p: string): boolean { + const rel = path.relative(root, p); + return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel)); +} + +const WALK_MAX_DEPTH = 40; +const WALK_MAX_ENTRIES = 50_000; + +/** + * Bounded recursive walk. `fn` may return `false` to abort. Only real + * directories are descended into and only real files are handed to `fn` — + * symlinks (and devices/fifos/sockets) are skipped entirely so a symlink inside + * the root cannot be followed out of it. + */ +async function walk( + root: string, + rel: string, + fn: (rel: string) => boolean | void | Promise, + signal?: AbortSignal | null | undefined, +): Promise { + let remaining = WALK_MAX_ENTRIES; + async function inner(rel: string, depth: number): Promise { + if (depth > WALK_MAX_DEPTH) return true; + if (signal?.aborted) return false; + let entries: fssync.Dirent[]; + try { + entries = await fs.readdir(path.join(root, rel), { withFileTypes: true }); + } catch { + return true; + } + for (const e of entries) { + if (e.name === '.git' || e.name === 'node_modules') continue; + if (remaining-- <= 0) return false; + if (signal?.aborted) return false; + const childRel = rel ? path.join(rel, e.name) : e.name; + if (e.isDirectory()) { + if (!(await inner(childRel, depth + 1))) return false; + } else if (e.isFile()) { + if ((await fn(childRel)) === false) return false; + } + // Symlinks, devices, fifos and sockets are intentionally skipped. + } + return true; + } + await inner(rel, 0); +} + +async function findRg(): Promise { + const dirs = (process.env['PATH'] ?? '').split(path.delimiter); + for (const d of dirs) { + const candidate = path.join(d, 'rg'); + try { + await fs.access(candidate, fssync.constants.X_OK); + return candidate; + } catch { + // not here + } + } + return null; +} diff --git a/src/tools/agent-toolset/skills.ts b/src/tools/agent-toolset/skills.ts new file mode 100644 index 00000000..04ca7a47 --- /dev/null +++ b/src/tools/agent-toolset/skills.ts @@ -0,0 +1,256 @@ +/** + * Node-only skill plumbing for the agent toolset: downloading a session + * agent's skills into the workdir and extracting the archives. Kept in its own + * file because it is a distinct concern from the tool implementations in + * `node.ts` — distinct enough, and large enough, to review on its own. + */ + +import * as fs from 'node:fs/promises'; +import * as fssync from 'node:fs'; +import * as path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import type { Anthropic } from '../../client'; +import { AnthropicError } from '../../core/error'; +import { loggerFor } from '../../internal/utils/log'; +import { DIR_CREATE_MODE } from './fs-util'; +import type { AgentToolContext } from './node'; + +const execFileAsync = promisify(execFile); + +/** + * Download the session agent's skills into `{ctx.workdir}/skills//`. + * + * No-op (returns a no-op cleanup) unless both `ctx.client` and `ctx.sessionId` + * are set. Looks up the session's resolved agent and, for each skill, fetches + * its files via `client.beta.skills.versions.download` and extracts the archive + * (a zip or tar.* archive) into a directory named after the skill. A failure on + * one skill is logged and does not block the others. Call this before starting + * the session tool runner (e.g. right after the bash session / workdir is + * ready). + * + * Returns a cleanup function that removes the skill directories this call + * created — call it once the work item is done so downloaded skills do not + * accumulate in the workdir across sessions. + */ +export async function setupSkills(ctx: AgentToolContext): Promise<() => Promise> { + const { client, sessionId } = ctx; + if (!client || !sessionId) return async () => {}; + const log = loggerFor(client); + const session = await client.beta.sessions.retrieve(sessionId); + const skillsRoot = path.resolve(ctx.workdir, 'skills'); + const created: string[] = []; + for (const skill of session.agent.skills) { + try { + const versionId = await resolveSkillVersion(client, skill.skill_id, skill.version); + const version = await client.beta.skills.versions.retrieve(versionId, { skill_id: skill.skill_id }); + // The directory is the skill's name, reduced to a single safe path + // component so a hostile name can't escape `skillsRoot`. + let dirname = path.basename(version.name.trim()); + if (dirname === '' || dirname === '.' || dirname === '..') dirname = skill.skill_id; + const dest = path.resolve(skillsRoot, dirname); + if (dest !== skillsRoot && !dest.startsWith(skillsRoot + path.sep)) { + log.warn('skill name escapes the skills dir; skipping', { + component: 'agent-tool-context', + name: version.name, + }); + continue; + } + const resp = await client.beta.skills.versions.download(versionId, { skill_id: skill.skill_id }); + await fs.rm(dest, { recursive: true, force: true }); + await fs.mkdir(dest, { recursive: true, mode: DIR_CREATE_MODE }); + created.push(dest); + await extractSkillArchive(resp, dest); + log.info('downloaded skill', { + component: 'agent-tool-context', + skill_id: skill.skill_id, + version: versionId, + dest, + }); + } catch (e) { + log.warn('failed to download skill', { + component: 'agent-tool-context', + skill_id: skill.skill_id, + error: String(e), + }); + } + } + return async () => { + for (const dest of created) { + await fs.rm(dest, { recursive: true, force: true }).catch((e) => { + log.warn('failed to clean up skill', { component: 'agent-tool-context', dest, error: String(e) }); + }); + } + }; +} + +/** + * Resolve `version` to the concrete numeric timestamp the + * `/v1/skills/{id}/versions/{version}` endpoints require — `session.agent.skills[].version` + * can be an alias such as `"latest"`, which those endpoints reject. Numeric + * versions pass through unchanged. + */ +export async function resolveSkillVersion( + client: Anthropic, + skillId: string, + version: string, +): Promise { + if (/^\d+$/.test(version)) return version; + let newest: string | undefined; + for await (const v of client.beta.skills.versions.list(skillId)) { + if (/^\d+$/.test(v.version) && (newest === undefined || BigInt(v.version) > BigInt(newest))) { + newest = v.version; + } + } + if (newest === undefined) { + throw new AnthropicError( + `skill ${JSON.stringify(skillId)} has no concrete version to resolve ${JSON.stringify( + version, + )} against`, + ); + } + return newest; +} + +/** Reject archive members that are absolute or contain a `..` component. */ +function assertSafeMemberNames(names: string): void { + for (const raw of names.split('\n')) { + const entry = raw.trim(); + if (!entry) continue; + if (path.isAbsolute(entry) || entry.split(/[\\/]/).includes('..')) { + throw new AnthropicError(`refusing to extract unsafe archive member: ${entry}`); + } + } +} + +/** + * Reject archives that contain anything other than regular files and + * directories. The type char is the first byte of each `ls`-style line emitted + * by `tar -tvf` / `unzip -Z`: `-` file, `d` dir, `l` symlink, `h` hardlink, + * `b`/`c` device, `p` fifo, `s` socket. A symlink/hardlink member is how an + * archive escapes its extraction dir even when no name contains `..`. + */ +function assertNoSpecialMembers(verboseListing: string): void { + for (const line of verboseListing.split('\n')) { + const type = line.trimStart()[0]; + if (type === 'l' || type === 'h' || type === 'b' || type === 'c' || type === 'p' || type === 's') { + throw new AnthropicError('refusing to extract archive with symlink/hardlink/device member'); + } + } +} + +/** + * Run an archive CLI (`unzip` for zip archives, `tar` for everything else), + * returning its stdout. Both binaries must be on `PATH`; a missing one would + * otherwise surface as an opaque `ENOENT` spawn failure, so it is turned into a + * clear, specific error naming the missing command. + */ +async function runArchiveTool(cmd: 'unzip' | 'tar', args: string[]): Promise { + try { + const { stdout } = await execFileAsync(cmd, args); + return stdout; + } catch (e) { + if (e != null && typeof e === 'object' && (e as { code?: unknown }).code === 'ENOENT') { + throw new AnthropicError( + `skill extraction requires the \`${cmd}\` command, but it was not found on PATH`, + ); + } + throw e; + } +} + +/** + * The single top-level directory shared by every entry in a newline-separated + * archive listing, or `''` if entries don't all live under one common + * directory. Skill bundles are packaged wrapped in one directory named after + * the skill (e.g. `pdf/SKILL.md`, `pdf/scripts/...`); the extractor strips it + * so contents land directly in the skill's dir instead of a redundant nested + * `//` level. A flat or multi-root archive yields `''`. + */ +function archiveTopDir(listing: string): string { + let top: string | undefined; + let nested = false; + for (const raw of listing.split('\n')) { + // Drop `.` / empty segments so a `./pdf/...`-style listing (e.g. from + // `tar -C dir .`) is treated the same as `pdf/...`. + const parts = raw + .trim() + .split('/') + .filter((p) => p !== '' && p !== '.'); + if (parts.length === 0) continue; + const first = parts[0]!; + if (top === undefined) top = first; + else if (first !== top) return ''; + if (parts.length > 1) nested = true; + } + return top !== undefined && nested ? top : ''; +} + +/** + * Extract a skill download (a zip or tar.* archive) into `dest`. Streams the + * response body straight to a temp file beside `dest` (so the whole archive is + * never buffered in memory — skills can contain large binaries), then shells out + * to `unzip`/`tar` — consistent with the rest of the toolset, which already + * invokes `bash` and `rg`. Both `unzip` and `tar` must be available on `PATH`; a + * missing binary surfaces as a clear error (see {@link runArchiveTool}). Refuses + * any member that would escape `dest` (zip-slip / tar-slip), including + * symlink/hardlink members: skill archives come from the API, but skills can be + * third-party. + * + * The skill bundle's single wrapper directory is stripped: the archive is + * extracted into a staging dir and the wrapper's contents are promoted into + * `dest`, so files land at `dest/SKILL.md` rather than a doubled + * `dest//SKILL.md` (`unzip` has no `--strip-components`, so this is + * done uniformly by staging + promote rather than per-tool flags). + */ +export async function extractSkillArchive(resp: Response, dest: string): Promise { + const tmp = path.join(dest, `.skill-archive-${process.pid}-${Date.now()}`); + if (!resp.body) { + throw new AnthropicError('skill download response had no body'); + } + await pipeline( + Readable.fromWeb(resp.body as Parameters[0]), + fssync.createWriteStream(tmp), + ); + const stage = path.join(path.dirname(dest), `.skill-stage-${process.pid}-${Date.now()}`); + try { + // Sniff the first bytes: zip archives start with "PK\x03\x04"; treat + // anything else as a tar.* archive (`tar -xf` autodetects gzip/bzip2/xz). + const head = await readHead(tmp, 4); + const isZip = + head.length >= 4 && head[0] === 0x50 && head[1] === 0x4b && head[2] === 0x03 && head[3] === 0x04; + const archiveCmd = isZip ? 'unzip' : 'tar'; + // List first, validate, then extract — `tar`/`unzip` will happily write a + // `../` member (or follow a symlink member) outside `-C`/`-d` otherwise. + const listing = await runArchiveTool(archiveCmd, isZip ? ['-Z1', tmp] : ['-tf', tmp]); + assertSafeMemberNames(listing); + assertNoSpecialMembers(await runArchiveTool(archiveCmd, isZip ? ['-Z', tmp] : ['-tvf', tmp])); + const top = archiveTopDir(listing); + await fs.mkdir(stage, { recursive: true, mode: DIR_CREATE_MODE }); + await runArchiveTool(archiveCmd, isZip ? ['-oq', tmp, '-d', stage] : ['-xf', tmp, '-C', stage]); + // Promote the wrapper's contents (or the staged tree itself, if the + // archive wasn't wrapped) into the already-created empty `dest`. `stage` + // is a sibling of `dest`, so each rename stays on one filesystem. + const srcRoot = top ? path.join(stage, top) : stage; + for (const entry of await fs.readdir(srcRoot)) { + await fs.rename(path.join(srcRoot, entry), path.join(dest, entry)); + } + } finally { + await fs.rm(tmp, { force: true }); + await fs.rm(stage, { recursive: true, force: true }); + } +} + +/** Read the first `n` bytes of `file`. */ +async function readHead(file: string, n: number): Promise { + const handle = await fs.open(file, 'r'); + try { + const buf = Buffer.alloc(n); + const { bytesRead } = await handle.read(buf, 0, n, 0); + return buf.subarray(0, bytesRead); + } finally { + await handle.close(); + } +} diff --git a/src/version.ts b/src/version.ts index b75d8d52..1b7c9366 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.96.0'; // x-release-please-version +export const VERSION = '0.97.0'; // x-release-please-version diff --git a/tests/api-resources/beta/environments.test.ts b/tests/api-resources/beta/environments/environments.test.ts similarity index 99% rename from tests/api-resources/beta/environments.test.ts rename to tests/api-resources/beta/environments/environments.test.ts index 7cc1b295..6b24d4d0 100644 --- a/tests/api-resources/beta/environments.test.ts +++ b/tests/api-resources/beta/environments/environments.test.ts @@ -42,6 +42,7 @@ describe('resource environments', () => { }, description: 'Python environment with data-analysis packages.', metadata: { foo: 'string' }, + scope: 'organization', betas: ['message-batches-2024-09-24'], }); }); diff --git a/tests/api-resources/beta/environments/work.test.ts b/tests/api-resources/beta/environments/work.test.ts new file mode 100644 index 00000000..fd2ac2a4 --- /dev/null +++ b/tests/api-resources/beta/environments/work.test.ts @@ -0,0 +1,194 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Anthropic from '@anthropic-ai/sdk'; + +const client = new Anthropic({ + apiKey: 'my-anthropic-api-key', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource work', () => { + test('retrieve: only required params', async () => { + const responsePromise = client.beta.environments.work.retrieve('work_id', { + environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + test('retrieve: required and optional params', async () => { + const response = await client.beta.environments.work.retrieve('work_id', { + environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW', + betas: ['message-batches-2024-09-24'], + }); + }); + + test('update: only required params', async () => { + const responsePromise = client.beta.environments.work.update('work_id', { + environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW', + metadata: { foo: 'string' }, + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + test('update: required and optional params', async () => { + const response = await client.beta.environments.work.update('work_id', { + environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW', + metadata: { foo: 'string' }, + betas: ['message-batches-2024-09-24'], + }); + }); + + // buildURL drops path-level query params (SDK-4349) + test.skip('list', async () => { + const responsePromise = client.beta.environments.work.list('env_011CZkZ9X2dpNyB7HsEFoRfW'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // buildURL drops path-level query params (SDK-4349) + test.skip('list: request options and params are passed correctly', async () => { + // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error + await expect( + client.beta.environments.work.list( + 'env_011CZkZ9X2dpNyB7HsEFoRfW', + { + limit: 1, + page: 'page', + betas: ['message-batches-2024-09-24'], + }, + { path: '/_stainless_unknown_path' }, + ), + ).rejects.toThrow(Anthropic.NotFoundError); + }); + + test('ack: only required params', async () => { + const responsePromise = client.beta.environments.work.ack('work_id', { + environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + test('ack: required and optional params', async () => { + const response = await client.beta.environments.work.ack('work_id', { + environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW', + betas: ['message-batches-2024-09-24'], + }); + }); + + test('heartbeat: only required params', async () => { + const responsePromise = client.beta.environments.work.heartbeat('work_id', { + environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + test('heartbeat: required and optional params', async () => { + const response = await client.beta.environments.work.heartbeat('work_id', { + environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW', + desired_ttl_seconds: 0, + expected_last_heartbeat: 'expected_last_heartbeat', + betas: ['message-batches-2024-09-24'], + }); + }); + + test('poll', async () => { + const responsePromise = client.beta.environments.work.poll('env_011CZkZ9X2dpNyB7HsEFoRfW'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + test('poll: request options and params are passed correctly', async () => { + // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error + await expect( + client.beta.environments.work.poll( + 'env_011CZkZ9X2dpNyB7HsEFoRfW', + { + block_ms: 1, + reclaim_older_than_ms: 1, + betas: ['message-batches-2024-09-24'], + 'Anthropic-Worker-ID': 'Anthropic-Worker-ID', + }, + { path: '/_stainless_unknown_path' }, + ), + ).rejects.toThrow(Anthropic.NotFoundError); + }); + + // buildURL drops path-level query params (SDK-4349) + test.skip('stats', async () => { + const responsePromise = client.beta.environments.work.stats('env_011CZkZ9X2dpNyB7HsEFoRfW'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // buildURL drops path-level query params (SDK-4349) + test.skip('stats: request options and params are passed correctly', async () => { + // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error + await expect( + client.beta.environments.work.stats( + 'env_011CZkZ9X2dpNyB7HsEFoRfW', + { betas: ['message-batches-2024-09-24'] }, + { path: '/_stainless_unknown_path' }, + ), + ).rejects.toThrow(Anthropic.NotFoundError); + }); + + test('stop: only required params', async () => { + const responsePromise = client.beta.environments.work.stop('work_id', { + environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + test('stop: required and optional params', async () => { + const response = await client.beta.environments.work.stop('work_id', { + environment_id: 'env_011CZkZ9X2dpNyB7HsEFoRfW', + force: true, + betas: ['message-batches-2024-09-24'], + }); + }); +}); diff --git a/tests/api-resources/beta/skills/versions.test.ts b/tests/api-resources/beta/skills/versions.test.ts index 1c07d8c7..cf5d6a37 100644 --- a/tests/api-resources/beta/skills/versions.test.ts +++ b/tests/api-resources/beta/skills/versions.test.ts @@ -94,4 +94,11 @@ describe('resource versions', () => { betas: ['message-batches-2024-09-24'], }); }); + + test('download: required and optional params', async () => { + const response = await client.beta.skills.versions.download('version', { + skill_id: 'skill_id', + betas: ['message-batches-2024-09-24'], + }); + }); }); diff --git a/tests/lib/environments/poller-iter.test.ts b/tests/lib/environments/poller-iter.test.ts new file mode 100644 index 00000000..25ebc37a --- /dev/null +++ b/tests/lib/environments/poller-iter.test.ts @@ -0,0 +1,379 @@ +import { WorkPoller, POLL_BLOCK_MS } from '@anthropic-ai/sdk/lib/environments'; +import { APIError } from '@anthropic-ai/sdk/core/error'; + +// Minimal fake `client.beta.environments.work` resource. Tests script +// poll/ack/stop responses; we record how each was called for assertions. +// +// The poller now scopes a sub-client via `copyClientForHelper` and +// routes every poll/ack/stop through it, so the fake exposes +// `withOptions` and `_options`; the sub-client reuses the same recorder +// so call-shape assertions still work as before. + +interface RecordedCall { + method: 'poll' | 'ack' | 'stop'; + args: unknown[]; +} + +interface PollResponse { + type: 'work' | 'null' | 'throw'; + value?: unknown; + err?: unknown; +} + +function makeFakeClient(opts: { + poll: PollResponse[]; + ack?: { type: 'ok' | 'throw'; err?: unknown }[]; + stop?: { type: 'ok' | 'throw'; err?: unknown }[]; +}) { + const calls: RecordedCall[] = []; + const withOptionsCalls: Array> = []; + let pollIdx = 0; + let ackIdx = 0; + let stopIdx = 0; + const fake: Record = { + // `copyClientForHelper` reads `_options.defaultHeaders` to merge + // them onto the sub-client; provide a minimal shape so the util doesn't + // throw on the fake. + _options: { defaultHeaders: undefined }, + withOptions: (options: Record) => { + withOptionsCalls.push(options); + return fake; + }, + beta: { + environments: { + work: { + poll: (...args: unknown[]) => { + calls.push({ method: 'poll', args }); + const r = opts.poll[pollIdx++] ?? { type: 'null' }; + if (r.type === 'throw') return Promise.reject(r.err); + if (r.type === 'null') return Promise.resolve(null); + return Promise.resolve(r.value); + }, + ack: (...args: unknown[]) => { + calls.push({ method: 'ack', args }); + const r = opts.ack?.[ackIdx++] ?? { type: 'ok' }; + if (r.type === 'throw') return Promise.reject(r.err); + return Promise.resolve({}); + }, + stop: (...args: unknown[]) => { + calls.push({ method: 'stop', args }); + const r = opts.stop?.[stopIdx++] ?? { type: 'ok' }; + if (r.type === 'throw') return Promise.reject(r.err); + return Promise.resolve({}); + }, + }, + }, + }, + }; + return { client: fake as never, calls, withOptionsCalls }; +} + +function makeWork(id = 'work_1', dataType = 'session'): Record { + return { + id, + state: 'queued', + environment_id: 'env_1', + data: { type: dataType, id: 'sesn_1' }, + }; +} + +describe('WorkPoller', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + test('yields the work item and posts ack before yield, stop after consumer body', async () => { + const work = makeWork(); + const { client, calls } = makeFakeClient({ poll: [{ type: 'work', value: work }] }); + + const iter = new WorkPoller({ + client, + environmentId: 'env_1', + environmentKey: 'env_key', + }); + + const items: Array<{ workId: string }> = []; + const consumer = (async () => { + for await (const work of iter) { + items.push({ workId: work.id }); + // Stop after first item. + iter.abort(); + break; + } + })(); + + await jest.advanceTimersByTimeAsync(5_000); + await consumer; + + expect(items).toEqual([{ workId: 'work_1' }]); + + const order = calls.map((c) => c.method); + // poll then ack then stop, in order. + expect(order.slice(0, 3)).toEqual(['poll', 'ack', 'stop']); + }); + + test('scopes a sub-client to the environment key and routes all calls through it', async () => { + // Previously every poll/ack/stop carried a per-request `Authorization: + // Bearer ` header. The poller now scopes a sub-client via + // `copyClientForHelper`, which clears the parent's `apiKey` so + // `X-Api-Key` doesn't ride alongside the bearer credential on the wire. + const work = makeWork(); + const { client, withOptionsCalls } = makeFakeClient({ poll: [{ type: 'work', value: work }] }); + + const iter = new WorkPoller({ + client, + environmentId: 'env_1', + environmentKey: 'env_key', + }); + + const consumer = (async () => { + for await (const _ of iter) { + iter.abort(); + break; + } + })(); + await jest.advanceTimersByTimeAsync(5_000); + await consumer; + + expect(withOptionsCalls).toContainEqual( + expect.objectContaining({ apiKey: null, authToken: 'env_key', credentials: undefined }), + ); + }); + + test('stops the work item even when the consumer breaks immediately', async () => { + const work = makeWork(); + const { client, calls } = makeFakeClient({ poll: [{ type: 'work', value: work }] }); + + const iter = new WorkPoller({ + client, + environmentId: 'env_1', + environmentKey: 'env_key', + }); + + const consumer = (async () => { + for await (const _ of iter) { + iter.abort(); + break; + } + })(); + await jest.advanceTimersByTimeAsync(5_000); + await consumer; + + expect(calls.some((c) => c.method === 'stop')).toBe(true); + }); + + test('backs off on poll errors using setTimeout-based delay', async () => { + const err = Object.assign(Object.create(APIError.prototype) as APIError, { status: 503 }); + const work = makeWork(); + const { client, calls } = makeFakeClient({ + poll: [ + { type: 'throw', err }, + { type: 'work', value: work }, + ], + }); + + const iter = new WorkPoller({ + client, + environmentId: 'env_1', + environmentKey: 'env_key', + }); + + const consumer = (async () => { + for await (const _ of iter) { + iter.abort(); + break; + } + })(); + // Drive past the initial 1s backoff window. + await jest.advanceTimersByTimeAsync(2_000); + await consumer; + + const polls = calls.filter((c) => c.method === 'poll').length; + expect(polls).toBe(2); + }); + + test('honors externally provided AbortSignal', async () => { + const abortCtl = new AbortController(); + const { client } = makeFakeClient({ poll: [] }); + + const iter = new WorkPoller({ + client, + environmentId: 'env_1', + environmentKey: 'env_key', + signal: abortCtl.signal, + }); + + const consumer = (async () => { + for await (const _ of iter) { + // never yields — poll always returns null + } + })(); + abortCtl.abort(); + await jest.advanceTimersByTimeAsync(5_000); + await consumer; + // Reaching here without timeout is the assertion. + expect(iter.signal.aborted).toBe(true); + }); + + test('throws when iterated twice', async () => { + const { client } = makeFakeClient({ poll: [] }); + const iter = new WorkPoller({ + client, + environmentId: 'env_1', + environmentKey: 'env_key', + }); + iter.abort(); + // First iteration drains the (already aborted) generator. + for await (const _ of iter) { + // unreachable + } + // Second iteration must throw the consumed-stream guard. + await expect( + (async () => { + for await (const _ of iter) { + // unreachable + } + })(), + ).rejects.toThrow(/consumed/); + }); + + test('default poll passes block_ms = POLL_BLOCK_MS and no reclaim_older_than_ms', async () => { + const work = makeWork(); + const { client, calls } = makeFakeClient({ poll: [{ type: 'work', value: work }] }); + + const iter = new WorkPoller({ client, environmentId: 'env_1', environmentKey: 'env_key' }); + const consumer = (async () => { + for await (const _ of iter) { + iter.abort(); + break; + } + })(); + await jest.advanceTimersByTimeAsync(5_000); + await consumer; + + const poll = calls.find((c) => c.method === 'poll')!; + const params = poll.args[1] as Record; + expect(params['block_ms']).toBe(POLL_BLOCK_MS); + expect('reclaim_older_than_ms' in params).toBe(false); + }); + + test('blockMs: null omits block_ms; reclaimOlderThanMs is forwarded', async () => { + const work = makeWork(); + const { client, calls } = makeFakeClient({ poll: [{ type: 'work', value: work }] }); + + const iter = new WorkPoller({ + client, + environmentId: 'env_1', + environmentKey: 'env_key', + blockMs: null, + reclaimOlderThanMs: 7_000, + }); + const consumer = (async () => { + for await (const _ of iter) { + iter.abort(); + break; + } + })(); + await jest.advanceTimersByTimeAsync(5_000); + await consumer; + + const poll = calls.find((c) => c.method === 'poll')!; + const params = poll.args[1] as Record; + expect('block_ms' in params).toBe(false); + expect(params['reclaim_older_than_ms']).toBe(7_000); + }); + + test('explicit blockMs is forwarded as block_ms', async () => { + const work = makeWork(); + const { client, calls } = makeFakeClient({ poll: [{ type: 'work', value: work }] }); + + const iter = new WorkPoller({ + client, + environmentId: 'env_1', + environmentKey: 'env_key', + blockMs: 250, + }); + const consumer = (async () => { + for await (const _ of iter) { + iter.abort(); + break; + } + })(); + await jest.advanceTimersByTimeAsync(5_000); + await consumer; + + const poll = calls.find((c) => c.method === 'poll')!; + expect((poll.args[1] as Record)['block_ms']).toBe(250); + }); + + test('drain ends iteration when the queue is empty instead of long-polling', async () => { + // poll always returns null; without `drain` this would loop forever. + const { client, calls } = makeFakeClient({ poll: [{ type: 'null' }] }); + + const iter = new WorkPoller({ + client, + environmentId: 'env_1', + environmentKey: 'env_key', + drain: true, + }); + + const items: unknown[] = []; + const consumer = (async () => { + for await (const work of iter) items.push(work); + })(); + await jest.advanceTimersByTimeAsync(5_000); + await consumer; + + expect(items).toEqual([]); + // Returned after a single empty poll — no backoff sleep, no re-poll. + expect(calls.filter((c) => c.method === 'poll').length).toBe(1); + }); + + test('without drain, an empty queue is retried (default long-poll behavior)', async () => { + const work = makeWork(); + const { client, calls } = makeFakeClient({ + poll: [{ type: 'null' }, { type: 'work', value: work }], + }); + + const iter = new WorkPoller({ client, environmentId: 'env_1', environmentKey: 'env_key' }); + const consumer = (async () => { + for await (const _ of iter) { + iter.abort(); + break; + } + })(); + // Advance past the empty-poll jittered wait (1-3s) so the second poll runs. + await jest.advanceTimersByTimeAsync(5_000); + await consumer; + + expect(calls.filter((c) => c.method === 'poll').length).toBe(2); + }); + + test('does not auto-post stop when autoStop is false (the consumer owns it)', async () => { + const work = makeWork(); + const { client, calls } = makeFakeClient({ poll: [{ type: 'work', value: work }] }); + + const iter = new WorkPoller({ + client, + environmentId: 'env_1', + environmentKey: 'env_key', + autoStop: false, + }); + + const consumer = (async () => { + for await (const _ of iter) { + iter.abort(); + break; + } + })(); + await jest.advanceTimersByTimeAsync(5_000); + await consumer; + + // poll + ack happened, but the poller left the stop to the consumer. + expect(calls.some((c) => c.method === 'ack')).toBe(true); + expect(calls.some((c) => c.method === 'stop')).toBe(false); + }); +}); diff --git a/tests/lib/environments/poller.test.ts b/tests/lib/environments/poller.test.ts new file mode 100644 index 00000000..0692d5db --- /dev/null +++ b/tests/lib/environments/poller.test.ts @@ -0,0 +1,66 @@ +import { backoff, jitter, isStatus, is4xx } from '@anthropic-ai/sdk/lib/environments'; +import { APIError } from '@anthropic-ai/sdk/core/error'; + +describe('backoff', () => { + const cases: { description: string; attempt: number; want: number }[] = [ + { description: 'first attempt yields the 1s base delay', attempt: 0, want: 1000 }, + { description: 'second attempt doubles to 2s', attempt: 1, want: 2000 }, + { description: 'third attempt doubles again to 4s', attempt: 2, want: 4000 }, + { + description: 'large attempt count is clamped to the 60s cap rather than overflowing', + attempt: 20, + want: 60_000, + }, + ]; + for (const tc of cases) { + test(tc.description, () => { + expect(backoff(tc.attempt)).toBe(tc.want); + }); + } +}); + +describe('jitter', () => { + test('result always falls within [low, high) so callers can rely on the bound', () => { + for (let i = 0; i < 200; i++) { + const v = jitter(1000, 3000); + expect(v).toBeGreaterThanOrEqual(1000); + expect(v).toBeLessThan(3000); + } + }); +}); + +describe('isStatus / is4xx', () => { + function makeErr(status: number): APIError { + return Object.assign(Object.create(APIError.prototype) as APIError, { status }); + } + + const cases: { description: string; err: unknown; status: number; wantIs: boolean; want4xx: boolean }[] = [ + { + description: 'an APIError with matching status is detected by both helpers', + err: makeErr(409), + status: 409, + wantIs: true, + want4xx: true, + }, + { + description: 'a 5xx APIError is not 4xx and does not match a 409 check', + err: makeErr(503), + status: 409, + wantIs: false, + want4xx: false, + }, + { + description: 'a plain Error is never treated as an APIError', + err: new Error('boom'), + status: 500, + wantIs: false, + want4xx: false, + }, + ]; + for (const tc of cases) { + test(tc.description, () => { + expect(isStatus(tc.err, tc.status)).toBe(tc.wantIs); + expect(is4xx(tc.err)).toBe(tc.want4xx); + }); + } +}); diff --git a/tests/lib/environments/worker.test.ts b/tests/lib/environments/worker.test.ts new file mode 100644 index 00000000..f1c7b333 --- /dev/null +++ b/tests/lib/environments/worker.test.ts @@ -0,0 +1,270 @@ +import { EnvironmentWorker } from '@anthropic-ai/sdk/lib/environments'; +import type { BetaRunnableTool } from '@anthropic-ai/sdk/lib/tools/BetaRunnableTool'; + +// ===== +// Test fakes +// +// EnvironmentWorker = WorkPoller (claim work) + per-session SessionToolRunner +// + a parallel lease heartbeat + force-stop on exit. We fake the whole client +// surface those touch so we can drive one claimed session end-to-end. +// ===== + +type AnyEvent = Record & { type: string }; + +interface WorkerCalls { + poll: number; + ack: number; + heartbeat: number; + stop: { force?: boolean }[]; + send: AnyEvent[][]; + retrieve: number; + withOptions: Array>; + // The `options` (last) argument captured per control-plane / session method. + opts: Record; +} + +function makeFake(opts: { sessionStream: AnyEvent[] }) { + const calls: WorkerCalls = { + poll: 0, + ack: 0, + heartbeat: 0, + stop: [], + send: [], + retrieve: 0, + withOptions: [], + opts: { poll: [], ack: [], heartbeat: [], stop: [], send: [], stream: [], list: [] }, + }; + const externalAbort = new AbortController(); + + const work = { + id: 'work_1', + environment_id: 'env_1', + data: { type: 'session', id: 'sesn_1' }, + }; + + const fake = { + // The per-item handler scopes a client to the environment key via + // `copyClientForHelper`, which calls `withOptions` with the bearer + // override, `apiKey: null` to clear the parent's `X-Api-Key`, and the + // helper-telemetry default header. The fake records each `withOptions` + // override and reuses itself so the rest of the surface stays wired up. + _options: { defaultHeaders: undefined }, + withOptions: (options: Record) => { + calls.withOptions.push(options); + return fake; + }, + beta: { + environments: { + work: { + poll: (_envId: string, _params: unknown, options?: unknown) => { + calls.poll++; + calls.opts['poll']!.push(options); + if (calls.poll === 1) return Promise.resolve(work); + // Second poll: end the run. + externalAbort.abort(); + return Promise.reject(new Error('aborted')); + }, + ack: (_workId: string, _params: unknown, options?: unknown) => { + calls.ack++; + calls.opts['ack']!.push(options); + return Promise.resolve(work); + }, + heartbeat: (_workId: string, _params: unknown, options?: unknown) => { + calls.heartbeat++; + calls.opts['heartbeat']!.push(options); + return Promise.resolve({ + last_heartbeat: `hb_${calls.heartbeat}`, + ttl_seconds: 60, + state: 'running', + lease_extended: true, + }); + }, + stop: (_workId: string, params: { force?: boolean }, options?: unknown) => { + calls.stop.push({ ...(params.force !== undefined ? { force: params.force } : {}) }); + calls.opts['stop']!.push(options); + return Promise.resolve(work); + }, + }, + }, + sessions: { + retrieve: () => { + calls.retrieve++; + return Promise.resolve({ agent: { skills: [] } }); + }, + events: { + list: (_sessionId: string, _params: unknown, options?: unknown) => { + calls.opts['list']!.push(options); + return makeAsyncIterable([]); + }, + send: (_sessionId: string, body: { events: AnyEvent[] }, options?: unknown) => { + calls.send.push(body.events); + calls.opts['send']!.push(options); + return Promise.resolve({}); + }, + stream: (_sessionId: string, _params: unknown, options?: { signal?: AbortSignal }) => { + calls.opts['stream']!.push(options); + return Promise.resolve(makeAbortableAsyncIterable(opts.sessionStream, options?.signal)); + }, + }, + }, + }, + }; + return { client: fake as never, calls, signal: externalAbort.signal }; +} + +function makeAsyncIterable(items: T[]): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + for (const it of items) yield it; + }, + }; +} + +function makeAbortableAsyncIterable(items: T[], signal?: AbortSignal): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + for (const it of items) yield it; + if (!signal || signal.aborted) return; + await new Promise((resolve) => signal.addEventListener('abort', () => resolve(), { once: true })); + }, + }; +} + +function okTool(name: string): BetaRunnableTool { + return { + type: 'custom', + name, + description: name, + input_schema: { type: 'object', properties: {} }, + parse: (x: unknown) => x as never, + run: async () => 'ok', + }; +} + +const TERMINATED: AnyEvent = { type: 'session.status_terminated', id: 'ev_term' }; + +describe('EnvironmentWorker', () => { + test('claims a session, dispatches its tools, heartbeats the lease, and force-stops on exit', async () => { + const { client, calls, signal } = makeFake({ + sessionStream: [{ type: 'agent.tool_use', id: 'tu_1', name: 'echo', input: {} }, TERMINATED], + }); + + const worker = new EnvironmentWorker({ + client, + environmentId: 'env_1', + environmentKey: 'env_key', + tools: [okTool('echo')], + workdir: '/tmp', + maxIdleMs: 0, + signal, + }); + await worker.run(); + + // Claimed + ack'd the work item. + expect(calls.poll).toBeGreaterThanOrEqual(1); + expect(calls.ack).toBe(1); + // Set up the workdir/skills for the session. + expect(calls.retrieve).toBe(1); + // Heartbeated the lease at least once while the session ran. + expect(calls.heartbeat).toBeGreaterThanOrEqual(1); + // Posted the tool result back to the session. + const sentResults = calls.send.flat().filter((e) => e.type === 'user.tool_result'); + expect(sentResults).toHaveLength(1); + expect(sentResults[0]!['tool_use_id']).toBe('tu_1'); + // Force-stopped the work item on exit. + expect(calls.stop.some((s) => s.force === true)).toBe(true); + // The per-session calls were scoped to the environment key, with the + // parent's `apiKey` cleared so `X-Api-Key` doesn't ride alongside the + // bearer credential. + expect(calls.withOptions).toContainEqual(expect.objectContaining({ apiKey: null, authToken: 'env_key' })); + }); + + test('forwards requestOptions custom headers to poll/ack/heartbeat/stop and the session calls', async () => { + const { client, calls, signal } = makeFake({ + sessionStream: [{ type: 'agent.tool_use', id: 'tu_1', name: 'echo', input: {} }, TERMINATED], + }); + + const worker = new EnvironmentWorker({ + client, + environmentId: 'env_1', + environmentKey: 'env_key', + tools: [okTool('echo')], + workdir: '/tmp', + maxIdleMs: 0, + signal, + requestOptions: { headers: { 'x-proxy-token': 'tok-abc' } }, + }); + await worker.run(); + + const header = (opt: unknown): string | null | undefined => + (opt as { headers?: { values?: Headers } } | undefined)?.headers?.values?.get('x-proxy-token'); + + for (const method of ['poll', 'ack', 'heartbeat', 'stop', 'stream', 'list', 'send'] as const) { + const captured = calls.opts[method]!; + expect(captured.length).toBeGreaterThanOrEqual(1); + for (const opt of captured) { + expect(header(opt)).toBe('tok-abc'); + } + } + }); + + test('run() requires environmentId and environmentKey', async () => { + const { client } = makeFake({ sessionStream: [TERMINATED] }); + await expect(new EnvironmentWorker({ client, environmentId: 'env_1' }).run()).rejects.toThrow( + /environmentId and environmentKey are required/, + ); + await expect(new EnvironmentWorker({ client, environmentKey: 'env_key' }).run()).rejects.toThrow( + /environmentId and environmentKey are required/, + ); + }); + + test('handleItem resolves the environment key from the worker or an explicit option', async () => { + const { client, calls } = makeFake({ sessionStream: [TERMINATED] }); + + // From the worker's own environmentKey. + await new EnvironmentWorker({ + client, + environmentKey: 'worker_key', + tools: [], + workdir: '/tmp', + maxIdleMs: 0, + }).handleItem({ workId: 'work_1', environmentId: 'env_1', sessionId: 'sesn_1' }); + expect(calls.withOptions).toContainEqual( + expect.objectContaining({ apiKey: null, authToken: 'worker_key' }), + ); + + // An explicit option wins over the worker's key. + await new EnvironmentWorker({ + client, + environmentKey: 'worker_key', + tools: [], + workdir: '/tmp', + maxIdleMs: 0, + }).handleItem({ + workId: 'work_1', + environmentId: 'env_1', + sessionId: 'sesn_1', + environmentKey: 'explicit_key', + }); + expect(calls.withOptions).toContainEqual( + expect.objectContaining({ apiKey: null, authToken: 'explicit_key' }), + ); + }); + + test('handleItem throws when the environment key cannot be resolved', async () => { + const { client } = makeFake({ sessionStream: [TERMINATED] }); + const saved = process.env['ANTHROPIC_ENVIRONMENT_KEY']; + delete process.env['ANTHROPIC_ENVIRONMENT_KEY']; + try { + await expect( + new EnvironmentWorker({ client, tools: [], workdir: '/tmp' }).handleItem({ + workId: 'work_1', + environmentId: 'env_1', + sessionId: 'sesn_1', + }), + ).rejects.toThrow(/environmentKey is required/); + } finally { + if (saved !== undefined) process.env['ANTHROPIC_ENVIRONMENT_KEY'] = saved; + } + }); +}); diff --git a/tests/lib/helper-client.test.ts b/tests/lib/helper-client.test.ts new file mode 100644 index 00000000..6805d9f3 --- /dev/null +++ b/tests/lib/helper-client.test.ts @@ -0,0 +1,200 @@ +// Direct unit tests for `copyClientForHelper`. +// +// These verify the load-bearing invariants of the util — auth replaced, +// parent's `X-Api-Key` cleared on the sub-client, helper telemetry header set, +// parent not mutated — without re-exercising `withOptions`'s own inheritance +// contract (which is the SDK's job to keep working). + +import Anthropic from '@anthropic-ai/sdk'; +import { copyClientForHelper } from '@anthropic-ai/sdk/lib/helper-client'; +import { AnthropicError } from '@anthropic-ai/sdk/core/error'; + +const VALID_MSG_RESPONSE = { + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [], + model: 'x', + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, +}; + +function jsonResponse(body: object, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function farFuture(): number { + return Math.floor(Date.now() / 1000) + 3600; +} + +function getHeader(init: RequestInit | undefined, name: string): string | null { + if (!init?.headers) return null; + if (init.headers instanceof Headers) return init.headers.get(name); + if (Array.isArray(init.headers)) { + const entry = init.headers.find(([k]) => k?.toLowerCase() === name.toLowerCase()); + return entry?.[1] ?? null; + } + return (init.headers as Record)[name] ?? null; +} + +describe('copyClientForHelper', () => { + test('sets the bearer auth token on the sub-client', () => { + const parent = new Anthropic({ apiKey: 'parent-key' }); + const scoped = copyClientForHelper(parent, { authToken: 'env-key', helper: 'environments-work-poller' }); + expect(scoped.authToken).toBe('env-key'); + }); + + test("clears the parent's X-Api-Key on the sub-client", () => { + // Without `apiKey: null` in the override, `withOptions` would inherit the + // parent's apiKey and the sub-client would emit *both* `X-Api-Key` *and* + // `Authorization: Bearer …` on every request — exactly the situation + // this util exists to prevent. + const parent = new Anthropic({ apiKey: 'parent-key' }); + const scoped = copyClientForHelper(parent, { authToken: 'env-key', helper: 'environments-work-poller' }); + expect(scoped.apiKey).toBeNull(); + }); + + test('stamps the helper telemetry header on the sub-client', () => { + const parent = new Anthropic({ apiKey: 'parent-key' }); + const scoped = copyClientForHelper(parent, { authToken: 'env-key', helper: 'environments-worker' }); + const defaults = (scoped as unknown as { _options: { defaultHeaders: { values: Headers } } })._options + .defaultHeaders; + expect(defaults.values.get('x-stainless-helper')).toBe('environments-worker'); + }); + + test("preserves the parent's custom defaultHeaders on the sub-client", () => { + // `withOptions` *replaces* defaultHeaders (no merge), so the util must + // explicitly fold in the parent's custom defaults — otherwise any + // headers the consumer configured at construction time would silently + // disappear from the sub-client's wire requests. + const parent = new Anthropic({ + apiKey: 'parent-key', + defaultHeaders: { 'X-Custom-Tenant': 'acme' }, + }); + const scoped = copyClientForHelper(parent, { authToken: 'env-key', helper: 'environments-work-poller' }); + const defaults = (scoped as unknown as { _options: { defaultHeaders: { values: Headers } } })._options + .defaultHeaders; + expect(defaults.values.get('x-custom-tenant')).toBe('acme'); + expect(defaults.values.get('x-stainless-helper')).toBe('environments-work-poller'); + }); + + test('does not mutate the parent client', () => { + // Building the sub-client must not touch the parent's auth state — a + // long-lived parent could otherwise be silently re-credentialed every + // time a runner helper started. + const parent = new Anthropic({ apiKey: 'parent-key' }); + copyClientForHelper(parent, { authToken: 'env-key', helper: 'environments-work-poller' }); + expect(parent.apiKey).toBe('parent-key'); + expect(parent.authToken).toBeNull(); + }); + + test('throws on an empty auth token', () => { + // An empty `authToken` would otherwise silently produce a sub-client + // with `authToken: ""` which both fails auth and looks like a + // misconfiguration — fail loudly instead. + const parent = new Anthropic({ apiKey: 'parent-key' }); + expect(() => copyClientForHelper(parent, { authToken: '', helper: 'environments-work-poller' })).toThrow( + AnthropicError, + ); + }); + + test('returns the same concrete subclass as the parent', () => { + // The generic `` keeps the caller's compile-time + // subclass; at runtime `withOptions()` uses `this.constructor`, so the + // sub-client is always an instance of the parent's class. + const parent = new Anthropic({ apiKey: 'parent-key' }); + const scoped = copyClientForHelper(parent, { authToken: 'env-key', helper: 'session-tool-runner' }); + expect(scoped).toBeInstanceOf(Anthropic); + }); +}); + +describe('copyClientForHelper — auth state inheritance', () => { + test('inherits workspace header from parent auth state but uses its own bearer token', async () => { + const captured: { + auth: string | null; + workspace: string | null; + helper: string | null; + apiKey: string | null; + }[] = []; + const parent = new Anthropic({ + apiKey: null, + credentials: async () => ({ token: 'parent-oauth-tok', expiresAt: farFuture() }), + fetch: async (_url: any, init?: RequestInit) => { + captured.push({ + auth: getHeader(init, 'authorization'), + workspace: getHeader(init, 'anthropic-workspace-id'), + helper: getHeader(init, 'x-stainless-helper'), + apiKey: getHeader(init, 'x-api-key'), + }); + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + (parent as any)._authState.extraHeaders = { 'anthropic-workspace-id': 'ws-tenant-42' }; + + await parent.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + + const helper = copyClientForHelper(parent, { + authToken: 'env-scoped-bearer', + helper: 'environments-work-poller', + }); + + await helper.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + + // Parent: OAuth token + workspace header, no helper tag + expect(captured[0]!.auth).toBe('Bearer parent-oauth-tok'); + expect(captured[0]!.workspace).toBe('ws-tenant-42'); + expect(captured[0]!.helper).toBeNull(); + + // Helper sub-client: new bearer token + same workspace + helper tag, no api key + expect(captured[1]!.auth).toBe('Bearer env-scoped-bearer'); + expect(captured[1]!.workspace).toBe('ws-tenant-42'); + expect(captured[1]!.helper).toBe('environments-work-poller'); + expect(captured[1]!.apiKey).toBeNull(); + }); + + test('inherits resolved baseURL from parent', async () => { + const seenURLs: string[] = []; + const parent = new Anthropic({ + apiKey: null, + credentials: async () => ({ token: 'tok', expiresAt: farFuture() }), + fetch: async (url: any) => { + seenURLs.push(String(url)); + return jsonResponse(VALID_MSG_RESPONSE); + }, + }); + // Simulate a profile-resolved baseURL (not set via constructor, so _baseURLIsExplicit stays false) + (parent as any).baseURL = 'https://custom.anthropic.example'; + + await parent.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + + const helper = copyClientForHelper(parent, { + authToken: 'env-bearer', + helper: 'environments-worker', + }); + + await helper.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }); + + expect(seenURLs[0]).toMatch(/^https:\/\/custom\.anthropic\.example\//); + expect(seenURLs[1]).toMatch(/^https:\/\/custom\.anthropic\.example\//); + }); +}); diff --git a/tests/lib/tools/SessionToolRunner.test.ts b/tests/lib/tools/SessionToolRunner.test.ts new file mode 100644 index 00000000..edacff20 --- /dev/null +++ b/tests/lib/tools/SessionToolRunner.test.ts @@ -0,0 +1,535 @@ +import { SessionToolRunner } from '@anthropic-ai/sdk/lib/tools/SessionToolRunner'; +import type { DispatchedToolCall } from '@anthropic-ai/sdk/lib/tools/SessionToolRunner'; +import type { BetaRunnableTool } from '@anthropic-ai/sdk/lib/tools/BetaRunnableTool'; +import { APIError } from '@anthropic-ai/sdk/core/error'; + +// ===== +// Test fakes +// +// We don't need SSE plumbing here — the runner orchestration is what we want +// to test, so we feed event sequences through a synchronous fake of +// `client.beta.sessions.events.stream()` and a fake of `events.list()`. +// ===== + +type AnyEvent = Record & { type: string }; + +interface RunnerCalls { + send: AnyEvent[][]; + list: number; + streams: number; + // The `options` (3rd) argument captured from each stream/list/send call. + options: Array; +} + +interface FakeOpts { + // Sequence of streams the runner will see when it calls events.stream(). + // Each stream yields an array of events. After the last stream, subsequent + // calls block forever (or until the controller aborts). + streams: AnyEvent[][]; + // Optional: events that events.list() will yield on each invocation. + list?: AnyEvent[][]; + // Optional: `events.send` failures, by call index. + sendErrors?: Array<{ at: number; err: unknown }>; + // Optional: stream indices that throw (disconnect) after yielding their + // scripted events, forcing the runner to reconnect + reconcile. + streamErrors?: number[]; +} + +function makeFake(opts: FakeOpts) { + const calls: RunnerCalls = { send: [], list: 0, streams: 0, options: [] }; + + let listIdx = 0; + let streamIdx = 0; + let sendIdx = 0; + + const fake = { + beta: { + sessions: { + events: { + list: (_sessionId: string, _params: unknown, options?: unknown) => { + calls.list++; + calls.options.push(options); + const items = opts.list?.[listIdx++] ?? []; + return makeAsyncIterable(items); + }, + send: (_sessionId: string, body: { events: AnyEvent[] }, options?: unknown) => { + const i = sendIdx++; + calls.send.push(body.events); + calls.options.push(options); + const fail = opts.sendErrors?.find((e) => e.at === i); + if (fail) return Promise.reject(fail.err); + return Promise.resolve({}); + }, + stream: (_sessionId: string, _params: unknown, options?: { signal?: AbortSignal }) => { + calls.streams++; + calls.options.push(options); + const i = streamIdx++; + const events = opts.streams[i] ?? []; + // Mirror APIPromise>: a thenable that resolves to an + // AsyncIterable. The real Stream tears down on abort; the fake + // does the same so tests don't rely on TERMINATED to end. + return Promise.resolve( + makeAbortableAsyncIterable(events, options?.signal, opts.streamErrors?.includes(i) ?? false), + ); + }, + }, + }, + }, + }; + return { client: fake as never, calls }; +} + +function makeAsyncIterable(items: T[]): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + for (const it of items) yield it; + }, + }; +} + +// Yields each item, then (if no items remain) either throws to mimic a +// disconnect, or parks until the signal aborts. Mirrors how the real Stream +// tears down on abort / errors out on a dropped connection. +function makeAbortableAsyncIterable( + items: T[], + signal?: AbortSignal, + throwAfter = false, +): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + for (const it of items) yield it; + // A scripted disconnect: the runner should reconnect + reconcile. + if (throwAfter) throw new Error('stream disconnected'); + // After the scripted items, idle until aborted. Lets the runner exit + // cleanly via the idle watchdog or an external abort. + if (!signal) return; + if (signal.aborted) return; + await new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { once: true }); + }); + }, + }; +} + +function makeOkTool(name: string, output: string | BetaRunnableTool['run']): BetaRunnableTool { + const run: BetaRunnableTool['run'] = typeof output === 'string' ? async () => output : output; + return { + type: 'custom', + name, + description: name, + input_schema: { type: 'object', properties: {} }, + parse: (x: unknown) => x as never, + run, + }; +} + +function toolUse(id: string, name: string, input: Record = {}): AnyEvent { + return { type: 'agent.tool_use', id, name, input }; +} + +function customToolUse(id: string, name: string, input: Record = {}): AnyEvent { + return { type: 'agent.custom_tool_use', id, name, input }; +} + +function idleEndTurn(): AnyEvent { + return { type: 'session.status_idle', id: 'ev_idle', stop_reason: { type: 'end_turn' } }; +} + +const TERMINATED: AnyEvent = { type: 'session.status_terminated', id: 'ev_term' }; + +// ===== + +describe('SessionToolRunner', () => { + test('yields DispatchedToolCall for a successful tool execution and posts the result', async () => { + const tool = makeOkTool('current_time', 'noon'); + const { client, calls } = makeFake({ + streams: [[toolUse('tu_1', 'current_time', { tz: 'UTC' }), TERMINATED]], + }); + const runner = new SessionToolRunner('sesn_1', { client, tools: [tool], maxIdleMs: 0 }); + + const items: DispatchedToolCall[] = []; + for await (const c of runner) items.push(c); + + expect(items).toHaveLength(1); + const call = items[0]!; + expect(call.toolUseId).toBe('tu_1'); + expect(call.name).toBe('current_time'); + expect(call.event.input).toEqual({ tz: 'UTC' }); + expect(call.isError).toBe(false); + expect(call.posted).toBe(true); + expect(call.result.content).toEqual([{ type: 'text', text: 'noon' }]); + + // The send carried a user.tool_result with matching tool_use_id. + const sentResults = calls.send.flat().filter((e) => e.type === 'user.tool_result'); + expect(sentResults).toHaveLength(1); + expect(sentResults[0]!['tool_use_id']).toBe('tu_1'); + expect(sentResults[0]!['is_error']).toBe(false); + }); + + test('yields isError=true when the tool throws', async () => { + const tool = makeOkTool('boom', async () => { + throw new Error('kaboom'); + }); + const { client } = makeFake({ streams: [[toolUse('tu_x', 'boom'), TERMINATED]] }); + const runner = new SessionToolRunner('s', { client, tools: [tool], maxIdleMs: 0 }); + const calls: DispatchedToolCall[] = []; + for await (const c of runner) calls.push(c); + + expect(calls).toHaveLength(1); + expect(calls[0]!.isError).toBe(true); + expect(JSON.stringify(calls[0]!.result.content)).toMatch(/kaboom/); + }); + + test('yields isError=true for an unknown tool name', async () => { + const { client } = makeFake({ streams: [[toolUse('tu_u', 'no_such_tool'), TERMINATED]] }); + const runner = new SessionToolRunner('s', { client, tools: [], maxIdleMs: 0 }); + const out: DispatchedToolCall[] = []; + for await (const c of runner) out.push(c); + + expect(out).toHaveLength(1); + expect(out[0]!.isError).toBe(true); + expect(JSON.stringify(out[0]!.result.content)).toMatch(/not found/); + }); + + test('does not re-execute a tool whose result is already in history', async () => { + let runs = 0; + const tool = makeOkTool('once', async () => { + runs++; + return 'ran'; + }); + const { client } = makeFake({ + // Reconcile sees both the tool_use AND its prior result, so execute is skipped. + list: [[toolUse('tu_already', 'once'), { type: 'user.tool_result', tool_use_id: 'tu_already' }]], + streams: [[TERMINATED]], + }); + const runner = new SessionToolRunner('s', { client, tools: [tool], maxIdleMs: 0 }); + const out: DispatchedToolCall[] = []; + for await (const c of runner) out.push(c); + + expect(runs).toBe(0); + expect(out).toHaveLength(0); + }); + + test('session.status_terminated ends iteration', async () => { + const { client } = makeFake({ streams: [[TERMINATED]] }); + const runner = new SessionToolRunner('s', { client, tools: [], maxIdleMs: 0 }); + const out: DispatchedToolCall[] = []; + for await (const c of runner) out.push(c); + expect(out).toEqual([]); + }); + + test('runs each tool.close() once when iteration ends, even when the consumer breaks early', async () => { + let closed = 0; + const tool: BetaRunnableTool = { + ...makeOkTool('first', 'ok'), + close: () => { + closed++; + }, + }; + const { client } = makeFake({ + streams: [[toolUse('tu_a', 'first'), toolUse('tu_b', 'first'), TERMINATED]], + }); + const runner = new SessionToolRunner('s', { client, tools: [tool], maxIdleMs: 0 }); + + let count = 0; + for await (const _ of runner) { + count++; + if (count === 1) break; + } + expect(count).toBe(1); + expect(closed).toBe(1); + }); + + test('posted=false when events.send hits a permanent 4xx', async () => { + const tool = makeOkTool('t', 'ok'); + const err = Object.assign(Object.create(APIError.prototype) as APIError, { status: 404 }); + const { client } = makeFake({ + streams: [[toolUse('tu_p', 't'), TERMINATED]], + sendErrors: [{ at: 0, err }], + }); + const runner = new SessionToolRunner('s', { client, tools: [tool], maxIdleMs: 0 }); + const out: DispatchedToolCall[] = []; + for await (const c of runner) out.push(c); + + expect(out).toHaveLength(1); + expect(out[0]!.posted).toBe(false); + expect(out[0]!.isError).toBe(false); + }); + + test('throws when iterated twice', async () => { + const { client } = makeFake({ streams: [[TERMINATED]] }); + const runner = new SessionToolRunner('s', { client, tools: [], maxIdleMs: 0 }); + for await (const _ of runner) { + // unreachable + } + await expect( + (async () => { + for await (const _ of runner) { + // unreachable + } + })(), + ).rejects.toThrow(/consumed/); + }); + + test('pre-aborted external signal settles cleanup without hanging', async () => { + // Reproducer for: a pre-aborted signal would cause idleWatchdog's + // maxIdleMs <= 0 branch to park on a signal that never re-fires, hanging + // the iterator's `Promise.allSettled` step forever. + const ctl = new AbortController(); + ctl.abort(); + const { client } = makeFake({ streams: [[]] }); + const runner = new SessionToolRunner('s', { + client, + tools: [], + maxIdleMs: 0, + signal: ctl.signal, + }); + // If the bug is back, this hangs and Jest's per-test timeout fires. + await Promise.race([ + (async () => { + for await (const _ of runner) { + // unreachable + } + })(), + new Promise((_, reject) => setTimeout(() => reject(new Error('iter hung')), 2_000)), + ]); + }, 5_000); + + test('idle watchdog stops the runner maxIdleMs after an end_turn idle', async () => { + jest.useFakeTimers(); + try { + // Session goes idle with end_turn and nothing else happens — the idle + // watchdog should stop the runner after maxIdleMs. + const { client } = makeFake({ streams: [[idleEndTurn()]] }); + + const runner = new SessionToolRunner('s', { client, tools: [], maxIdleMs: 1_000 }); + + let done = false; + const consumer = (async () => { + for await (const _ of runner) { + // unreachable — no tool calls + } + done = true; + })(); + + await jest.advanceTimersByTimeAsync(2_000); + await consumer; + expect(done).toBe(true); + } finally { + jest.useRealTimers(); + } + }); + + test('a new event after end_turn resets the idle watchdog', async () => { + const tool = makeOkTool('echo', 'ok'); + // end_turn arms the timer; the tool_use that follows resets it and is + // dispatched; the run only ends on the terminated event. + const { client } = makeFake({ + streams: [[idleEndTurn(), toolUse('tu_1', 'echo'), TERMINATED]], + }); + const runner = new SessionToolRunner('s', { + client, + tools: [tool], + // Generous grace — the timer must not fire between the scripted events. + maxIdleMs: 60_000, + }); + const out: DispatchedToolCall[] = []; + for await (const c of runner) out.push(c); + expect(out.map((c) => c.toolUseId)).toEqual(['tu_1']); + }); + + test('dispatches an agent.custom_tool_use and answers with a matching user.custom_tool_result', async () => { + const tool = makeOkTool('lookup_order', 'shipped'); + const { client, calls } = makeFake({ + streams: [[customToolUse('ctu_1', 'lookup_order', { order_id: 42 }), TERMINATED]], + }); + const runner = new SessionToolRunner('s', { client, tools: [tool], maxIdleMs: 0 }); + + const out: DispatchedToolCall[] = []; + for await (const c of runner) out.push(c); + + expect(out).toHaveLength(1); + const call = out[0]!; + expect(call.event.type).toBe('agent.custom_tool_use'); + expect(call.toolUseId).toBe('ctu_1'); + expect(call.name).toBe('lookup_order'); + expect(call.event.input).toEqual({ order_id: 42 }); + expect(call.isError).toBe(false); + expect(call.posted).toBe(true); + expect(call.result.type).toBe('user.custom_tool_result'); + expect((call.result as { custom_tool_use_id?: string }).custom_tool_use_id).toBe('ctu_1'); + expect(call.result.content).toEqual([{ type: 'text', text: 'shipped' }]); + + // A custom tool call must be answered with user.custom_tool_result, never + // user.tool_result — the wrong type leaves the session hung. + const sent = calls.send.flat(); + expect(sent.filter((e) => e.type === 'user.tool_result')).toHaveLength(0); + const customResults = sent.filter((e) => e.type === 'user.custom_tool_result'); + expect(customResults).toHaveLength(1); + expect(customResults[0]!['custom_tool_use_id']).toBe('ctu_1'); + }); + + test('dispatches builtin and custom tool calls in one stream, each with its matching result type', async () => { + const { client, calls } = makeFake({ + streams: [[toolUse('tu_b', 'echo'), customToolUse('ctu_c', 'echo'), TERMINATED]], + }); + const runner = new SessionToolRunner('s', { + client, + tools: [makeOkTool('echo', 'ok')], + maxIdleMs: 0, + }); + + const out: DispatchedToolCall[] = []; + for await (const c of runner) out.push(c); + + expect(out.map((c) => [c.event.type, c.toolUseId])).toEqual([ + ['agent.tool_use', 'tu_b'], + ['agent.custom_tool_use', 'ctu_c'], + ]); + const sent = calls.send.flat(); + expect(sent.filter((e) => e.type === 'user.tool_result').map((e) => e['tool_use_id'])).toEqual(['tu_b']); + expect( + sent.filter((e) => e.type === 'user.custom_tool_result').map((e) => e['custom_tool_use_id']), + ).toEqual(['ctu_c']); + }); + + test('reconcile does not re-execute a custom tool whose user.custom_tool_result is already in history', async () => { + let runs = 0; + const tool = makeOkTool('once_custom', async () => { + runs++; + return 'ran'; + }); + const { client } = makeFake({ + // Reconcile sees the custom tool_use AND its prior custom result. + list: [ + [ + customToolUse('ctu_done', 'once_custom'), + { type: 'user.custom_tool_result', custom_tool_use_id: 'ctu_done' }, + ], + ], + streams: [[TERMINATED]], + }); + const runner = new SessionToolRunner('s', { client, tools: [tool], maxIdleMs: 0 }); + const out: DispatchedToolCall[] = []; + for await (const c of runner) out.push(c); + + expect(runs).toBe(0); + expect(out).toHaveLength(0); + }); + + test('passes a search_result tool-result block through as a search_result event, not stringified', async () => { + const tool = makeOkTool('web_search', async () => [ + { + type: 'search_result', + source: 'https://example.com/doc', + title: 'Example Doc', + content: [{ type: 'text', text: 'the answer is 42' }], + citations: { enabled: true }, + }, + ]); + const { client, calls } = makeFake({ + streams: [[toolUse('tu_s', 'web_search'), TERMINATED]], + }); + const runner = new SessionToolRunner('s', { client, tools: [tool], maxIdleMs: 0 }); + const out: DispatchedToolCall[] = []; + for await (const c of runner) out.push(c); + + expect(out).toHaveLength(1); + // Mapped to the Sessions search_result block shape, NOT a text block with + // a JSON.stringify of the original block. + expect(out[0]!.result.content).toEqual([ + { + type: 'search_result', + source: 'https://example.com/doc', + title: 'Example Doc', + content: [{ type: 'text', text: 'the answer is 42' }], + citations: { enabled: true }, + }, + ]); + const sent = calls.send.flat().filter((e) => e.type === 'user.tool_result'); + expect(sent).toHaveLength(1); + const content = sent[0]!['content'] as Array<{ type: string }>; + expect(content[0]!.type).toBe('search_result'); + // Must not have been buried inside a stringified text block. + expect(JSON.stringify(content)).not.toContain('\\"type\\":\\"search_result\\"'); + }); + + test('citations defaults to { enabled: false } when the producer omits it', async () => { + const tool = makeOkTool('web_search', async () => [ + { + type: 'search_result', + source: 'https://example.com', + title: 'No citations', + content: [{ type: 'text', text: 'body' }], + }, + ]); + const { client } = makeFake({ streams: [[toolUse('tu_s2', 'web_search'), TERMINATED]] }); + const runner = new SessionToolRunner('s', { client, tools: [tool], maxIdleMs: 0 }); + const out: DispatchedToolCall[] = []; + for await (const c of runner) out.push(c); + + expect(out[0]!.result.content).toEqual([ + { + type: 'search_result', + source: 'https://example.com', + title: 'No citations', + content: [{ type: 'text', text: 'body' }], + citations: { enabled: false }, + }, + ]); + }); + + test('forwards requestOptions custom headers to stream/list/send (alongside the helper header)', async () => { + const tool = makeOkTool('echo', 'ok'); + const { client, calls } = makeFake({ + streams: [[toolUse('tu_h', 'echo')]], + list: [[]], + }); + const runner = new SessionToolRunner('s', { + client, + tools: [tool], + maxIdleMs: 0, + requestOptions: { headers: { 'x-proxy-token': 'secret-123' } }, + }); + const consume = (async () => { + for await (const _ of runner) runner.abort(); + })(); + await consume; + + // stream, list (reconcile) and send were all called and each carried the + // custom proxy header plus the helper telemetry header. + expect(calls.options.length).toBeGreaterThanOrEqual(3); + for (const opt of calls.options) { + const headers = (opt as { headers?: { values?: Headers } }).headers; + expect(headers?.values?.get('x-proxy-token')).toBe('secret-123'); + expect(headers?.values?.get('x-stainless-helper')).toBe('SessionToolRunner'); + } + }); + + test('retries a tool_use whose result post failed on the next reconcile instead of dropping it', async () => { + let runs = 0; + const tool = makeOkTool('retry_me', async () => { + runs++; + return 'ok'; + }); + const fatal = Object.assign(Object.create(APIError.prototype) as APIError, { status: 400 }); + const { client, calls } = makeFake({ + // Stream 1 yields the tool_use then disconnects; stream 2 terminates. + streams: [[toolUse('tu_r', 'retry_me')], [TERMINATED]], + streamErrors: [0], + // Reconcile on reconnect still sees the tool_use with no result event — + // because the first post failed — so it must be retried, not dropped. + list: [[], [toolUse('tu_r', 'retry_me')]], + // The first post fails permanently; the retry after reconnect succeeds. + sendErrors: [{ at: 0, err: fatal }], + }); + const runner = new SessionToolRunner('s', { client, tools: [tool], maxIdleMs: 0 }); + const out: DispatchedToolCall[] = []; + for await (const c of runner) out.push(c); + + // Re-executed (and re-posted) once the failed post was retried. + expect(runs).toBe(2); + expect(calls.send.flat().filter((e) => e.type === 'user.tool_result')).toHaveLength(2); + expect(out.map((c) => c.posted)).toEqual([false, true]); + }); +}); diff --git a/tests/tools/agent-toolset.test.ts b/tests/tools/agent-toolset.test.ts new file mode 100644 index 00000000..a9bf0579 --- /dev/null +++ b/tests/tools/agent-toolset.test.ts @@ -0,0 +1,349 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + resolvePath, + betaAgentToolset20260401, + betaBashTool, + betaReadTool, + betaWriteTool, + betaEditTool, + betaGlobTool, + betaGrepTool, + BashSession, + type AgentToolContext, +} from '@anthropic-ai/sdk/tools/agent-toolset/node'; +import type { BetaRunnableTool } from '@anthropic-ai/sdk/lib/tools/BetaRunnableTool'; + +function tmpdir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'runner-test-')); +} + +describe('betaAgentToolset20260401', () => { + test('returns the agent_toolset_20260401 tool list as BetaRunnableTool objects so it can be filtered or extended', () => { + const tools = betaAgentToolset20260401({ workdir: '.' }); + expect(tools.map((t) => t.name)).toEqual(['bash', 'read', 'write', 'edit', 'glob', 'grep']); + for (const t of tools) { + expect(typeof t.run).toBe('function'); + expect(typeof t.parse).toBe('function'); + expect(t.type).toBe('custom'); + } + }); +}); + +describe('resolvePath', () => { + const root = '/tmp/work'; + const cases: { description: string; env: AgentToolContext; p: string; want?: string; wantErr?: RegExp }[] = + [ + { + description: 'relative path under workdir resolves to an absolute child of the workdir', + env: { workdir: root }, + p: 'a/b.txt', + want: path.resolve(root, 'a/b.txt'), + }, + { + description: 'dot-dot that escapes the workdir is rejected when unrestrictedPaths is false', + env: { workdir: root }, + p: '../etc/passwd', + wantErr: /escapes workdir/, + }, + { + description: 'absolute path is rejected by default so the jail is the explicit opt-out', + env: { workdir: root }, + p: '/etc/passwd', + wantErr: /not permitted/, + }, + { + description: 'absolute path is allowed when unrestrictedPaths is set', + env: { workdir: root, unrestrictedPaths: true }, + p: '/etc/passwd', + want: '/etc/passwd', + }, + { + description: 'sibling directory with a shared prefix (work vs workdir2) is correctly rejected', + env: { workdir: '/tmp/work' }, + p: '../work2/file', + wantErr: /escapes workdir/, + }, + { + description: 'dot-dot that stays inside the workdir after normalisation is permitted', + env: { workdir: root }, + p: 'a/../b.txt', + want: path.resolve(root, 'b.txt'), + }, + ]; + + for (const tc of cases) { + test(tc.description, async () => { + if (tc.wantErr) { + await expect(resolvePath(tc.env, tc.p)).rejects.toThrow(tc.wantErr); + } else { + await expect(resolvePath(tc.env, tc.p)).resolves.toEqual(tc.want); + } + }); + } +}); + +describe('fs tools (read/write/edit)', () => { + let dir: string; + let env: AgentToolContext; + + beforeEach(() => { + dir = tmpdir(); + env = { workdir: dir }; + }); + afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); + + test('write creates a file then read returns its content verbatim', async () => { + await betaWriteTool(env).run({ file_path: 'a.txt', content: 'hello' }); + const out = await betaReadTool(env).run({ file_path: 'a.txt' }); + expect(out).toBe('hello'); + }); + + test('read with view_range returns the 1-indexed inclusive line slice', async () => { + fs.writeFileSync(path.join(dir, 'f.txt'), 'a\nb\nc\nd\n'); + const out = await betaReadTool(env).run({ file_path: 'f.txt', view_range: [2, 3] }); + expect(out).toBe('b\nc'); + }); + + test('read of a missing file throws ToolError so the dispatcher reports is_error', async () => { + await expect(betaReadTool(env).run({ file_path: 'nope.txt' })).rejects.toThrow(/ENOENT|no such file/); + }); + + test('edit with a unique old_string performs exactly one replacement', async () => { + fs.writeFileSync(path.join(dir, 'f.txt'), 'foo bar foo'); + await betaEditTool(env).run({ file_path: 'f.txt', old_string: 'bar', new_string: 'BAZ' }); + expect(fs.readFileSync(path.join(dir, 'f.txt'), 'utf8')).toBe('foo BAZ foo'); + }); + + test('edit refuses a non-unique old_string when replace_all is not set', async () => { + fs.writeFileSync(path.join(dir, 'f.txt'), 'foo bar foo'); + await expect( + betaEditTool(env).run({ file_path: 'f.txt', old_string: 'foo', new_string: 'X' }), + ).rejects.toThrow(/appears 2 times/); + }); + + test('edit with replace_all replaces every occurrence', async () => { + fs.writeFileSync(path.join(dir, 'f.txt'), 'a a a'); + await betaEditTool(env).run({ file_path: 'f.txt', old_string: 'a', new_string: 'b', replace_all: true }); + expect(fs.readFileSync(path.join(dir, 'f.txt'), 'utf8')).toBe('b b b'); + }); + + test('edit inserts new_string literally instead of expanding $& / $1 replacement patterns', async () => { + fs.writeFileSync(path.join(dir, 'f.txt'), 'echo NAME'); + await betaEditTool(env).run({ file_path: 'f.txt', old_string: 'NAME', new_string: '$&_$1_${HOME}' }); + expect(fs.readFileSync(path.join(dir, 'f.txt'), 'utf8')).toBe('echo $&_$1_${HOME}'); + }); + + test('edit with replace_all also inserts $-containing new_string literally', async () => { + fs.writeFileSync(path.join(dir, 'f.txt'), 'X X'); + await betaEditTool(env).run({ file_path: 'f.txt', old_string: 'X', new_string: '$`', replace_all: true }); + expect(fs.readFileSync(path.join(dir, 'f.txt'), 'utf8')).toBe('$` $`'); + }); + + test('read refuses a file over the size cap so a huge file cannot OOM the runner', async () => { + fs.writeFileSync(path.join(dir, 'big.txt'), Buffer.alloc(257 * 1024, 'a')); + await expect(betaReadTool(env).run({ file_path: 'big.txt' })).rejects.toThrow(/exceeds .*limit/); + }); + + test('read refuses a directory so it cannot dump a Dirent listing as bytes', async () => { + fs.mkdirSync(path.join(dir, 'sub')); + await expect(betaReadTool(env).run({ file_path: 'sub' })).rejects.toThrow(/not a regular file/); + }); + + test('edit refuses a file over the size cap so a huge file cannot OOM the runner', async () => { + fs.writeFileSync(path.join(dir, 'big.txt'), Buffer.alloc(257 * 1024, 'a')); + await expect( + betaEditTool(env).run({ file_path: 'big.txt', old_string: 'a', new_string: 'b' }), + ).rejects.toThrow(/exceeds .*limit/); + }); + + test('edit refuses a directory so a non-regular path cannot hang or be misread', async () => { + fs.mkdirSync(path.join(dir, 'sub')); + await expect( + betaEditTool(env).run({ file_path: 'sub', old_string: 'a', new_string: 'b' }), + ).rejects.toThrow(/not a regular file/); + }); + + test('edit still works on a normal file under the size cap', async () => { + fs.writeFileSync(path.join(dir, 'ok.txt'), 'foo bar'); + await betaEditTool(env).run({ file_path: 'ok.txt', old_string: 'bar', new_string: 'baz' }); + expect(fs.readFileSync(path.join(dir, 'ok.txt'), 'utf8')).toBe('foo baz'); + }); + + test('write outside workdir via dot-dot is rejected by the path jail', async () => { + await expect(betaWriteTool(env).run({ file_path: '../escape.txt', content: 'x' })).rejects.toThrow( + /escapes workdir/, + ); + }); +}); + +describe('search tools (glob/grep)', () => { + let dir: string; + let env: AgentToolContext; + + beforeEach(() => { + dir = tmpdir(); + env = { workdir: dir }; + fs.mkdirSync(path.join(dir, 'sub'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'a.txt'), 'alpha\nbeta\n'); + fs.writeFileSync(path.join(dir, 'sub', 'b.ts'), 'gamma\n'); + }); + afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); + + test('glob with **/*.ts finds nested TypeScript files', async () => { + const out = await betaGlobTool(env).run({ pattern: '**/*.ts' }); + expect(out).toContain(path.join(dir, 'sub', 'b.ts')); + expect(out).not.toContain('a.txt'); + }); + + test('glob returns the literal "no matches" string when nothing matches', async () => { + const out = await betaGlobTool(env).run({ pattern: '**/*.nomatch' }); + expect(out).toBe('no matches'); + }); + + test('glob with a path argument searches only under that subdirectory', async () => { + const out = await betaGlobTool(env).run({ pattern: '*.ts', path: 'sub' }); + expect(out).toContain(path.join(dir, 'sub', 'b.ts')); + expect(out).not.toContain('a.txt'); + }); + + test('glob rejects a ".." pattern that would walk fs.glob out of the workdir', async () => { + await expect(betaGlobTool(env).run({ pattern: '../*' })).rejects.toThrow(/not permitted in the pattern/); + await expect(betaGlobTool(env).run({ pattern: '../../**/*.ts' })).rejects.toThrow( + /not permitted in the pattern/, + ); + }); + + test('grep finds a line and reports file:lineno:content (works with or without rg on PATH)', async () => { + const out = await betaGrepTool(env).run({ pattern: 'beta' }); + expect(out).toMatch(/a\.txt:2:beta/); + }); +}); + +const describeBash = process.platform === 'win32' ? describe.skip : describe; + +describeBash('betaBashTool', () => { + let dir: string; + let tool: BetaRunnableTool<{ command?: string; restart?: boolean; timeout_ms?: number }>; + + beforeEach(() => { + dir = tmpdir(); + tool = betaBashTool({ workdir: dir }); + }); + afterEach(() => { + tool.close?.(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('exporting a variable in one call makes it readable in a later call (state persists across the closure-held session)', async () => { + expect(await tool.run({ command: 'export FOO=bar; echo set' })).toBe('set'); + expect(await tool.run({ command: 'echo $FOO' })).toBe('bar'); + }); + + test('a non-zero exit throws ToolError carrying the captured output', async () => { + await expect(tool.run({ command: 'echo oops; (exit 7)' })).rejects.toThrow(/oops/); + }); + + test('restart drops state so a previously exported variable is no longer set', async () => { + await tool.run({ command: 'export FOO=bar' }); + expect(await tool.run({ command: 'echo $FOO', restart: true })).toBe(''); + }); + + test('a timed-out call discards the corrupted session so the next call starts a fresh shell instead of seeing the stale sentinel', async () => { + await expect(tool.run({ command: 'sleep 2', timeout_ms: 200 })).rejects.toThrow(/timed out/); + expect(await tool.run({ command: 'echo recovered' })).toBe('recovered'); + }); + + test('the spawned shell does not inherit ANTHROPIC_* credential env vars', async () => { + // The runner process holds Anthropic credentials in ANTHROPIC_* env vars; + // an unrestricted shell must not be able to read them back out. + process.env['ANTHROPIC_API_KEY'] = 'sk-ant-should-not-leak'; + const scoped = betaBashTool({ workdir: dir }); + try { + expect(await scoped.run({ command: 'echo "[$ANTHROPIC_API_KEY]"' })).toBe('[]'); + // Non-credential vars (PATH etc.) still pass through. + expect(await scoped.run({ command: 'test -n "$PATH" && echo has-path' })).toBe('has-path'); + } finally { + scoped.close?.(); + delete process.env['ANTHROPIC_API_KEY']; + } + }); + + test('ctx.env FULLY REPLACES the scrubbed default environment (verbatim, not merged)', async () => { + // A var that exists in the real process env must NOT leak through when the + // caller supplies its own env — the mapping is used verbatim. + process.env['LEAKY_VAR'] = 'should-not-be-visible'; + const scoped = betaBashTool({ workdir: dir, env: { ONLY_THIS: 'yes', PATH: process.env['PATH'] } }); + try { + expect(await scoped.run({ command: 'echo "[$ONLY_THIS]"' })).toBe('[yes]'); + // Not merged with the scrubbed process env — LEAKY_VAR is absent. + expect(await scoped.run({ command: 'echo "[$LEAKY_VAR]"' })).toBe('[]'); + } finally { + scoped.close?.(); + delete process.env['LEAKY_VAR']; + } + }); + + test('concurrent run() calls are serialized so the shared shells stdin is not interleaved (e.g. BetaToolRunner Promise.all)', async () => { + // Each call prints a unique marker. With no mutex, both commands write to + // the same stdin and the sentinel-match logic would attribute output to + // the wrong call. With the mutex, the second call only runs once the + // first has finished, so outputs are clean and ordering is stable. + const results = await Promise.all([ + tool.run({ command: "sleep 0.05; printf 'A'" }), + tool.run({ command: "sleep 0.05; printf 'B'" }), + ]); + expect(results).toEqual(['A', 'B']); + }); +}); + +describeBash('BashSession (direct)', () => { + let dir: string; + let session: BashSession; + + beforeEach(() => { + dir = tmpdir(); + session = new BashSession(dir); + }); + afterEach(() => { + session.close(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('changing directory persists so a later pwd reports the new location', async () => { + await session.exec('cd /tmp'); + const r = await session.exec('pwd'); + expect(r.output).toMatch(/\/tmp$/); + }); + + test('a non-zero exit is surfaced via exitCode without throwing', async () => { + const r = await session.exec('(exit 7)'); + expect(r.exitCode).toBe(7); + }); + + test('exec rejects when the command exceeds the timeout budget', async () => { + await expect(session.exec('sleep 2', { timeoutMs: 200 })).rejects.toThrow(/timed out/); + }); + + test('a command that prints a hardcoded sentinel-like marker cannot truncate its own output or spoof the exit code', async () => { + const r = await session.exec("printf '__ANT_CMD_DONE__7\\nafter\\n'; (exit 3)"); + expect(r.output).toContain('__ANT_CMD_DONE__7'); + expect(r.output).toContain('after'); + expect(r.exitCode).toBe(3); + }); + + test('a command that reads stdin gets immediate EOF instead of hanging until the timeout', async () => { + const r = await session.exec('cat; echo done', { timeoutMs: 2000 }); + expect(r.output).toBe('done'); + expect(r.exitCode).toBe(0); + }); + + test('a command that streams more output than the cap is truncated rather than buffered unbounded', async () => { + const r = await session.exec("head -c 300000 /dev/zero | tr '\\0' a; echo END"); + expect(r.output).toMatch(/^\[output truncated\]\n/); + expect(r.output).toMatch(/END$/); + expect(r.output.length).toBeLessThanOrEqual(101 * 1024); + expect(r.exitCode).toBe(0); + }); +}); diff --git a/tests/tools/skills.test.ts b/tests/tools/skills.test.ts new file mode 100644 index 00000000..0ac78712 --- /dev/null +++ b/tests/tools/skills.test.ts @@ -0,0 +1,95 @@ +import * as fs from 'node:fs'; +import * as fsp from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { extractSkillArchive } from '@anthropic-ai/sdk/tools/agent-toolset/node'; + +/** + * Skill version archives are packaged wrapped in a single directory named + * after the skill (e.g. `pdf/SKILL.md`). Extraction must strip that wrapper so + * files land at `dest/SKILL.md`, not the doubled `dest/pdf/SKILL.md` the + * agent's skill discovery does not find. It must also still refuse zip-slip. + */ +describe('extractSkillArchive', () => { + let work: string; + + beforeEach(() => { + work = fs.mkdtempSync(path.join(os.tmpdir(), 'skilltest-')); + }); + afterEach(() => { + fs.rmSync(work, { recursive: true, force: true }); + }); + + /** Lay out `files` under a temp tree, then pack it with the given CLI. */ + function pack(kind: 'zip' | 'targz', files: Record): Buffer { + const src = fs.mkdtempSync(path.join(os.tmpdir(), 'skillsrc-')); + try { + for (const [rel, body] of Object.entries(files)) { + const p = path.join(src, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, body); + } + const archive = path.join(work, `a.${kind === 'zip' ? 'zip' : 'tgz'}`); + if (kind === 'zip') { + execFileSync('zip', ['-rq', archive, '.'], { cwd: src }); + } else { + execFileSync('tar', ['-czf', archive, '-C', src, '.']); + } + const buf = fs.readFileSync(archive); + fs.rmSync(archive, { force: true }); + return buf; + } finally { + fs.rmSync(src, { recursive: true, force: true }); + } + } + + async function extractInto(buf: Buffer, dest: string): Promise { + await fsp.mkdir(dest, { recursive: true }); + await extractSkillArchive(new Response(buf), dest); + } + + for (const kind of ['zip', 'targz'] as const) { + test(`${kind}: strips the skill wrapper directory (no doubling)`, async () => { + const buf = pack(kind, { + 'pdf/SKILL.md': '# PDF', + 'pdf/scripts/run.py': 'print(1)', + }); + const dest = path.join(work, 'skills', 'pdf'); + await extractInto(buf, dest); + + expect(fs.readFileSync(path.join(dest, 'SKILL.md'), 'utf8')).toBe('# PDF'); + expect(fs.readFileSync(path.join(dest, 'scripts', 'run.py'), 'utf8')).toBe('print(1)'); + expect(fs.existsSync(path.join(dest, 'pdf'))).toBe(false); + }); + + test(`${kind}: flat archive (no wrapper) extracts unchanged`, async () => { + const buf = pack(kind, { 'SKILL.md': '# flat', 'scripts/run.py': 'x' }); + const dest = path.join(work, 'skills', 'flat'); + await extractInto(buf, dest); + expect(fs.readFileSync(path.join(dest, 'SKILL.md'), 'utf8')).toBe('# flat'); + expect(fs.readFileSync(path.join(dest, 'scripts', 'run.py'), 'utf8')).toBe('x'); + }); + } + + test('refuses a zip-slip member', async () => { + const src = fs.mkdtempSync(path.join(os.tmpdir(), 'evil-')); + const archive = path.join(work, 'evil.zip'); + try { + fs.writeFileSync(path.join(src, 'escape.txt'), 'pwned'); + execFileSync('zip', ['-q', archive, 'escape.txt'], { cwd: src }); + // Rewrite the entry name to a traversal path via zipnote. + execFileSync('zipnote', ['-w', archive], { input: '@ escape.txt\n@=../escape.txt\n' }); + + const dest = path.join(work, 'skills', 'x'); + await fsp.mkdir(dest, { recursive: true }); + await expect(extractSkillArchive(new Response(fs.readFileSync(archive)), dest)).rejects.toThrow( + /unsafe archive member/, + ); + expect(fs.existsSync(path.join(work, 'skills', 'escape.txt'))).toBe(false); + expect(fs.existsSync(path.join(work, 'escape.txt'))).toBe(false); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/uploads.test.ts b/tests/uploads.test.ts index 2e94efaf..8e23e356 100644 --- a/tests/uploads.test.ts +++ b/tests/uploads.test.ts @@ -1,7 +1,6 @@ import fs from 'fs'; import type { ResponseLike } from '@anthropic-ai/sdk/internal/to-file'; import { toFile } from '@anthropic-ai/sdk/core/uploads'; -import { File } from 'node:buffer'; class MyClass { name: string = 'foo'; diff --git a/yarn.lock b/yarn.lock index b8158ccb..6fdb9bd9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3858,9 +3858,9 @@ ts-node@^10.5.0: v8-compile-cache-lib "^3.0.0" yn "3.1.1" -"tsc-multi@https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz": - version "1.1.9" - resolved "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz#777f6f5d9e26bf0e94e5170990dd3a841d6707cd" +"tsc-multi@https://github.com/stainless-api/tsc-multi/releases/download/v1.1.11/tsc-multi.tgz": + version "1.1.11" + resolved "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.11/tsc-multi.tgz#010247051be13b55abdc98f787c017285149f4f2" dependencies: debug "^4.3.7" fast-glob "^3.3.2"