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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .changeset/small-rooms-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
"@voltagent/core": patch
---

feat: forward workspace runtime context to sandbox, search, and skills operations

### What's New

Workspace runtime context is now consistently propagated across workspace toolkits and internals, which enables tenant-aware routing patterns.

- `WorkspaceSandboxExecuteOptions` now includes `operationContext`.
- `execute_command` now forwards `operationContext` to `workspace.sandbox.execute(...)`.
- Search operations now accept/forward filesystem call context in indexing and query flows.
- Skills operations now accept/forward context in discovery, indexing, loading, activation, deactivation, search, and file reads.
- Skills `rootPaths` resolver now receives `operationContext` for dynamic root resolution.

### Multi-tenant workspace example (filesystem + search + skills)

```ts
import { Agent, NodeFilesystemBackend, Workspace } from "@voltagent/core";

const workspace = new Workspace({
filesystem: {
backend: ({ operationContext }) => {
const tenantId = String(operationContext?.context.get("tenantId") ?? "default");
return new NodeFilesystemBackend({
rootDir: `./.workspace/${tenantId}`,
});
},
},
search: {
autoIndexPaths: [{ path: "/", glob: "**/*.md" }],
},
skills: {
rootPaths: async ({ operationContext }) => {
const tenantId = String(operationContext?.context.get("tenantId") ?? "default");
return ["/skills/common", `/skills/tenants/${tenantId}`];
},
},
});

const agent = new Agent({
name: "tenant-aware-agent",
model,
workspace,
});

await agent.generateText("Search tenant docs and use relevant skills", {
context: new Map([["tenantId", "acme"]]),
});
```

### Tenant-aware remote sandbox routing example (E2B/Daytona)

```ts
import type {
WorkspaceSandbox,
WorkspaceSandboxExecuteOptions,
WorkspaceSandboxResult,
} from "@voltagent/core";

class TenantSandboxRouter implements WorkspaceSandbox {
name = "tenant-router";
private readonly sandboxes = new Map<string, WorkspaceSandbox>();

constructor(private readonly factory: (tenantId: string) => WorkspaceSandbox) {}

async execute(options: WorkspaceSandboxExecuteOptions): Promise<WorkspaceSandboxResult> {
const tenantId = String(options.operationContext?.context.get("tenantId") ?? "default");

let sandbox = this.sandboxes.get(tenantId);
if (!sandbox) {
sandbox = this.factory(tenantId);
this.sandboxes.set(tenantId, sandbox);
}

return sandbox.execute(options);
}
}
```

If you call `workspace.sandbox.execute(...)` directly (outside toolkit execution), pass `operationContext` explicitly when you need tenant/account routing.
41 changes: 41 additions & 0 deletions packages/core/src/workspace/sandbox/toolkit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,47 @@ const buildExecuteOptions = () => ({
});

describe("Workspace sandbox toolkit", () => {
it("forwards operation context to sandbox execute options", async () => {
const executeCalls: Array<Record<string, unknown>> = [];
const workspace = new Workspace({
sandbox: {
name: "recording",
status: "ready",
async execute(options) {
executeCalls.push(options as unknown as Record<string, unknown>);
return {
stdout: "",
stderr: "",
exitCode: 0,
durationMs: 1,
timedOut: false,
aborted: false,
stdoutTruncated: false,
stderrTruncated: false,
};
},
},
filesystem: {},
});

const toolkit = createWorkspaceSandboxToolkit({
sandbox: workspace.sandbox,
workspace,
filesystem: workspace.filesystem,
});

const executeTool = toolkit.tools.find((tool) => tool.name === "execute_command");
if (!executeTool?.execute) {
throw new Error("execute_command tool not found");
}

const executeOptions = buildExecuteOptions() as any;
await executeTool.execute({ command: "echo", args: ["ok"] }, executeOptions);

expect(executeCalls).toHaveLength(1);
expect(executeCalls[0]?.operationContext).toBe(executeOptions);
});

it("evicts large stdout to workspace files", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "voltagent-sandbox-"));
const sandbox = new LocalSandbox({ rootDir: tempDir });
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/workspace/sandbox/toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export const createWorkspaceSandboxToolkit = (
maxOutputBytes: input.max_output_bytes,
stdin: input.stdin,
signal: operationContext.abortController?.signal,
operationContext,
});

setWorkspaceSpanAttributes(operationContext, {
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/workspace/sandbox/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { OperationContext } from "../../agent/types";

export type WorkspaceSandboxExecuteOptions = {
command: string;
args?: string[];
Expand All @@ -9,6 +11,7 @@ export type WorkspaceSandboxExecuteOptions = {
signal?: AbortSignal;
onStdout?: (chunk: string) => void;
onStderr?: (chunk: string) => void;
operationContext?: OperationContext;
};

export type WorkspaceSandboxStatus = "idle" | "ready" | "destroyed" | "error";
Expand Down
42 changes: 40 additions & 2 deletions packages/core/src/workspace/search/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { Workspace } from "..";
import type { EmbeddingAdapter } from "../../memory/adapters/embedding/types";
import { InMemoryVectorAdapter } from "../../memory/adapters/vector/in-memory";
import { WorkspaceFilesystem } from "../filesystem";
import { WorkspaceSearch } from "./index";
import { WorkspaceSearch, createWorkspaceSearchToolkit } from "./index";

const createExecuteOptions = () => ({
systemContext: new Map(),
Expand All @@ -20,6 +20,44 @@ const buildFileData = (content: string) => {
};

describe("WorkspaceSearch", () => {
it("forwards operation context from toolkit to search calls", async () => {
const indexPaths = vi.fn(async () => ({
indexed: 0,
skipped: 0,
errors: [] as string[],
}));
const indexContent = vi.fn(async () => ({
indexed: 1,
skipped: 0,
errors: [] as string[],
}));
const search = vi.fn(async () => []);

const toolkit = createWorkspaceSearchToolkit({
search: {
indexPaths,
indexContent,
search,
} as any,
});
const executeOptions = createExecuteOptions() as any;

const indexTool = toolkit.tools.find((tool) => tool.name === "workspace_index");
const indexContentTool = toolkit.tools.find((tool) => tool.name === "workspace_index_content");
const searchTool = toolkit.tools.find((tool) => tool.name === "workspace_search");
if (!indexTool?.execute || !indexContentTool?.execute || !searchTool?.execute) {
throw new Error("Workspace search tools not found");
}

await indexTool.execute({ path: "/", glob: "**/*.txt" }, executeOptions);
await indexContentTool.execute({ path: "/inline.txt", content: "hello world" }, executeOptions);
await searchTool.execute({ query: "hello" }, executeOptions);

expect(indexPaths.mock.calls[0]?.[1]?.context?.operationContext).toBe(executeOptions);
expect(indexContent.mock.calls[0]?.[3]?.context?.operationContext).toBe(executeOptions);
expect(search.mock.calls[0]?.[1]?.context?.operationContext).toBe(executeOptions);
});

it("returns normalized scores, line ranges, and score details", async () => {
const filesystem = new WorkspaceFilesystem();
const search = new WorkspaceSearch({ filesystem });
Expand Down
26 changes: 18 additions & 8 deletions packages/core/src/workspace/search/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
import { createTool } from "../../tool";
import { createToolkit } from "../../tool/toolkit";
import type { Toolkit } from "../../tool/toolkit";
import type { WorkspaceFilesystem } from "../filesystem";
import type { WorkspaceFilesystem, WorkspaceFilesystemCallContext } from "../filesystem";
import { truncateIfTooLong, validatePath } from "../filesystem/utils";
import { withOperationTimeout } from "../timeout";
import type {
Expand Down Expand Up @@ -271,12 +271,12 @@ export class WorkspaceSearch {
return WORKSPACE_SEARCH_SYSTEM_PROMPT;
}

private async ensureAutoIndex(): Promise<void> {
private async ensureAutoIndex(context?: WorkspaceFilesystemCallContext): Promise<void> {
if (!this.autoIndexPaths || this.autoIndexPaths.length === 0) {
return;
}
if (!this.autoIndexPromise) {
this.autoIndexPromise = this.indexPaths(this.autoIndexPaths)
this.autoIndexPromise = this.indexPaths(this.autoIndexPaths, { context })
.then(() => undefined)
.catch((error) => {
console.error("Workspace search auto-index failed:", error);
Expand All @@ -288,7 +288,7 @@ export class WorkspaceSearch {

async indexPaths(
paths?: Array<WorkspaceSearchIndexPath | string>,
options?: { maxFileBytes?: number },
options?: { maxFileBytes?: number; context?: WorkspaceFilesystemCallContext },
): Promise<WorkspaceSearchIndexSummary> {
const targets = paths && paths.length > 0 ? paths : (this.autoIndexPaths ?? []);
const summary: WorkspaceSearchIndexSummary = {
Expand All @@ -311,7 +311,9 @@ export class WorkspaceSearch {

let infos: Awaited<ReturnType<WorkspaceFilesystem["globInfo"]>>;
try {
infos = await this.filesystem.globInfo(glob, basePath);
infos = await this.filesystem.globInfo(glob, basePath, {
context: options?.context,
});
} catch (error: any) {
summary.errors.push(
`Failed to glob ${basePath}: ${error?.message ? String(error.message) : "unknown error"}`,
Expand All @@ -327,7 +329,9 @@ export class WorkspaceSearch {
}

try {
const data = await this.filesystem.readRaw(info.path);
const data = await this.filesystem.readRaw(info.path, {
context: options?.context,
});
const content = data.content.join("\n");
const contentBytes = Buffer.byteLength(content, "utf-8");

Expand Down Expand Up @@ -414,6 +418,7 @@ export class WorkspaceSearch {
path: string,
content: string,
metadata?: Record<string, unknown>,
_options?: { context?: WorkspaceFilesystemCallContext },
): Promise<WorkspaceSearchIndexSummary> {
const normalizedPath = normalizeDocumentPath(path);
return this.indexDocuments([
Expand All @@ -430,7 +435,7 @@ export class WorkspaceSearch {
query: string,
options: WorkspaceSearchOptions = {},
): Promise<WorkspaceSearchResult[]> {
await this.ensureAutoIndex();
await this.ensureAutoIndex(options.context);

const mode = this.resolveMode(options.mode);
const topK = options.topK ?? DEFAULT_TOP_K;
Expand Down Expand Up @@ -767,7 +772,10 @@ export const createWorkspaceSearchToolkit = (

const summary = await context.search.indexPaths(
[{ path: input.path || "/", glob: input.glob }],
{ maxFileBytes: input.max_file_bytes },
{
maxFileBytes: input.max_file_bytes,
context: { agent: context.agent, operationContext },
},
);

setWorkspaceSpanAttributes(operationContext, {
Expand Down Expand Up @@ -809,6 +817,7 @@ export const createWorkspaceSearchToolkit = (
input.path,
input.content,
input.metadata,
{ context: { agent: context.agent, operationContext } },
);

setWorkspaceSpanAttributes(operationContext, {
Expand Down Expand Up @@ -888,6 +897,7 @@ export const createWorkspaceSearchToolkit = (
snippetLength: input.snippet_length,
lexicalWeight: input.lexical_weight,
vectorWeight: input.vector_weight,
context: { agent: context.agent, operationContext },
});

setWorkspaceSpanAttributes(operationContext, {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/workspace/search/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { EmbeddingAdapterInput, VectorAdapter } from "../../memory/types";
import type { WorkspaceFilesystemCallContext } from "../filesystem";

export type WorkspaceSearchMode = "bm25" | "vector" | "hybrid";

Expand Down Expand Up @@ -36,6 +37,7 @@ export type WorkspaceSearchOptions = {
snippetLength?: number;
lexicalWeight?: number;
vectorWeight?: number;
context?: WorkspaceFilesystemCallContext;
};

export type WorkspaceSearchResult = {
Expand Down
Loading
Loading