Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5c49fa4
feat(core): support dynamic sandbox resolution via requestContext in …
intojhanurag May 1, 2026
d7f1345
fix(core): resolve dynamic workspace instructions
intojhanurag May 1, 2026
00b4956
fix(core): forward synthesized requestContext to provider instruction…
intojhanurag May 1, 2026
d7c6d86
perf(core): memoize workspace resolvers per requestContext
intojhanurag May 1, 2026
a3f4be8
fix(core): wire dynamic workspace instructions into durable agent pre…
intojhanurag May 1, 2026
9028811
test(core): cover durable workspace instructions
intojhanurag May 1, 2026
1929718
test(core): cover dynamic sandbox capability errors
intojhanurag May 1, 2026
baebd85
chore(core): drop sandbox class-constructor guard
intojhanurag May 4, 2026
cf038ad
fix(core): avoid resolving dynamic providers for metadata
intojhanurag May 18, 2026
d4b0e54
fix(core): report request-resolved provider in workspace metadata
intojhanurag May 19, 2026
6cf3f20
refactor(core): drop unused getPathContextAsync from Workspace
intojhanurag May 19, 2026
8dca207
fix(core): resolve dynamic filesystem for the lsp_inspect tool
intojhanurag May 19, 2026
88363e5
test(core): tidy dynamic sandbox/filesystem test organization
intojhanurag May 19, 2026
b859b20
Merge branch 'main' into feat/dynamic-sandbox-resolver
abhiaiyer91 May 19, 2026
9a994b0
feat(core): make dynamic sandbox instructions opt-in, add sandboxCach…
intojhanurag May 19, 2026
cc195aa
docs(core): correct dynamic-sandbox instruction docs, trim comments
intojhanurag May 19, 2026
6c9f088
Merge branch 'main' into feat/dynamic-sandbox-resolver
abhiaiyer91 May 19, 2026
8ca508c
fix(core): clear dynamic sandbox cache
intojhanurag May 25, 2026
694c8ba
Merge branch 'main' into feat/dynamic-sandbox-resolver
abhiaiyer91 May 25, 2026
5f42344
Merge branch 'main' into feat/dynamic-sandbox-resolver
intojhanurag May 26, 2026
d799b83
Merge branch 'main' into feat/dynamic-sandbox-resolver
intojhanurag May 27, 2026
e28e015
Merge branch 'main' into feat/dynamic-sandbox-resolver
NikAiyer Jun 2, 2026
45940b2
fix(core): retry failed sandbox resolvers and hint sandboxCacheKey
intojhanurag Jun 3, 2026
62fd2fd
Merge branch 'main' into feat/dynamic-sandbox-resolver
NikAiyer Jun 3, 2026
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
47 changes: 47 additions & 0 deletions .changeset/giant-adults-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
'@mastra/core': minor
---

Workspace `sandbox` now accepts a resolver function for per-request sandboxes.

**Before:** `sandbox: WorkspaceSandbox` (static, same sandbox for every request)
**After:** `sandbox: WorkspaceSandbox | (({ requestContext }) => WorkspaceSandbox)` (static or per-request)

This enables per-request sandbox routing from a single Workspace — useful for multi-tenant deployments where each user/role needs an isolated working directory or different execution permissions.

```ts
const workspace = new Workspace({
sandbox: ({ requestContext }) => {
const userId = requestContext.get('user-id') as string;
return new LocalSandbox({ workingDirectory: `/workspaces/${userId}` });
},
});
```

When using a resolver, the caller owns the returned sandbox's lifecycle — the Workspace will not call `start()` or `destroy()` on it. `mounts` throws an `INVALID_CONFIG` error with a resolver, and `lsp: true` is disabled with a warning because both require a concrete sandbox instance up front.

**Stable prompts by default**

Building workspace instructions no longer calls a sandbox resolver. Resolver-backed sandboxes contribute stable placeholder text to the agent's system message, so constructing the prompt never provisions a caller-owned sandbox and the prompt stays cache-friendly. Opt into concrete per-request instructions with `instructions.dynamicSandbox`:

```ts
const workspace = new Workspace({
sandbox: ({ requestContext }) => resolveSandbox(requestContext),
instructions: { dynamicSandbox: 'resolve' }, // or a ({ requestContext }) => string function
});
```

**Background process continuity**

Set `sandboxCacheKey` to keep `execute_command({ background: true })`, `get_process_output`, and `kill_process` on the same sandbox across follow-up requests — continuity is keyed by a stable id rather than the `RequestContext` instance:

```ts
const workspace = new Workspace({
sandbox: ({ requestContext }) => resolveSandbox(requestContext),
sandboxCacheKey: ({ requestContext }) => requestContext.get('thread-id') as string,
});
```

Failed sandbox resolver calls are removed from the cache so later calls can retry. Use `workspace.clearSandboxCache(cacheKey)` to drop a keyed sandbox reference when your own lifecycle code has destroyed or replaced that sandbox.

When background process tools cannot find a PID on a dynamic sandbox without `sandboxCacheKey`, the tool output now points to `sandboxCacheKey` so callers can fix continuity across follow-up requests.
4 changes: 3 additions & 1 deletion docs/src/content/en/docs/workspace/filesystem.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ const agent = new Agent({
})
```

Each request resolves its own filesystem at tool execution time:
Each request resolves its own filesystem for workspace tools and workspace instructions:

```typescript
import { RequestContext } from '@mastra/core/request-context'
Expand All @@ -150,6 +150,8 @@ const viewerCtx = new RequestContext([['agent-role', 'viewer']])
await agent.generate('Read info.txt', { requestContext: viewerCtx })
```

Workspace instructions use the same `requestContext`, so the agent sees the filesystem context for the resolved provider.

The resolver can also be asynchronous, for example to look up configuration from a database:

```typescript
Expand Down
18 changes: 18 additions & 0 deletions docs/src/content/en/docs/workspace/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,23 @@ const workspace = new Workspace({

One workspace instance serves all requests. The resolver runs at tool execution time, so each request gets its own filesystem. See [dynamic filesystem](/docs/workspace/filesystem#dynamic-filesystem) for details.

### Dynamic sandbox (per-request)

Pass a resolver function to `sandbox` to return a different sandbox per request. This is useful for multi-tenant deployments where each user or role needs an isolated working directory or different execution permissions.

```typescript
const workspace = new Workspace({
sandbox: ({ requestContext }) => {
const userId = requestContext.get('user-id') as string
return new LocalSandbox({
workingDirectory: `/workspaces/${userId}`,
})
},
})
```

The resolver is incompatible with `mounts` and `lsp: true`, since both require a concrete sandbox instance at construction time. See [dynamic sandbox](/docs/workspace/sandbox#dynamic-sandbox) for details.

### Which pattern should I use?

| Scenario | Pattern |
Expand All @@ -192,6 +209,7 @@ One workspace instance serves all requests. The resolver runs at tool execution
| Agent reads/writes files, no command execution needed | `filesystem` only |
| Agent runs commands, no file tools needed | `sandbox` only |
| Multi-role or multi-tenant agent with per-request storage | `filesystem` with resolver function |
| Multi-tenant agent with per-request execution scope | `sandbox` with resolver function |

## Tool configuration

Expand Down
109 changes: 109 additions & 0 deletions docs/src/content/en/docs/workspace/sandbox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,115 @@ const response = await agent.generate('Run `ls -la` in the workspace directory')

See [`LocalSandbox` reference](/reference/workspace/local-sandbox) for configuration options including environment isolation and native OS sandboxing.

## Dynamic sandbox

The `sandbox` option accepts a resolver function instead of a static instance. The resolver receives `requestContext` and returns a sandbox per request, allowing a single workspace to serve different sandboxes based on the caller's identity, role, or tenant.

```typescript title="src/mastra/workspaces.ts"
import { Agent } from '@mastra/core/agent'
import { Workspace, LocalSandbox } from '@mastra/core/workspace'

const workspace = new Workspace({
// highlight-start
sandbox: ({ requestContext }) => {
const userId = requestContext.get('user-id') as string
return new LocalSandbox({
workingDirectory: `/workspaces/${userId}`,
})
},
// highlight-end
})

const agent = new Agent({
id: 'multi-tenant-agent',
model: 'your-provider/your-model',
workspace,
})
```

Each request resolves its own sandbox at tool execution time:

```typescript
import { RequestContext } from '@mastra/core/request-context'

// User Alice — commands run in /workspaces/alice
const aliceCtx = new RequestContext([['user-id', 'alice']])
await agent.generate('List files in cwd', { requestContext: aliceCtx })

// User Bob — commands run in /workspaces/bob
const bobCtx = new RequestContext([['user-id', 'bob']])
await agent.generate('List files in cwd', { requestContext: bobCtx })
```

By default, workspace instructions describe the dynamic sandbox with stable placeholder text. See [Workspace instructions](#workspace-instructions) to include concrete per-request details.

The resolver can also be asynchronous — for example to look up tenant configuration from a database:

```typescript
const workspace = new Workspace({
sandbox: async ({ requestContext }) => {
const tenant = await db.getTenant(requestContext.get('tenant-id'))
return new LocalSandbox({ workingDirectory: tenant.workspacePath })
},
})
```

### Lifecycle ownership

When the sandbox is a static instance, `workspace.init()` calls its `start()` method and `workspace.destroy()` calls its `destroy()` method. With a resolver, the workspace has no instance to manage at construction time — the caller owns the returned sandbox's lifecycle.

The resolver must return a sandbox that is ready to use, either already started or able to handle calls without explicit startup. The caller also owns cleanup timing for returned sandboxes. Cleanup might happen per request, per tenant, per user, or as part of a long-lived sandbox pool; `workspace.destroy()` does not destroy resolver-returned sandboxes.

Comment thread
intojhanurag marked this conversation as resolved.
:::note

`sandbox` resolvers are incompatible with `mounts` and `lsp: true`. Both require a concrete sandbox instance at construction time, so combining them with a resolver throws an `INVALID_CONFIG` error (for `mounts`) or disables LSP with a warning (for `lsp: true`).

:::

### Tool registration

With a static sandbox, the workspace inspects the instance to decide which tools to register. With a resolver, the workspace assumes full capabilities and registers `execute_command` (with `background` support), `get_process_output`, and `kill_process`. If the resolved sandbox does not implement a capability, the runtime throws a clear `SandboxFeatureNotSupportedError`.

### Background process continuity

Background processes can outlive a single tool call, so `get_process_output` and `kill_process` must reach the same sandbox that started the process. By default, a resolved sandbox is cached per request. For continuity across follow-up requests, such as a later conversation turn, set `sandboxCacheKey` to a stable identifier. The resolved sandbox is then cached by that key instead of by request:

```typescript
const workspace = new Workspace({
sandbox: ({ requestContext }) => resolveSandbox(requestContext),
sandboxCacheKey: ({ requestContext }) => requestContext.get('thread-id') as string,
})
```

Without a `sandboxCacheKey`, the resolver must return the same sandbox itself for follow-up calls that share a tenant, user, or session.

When a cached sandbox is no longer needed, destroy the sandbox in your own lifecycle code and call `workspace.clearSandboxCache(cacheKey)` to drop the workspace cache entry. Call `workspace.clearSandboxCache()` to clear all keyed sandbox entries.

### Workspace instructions

Workspace instructions describe the environment in the agent's system message. With a sandbox resolver, the workspace does not call the resolver to build these instructions. It emits stable placeholder text, so constructing the prompt never provisions a caller-owned sandbox and the system message stays consistent across requests, which keeps prompt caching effective.

To include concrete per-request sandbox details, set `instructions.dynamicSandbox` to `'resolve'`:

```typescript
const workspace = new Workspace({
sandbox: ({ requestContext }) => resolveSandbox(requestContext),
instructions: { dynamicSandbox: 'resolve' },
})
```

`'resolve'` calls the resolver on every request, which may provision the sandbox and makes the system message request-specific. Pass a function instead to return custom text from `requestContext` without resolving the sandbox:

```typescript
const workspace = new Workspace({
sandbox: ({ requestContext }) => resolveSandbox(requestContext),
instructions: {
dynamicSandbox: ({ requestContext }) =>
`Sandbox scoped to tenant ${requestContext.get('tenant-id')}.`,
},
})
```

## Agent tools

When you configure a sandbox on a workspace, agents receive the `execute_command` tool for running shell commands.
Expand Down
Loading
Loading