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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions site/src/content/docs/user-guide/sdk/tools/vended-tools-imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
39 changes: 36 additions & 3 deletions site/src/content/docs/user-guide/sdk/tools/vended-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
:::

<Tabs>
<Tab label="TypeScript">

```typescript
--8<-- "user-guide/sdk/tools/vended-tools-imports.ts:python_repl_import"

--8<-- "user-guide/sdk/tools/vended-tools.ts:python_repl_example"
```

</Tab>
<Tab label="Python">

```python
from strands import Agent
from strands.vended_tools import python_repl
Expand All @@ -752,8 +765,23 @@ agent = Agent(tools=[python_repl])
agent("Use python_repl to compute the first 10 Fibonacci numbers and print them.")
```

</Tab>
</Tabs>

**Bound sandbox or custom interpreter:**

<Tabs>
<Tab label="TypeScript">

```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"
```

</Tab>
<Tab label="Python">

```python
from strands import Agent
from strands.sandbox.docker import DockerSandbox
Expand All @@ -766,6 +794,11 @@ python_repl = make_python_repl(
agent = Agent(tools=[python_repl])
```

</Tab>
</Tabs>

[Full API reference](https://github.com/strands-agents/harness-sdk/blob/main/strands-ts/src/vended-tools/python-repl/README.md)

---

### Subagent
Expand Down
21 changes: 21 additions & 0 deletions site/src/content/docs/user-guide/sdk/tools/vended-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions strands-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions strands-ts/src/vended-tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* handoffToUser,
* httpRequest,
* notebook,
* pythonRepl,
* sleep,
* subagent,
* webFetch,
Expand All @@ -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'
64 changes: 64 additions & 0 deletions strands-ts/src/vended-tools/python-repl/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# 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`.
- `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`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
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 { 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(),
cancelSignal = new AbortController().signal
): ToolContext => {
const agent = createMockAgent({ extra: { sandbox } })
return {
toolUse: { name: 'python_repl', toolUseId: 'id', input: {} },
agent,
invocationState: {},
cancelSignal,
interrupt: () => {
throw new Error('interrupt not available in mock context')
},
}
}

const mockSandbox = (
impl: () => Promise<unknown> = 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<PythonReplOutput>

describe('python_repl shim', () => {
it('uses the agent sandbox and defaults', async () => {
const { sandbox, executeCode } = mockSandbox()
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 })
})

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',
expect.objectContaining({ timeout: 7 })
)
expect(agentSandbox.executeCode).not.toHaveBeenCalled()
})

it('rejects a non-positive timeout', async () => {
const { sandbox, executeCode } = mockSandbox()
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()
})

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.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).toStrictEqual({ output: '42\n', error: '', exit_code: 0 })
})

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 })
})

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion
Issue: The timeout test asserts the partial payload by splitting the error message on \n and parsing index [1]. This couples the test to the exact message layout, so any formatting change (even an extra newline in the base message) breaks it in a non-obvious way.
Suggestion: If the partial output were exposed as a structured field on the error (see comment in python-repl.ts), this assertion could read that field directly instead of parsing the message string.

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)
const start = Date.now()
await expect(pending).rejects.toBeInstanceOf(SandboxAbortError)
expect(Date.now() - start).toBeLessThan(5000)
})
})

describe('makePythonRepl', () => {
it.each([
[{ name: '' }, /name/],
[{ language: 'python3; rm -rf /' }, /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<string, unknown>; 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('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')
expect(makePythonRepl(new NotASandboxLocalEnvironment()).name).toBe('python_repl')
})
})
Loading
Loading