From 174ad625ba24ea2d920243eb7a95c88e0ff63251 Mon Sep 17 00:00:00 2001 From: liramon2 Date: Fri, 9 Oct 2026 13:19:41 -0400 Subject: [PATCH 1/3] feat(ts): add python_repl vended tool (TS port of #4384) --- .../sdk/tools/vended-tools-imports.ts | 11 ++ .../user-guide/sdk/tools/vended-tools.mdx | 39 ++++- .../docs/user-guide/sdk/tools/vended-tools.ts | 21 +++ strands-ts/package.json | 4 + strands-ts/src/vended-tools/index.ts | 2 + .../src/vended-tools/python-repl/README.md | 63 ++++++++ .../__tests__/python-repl.test.node.ts | 151 ++++++++++++++++++ .../src/vended-tools/python-repl/index.ts | 19 +++ .../vended-tools/python-repl/python-repl.ts | 122 ++++++++++++++ .../src/vended-tools/python-repl/types.ts | 49 ++++++ 10 files changed, 478 insertions(+), 3 deletions(-) create mode 100644 strands-ts/src/vended-tools/python-repl/README.md create mode 100644 strands-ts/src/vended-tools/python-repl/__tests__/python-repl.test.node.ts create mode 100644 strands-ts/src/vended-tools/python-repl/index.ts create mode 100644 strands-ts/src/vended-tools/python-repl/python-repl.ts create mode 100644 strands-ts/src/vended-tools/python-repl/types.ts diff --git a/site/src/content/docs/user-guide/sdk/tools/vended-tools-imports.ts b/site/src/content/docs/user-guide/sdk/tools/vended-tools-imports.ts index c91c751aa7..e2d608175b 100644 --- a/site/src/content/docs/user-guide/sdk/tools/vended-tools-imports.ts +++ b/site/src/content/docs/user-guide/sdk/tools/vended-tools-imports.ts @@ -56,6 +56,17 @@ import { Agent } from '@strands-agents/sdk' import { makeSleep } from '@strands-agents/sdk/vended-tools/sleep' // --8<-- [end:sleep_custom_import] +// --8<-- [start:python_repl_import] +import { Agent } from '@strands-agents/sdk' +import { pythonRepl } from '@strands-agents/sdk/vended-tools/python-repl' +// --8<-- [end:python_repl_import] + +// --8<-- [start:python_repl_custom_import] +import { Agent } from '@strands-agents/sdk' +import { DockerSandbox } from '@strands-agents/sdk/sandbox/docker' +import { makePythonRepl } from '@strands-agents/sdk/vended-tools/python-repl' +// --8<-- [end:python_repl_custom_import] + // --8<-- [start:stop_import] import { Agent } from '@strands-agents/sdk' import { stop } from '@strands-agents/sdk/experimental/vended-tools/stop' diff --git a/site/src/content/docs/user-guide/sdk/tools/vended-tools.mdx b/site/src/content/docs/user-guide/sdk/tools/vended-tools.mdx index ff24a16ee8..4fa4dfb829 100644 --- a/site/src/content/docs/user-guide/sdk/tools/vended-tools.mdx +++ b/site/src/content/docs/user-guide/sdk/tools/vended-tools.mdx @@ -16,6 +16,7 @@ sourceLinks: - path: strands-ts/src/experimental/vended-tools/stop/stop.ts - path: strands-ts/src/vended-tools/a2a-client/a2a-client.ts - path: strands-ts/src/vended-tools/subagent/subagent.ts + - path: strands-ts/src/vended-tools/python-repl/python-repl.ts - path: strands-py/src/strands/vended_tools/handoff_to_user/handoff_to_user.py - path: strands-py/src/strands/vended_tools/http_request/http_request.py - path: strands-py/src/strands/vended_tools/notebook/notebook.py @@ -57,7 +58,7 @@ Each tool is imported from its own subpath under `@strands-agents/sdk/vended-too | [Stop](#stop-experimental) | Gracefully end the agent loop when the task is complete | Python, TypeScript (Node.js, browsers) | | [Web Fetch](#web-fetch) | Fetch a URL and return cleaned markdown for a model to read | Python, TypeScript (Node.js) | | [A2A Client](#a2a-client) | Discover and send messages to remote A2A-protocol agents | Python, TypeScript (Node.js, browsers) | -| [Python REPL](#python-repl) | Execute Python code through the agent's sandbox | Python | +| [Python REPL](#python-repl) | Execute Python code through the agent's sandbox | Python, TypeScript (Node.js, Unix/Linux/macOS) | | [Subagent](#subagent) | Delegate a self-contained task to a child agent | Python, TypeScript (Node.js, browsers) | ### File editor @@ -736,14 +737,26 @@ Full API reference: [TypeScript](https://github.com/strands-agents/harness-sdk/b ### Python REPL -Runs model-generated Python through the agent's [sandbox](../sandbox/index.mdx) by calling `sandbox.execute_code(code, "python3")`. It returns `output` (stdout), `error` (stderr, including tracebacks), and `exit_code`. Each call runs in a fresh interpreter, so variables, imports, and definitions do not persist across calls. Files written to the sandbox filesystem do persist, so the model can checkpoint intermediate results to disk and load them in a later call. +Runs model-generated Python through the agent's [sandbox](../sandbox/index.mdx) by calling `sandbox.execute_code(code, "python3")` (`sandbox.executeCode(code, 'python3')` in TypeScript). It returns `output` (stdout), `error` (stderr, including tracebacks), and `exit_code`. Each call runs in a fresh interpreter, so variables, imports, and definitions do not persist across calls. Files written to the sandbox filesystem do persist, so the model can checkpoint intermediate results to disk and load them in a later call. -_Supported in: Python only._ +_Supported in: Node.js on Unix/Linux/macOS (TypeScript), Python._ :::caution[Security Warning] This tool executes arbitrary Python code. Without a [Sandbox](../sandbox/index.mdx), code runs with the full permissions of the process. Only use with trusted input and consider running in a [sandboxed environment](../sandbox/index.mdx) for production. ::: + + + +```typescript +--8<-- "user-guide/sdk/tools/vended-tools-imports.ts:python_repl_import" + +--8<-- "user-guide/sdk/tools/vended-tools.ts:python_repl_example" +``` + + + + ```python from strands import Agent from strands.vended_tools import python_repl @@ -752,8 +765,23 @@ agent = Agent(tools=[python_repl]) agent("Use python_repl to compute the first 10 Fibonacci numbers and print them.") ``` + + + **Bound sandbox or custom interpreter:** + + + +```typescript +--8<-- "user-guide/sdk/tools/vended-tools-imports.ts:python_repl_custom_import" + +--8<-- "user-guide/sdk/tools/vended-tools.ts:python_repl_custom_example" +``` + + + + ```python from strands import Agent from strands.sandbox.docker import DockerSandbox @@ -766,6 +794,11 @@ python_repl = make_python_repl( agent = Agent(tools=[python_repl]) ``` + + + +[Full API reference](https://github.com/strands-agents/harness-sdk/blob/main/strands-ts/src/vended-tools/python-repl/README.md) + --- ### Subagent diff --git a/site/src/content/docs/user-guide/sdk/tools/vended-tools.ts b/site/src/content/docs/user-guide/sdk/tools/vended-tools.ts index 21e6a9fde5..d8ceb20716 100644 --- a/site/src/content/docs/user-guide/sdk/tools/vended-tools.ts +++ b/site/src/content/docs/user-guide/sdk/tools/vended-tools.ts @@ -13,6 +13,8 @@ import { import { handoffToUser, HANDOFF_INTERRUPT_NAME } from '@strands-agents/sdk/vended-tools/handoff-to-user' import { sleep, makeSleep } from '@strands-agents/sdk/vended-tools/sleep' import { stop } from '@strands-agents/sdk/experimental/vended-tools/stop' +import { pythonRepl, makePythonRepl } from '@strands-agents/sdk/vended-tools/python-repl' +import { DockerSandbox } from '@strands-agents/sdk/sandbox/docker' import { webFetch, makeWebFetch } from '@strands-agents/sdk/vended-tools/web-fetch' import { BedrockModel } from '@strands-agents/sdk/models/bedrock' import { makeMcpRouter } from '@strands-agents/sdk/vended-tools' @@ -195,6 +197,25 @@ async function sleepCustomExample() { // --8<-- [end:sleep_custom_example] } +// Python REPL tool example +async function pythonReplExample() { + // --8<-- [start:python_repl_example] + const agent = new Agent({ tools: [pythonRepl] }) + await agent.invoke('Use python_repl to compute the first 10 Fibonacci numbers and print them.') + // --8<-- [end:python_repl_example] +} + +// Python REPL tool with a bound sandbox and custom interpreter +async function pythonReplCustomExample() { + // --8<-- [start:python_repl_custom_example] + const pythonRepl = makePythonRepl( + new DockerSandbox({ container: 'my-container' }), + { language: 'python3.12' } + ) + const agent = new Agent({ tools: [pythonRepl] }) + // --8<-- [end:python_repl_custom_example] +} + // Stop tool example async function stopExample() { // --8<-- [start:stop_example] diff --git a/strands-ts/package.json b/strands-ts/package.json index 1ed9b3a1e3..2d2f57769b 100644 --- a/strands-ts/package.json +++ b/strands-ts/package.json @@ -80,6 +80,10 @@ "types": "./dist/src/vended-tools/bash/index.d.ts", "default": "./dist/src/vended-tools/bash/index.js" }, + "./vended-tools/python-repl": { + "types": "./dist/src/vended-tools/python-repl/index.d.ts", + "default": "./dist/src/vended-tools/python-repl/index.js" + }, "./vended-tools/sleep": { "types": "./dist/src/vended-tools/sleep/index.d.ts", "default": "./dist/src/vended-tools/sleep/index.js" diff --git a/strands-ts/src/vended-tools/index.ts b/strands-ts/src/vended-tools/index.ts index 6d0caacc77..e18079ef41 100644 --- a/strands-ts/src/vended-tools/index.ts +++ b/strands-ts/src/vended-tools/index.ts @@ -9,6 +9,7 @@ * handoffToUser, * httpRequest, * notebook, + * pythonRepl, * sleep, * subagent, * webFetch, @@ -28,6 +29,7 @@ export * from './mcp-router/index.js' export * from './shell/index.js' export * from './http-request/index.js' export * from './notebook/index.js' +export * from './python-repl/index.js' export * from './sleep/index.js' export * from './subagent/index.js' export * from './web-fetch/index.js' diff --git a/strands-ts/src/vended-tools/python-repl/README.md b/strands-ts/src/vended-tools/python-repl/README.md new file mode 100644 index 0000000000..c21d6719c7 --- /dev/null +++ b/strands-ts/src/vended-tools/python-repl/README.md @@ -0,0 +1,63 @@ +# Python REPL Tool + +Runs model-generated Python through a [Sandbox](../../sandbox/base.ts) by calling `sandbox.executeCode(code, 'python3')`. + +Each call runs in a fresh interpreter, so variables, imports, and definitions do not persist across calls. Files written to the sandbox filesystem persist for as long as the sandbox does, so the model can checkpoint intermediate results to disk and load them in a later call. stdin is not available, so `input()` fails. + +> **Security warning**: this tool executes arbitrary Python code. Without an isolating sandbox (the default `NotASandboxLocalEnvironment`), code runs with the full permissions of the process. + +## Usage + +```typescript +import { Agent } from '@strands-agents/sdk' +import { pythonRepl } from '@strands-agents/sdk/vended-tools/python-repl' + +const agent = new Agent({ tools: [pythonRepl] }) +await agent.invoke('Use python_repl to compute the first 10 Fibonacci numbers and print them.') +``` + +With a bound sandbox or a custom interpreter: + +```typescript +import { DockerSandbox } from '@strands-agents/sdk/sandbox/docker' +import { makePythonRepl } from '@strands-agents/sdk/vended-tools/python-repl' + +const pythonTool = makePythonRepl(new DockerSandbox({ container: 'my-container' }), { language: 'python3.12' }) +``` + +Without a bound sandbox, the tool reads `context.agent.sandbox` at call time. + +## API + +### `makePythonRepl(options?)` / `makePythonRepl(sandbox, options?)` + +| Option | Type | Default | Description | +| ------------- | -------- | ------------- | ------------------------------------------------------------------------------------------ | +| `name` | `string` | `python_repl` | Tool name. Must be non-empty. | +| `description` | `string` | (built-in) | Description shown to the model. | +| `language` | `string` | `python3` | Interpreter on the sandbox's `PATH`. Validated against `LANGUAGE_PATTERN` at construction. | + +### Input + +```typescript +{ + code: string // Python source to execute + timeout?: number // Timeout in seconds (default: 120). Must be positive. +} +``` + +### Return Value + +```typescript +interface PythonReplOutput { + output: string // Standard output (stdout) + error: string // Standard error (stderr), including tracebacks - empty string if none + exit_code: number // Exit code of the interpreter - non-zero means the code failed +} +``` + +### Error Handling + +- Errors in the Python code itself are reported through `error` and a non-zero `exit_code`, not thrown. +- `SandboxTimeoutError`: execution exceeded `timeout`. The message carries the partial output as JSON with the success field names and `exit_code: 124`. +- `PythonReplError`: the sandbox failed to run the code (for example, an unreachable container). The original error is available as `cause`. diff --git a/strands-ts/src/vended-tools/python-repl/__tests__/python-repl.test.node.ts b/strands-ts/src/vended-tools/python-repl/__tests__/python-repl.test.node.ts new file mode 100644 index 0000000000..26d955dbfd --- /dev/null +++ b/strands-ts/src/vended-tools/python-repl/__tests__/python-repl.test.node.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi } from 'vitest' +import { mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { + makePythonRepl, + pythonRepl, + PYTHON_REPL_DESCRIPTION, + PythonReplError, + type PythonReplOutput, +} from '../index.js' +import * as vendedTools from '../../index.js' +import { NotASandboxLocalEnvironment } from '../../../sandbox/not-a-sandbox-local-environment.js' +import { SandboxTimeoutError } from '../../../sandbox/errors.js' +import type { Sandbox } from '../../../sandbox/base.js' +import type { ToolContext } from '../../../index.js' +import { createMockAgent } from '../../../__fixtures__/agent-helpers.js' + +const toolContext = (sandbox: Sandbox = new NotASandboxLocalEnvironment()): ToolContext => { + const agent = createMockAgent({ extra: { sandbox } }) + return { + toolUse: { name: 'python_repl', toolUseId: 'id', input: {} }, + agent, + invocationState: {}, + cancelSignal: agent.cancelSignal, + interrupt: () => { + throw new Error('interrupt not available in mock context') + }, + } +} + +const mockSandbox = ( + impl: () => Promise = async () => ({ type: 'executionResult', exitCode: 0, stdout: 'hi\n', stderr: '' }) +) => { + const sandbox = new NotASandboxLocalEnvironment() + const executeCode = vi.spyOn(sandbox, 'executeCode').mockImplementation(impl as Sandbox['executeCode']) + return { sandbox, executeCode } +} + +const run = (input: { code: string; timeout?: number }, context: ToolContext, tool = pythonRepl) => + tool.invoke(input, context) as Promise + +describe('python_repl shim', () => { + it('uses the agent sandbox and defaults', async () => { + const { sandbox, executeCode } = mockSandbox() + const result = await run({ code: "print('hi')" }, toolContext(sandbox)) + expect(executeCode).toHaveBeenCalledExactlyOnceWith("print('hi')", 'python3', { timeout: 120 }) + expect(result).toStrictEqual({ output: 'hi\n', error: '', exit_code: 0 }) + }) + + it('prefers a bound sandbox and forwards language and timeout', async () => { + const bound = mockSandbox() + const agentSandbox = mockSandbox() + const tool = makePythonRepl(bound.sandbox, { language: 'python3.12' }) + await run({ code: 'pass', timeout: 7 }, toolContext(agentSandbox.sandbox), tool) + expect(bound.executeCode).toHaveBeenCalledExactlyOnceWith('pass', 'python3.12', { timeout: 7 }) + expect(agentSandbox.executeCode).not.toHaveBeenCalled() + }) + + it.each([0, -1])('rejects non-positive timeout %d', async (timeout) => { + const { sandbox, executeCode } = mockSandbox() + await expect(run({ code: 'pass', timeout }, toolContext(sandbox))).rejects.toThrow(/too_small/) + expect(executeCode).not.toHaveBeenCalled() + }) + + it.each([ + [new Error('container gone'), 'container gone'], + ['container gone', 'container gone'], + ])('wraps sandbox error %o as PythonReplError', async (boom, message) => { + const { sandbox } = mockSandbox(async () => { + throw boom + }) + const error = await run({ code: 'pass' }, toolContext(sandbox)).catch((e: unknown) => e) + expect(error).toBeInstanceOf(PythonReplError) + expect(error).toMatchObject({ message, cause: boom }) + }) + + it('requires a tool context', async () => { + await expect(pythonRepl.invoke({ code: 'pass' })).rejects.toThrow('Tool context is required') + }) +}) + +describe.skipIf(process.platform === 'win32')('python_repl local execution', () => { + it('runs python', async () => { + const result = await run({ code: 'print(sum(range(10)))' }, toolContext()) + expect(result).toStrictEqual({ output: '45\n', error: '', exit_code: 0 }) + }) + + it.each([ + ["raise ValueError('bad')", 'ValueError: bad'], + ['input()', 'EOFError'], + ])('reports failure of %s via error and non-zero exit', async (code, expectedError) => { + const result = await run({ code }, toolContext()) + expect(result.exit_code).not.toBe(0) + expect(result.error).toContain(expectedError) + }) + + it('does not persist state across calls but files do', async () => { + const path = join(mkdtempSync(join(tmpdir(), 'python-repl-test-')), 'state.json') + const ctx = toolContext() + await run({ code: `import json; x = 42; json.dump({'x': x}, open(${JSON.stringify(path)}, 'w'))` }, ctx) + + const missing = await run({ code: 'print(x)' }, ctx) + expect(missing.error).toContain('NameError') + + const restored = await run({ code: `import json; print(json.load(open(${JSON.stringify(path)}))['x'])` }, ctx) + expect(restored.output).toBe('42\n') + }) + + it('timeout carries partial output with the success field names', async () => { + const error = await run( + { code: "print('partial', flush=True)\nimport time; time.sleep(10)", timeout: 0.5 }, + toolContext() + ).catch((e: unknown) => e) + expect(error).toBeInstanceOf(SandboxTimeoutError) + const payload = JSON.parse((error as Error).message.split('\n')[1]!) + expect(payload).toStrictEqual({ output: 'partial\n', error: '', exit_code: 124 }) + }) +}) + +describe('makePythonRepl', () => { + it.each([ + [{ name: '' }, /name/], + [{ language: '' }, /language/], + [{ language: 'python3; rm -rf /' }, /language/], + [{ language: 'py thon' }, /language/], + ])('rejects invalid arguments %o', (options, match) => { + expect(() => makePythonRepl(options)).toThrow(match) + }) + + it('has the default tool spec and exports', () => { + expect(pythonRepl.name).toBe('python_repl') + expect(pythonRepl.toolSpec.description).toBe(PYTHON_REPL_DESCRIPTION) + const schema = pythonRepl.toolSpec.inputSchema as { properties: Record; required: string[] } + expect(Object.keys(schema.properties).sort()).toStrictEqual(['code', 'timeout']) + expect(schema.required).toStrictEqual(['code']) + expect(vendedTools.pythonRepl).toBe(pythonRepl) + expect(vendedTools.makePythonRepl).toBe(makePythonRepl) + }) + + it('accepts a custom name and description', () => { + const tool = makePythonRepl({ name: 'run_python', description: 'custom' }) + expect(tool.name).toBe('run_python') + expect(tool.toolSpec.description).toBe('custom') + }) + + it('resolves both overloads', () => { + expect(makePythonRepl(undefined, { name: 'run_python' }).name).toBe('run_python') + expect(makePythonRepl(new NotASandboxLocalEnvironment()).name).toBe('python_repl') + }) +}) diff --git a/strands-ts/src/vended-tools/python-repl/index.ts b/strands-ts/src/vended-tools/python-repl/index.ts new file mode 100644 index 0000000000..4079b51378 --- /dev/null +++ b/strands-ts/src/vended-tools/python-repl/index.ts @@ -0,0 +1,19 @@ +/** + * Python REPL tool for running Python through the agent's sandbox. + * + * A thin shim over `Sandbox.executeCode`. Each call runs in a fresh interpreter, + * so in-memory state does not persist across calls. + * + * @example + * ```typescript + * import { Agent } from '@strands-agents/sdk' + * import { pythonRepl } from '@strands-agents/sdk/vended-tools/python-repl' + * + * const agent = new Agent({ tools: [pythonRepl] }) + * ``` + */ + +export { makePythonRepl, pythonRepl } from './python-repl.js' +export type { MakePythonReplOptions } from './python-repl.js' +export { PYTHON_REPL_DESCRIPTION, PythonReplError } from './types.js' +export type { PythonReplOutput } from './types.js' diff --git a/strands-ts/src/vended-tools/python-repl/python-repl.ts b/strands-ts/src/vended-tools/python-repl/python-repl.ts new file mode 100644 index 0000000000..5bda567ff2 --- /dev/null +++ b/strands-ts/src/vended-tools/python-repl/python-repl.ts @@ -0,0 +1,122 @@ +/** + * Python REPL tool: run Python through a sandbox. + * + * Provides {@link makePythonRepl} (a factory for a sandbox-routed Python tool) and + * {@link pythonRepl} (the default instance that uses the agent's sandbox). + * Each call pipes the code to a fresh interpreter process in the sandbox, + * so variables, imports, and definitions do not persist across calls. + * Files written to the sandbox filesystem persist for as long as the sandbox does, + * so code can checkpoint intermediate results to disk. + */ + +import { z } from 'zod' +import { tool } from '../../tools/tool-factory.js' +import { Sandbox } from '../../sandbox/base.js' +import { LANGUAGE_PATTERN } from '../../sandbox/constants.js' +import { SandboxTimeoutError } from '../../sandbox/errors.js' +import { PYTHON_REPL_DESCRIPTION, PythonReplError, type PythonReplOutput } from './types.js' + +const DEFAULT_LANGUAGE = 'python3' +const DEFAULT_TIMEOUT = 120 + +const pythonReplInputSchema = z.object({ + code: z.string().describe('Python source to execute.'), + timeout: z.number().positive().optional().describe('Timeout in seconds (default: 120). Must be positive.'), +}) + +/** + * Options for {@link makePythonRepl}. + */ +export interface MakePythonReplOptions { + /** + * Tool name. Defaults to `"python_repl"`. + */ + name?: string + + /** + * Tool description shown to the model. + */ + description?: string + + /** + * Python interpreter used to run the code. Defaults to `"python3"`. + */ + language?: string +} + +/** + * Resolve the `makePythonRepl` overloads. If a sandbox is passed, it's bound at creation time. + * Otherwise, the tool reads from `context.agent.sandbox` at call time. + */ +function resolvePythonReplArgs( + sandboxOrOptions?: Sandbox | MakePythonReplOptions, + maybeOptions?: MakePythonReplOptions +): { boundSandbox: Sandbox | undefined; options: MakePythonReplOptions } { + const boundSandbox = sandboxOrOptions instanceof Sandbox ? sandboxOrOptions : undefined + const options = sandboxOrOptions instanceof Sandbox || maybeOptions ? (maybeOptions ?? {}) : (sandboxOrOptions ?? {}) + return { boundSandbox, options } +} + +/** + * Create a sandbox-routed Python REPL tool. + * + * If a sandbox is passed, it is bound at creation time. Otherwise the tool reads + * `context.agent.sandbox` at call time. + * + * The tool throws {@link SandboxTimeoutError} when execution exceeds `timeout`, with the partial + * output appended to the message as JSON using the success field names and `exit_code` 124, and + * {@link PythonReplError} when the sandbox fails to run the code. + * + * @param sandbox - Sandbox to bind at creation. When omitted, the agent's sandbox is used at call time. + * @param options - Tool name, description, and interpreter. + * @returns A tool that executes Python code through the sandbox. + * @throws Error if `name` is empty or `language` contains invalid characters. + */ +export function makePythonRepl(options?: MakePythonReplOptions): ReturnType +export function makePythonRepl(sandbox: Sandbox | undefined, options?: MakePythonReplOptions): ReturnType +export function makePythonRepl( + sandboxOrOptions?: Sandbox | MakePythonReplOptions, + maybeOptions?: MakePythonReplOptions +): ReturnType { + const { boundSandbox, options } = resolvePythonReplArgs(sandboxOrOptions, maybeOptions) + const name = options.name ?? 'python_repl' + const language = options.language ?? DEFAULT_LANGUAGE + + if (!name) { + throw new Error('name must be a non-empty string') + } + // Sandboxes validate this too, but only at call time; fail at construction instead. + if (!LANGUAGE_PATTERN.test(language)) { + throw new Error(`language contains invalid characters: ${language}`) + } + + return tool({ + name, + description: options.description ?? PYTHON_REPL_DESCRIPTION, + inputSchema: pythonReplInputSchema, + callback: async (input, context): Promise => { + if (!context) { + throw new Error('Tool context is required for python_repl operations') + } + + const sandbox = boundSandbox ?? context.agent.sandbox + try { + const result = await sandbox.executeCode(input.code, language, { timeout: input.timeout ?? DEFAULT_TIMEOUT }) + return { output: result.stdout, error: result.stderr, exit_code: result.exitCode } + } catch (err) { + if (err instanceof SandboxTimeoutError) { + // Thrown errors reach the model as `Error: `, so the partial output rides in the message. + const partial: PythonReplOutput = { output: err.stdout, error: err.stderr, exit_code: 124 } + err.message = `${err.message}\n${JSON.stringify(partial)}` + throw err + } + throw new PythonReplError(err instanceof Error ? err.message : String(err), { cause: err }) + } + }, + }) +} + +/** + * Default Python REPL tool. Reads the sandbox from the agent's context at call time. + */ +export const pythonRepl = makePythonRepl() diff --git a/strands-ts/src/vended-tools/python-repl/types.ts b/strands-ts/src/vended-tools/python-repl/types.ts new file mode 100644 index 0000000000..0f7957f638 --- /dev/null +++ b/strands-ts/src/vended-tools/python-repl/types.ts @@ -0,0 +1,49 @@ +/** + * Shared types and constants for the Python REPL tool. + */ + +export const PYTHON_REPL_DESCRIPTION = + 'Executes Python code and returns output (stdout), error (stderr), and exit_code (non-zero means the ' + + 'code failed). Each call runs in a fresh interpreter; variables, imports, and definitions do not persist ' + + 'across calls. Files written to the working directory persist while the sandbox is alive, so save ' + + 'intermediate results to files when later calls need them. stdin is not available (input() fails). ' + + 'Use print() to surface values.' + +/** + * Error thrown when the sandbox fails to run the Python code. + * + * Thrown for sandbox-level failures (for example, an unreachable container), not for + * errors in the code itself; those are reported through `error` and a non-zero `exit_code`. + * The underlying sandbox error is available as `cause`. + */ +export class PythonReplError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options) + this.name = 'PythonReplError' + } +} + +/** + * Output of a Python REPL execution. Mirrors the Python SDK's `PythonReplOutput`. + */ +export interface PythonReplOutput { + /** + * Standard output captured from the interpreter. + */ + output: string + + /** + * Standard error captured from the interpreter, including tracebacks. Empty when there was none. + */ + error: string + + /** + * Exit code of the interpreter. Non-zero means the code failed. + */ + exit_code: number + + /** + * Allow indexing with string keys for JSONValue compatibility. + */ + [key: string]: string | number +} From 547ca72f2de350012204ead1490c01ac644f4029 Mon Sep 17 00:00:00 2001 From: liramon2 Date: Fri, 9 Oct 2026 14:32:37 -0400 Subject: [PATCH 2/3] fix: handle cancellation --- .../src/vended-tools/python-repl/README.md | 1 + .../__tests__/python-repl.test.node.ts | 53 +++++++++++-------- .../vended-tools/python-repl/python-repl.ts | 14 +++-- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/strands-ts/src/vended-tools/python-repl/README.md b/strands-ts/src/vended-tools/python-repl/README.md index c21d6719c7..126d93baa5 100644 --- a/strands-ts/src/vended-tools/python-repl/README.md +++ b/strands-ts/src/vended-tools/python-repl/README.md @@ -60,4 +60,5 @@ interface PythonReplOutput { - Errors in the Python code itself are reported through `error` and a non-zero `exit_code`, not thrown. - `SandboxTimeoutError`: execution exceeded `timeout`. The message carries the partial output as JSON with the success field names and `exit_code: 124`. +- `SandboxAbortError`: the agent cancelled the call (`context.cancelSignal`); the interpreter is killed and the error propagates unwrapped. - `PythonReplError`: the sandbox failed to run the code (for example, an unreachable container). The original error is available as `cause`. diff --git a/strands-ts/src/vended-tools/python-repl/__tests__/python-repl.test.node.ts b/strands-ts/src/vended-tools/python-repl/__tests__/python-repl.test.node.ts index 26d955dbfd..f198a26c5d 100644 --- a/strands-ts/src/vended-tools/python-repl/__tests__/python-repl.test.node.ts +++ b/strands-ts/src/vended-tools/python-repl/__tests__/python-repl.test.node.ts @@ -11,18 +11,21 @@ import { } from '../index.js' import * as vendedTools from '../../index.js' import { NotASandboxLocalEnvironment } from '../../../sandbox/not-a-sandbox-local-environment.js' -import { SandboxTimeoutError } from '../../../sandbox/errors.js' +import { SandboxAbortError, SandboxTimeoutError } from '../../../sandbox/errors.js' import type { Sandbox } from '../../../sandbox/base.js' import type { ToolContext } from '../../../index.js' import { createMockAgent } from '../../../__fixtures__/agent-helpers.js' -const toolContext = (sandbox: Sandbox = new NotASandboxLocalEnvironment()): ToolContext => { +const toolContext = ( + sandbox: Sandbox = new NotASandboxLocalEnvironment(), + cancelSignal = new AbortController().signal +): ToolContext => { const agent = createMockAgent({ extra: { sandbox } }) return { toolUse: { name: 'python_repl', toolUseId: 'id', input: {} }, agent, invocationState: {}, - cancelSignal: agent.cancelSignal, + cancelSignal, interrupt: () => { throw new Error('interrupt not available in mock context') }, @@ -43,8 +46,12 @@ const run = (input: { code: string; timeout?: number }, context: ToolContext, to describe('python_repl shim', () => { it('uses the agent sandbox and defaults', async () => { const { sandbox, executeCode } = mockSandbox() - const result = await run({ code: "print('hi')" }, toolContext(sandbox)) - expect(executeCode).toHaveBeenCalledExactlyOnceWith("print('hi')", 'python3', { timeout: 120 }) + const context = toolContext(sandbox) + const result = await run({ code: "print('hi')" }, context) + expect(executeCode).toHaveBeenCalledExactlyOnceWith("print('hi')", 'python3', { + timeout: 120, + signal: context.cancelSignal, + }) expect(result).toStrictEqual({ output: 'hi\n', error: '', exit_code: 0 }) }) @@ -53,13 +60,17 @@ describe('python_repl shim', () => { const agentSandbox = mockSandbox() const tool = makePythonRepl(bound.sandbox, { language: 'python3.12' }) await run({ code: 'pass', timeout: 7 }, toolContext(agentSandbox.sandbox), tool) - expect(bound.executeCode).toHaveBeenCalledExactlyOnceWith('pass', 'python3.12', { timeout: 7 }) + expect(bound.executeCode).toHaveBeenCalledExactlyOnceWith( + 'pass', + 'python3.12', + expect.objectContaining({ timeout: 7 }) + ) expect(agentSandbox.executeCode).not.toHaveBeenCalled() }) - it.each([0, -1])('rejects non-positive timeout %d', async (timeout) => { + it('rejects a non-positive timeout', async () => { const { sandbox, executeCode } = mockSandbox() - await expect(run({ code: 'pass', timeout }, toolContext(sandbox))).rejects.toThrow(/too_small/) + await expect(run({ code: 'pass', timeout: 0 }, toolContext(sandbox))).rejects.toThrow(/too_small/) expect(executeCode).not.toHaveBeenCalled() }) @@ -81,11 +92,6 @@ describe('python_repl shim', () => { }) describe.skipIf(process.platform === 'win32')('python_repl local execution', () => { - it('runs python', async () => { - const result = await run({ code: 'print(sum(range(10)))' }, toolContext()) - expect(result).toStrictEqual({ output: '45\n', error: '', exit_code: 0 }) - }) - it.each([ ["raise ValueError('bad')", 'ValueError: bad'], ['input()', 'EOFError'], @@ -104,7 +110,7 @@ describe.skipIf(process.platform === 'win32')('python_repl local execution', () expect(missing.error).toContain('NameError') const restored = await run({ code: `import json; print(json.load(open(${JSON.stringify(path)}))['x'])` }, ctx) - expect(restored.output).toBe('42\n') + expect(restored).toStrictEqual({ output: '42\n', error: '', exit_code: 0 }) }) it('timeout carries partial output with the success field names', async () => { @@ -116,14 +122,21 @@ describe.skipIf(process.platform === 'win32')('python_repl local execution', () const payload = JSON.parse((error as Error).message.split('\n')[1]!) expect(payload).toStrictEqual({ output: 'partial\n', error: '', exit_code: 124 }) }) + + it('cancellation kills the interpreter and propagates unwrapped', async () => { + const controller = new AbortController() + const pending = run({ code: 'import time; time.sleep(10)' }, toolContext(undefined, controller.signal)) + setTimeout(() => controller.abort(), 100) + const start = Date.now() + await expect(pending).rejects.toBeInstanceOf(SandboxAbortError) + expect(Date.now() - start).toBeLessThan(5000) + }) }) describe('makePythonRepl', () => { it.each([ [{ name: '' }, /name/], - [{ language: '' }, /language/], [{ language: 'python3; rm -rf /' }, /language/], - [{ language: 'py thon' }, /language/], ])('rejects invalid arguments %o', (options, match) => { expect(() => makePythonRepl(options)).toThrow(match) }) @@ -138,14 +151,10 @@ describe('makePythonRepl', () => { expect(vendedTools.makePythonRepl).toBe(makePythonRepl) }) - it('accepts a custom name and description', () => { - const tool = makePythonRepl({ name: 'run_python', description: 'custom' }) + it('resolves both overloads and applies a custom name and description', () => { + const tool = makePythonRepl(undefined, { name: 'run_python', description: 'custom' }) expect(tool.name).toBe('run_python') expect(tool.toolSpec.description).toBe('custom') - }) - - it('resolves both overloads', () => { - expect(makePythonRepl(undefined, { name: 'run_python' }).name).toBe('run_python') expect(makePythonRepl(new NotASandboxLocalEnvironment()).name).toBe('python_repl') }) }) diff --git a/strands-ts/src/vended-tools/python-repl/python-repl.ts b/strands-ts/src/vended-tools/python-repl/python-repl.ts index 5bda567ff2..af2ff4d52f 100644 --- a/strands-ts/src/vended-tools/python-repl/python-repl.ts +++ b/strands-ts/src/vended-tools/python-repl/python-repl.ts @@ -13,7 +13,7 @@ import { z } from 'zod' import { tool } from '../../tools/tool-factory.js' import { Sandbox } from '../../sandbox/base.js' import { LANGUAGE_PATTERN } from '../../sandbox/constants.js' -import { SandboxTimeoutError } from '../../sandbox/errors.js' +import { SandboxAbortError, SandboxTimeoutError } from '../../sandbox/errors.js' import { PYTHON_REPL_DESCRIPTION, PythonReplError, type PythonReplOutput } from './types.js' const DEFAULT_LANGUAGE = 'python3' @@ -64,8 +64,9 @@ function resolvePythonReplArgs( * `context.agent.sandbox` at call time. * * The tool throws {@link SandboxTimeoutError} when execution exceeds `timeout`, with the partial - * output appended to the message as JSON using the success field names and `exit_code` 124, and - * {@link PythonReplError} when the sandbox fails to run the code. + * output appended to the message as JSON using the success field names and `exit_code` 124, + * {@link PythonReplError} when the sandbox fails to run the code, and rethrows `SandboxAbortError` + * when the agent cancels the call. * * @param sandbox - Sandbox to bind at creation. When omitted, the agent's sandbox is used at call time. * @param options - Tool name, description, and interpreter. @@ -101,9 +102,14 @@ export function makePythonRepl( const sandbox = boundSandbox ?? context.agent.sandbox try { - const result = await sandbox.executeCode(input.code, language, { timeout: input.timeout ?? DEFAULT_TIMEOUT }) + const result = await sandbox.executeCode(input.code, language, { + timeout: input.timeout ?? DEFAULT_TIMEOUT, + signal: context.cancelSignal, + }) return { output: result.stdout, error: result.stderr, exit_code: result.exitCode } } catch (err) { + // Let cancellation propagate as-is rather than reporting it as a sandbox failure. + if (err instanceof SandboxAbortError) throw err if (err instanceof SandboxTimeoutError) { // Thrown errors reach the model as `Error: `, so the partial output rides in the message. const partial: PythonReplOutput = { output: err.stdout, error: err.stderr, exit_code: 124 } From 4c2f1a03fd67aba8ff83d1cfd97bc9875da3b09b Mon Sep 17 00:00:00 2001 From: liramon2 Date: Fri, 9 Oct 2026 14:49:27 -0400 Subject: [PATCH 3/3] fix: clarify docs and validation errors --- strands-ts/src/vended-tools/python-repl/README.md | 2 +- .../python-repl/__tests__/python-repl.test.node.ts | 6 ++++-- strands-ts/src/vended-tools/python-repl/python-repl.ts | 6 +++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/strands-ts/src/vended-tools/python-repl/README.md b/strands-ts/src/vended-tools/python-repl/README.md index 126d93baa5..b0252f5486 100644 --- a/strands-ts/src/vended-tools/python-repl/README.md +++ b/strands-ts/src/vended-tools/python-repl/README.md @@ -60,5 +60,5 @@ interface PythonReplOutput { - Errors in the Python code itself are reported through `error` and a non-zero `exit_code`, not thrown. - `SandboxTimeoutError`: execution exceeded `timeout`. The message carries the partial output as JSON with the success field names and `exit_code: 124`. -- `SandboxAbortError`: the agent cancelled the call (`context.cancelSignal`); the interpreter is killed and the error propagates unwrapped. +- `SandboxAbortError`: the agent cancelled the call (`context.cancelSignal`); the sandbox is told to stop the interpreter and the error propagates unwrapped. - `PythonReplError`: the sandbox failed to run the code (for example, an unreachable container). The original error is available as `cause`. diff --git a/strands-ts/src/vended-tools/python-repl/__tests__/python-repl.test.node.ts b/strands-ts/src/vended-tools/python-repl/__tests__/python-repl.test.node.ts index f198a26c5d..aa807b201b 100644 --- a/strands-ts/src/vended-tools/python-repl/__tests__/python-repl.test.node.ts +++ b/strands-ts/src/vended-tools/python-repl/__tests__/python-repl.test.node.ts @@ -70,7 +70,9 @@ describe('python_repl shim', () => { it('rejects a non-positive timeout', async () => { const { sandbox, executeCode } = mockSandbox() - await expect(run({ code: 'pass', timeout: 0 }, toolContext(sandbox))).rejects.toThrow(/too_small/) + await expect(run({ code: 'pass', timeout: 0 }, toolContext(sandbox))).rejects.toThrow( + /timeout must be a positive number of seconds, got 0/ + ) expect(executeCode).not.toHaveBeenCalled() }) @@ -123,7 +125,7 @@ describe.skipIf(process.platform === 'win32')('python_repl local execution', () expect(payload).toStrictEqual({ output: 'partial\n', error: '', exit_code: 124 }) }) - it('cancellation kills the interpreter and propagates unwrapped', async () => { + it('cancellation stops the call and propagates unwrapped', async () => { const controller = new AbortController() const pending = run({ code: 'import time; time.sleep(10)' }, toolContext(undefined, controller.signal)) setTimeout(() => controller.abort(), 100) diff --git a/strands-ts/src/vended-tools/python-repl/python-repl.ts b/strands-ts/src/vended-tools/python-repl/python-repl.ts index af2ff4d52f..ae8b0ec6e2 100644 --- a/strands-ts/src/vended-tools/python-repl/python-repl.ts +++ b/strands-ts/src/vended-tools/python-repl/python-repl.ts @@ -21,7 +21,11 @@ const DEFAULT_TIMEOUT = 120 const pythonReplInputSchema = z.object({ code: z.string().describe('Python source to execute.'), - timeout: z.number().positive().optional().describe('Timeout in seconds (default: 120). Must be positive.'), + timeout: z + .number() + .positive({ error: (iss) => `timeout must be a positive number of seconds, got ${String(iss.input)}` }) + .optional() + .describe('Timeout in seconds (default: 120). Must be positive.'), }) /**