diff --git a/.changeset/small-rooms-eat.md b/.changeset/small-rooms-eat.md new file mode 100644 index 000000000..f771c47bd --- /dev/null +++ b/.changeset/small-rooms-eat.md @@ -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(); + + constructor(private readonly factory: (tenantId: string) => WorkspaceSandbox) {} + + async execute(options: WorkspaceSandboxExecuteOptions): Promise { + 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. diff --git a/packages/core/src/workspace/sandbox/toolkit.spec.ts b/packages/core/src/workspace/sandbox/toolkit.spec.ts index ae400ccbe..69e4305f4 100644 --- a/packages/core/src/workspace/sandbox/toolkit.spec.ts +++ b/packages/core/src/workspace/sandbox/toolkit.spec.ts @@ -13,6 +13,47 @@ const buildExecuteOptions = () => ({ }); describe("Workspace sandbox toolkit", () => { + it("forwards operation context to sandbox execute options", async () => { + const executeCalls: Array> = []; + const workspace = new Workspace({ + sandbox: { + name: "recording", + status: "ready", + async execute(options) { + executeCalls.push(options as unknown as Record); + 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 }); diff --git a/packages/core/src/workspace/sandbox/toolkit.ts b/packages/core/src/workspace/sandbox/toolkit.ts index fdf435f65..079a1e162 100644 --- a/packages/core/src/workspace/sandbox/toolkit.ts +++ b/packages/core/src/workspace/sandbox/toolkit.ts @@ -226,6 +226,7 @@ export const createWorkspaceSandboxToolkit = ( maxOutputBytes: input.max_output_bytes, stdin: input.stdin, signal: operationContext.abortController?.signal, + operationContext, }); setWorkspaceSpanAttributes(operationContext, { diff --git a/packages/core/src/workspace/sandbox/types.ts b/packages/core/src/workspace/sandbox/types.ts index f4a121096..cf6e9dffa 100644 --- a/packages/core/src/workspace/sandbox/types.ts +++ b/packages/core/src/workspace/sandbox/types.ts @@ -1,3 +1,5 @@ +import type { OperationContext } from "../../agent/types"; + export type WorkspaceSandboxExecuteOptions = { command: string; args?: string[]; @@ -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"; diff --git a/packages/core/src/workspace/search/index.spec.ts b/packages/core/src/workspace/search/index.spec.ts index 33d13758c..da0c6b7c2 100644 --- a/packages/core/src/workspace/search/index.spec.ts +++ b/packages/core/src/workspace/search/index.spec.ts @@ -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(), @@ -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 }); diff --git a/packages/core/src/workspace/search/index.ts b/packages/core/src/workspace/search/index.ts index 9d137e615..f7af29a6c 100644 --- a/packages/core/src/workspace/search/index.ts +++ b/packages/core/src/workspace/search/index.ts @@ -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 { @@ -271,12 +271,12 @@ export class WorkspaceSearch { return WORKSPACE_SEARCH_SYSTEM_PROMPT; } - private async ensureAutoIndex(): Promise { + private async ensureAutoIndex(context?: WorkspaceFilesystemCallContext): Promise { 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); @@ -288,7 +288,7 @@ export class WorkspaceSearch { async indexPaths( paths?: Array, - options?: { maxFileBytes?: number }, + options?: { maxFileBytes?: number; context?: WorkspaceFilesystemCallContext }, ): Promise { const targets = paths && paths.length > 0 ? paths : (this.autoIndexPaths ?? []); const summary: WorkspaceSearchIndexSummary = { @@ -311,7 +311,9 @@ export class WorkspaceSearch { let infos: Awaited>; 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"}`, @@ -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"); @@ -414,6 +418,7 @@ export class WorkspaceSearch { path: string, content: string, metadata?: Record, + _options?: { context?: WorkspaceFilesystemCallContext }, ): Promise { const normalizedPath = normalizeDocumentPath(path); return this.indexDocuments([ @@ -430,7 +435,7 @@ export class WorkspaceSearch { query: string, options: WorkspaceSearchOptions = {}, ): Promise { - await this.ensureAutoIndex(); + await this.ensureAutoIndex(options.context); const mode = this.resolveMode(options.mode); const topK = options.topK ?? DEFAULT_TOP_K; @@ -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, { @@ -809,6 +817,7 @@ export const createWorkspaceSearchToolkit = ( input.path, input.content, input.metadata, + { context: { agent: context.agent, operationContext } }, ); setWorkspaceSpanAttributes(operationContext, { @@ -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, { diff --git a/packages/core/src/workspace/search/types.ts b/packages/core/src/workspace/search/types.ts index 417ebe75c..89cf7372f 100644 --- a/packages/core/src/workspace/search/types.ts +++ b/packages/core/src/workspace/search/types.ts @@ -1,4 +1,5 @@ import type { EmbeddingAdapterInput, VectorAdapter } from "../../memory/types"; +import type { WorkspaceFilesystemCallContext } from "../filesystem"; export type WorkspaceSearchMode = "bm25" | "vector" | "hybrid"; @@ -36,6 +37,7 @@ export type WorkspaceSearchOptions = { snippetLength?: number; lexicalWeight?: number; vectorWeight?: number; + context?: WorkspaceFilesystemCallContext; }; export type WorkspaceSearchResult = { diff --git a/packages/core/src/workspace/skills/index.spec.ts b/packages/core/src/workspace/skills/index.spec.ts index d6faa7a4e..0a4d6f590 100644 --- a/packages/core/src/workspace/skills/index.spec.ts +++ b/packages/core/src/workspace/skills/index.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { Workspace } from ".."; import type { FileData } from "../filesystem"; +import { createWorkspaceSkillsToolkit } from "./index"; describe("WorkspaceSkills root resolver context", () => { it("provides workspace identity and filesystem to the resolver", async () => { @@ -21,6 +22,82 @@ describe("WorkspaceSkills root resolver context", () => { }); }); +describe("WorkspaceSkills toolkit context forwarding", () => { + it("forwards operation context to skills service calls", async () => { + const discoverSkills = vi.fn(async () => []); + const searchSkills = vi.fn(async () => []); + const loadSkill = vi.fn(async () => ({ + id: "/skills/data", + name: "Data Analyst", + path: "/skills/data/SKILL.md", + root: "/skills/data", + references: ["references/schema.md"], + instructions: "Analyze data.", + })); + const activateSkill = vi.fn(async () => ({ + id: "/skills/data", + name: "Data Analyst", + path: "/skills/data/SKILL.md", + root: "/skills/data", + })); + const deactivateSkill = vi.fn(async () => true); + const readFileContent = vi.fn(async () => "schema"); + const toolkit = createWorkspaceSkillsToolkit({ + skills: { + discoverSkills, + getActiveSkills: vi.fn(() => []), + search: searchSkills, + loadSkill, + activateSkill, + deactivateSkill, + resolveSkillFilePath: vi.fn(() => "/skills/data/references/schema.md"), + readFileContent, + } as any, + }); + + const executeOptions = { + systemContext: new Map(), + abortController: new AbortController(), + } as any; + + const listTool = toolkit.tools.find((tool) => tool.name === "workspace_list_skills"); + const searchTool = toolkit.tools.find((tool) => tool.name === "workspace_search_skills"); + const readSkillTool = toolkit.tools.find((tool) => tool.name === "workspace_read_skill"); + const activateTool = toolkit.tools.find((tool) => tool.name === "workspace_activate_skill"); + const deactivateTool = toolkit.tools.find((tool) => tool.name === "workspace_deactivate_skill"); + const readReferenceTool = toolkit.tools.find( + (tool) => tool.name === "workspace_read_skill_reference", + ); + if ( + !listTool?.execute || + !searchTool?.execute || + !readSkillTool?.execute || + !activateTool?.execute || + !deactivateTool?.execute || + !readReferenceTool?.execute + ) { + throw new Error("Workspace skills tools not found"); + } + + await listTool.execute({}, executeOptions); + await searchTool.execute({ query: "data" }, executeOptions); + await readSkillTool.execute({ skill_id: "/skills/data" }, executeOptions); + await activateTool.execute({ skill_id: "/skills/data" }, executeOptions); + await deactivateTool.execute({ skill_id: "/skills/data" }, executeOptions); + await readReferenceTool.execute( + { skill_id: "/skills/data", reference: "references/schema.md" }, + executeOptions, + ); + + expect(discoverSkills.mock.calls[0]?.[0]?.context?.operationContext).toBe(executeOptions); + expect(searchSkills.mock.calls[0]?.[1]?.context?.operationContext).toBe(executeOptions); + expect(loadSkill.mock.calls[0]?.[1]?.context?.operationContext).toBe(executeOptions); + expect(activateSkill.mock.calls[0]?.[1]?.context?.operationContext).toBe(executeOptions); + expect(deactivateSkill.mock.calls[0]?.[1]?.context?.operationContext).toBe(executeOptions); + expect(readFileContent.mock.calls[0]?.[1]?.context?.operationContext).toBe(executeOptions); + }); +}); + describe("WorkspaceSkills discovery and activation", () => { it("discovers, loads, and activates skills", async () => { const skillContent = `--- diff --git a/packages/core/src/workspace/skills/index.ts b/packages/core/src/workspace/skills/index.ts index 5220b6606..d4cb1591b 100644 --- a/packages/core/src/workspace/skills/index.ts +++ b/packages/core/src/workspace/skills/index.ts @@ -17,7 +17,7 @@ import { createTool } from "../../tool"; import { createToolkit } from "../../tool/toolkit"; import type { Toolkit } from "../../tool/toolkit"; import { randomUUID } from "../../utils/id"; -import type { WorkspaceFilesystem } from "../filesystem"; +import type { WorkspaceFilesystem, WorkspaceFilesystemCallContext } from "../filesystem"; import { WorkspaceBm25Index, tokenizeSearchText } from "../search/bm25"; import { withOperationTimeout } from "../timeout"; import type { WorkspaceToolPolicies, WorkspaceToolPolicyGroup } from "../tool-policy"; @@ -344,6 +344,10 @@ type WorkspaceSkillDocument = { metadata?: Record; }; +type WorkspaceSkillsOperationOptions = { + context?: WorkspaceFilesystemCallContext; +}; + export class WorkspaceSkills { private readonly filesystem: WorkspaceFilesystem; private readonly workspaceIdentity: WorkspaceSkillsRootResolverContext["workspace"]; @@ -455,12 +459,12 @@ export class WorkspaceSkills { return SKILLS_SYSTEM_PROMPT; } - private async ensureDiscovered(): Promise { + private async ensureDiscovered(options: WorkspaceSkillsOperationOptions = {}): Promise { if (this.discovered) { return; } if (!this.autoDiscoverPromise) { - const promise = this.discoverSkills().then(() => undefined); + const promise = this.discoverSkills({ context: options.context }).then(() => undefined); this.autoDiscoverPromise = promise; promise.catch(() => { if (this.autoDiscoverPromise === promise) { @@ -479,7 +483,7 @@ export class WorkspaceSkills { } } - private async ensureRootPaths(): Promise { + private async ensureRootPaths(options: WorkspaceSkillsOperationOptions = {}): Promise { if (this.rootResolved) { return; } @@ -496,6 +500,7 @@ export class WorkspaceSkills { const resolved = await this.rootResolver?.({ workspace: this.workspaceIdentity, filesystem: this.filesystem, + operationContext: options.context?.operationContext, }); const normalized = normalizeStringArray(resolved) ?? DEFAULT_SKILL_ROOTS; this.rootPaths = normalized.map(normalizeRootPath); @@ -509,12 +514,12 @@ export class WorkspaceSkills { await this.rootResolvePromise; } - private async ensureIndexed(): Promise { + private async ensureIndexed(options: WorkspaceSkillsOperationOptions = {}): Promise { if (this.indexed) { return; } if (!this.autoIndexPromise) { - const promise = this.indexSkills().then(() => undefined); + const promise = this.indexSkills({ context: options.context }).then(() => undefined); this.autoIndexPromise = promise; promise.catch(() => { if (this.autoIndexPromise === promise) { @@ -533,12 +538,14 @@ export class WorkspaceSkills { } } - async discoverSkills(options: { refresh?: boolean } = {}): Promise { + async discoverSkills( + options: { refresh?: boolean; context?: WorkspaceFilesystemCallContext } = {}, + ): Promise { if (this.discovered && !options.refresh) { return Array.from(this.skillsById.values()); } - await this.ensureRootPaths(); + await this.ensureRootPaths({ context: options.context }); this.skillsById.clear(); this.skillNameMap.clear(); @@ -551,7 +558,9 @@ export class WorkspaceSkills { for (const root of this.rootPaths) { let infos: Awaited>; try { - infos = await this.filesystem.globInfo(this.glob, root); + infos = await this.filesystem.globInfo(this.glob, root, { + context: options.context, + }); } catch { continue; } @@ -563,7 +572,9 @@ export class WorkspaceSkills { } try { - const data = await this.filesystem.readRaw(skillPath); + const data = await this.filesystem.readRaw(skillPath, { + context: options.context, + }); const content = data.content.join("\n"); const contentBytes = Buffer.byteLength(content, "utf-8"); if (this.maxFileBytes > 0 && contentBytes > this.maxFileBytes) { @@ -622,8 +633,11 @@ export class WorkspaceSkills { return Array.from(this.skillsById.values()); } - async loadSkill(identifier: string): Promise { - await this.ensureDiscovered(); + async loadSkill( + identifier: string, + options: WorkspaceSkillsOperationOptions = {}, + ): Promise { + await this.ensureDiscovered({ context: options.context }); const id = this.resolveSkillId(identifier); if (!id) { return null; @@ -639,7 +653,9 @@ export class WorkspaceSkills { return null; } - const data = await this.filesystem.readRaw(metadata.path); + const data = await this.filesystem.readRaw(metadata.path, { + context: options.context, + }); const content = data.content.join("\n"); const { data: frontmatter, instructions } = parseSkillFile(content); const detail: WorkspaceSkill = { @@ -660,8 +676,11 @@ export class WorkspaceSkills { return detail; } - async activateSkill(identifier: string): Promise { - await this.ensureDiscovered(); + async activateSkill( + identifier: string, + options: WorkspaceSkillsOperationOptions = {}, + ): Promise { + await this.ensureDiscovered({ context: options.context }); const id = this.resolveSkillId(identifier); if (!id) { return null; @@ -670,8 +689,11 @@ export class WorkspaceSkills { return this.skillsById.get(id) ?? null; } - async deactivateSkill(identifier: string): Promise { - await this.ensureDiscovered(); + async deactivateSkill( + identifier: string, + options: WorkspaceSkillsOperationOptions = {}, + ): Promise { + await this.ensureDiscovered({ context: options.context }); const id = this.resolveSkillId(identifier); if (!id) { return false; @@ -685,8 +707,10 @@ export class WorkspaceSkills { .filter((skill): skill is WorkspaceSkillMetadata => Boolean(skill)); } - async indexSkills(): Promise { - await this.ensureDiscovered(); + async indexSkills( + options: WorkspaceSkillsOperationOptions = {}, + ): Promise { + await this.ensureDiscovered({ context: options.context }); const summary: WorkspaceSkillIndexSummary = { indexed: 0, @@ -718,7 +742,7 @@ export class WorkspaceSkills { for (const metadata of this.skillsById.values()) { try { - const skill = await this.loadSkill(metadata.id); + const skill = await this.loadSkill(metadata.id, { context: options.context }); if (!skill) { summary.skipped += 1; continue; @@ -803,7 +827,7 @@ export class WorkspaceSkills { query: string, options: WorkspaceSkillSearchOptions = {}, ): Promise { - await this.ensureIndexed(); + await this.ensureIndexed({ context: options.context }); const mode = this.resolveMode(options.mode); const topK = options.topK ?? DEFAULT_TOP_K; @@ -876,8 +900,10 @@ export class WorkspaceSkills { ); } - async buildPrompt(options: WorkspaceSkillsPromptOptions = {}): Promise { - await this.ensureDiscovered(); + async buildPrompt( + options: WorkspaceSkillsPromptOptions & { context?: WorkspaceFilesystemCallContext } = {}, + ): Promise { + await this.ensureDiscovered({ context: options.context }); const includeAvailable = options.includeAvailable ?? true; const includeActivated = options.includeActivated ?? true; @@ -904,7 +930,7 @@ export class WorkspaceSkills { if (activeIds.length > 0) { const entries: string[] = []; for (const id of activeIds) { - const skill = await this.loadSkill(id); + const skill = await this.loadSkill(id, { context: options.context }); if (!skill) { continue; } @@ -1034,22 +1060,34 @@ export class WorkspaceSkills { return joinPaths(skill.root, cleaned); } - async readFileContent(filePath: string): Promise { - const data = await this.filesystem.readRaw(filePath); + async readFileContent( + filePath: string, + options: WorkspaceSkillsOperationOptions = {}, + ): Promise { + const data = await this.filesystem.readRaw(filePath, { + context: options.context, + }); return data.content.join("\n"); } } export const createWorkspaceSkillsPromptHook = ( - context: WorkspaceSkillsPromptHookContext, + hookContext: WorkspaceSkillsPromptHookContext, options: WorkspaceSkillsPromptOptions = {}, ): AgentHooks => ({ - onPrepareMessages: async ({ messages }): Promise => { - if (!context.skills) { + onPrepareMessages: async ({ + messages, + context: operationContext, + agent, + }): Promise => { + if (!hookContext.skills) { return { messages }; } - const prompt = await context.skills.buildPrompt(options); + const prompt = await hookContext.skills.buildPrompt({ + ...options, + context: { agent, operationContext }, + }); if (!prompt) { return { messages }; } @@ -1115,7 +1153,10 @@ export const createWorkspaceSkillsToolkit = ( return "Workspace skills are not configured."; } - const skills = await context.skills.discoverSkills({ refresh: Boolean(input.refresh) }); + const skills = await context.skills.discoverSkills({ + refresh: Boolean(input.refresh), + context: { agent: context.agent, operationContext }, + }); const activeIds = new Set(context.skills.getActiveSkills().map((skill) => skill.id)); const listed = input.active_only ? skills.filter((skill) => activeIds.has(skill.id)) @@ -1163,6 +1204,7 @@ export const createWorkspaceSkillsToolkit = ( snippetLength: input.snippet_length, lexicalWeight: input.lexical_weight, vectorWeight: input.vector_weight, + context: { agent: context.agent, operationContext }, }); setWorkspaceSpanAttributes(operationContext, { @@ -1202,7 +1244,9 @@ export const createWorkspaceSkillsToolkit = ( return "Workspace skills are not configured."; } - const skill = await context.skills.loadSkill(input.skill_id); + const skill = await context.skills.loadSkill(input.skill_id, { + context: { agent: context.agent, operationContext }, + }); if (!skill) { return `Skill not found: ${input.skill_id}`; } @@ -1241,7 +1285,9 @@ export const createWorkspaceSkillsToolkit = ( return "Workspace skills are not configured."; } - const skill = await context.skills.activateSkill(input.skill_id); + const skill = await context.skills.activateSkill(input.skill_id, { + context: { agent: context.agent, operationContext }, + }); if (!skill) { return `Skill not found: ${input.skill_id}`; } @@ -1280,7 +1326,9 @@ export const createWorkspaceSkillsToolkit = ( return "Workspace skills are not configured."; } - const success = await context.skills.deactivateSkill(input.skill_id); + const success = await context.skills.deactivateSkill(input.skill_id, { + context: { agent: context.agent, operationContext }, + }); if (!success) { return `Skill not found: ${input.skill_id}`; } @@ -1309,7 +1357,9 @@ export const createWorkspaceSkillsToolkit = ( return "Workspace skills are not configured."; } - const skill = await context.skills.loadSkill(skillId); + const skill = await context.skills.loadSkill(skillId, { + context: { agent: context.agent, operationContext }, + }); if (!skill) { return `Skill not found: ${skillId}`; } @@ -1330,7 +1380,9 @@ export const createWorkspaceSkillsToolkit = ( }); try { - const content = await context.skills.readFileContent(resolvedPath); + const content = await context.skills.readFileContent(resolvedPath, { + context: { agent: context.agent, operationContext }, + }); return content || "(empty)"; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/core/src/workspace/skills/types.ts b/packages/core/src/workspace/skills/types.ts index e65ea7a32..4707d4f65 100644 --- a/packages/core/src/workspace/skills/types.ts +++ b/packages/core/src/workspace/skills/types.ts @@ -1,5 +1,6 @@ +import type { OperationContext } from "../../agent/types"; import type { EmbeddingAdapterInput, VectorAdapter } from "../../memory/types"; -import type { WorkspaceFilesystem } from "../filesystem"; +import type { WorkspaceFilesystem, WorkspaceFilesystemCallContext } from "../filesystem"; import type { WorkspaceIdentity } from "../types"; export type WorkspaceSkillSearchMode = "bm25" | "vector" | "hybrid"; @@ -12,6 +13,7 @@ export type WorkspaceSkillSearchHybridWeights = { export type WorkspaceSkillsRootResolverContext = { workspace: WorkspaceIdentity; filesystem: WorkspaceFilesystem; + operationContext?: OperationContext; }; export type WorkspaceSkillsRootResolver = ( @@ -57,6 +59,7 @@ export type WorkspaceSkillSearchOptions = { snippetLength?: number; lexicalWeight?: number; vectorWeight?: number; + context?: WorkspaceFilesystemCallContext; }; export type WorkspaceSkillSearchResult = { diff --git a/website/docs/workspaces/filesystem.md b/website/docs/workspaces/filesystem.md index 5055b808a..24f3307c9 100644 --- a/website/docs/workspaces/filesystem.md +++ b/website/docs/workspaces/filesystem.md @@ -28,6 +28,27 @@ const workspace = new Workspace({ All filesystem tool paths are workspace-relative and must start with `/`. +### Runtime context in backend factories + +Custom backend factories receive `operationContext`, so you can route filesystem storage at runtime (for example, by tenant/account). + +```ts +import { Workspace, NodeFilesystemBackend } from "@voltagent/core"; + +const workspace = new Workspace({ + filesystem: { + backend: ({ operationContext }) => { + const tenantId = String(operationContext?.context.get("tenantId") ?? "default"); + return new NodeFilesystemBackend({ + rootDir: `./.workspace/${tenantId}`, + }); + }, + }, +}); +``` + +For production, sanitize tenant IDs before using them in file paths. + ## Filesystem tools - `ls`: list files in a directory diff --git a/website/docs/workspaces/sandbox.md b/website/docs/workspaces/sandbox.md index 1ef57a42b..570368480 100644 --- a/website/docs/workspaces/sandbox.md +++ b/website/docs/workspaces/sandbox.md @@ -170,6 +170,146 @@ const workspace = new Workspace({ }); ``` +## Access runtime context in custom sandboxes + +When `execute_command` runs through the workspace sandbox toolkit, VoltAgent forwards the current operation context to your sandbox as `options.operationContext`. + +This lets you build custom routing, such as tenant-aware sandbox selection. + +```ts +import type { + WorkspaceSandbox, + WorkspaceSandboxExecuteOptions, + WorkspaceSandboxResult, +} from "@voltagent/core"; + +class TenantAwareSandbox implements WorkspaceSandbox { + name = "tenant-aware"; + status = "ready" as const; + + async execute(options: WorkspaceSandboxExecuteOptions): Promise { + const tenantId = String(options.operationContext?.context.get("tenantId") ?? "default"); + + // Route by tenant (for example: separate container/session per tenant). + // Implement your own provider lookup here. + const start = Date.now(); + return { + stdout: `running for tenant ${tenantId}`, + stderr: "", + exitCode: 0, + durationMs: Date.now() - start, + timedOut: false, + aborted: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + } +} +``` + +If you call `workspace.sandbox.execute(...)` directly (outside the toolkit), pass `operationContext` yourself if you need it. + +### Tenant-aware E2B router example + +```ts +import type { + WorkspaceSandbox, + WorkspaceSandboxExecuteOptions, + WorkspaceSandboxResult, +} from "@voltagent/core"; +import { Workspace } from "@voltagent/core"; +import { E2BSandbox } from "@voltagent/sandbox-e2b"; + +class TenantE2BSandboxRouter implements WorkspaceSandbox { + name = "tenant-e2b-router"; + status = "ready" as const; + // In production, add LRU/TTL eviction here and dispose evicted sandboxes + // (for example via stop/destroy) to avoid unbounded per-tenant growth. + private readonly sandboxes = new Map(); + + getInfo() { + return { + provider: "tenant-e2b-router", + status: this.status, + sandboxCount: this.sandboxes.size, + }; + } + + private getSandboxForTenant(tenantId: string): E2BSandbox { + let sandbox = this.sandboxes.get(tenantId); + if (!sandbox) { + sandbox = new E2BSandbox({ + apiKey: process.env.E2B_API_KEY, + // Example strategy: map tenant to a template/session naming scheme + template: `tenant-${tenantId}`, + }); + this.sandboxes.set(tenantId, sandbox); + } + return sandbox; + } + + async execute(options: WorkspaceSandboxExecuteOptions): Promise { + const tenantId = String(options.operationContext?.context.get("tenantId") ?? "default"); + return this.getSandboxForTenant(tenantId).execute(options); + } +} + +const workspace = new Workspace({ + sandbox: new TenantE2BSandboxRouter(), +}); +``` + +### Tenant-aware Daytona router example + +```ts +import type { + WorkspaceSandbox, + WorkspaceSandboxExecuteOptions, + WorkspaceSandboxResult, +} from "@voltagent/core"; +import { Workspace } from "@voltagent/core"; +import { DaytonaSandbox } from "@voltagent/sandbox-daytona"; + +class TenantDaytonaSandboxRouter implements WorkspaceSandbox { + name = "tenant-daytona-router"; + status = "ready" as const; + // In production, add LRU/TTL eviction here and dispose evicted sandboxes + // (for example via stop/destroy) to avoid unbounded per-tenant growth. + private readonly sandboxes = new Map(); + + getInfo() { + return { + provider: "tenant-daytona-router", + status: this.status, + sandboxCount: this.sandboxes.size, + }; + } + + private getSandboxForTenant(tenantId: string): DaytonaSandbox { + let sandbox = this.sandboxes.get(tenantId); + if (!sandbox) { + sandbox = new DaytonaSandbox({ + apiKey: process.env.DAYTONA_API_KEY, + apiUrl: process.env.DAYTONA_API_URL, + // Example strategy: pass tenant metadata to your Daytona create params + createParams: { name: `tenant-${tenantId}` }, + }); + this.sandboxes.set(tenantId, sandbox); + } + return sandbox; + } + + async execute(options: WorkspaceSandboxExecuteOptions): Promise { + const tenantId = String(options.operationContext?.context.get("tenantId") ?? "default"); + return this.getSandboxForTenant(tenantId).execute(options); + } +} + +const workspace = new Workspace({ + sandbox: new TenantDaytonaSandboxRouter(), +}); +``` + Notes: - `onStdout`/`onStderr` are optional streaming hooks for UI integration. diff --git a/website/docs/workspaces/search.md b/website/docs/workspaces/search.md index e01b831f5..63f2d138b 100644 --- a/website/docs/workspaces/search.md +++ b/website/docs/workspaces/search.md @@ -51,6 +51,42 @@ const results = await workspace.search("workspace isolation", { Direct calls still respect search tool policies (`enabled` / `needsApproval`). +## Runtime context in search operations + +When search tools run, VoltAgent forwards the current operation context through search indexing calls. +This means context-aware filesystem backends (for example tenant-based roots) also apply to `workspace_index` and `workspace_search`. + +```ts +import { Agent, Workspace, NodeFilesystemBackend } 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" }], + }, +}); + +const agent = new Agent({ + name: "workspace-search-agent", + model: "openai/gpt-4o-mini", // Replace with your preferred provider/model + instructions: "Use workspace search tools when needed.", + workspace, +}); + +const response = await agent.generateText("Index and search tenant docs", { + context: new Map([["tenantId", "acme"]]), +}); +``` + +For production, sanitize tenant IDs before using them in file paths. + ## Snippet-only output To reduce token usage, you can omit full content in tool output: diff --git a/website/docs/workspaces/skills.md b/website/docs/workspaces/skills.md index b467ea737..b2d11970f 100644 --- a/website/docs/workspaces/skills.md +++ b/website/docs/workspaces/skills.md @@ -36,6 +36,25 @@ const workspace = new Workspace({ }); ``` +### Runtime context in skills operations + +Skill tool calls forward the current operation context to `WorkspaceSkills` internals. +This lets you build tenant-aware or account-aware skill routing. + +```ts +const workspace = new Workspace({ + skills: { + // operationContext is available when discovery is triggered from an agent operation. + rootPaths: async ({ operationContext }) => { + const tenantId = String(operationContext?.context.get("tenantId") ?? "default"); + return ["/skills/common", `/skills/tenants/${tenantId}`]; + }, + }, +}); +``` + +If you use tenant-scoped roots, prefer `autoDiscover: false` and call discovery with `refresh: true` from tools when tenant context changes. + ## SKILL.md format `SKILL.md` uses YAML frontmatter plus Markdown instructions: