diff --git a/.changeset/subagent-capability-inheritance.md b/.changeset/subagent-capability-inheritance.md new file mode 100644 index 000000000..8eb6b2ebd --- /dev/null +++ b/.changeset/subagent-capability-inheritance.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Allow declared subagents to explicitly inherit the immediate parent's live sandbox session and resolved connection definitions while staying isolated by default. diff --git a/apps/docs/public/r/channel/photon-imessage.json b/apps/docs/public/r/channel/photon-imessage.json deleted file mode 100644 index 6a9b2309b..000000000 --- a/apps/docs/public/r/channel/photon-imessage.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "channel/photon-imessage", - "title": "Photon iMessage", - "description": "Connect an eve agent to iMessage through Photon with guided project and phone setup.", - "dependencies": ["@vercel/connect@0.5.0"], - "meta": { - "eve": { - "setup": { - "command": "eve", - "package": "eve", - "bin": "eve", - "args": ["integration", "setup", "photon"] - }, - "requires": ">=0.29.0" - } - }, - "type": "registry:item" -} diff --git a/docs/subagents.mdx b/docs/subagents.mdx index d6d51635c..6e4b47980 100644 --- a/docs/subagents.mdx +++ b/docs/subagents.mdx @@ -1,6 +1,6 @@ --- title: "Subagents" -description: "Delegate work to root-agent copies or declared specialists with their own tools and sandbox." +description: "Delegate work to root-agent copies or declared specialists with isolated or explicitly inherited capabilities." --- eve supports two ways to delegate work: the root-only built-in `agent` tool, which runs a fresh copy of the root agent, and declared subagents, which are specialists with their own directories. Use a subagent to run independent work in parallel, narrow the available tools, or give a task to a specialist. @@ -93,26 +93,55 @@ agent/subagents/researcher/ ## The isolation boundary -A declared subagent inherits nothing from the root's authored slots. Discovery treats its directory as its own agent root, so it has only the instructions, tools, connections, skills, sandbox, hooks, and nested subagents authored under `agent/subagents//`. An absent slot falls back to the framework default, not to the root's version. +A declared subagent is isolated by default. Discovery treats its directory as its own agent root, so it has only the instructions, tools, connections, skills, sandbox, hooks, and nested subagents authored under `agent/subagents//`. An absent slot falls back to the framework default, not to the root's version, unless the subagent explicitly opts into one of the supported inherited capabilities. -| Slot | Root built-in `agent` tool | Declared subagent | -| ------------ | ----------------------------- | -------------------------------------- | -| Instructions | Inherited (copy of the agent) | Own `instructions.{md,ts}`, optional | -| Tools | Inherited except root-only | Own `tools/` | -| Connections | Inherited | Own `connections/` | -| Skills | Inherited | Own `skills/` | -| Sandbox | Shared with parent | Own `sandbox/`, else framework default | -| Hooks | Inherited | Own `hooks/` | -| State | Fresh | Fresh | -| Channels | Root-only | Root-only | -| Schedules | Root-only | Root-only | +| Slot | Root built-in `agent` tool | Declared subagent | +| ------------ | ----------------------------- | ------------------------------------------------------ | +| Instructions | Inherited (copy of the agent) | Own `instructions.{md,ts}`, optional | +| Tools | Inherited except root-only | Own `tools/` | +| Connections | Inherited | Own `connections/`, or explicit inherit | +| Skills | Inherited | Own `skills/` | +| Sandbox | Shared with parent | Own `sandbox/`, framework default, or explicit inherit | +| Hooks | Inherited | Own `hooks/` | +| State | Fresh | Fresh | +| Channels | Root-only | Root-only | +| Schedules | Root-only | Root-only | -For a declared subagent this means duplicating anything the child needs. When two subagents need the same procedure, copy the markdown under each `skills/` directory, or share typed helpers via `lib/`. The sandbox does not inherit from the parent; it falls back to the framework default unless the subagent authors `subagents//sandbox.ts` or seeds files via `subagents//sandbox/workspace/`. +For a declared subagent this means duplicating anything the child needs unless the capability is intentionally shared. When two subagents need the same procedure, copy the markdown under each `skills/` directory, or share typed helpers via `lib/`. The sandbox does not inherit from the parent by default; it falls back to the framework default unless the subagent authors `subagents//sandbox.ts`, seeds files via `subagents//sandbox/workspace/`, or explicitly inherits the parent's live sandbox session. The root built-in `agent` tool is the exception. Its children share the root's sandbox and tools because they are copies of the same agent working on the same files. `defineState` is never shared, for either kind. Each child starts with fresh durable state. +## Explicit inheritance for declared subagents + +A declared subagent may opt into sharing selected capabilities from its immediate parent with `inherit`. Only `sandbox` and `connections` are supported today: + +```ts title="agent/subagents/reviewer/agent.ts" +import { defineAgent } from "eve"; + +export default defineAgent({ + description: "Review the current checkout and file focused comments.", + model: "anthropic/claude-sonnet-5", + inherit: { + sandbox: true, + connections: true, + }, +}); +``` + +`inherit.sandbox: true` runs the child against the parent's live sandbox session, so files already checked out or written by the parent are visible to the child, and child writes are visible to the parent. The child still keeps its own instructions, tools, skills, hooks, durable state, and nested subagent list. + +Because inherited sandboxes merge the parent's and child's static seed files into one live sandbox template, static skill names must not collide across that inherited chain. Rename one of the skills before sharing the sandbox. + +`inherit.connections: true` reuses the parent's resolved connection definitions. Authorization still goes through the normal connection flow for the current session and principal; eve does not copy credentials into prompts, compiled manifests, durable JSON, or sandbox files. A subagent that inherits connections cannot also declare a connection with the same name, because the effective tool namespace would be ambiguous. + +Sharing sandbox or connections expands the child's trust boundary to that parent capability. Treat inherited reviewers as able to read whatever is already in the parent sandbox and to invoke the parent's connections under the same session principal. Prefer isolation when the specialist should not see that workspace or those credentials, and keep concurrent parent/child writes non-overlapping when they share a live sandbox. + +Inheritance is immediate-parent based. If `researcher` inherits root connections and nested `reviewer` inherits from `researcher`, `reviewer` sees `researcher`'s effective connections. If `reviewer` does not opt in, it stays isolated even when its parent inherited something. + +A subagent cannot both inherit the parent sandbox and own a sandbox or sandbox workspace seed. Choose one live filesystem boundary per subagent: inherited parent session, authored subagent sandbox, or the framework default. + ## What the parent sees eve lowers every subagent visible to the current agent (the root built-in copy, declared, or [remote](./guides/remote-agents)) into a model-visible tool with the same `{ message, outputSchema? }` shape. The parent packs `message` with everything the child needs, since the child never sees the parent's history. Set `outputSchema` to run the child in task mode, returning structured output as the tool result. diff --git a/packages/eve/extension-contracts/compatibility/dynamicInstructions/v3.ts b/packages/eve/extension-contracts/compatibility/dynamicInstructions/v3.ts new file mode 100644 index 000000000..e0433660b --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/dynamicInstructions/v3.ts @@ -0,0 +1,10 @@ +import { defineDynamic, defineInstructions } from "#public/instructions/index.js"; + +export default defineDynamic({ + events: { + "session.started": () => + defineInstructions({ + markdown: "Answer with evidence from the current session.", + }), + }, +}); diff --git a/packages/eve/extension-contracts/compatibility/dynamicSkill/v3.ts b/packages/eve/extension-contracts/compatibility/dynamicSkill/v3.ts new file mode 100644 index 000000000..942404504 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/dynamicSkill/v3.ts @@ -0,0 +1,11 @@ +import { defineDynamic, defineSkill } from "#public/skills/index.js"; + +export default defineDynamic({ + events: { + "session.started": () => + defineSkill({ + description: "Triage incoming requests.", + markdown: "# Triage\n\nInspect the request before acting.", + }), + }, +}); diff --git a/packages/eve/extension-contracts/compatibility/dynamicTool/v6.ts b/packages/eve/extension-contracts/compatibility/dynamicTool/v6.ts new file mode 100644 index 000000000..624876617 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/dynamicTool/v6.ts @@ -0,0 +1,12 @@ +import { defineDynamic, defineTool } from "#public/tools/index.js"; + +export default defineDynamic({ + events: { + "session.started": () => + defineTool({ + description: "Return the current status.", + inputSchema: { type: "object", properties: {} }, + execute: () => ({ status: "ready" }), + }), + }, +}); diff --git a/packages/eve/extension-contracts/compatibility/hook/v4.ts b/packages/eve/extension-contracts/compatibility/hook/v4.ts new file mode 100644 index 000000000..98cd2e775 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/hook/v4.ts @@ -0,0 +1,12 @@ +import { defineHook } from "#public/hooks/index.js"; + +export default defineHook({ + events: { + "input.requested"(event) { + console.info( + "input requested", + event.data.requests.map((request) => request.requestId), + ); + }, + }, +}); diff --git a/packages/eve/extension-contracts/compatibility/tool/v3.ts b/packages/eve/extension-contracts/compatibility/tool/v3.ts new file mode 100644 index 000000000..801d59638 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/tool/v3.ts @@ -0,0 +1,19 @@ +import { defineTool } from "#public/tools/index.js"; + +export default defineTool({ + description: "Summarize a report for the model", + inputSchema: { type: "object", properties: {} }, + async execute(_input, ctx) { + return { + internal: "details", + sessionId: ctx.session.id, + summary: "Report generated", + }; + }, + toModelOutput(output) { + return { + type: "text", + value: (output as { summary: string }).summary, + }; + }, +}); diff --git a/packages/eve/extension-contracts/reports/dynamicInstructions/v4.json b/packages/eve/extension-contracts/reports/dynamicInstructions/v4.json new file mode 100644 index 000000000..8fc3adf28 --- /dev/null +++ b/packages/eve/extension-contracts/reports/dynamicInstructions/v4.json @@ -0,0 +1,7 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "dynamicInstructions", + "epoch": 4, + "sha256": "be64e6c08ddd69d5e477930659652dd6e7ce68620b95d1a93cf2cd828cbec1fd", + "exports": ["defineDynamic"] +} diff --git a/packages/eve/extension-contracts/reports/dynamicSkill/v4.json b/packages/eve/extension-contracts/reports/dynamicSkill/v4.json new file mode 100644 index 000000000..ff148dc76 --- /dev/null +++ b/packages/eve/extension-contracts/reports/dynamicSkill/v4.json @@ -0,0 +1,7 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "dynamicSkill", + "epoch": 4, + "sha256": "be64e6c08ddd69d5e477930659652dd6e7ce68620b95d1a93cf2cd828cbec1fd", + "exports": ["defineDynamic"] +} diff --git a/packages/eve/extension-contracts/reports/dynamicTool/v7.json b/packages/eve/extension-contracts/reports/dynamicTool/v7.json new file mode 100644 index 000000000..f8fd66187 --- /dev/null +++ b/packages/eve/extension-contracts/reports/dynamicTool/v7.json @@ -0,0 +1,13 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "dynamicTool", + "epoch": 7, + "sha256": "c658f3bacd5bd9432be1383f091ba49354071bcc1c7163811b9f57c030d2550e", + "exports": [ + "DynamicToolEntry", + "DynamicToolEvents", + "DynamicToolResult", + "DynamicToolSet", + "defineDynamic" + ] +} diff --git a/packages/eve/extension-contracts/reports/hook/v5.json b/packages/eve/extension-contracts/reports/hook/v5.json new file mode 100644 index 000000000..fb3725216 --- /dev/null +++ b/packages/eve/extension-contracts/reports/hook/v5.json @@ -0,0 +1,7 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "hook", + "epoch": 5, + "sha256": "763eb0a42ebd46ae315115422d54204e916e59d84b43fa4c105cf485faf0dbc5", + "exports": ["defineHook"] +} diff --git a/packages/eve/extension-contracts/reports/tool/v4.json b/packages/eve/extension-contracts/reports/tool/v4.json new file mode 100644 index 000000000..2cc675ddc --- /dev/null +++ b/packages/eve/extension-contracts/reports/tool/v4.json @@ -0,0 +1,21 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "tool", + "epoch": 4, + "sha256": "831ffd895eae757f952882d11e547c01e8d5ded44f24f7ebceca59f083995586", + "exports": [ + "defineBashTool", + "defineGlobTool", + "defineGrepTool", + "defineReadFileTool", + "defineTool", + "defineWriteFileTool", + "disableTool", + "experimental_workflow", + "isDisabledToolSentinel", + "isExperimentalWorkflowToolDefinition", + "toolOutput", + "toolOutputPart", + "toolResultFrom" + ] +} diff --git a/packages/eve/src/cli/commands/info.test.ts b/packages/eve/src/cli/commands/info.test.ts index 58e642271..2df98cc38 100644 --- a/packages/eve/src/cli/commands/info.test.ts +++ b/packages/eve/src/cli/commands/info.test.ts @@ -38,18 +38,35 @@ function makeSchedule(name: string): CompiledScheduleDefinition { }; } -function makeSubagent(name: string): CompiledSubagentNode { +function makeSubagent( + name: string, + options: { + inherit?: { connections?: boolean; sandbox?: boolean }; + } = {}, +): CompiledSubagentNode { + const config: { + inherit?: { connections?: boolean; sandbox?: boolean }; + model: { + id: string; + routing: { kind: "gateway"; target: string }; + }; + name: string; + } = { + model: { + id: "anthropic/claude-sonnet-5", + routing: { kind: "gateway", target: "anthropic" }, + }, + name, + }; + if (options.inherit !== undefined) { + config.inherit = options.inherit; + } + return { agent: createCompiledAgentNodeManifest({ agentRoot: `${AGENT_ROOT}/subagents/${name}`, appRoot: APP_ROOT, - config: { - model: { - id: "anthropic/claude-sonnet-5", - routing: { kind: "gateway", target: "anthropic" }, - }, - name, - }, + config, }), description: `${name} subagent description`, entryPath: `subagents/${name}/agent.ts`, @@ -174,12 +191,44 @@ describe("buildApplicationInfoJson", () => { application: getApplicationInfo(APP_ROOT), compiledState: makeCompiledState({ schedules: [makeSchedule("morning-digest"), makeSchedule("weekly-report")], - subagents: [makeSubagent("research")], + subagents: [ + makeSubagent("research"), + makeSubagent("reviewer", { inherit: { connections: true, sandbox: true } }), + ], }), messaging: MESSAGING, }); - expect(json.subagents).toEqual(["research"]); + expect(json.subagents).toEqual([ + { + name: "research", + effective: { + connections: { + inherited: false, + owned: 0, + }, + sandbox: "default", + }, + inherit: { + connections: false, + sandbox: false, + }, + }, + { + name: "reviewer", + effective: { + connections: { + inherited: true, + owned: 0, + }, + sandbox: "inherited", + }, + inherit: { + connections: true, + sandbox: true, + }, + }, + ]); expect(json.schedules).toEqual(["morning-digest", "weekly-report"]); }); diff --git a/packages/eve/src/cli/commands/info.ts b/packages/eve/src/cli/commands/info.ts index f4241a9af..4955370b2 100644 --- a/packages/eve/src/cli/commands/info.ts +++ b/packages/eve/src/cli/commands/info.ts @@ -1,3 +1,4 @@ +import { projectSubagentCapabilities } from "#compiler/subagent-capabilities.js"; import { type ApplicationInspection, inspectApplication } from "#services/inspect-application.js"; import { type CliRow, createCliTheme, renderCliBanner, renderCliSection } from "#cli/ui/output.js"; @@ -22,7 +23,20 @@ export interface ApplicationInfoJson { instructions: string | null; skills: string[]; tools: string[]; - subagents: string[]; + subagents: { + name: string; + effective: { + connections: { + inherited: boolean; + owned: number; + }; + sandbox: "default" | "inherited" | "owned"; + }; + inherit: { + connections: boolean; + sandbox: boolean; + }; + }[]; schedules: string[]; channels: { name: string; kind: string | null; method: string | null; urlPath: string | null }[]; messaging: { create: string; continue: string; stream: string }; @@ -57,7 +71,14 @@ export function buildApplicationInfoJson(inspection: ApplicationInspection): App instructions: compiledState?.manifest.instructions?.logicalPath ?? null, skills: (compiledState?.manifest.skills ?? []).map((skill) => skill.name), tools: (compiledState?.manifest.tools ?? []).map((tool) => tool.name), - subagents: (compiledState?.manifest.subagents ?? []).map((subagent) => subagent.name), + subagents: (compiledState?.manifest.subagents ?? []).map((subagent) => { + const capabilities = projectSubagentCapabilities(subagent); + return { + name: subagent.name, + effective: capabilities.effective, + inherit: capabilities.inherit, + }; + }), schedules: (compiledState?.manifest.schedules ?? []).map((schedule) => schedule.name), channels: (compiledState?.manifest.channels ?? []).map((channel) => channel.kind === "channel" @@ -94,6 +115,19 @@ function formatDiscoverySummary(errors: number, warnings: number): string { return `${pluralize(errors, "error")}, ${pluralize(warnings, "warning")}`; } +function formatSubagentCapabilitySummary( + subagent: ApplicationInfoJson["subagents"][number], +): string { + const connectionMode = subagent.effective.connections.inherited + ? "connections inherited" + : "connections isolated"; + return [ + `sandbox ${subagent.effective.sandbox}`, + connectionMode, + pluralize(subagent.effective.connections.owned, "owned connection"), + ].join("; "); +} + function resolveCompileTone(status: string): "danger" | "success" | "warning" { switch (status) { case "ready": @@ -122,6 +156,7 @@ export async function printApplicationInfo( const compiledState = inspection.compiledState; const info = inspection.application; + const applicationInfoJson = buildApplicationInfoJson(inspection); const theme = createCliTheme(); const applicationRows: CliRow[] = [ { @@ -140,6 +175,14 @@ export async function printApplicationInfo( }, ]; const instructionsRows: CliRow[] = []; + const subagentRows: CliRow[] = applicationInfoJson.subagents.map((subagent) => ({ + label: subagent.name, + tone: + subagent.effective.sandbox === "inherited" || subagent.effective.connections.inherited + ? "subagent" + : "muted", + value: formatSubagentCapabilitySummary(subagent), + })); if (compiledState !== null) { applicationRows.push( @@ -256,6 +299,15 @@ export async function printApplicationInfo( title: "Instructions", }), ]), + ...(compiledState === null || subagentRows.length === 0 + ? [] + : [ + "", + renderCliSection(theme, { + rows: subagentRows, + title: "Subagents", + }), + ]), "", renderCliSection(theme, { rows: [ diff --git a/packages/eve/src/client/agent-info-schema.ts b/packages/eve/src/client/agent-info-schema.ts index 31ee51b6b..fb922ee17 100644 --- a/packages/eve/src/client/agent-info-schema.ts +++ b/packages/eve/src/client/agent-info-schema.ts @@ -67,6 +67,17 @@ const schedule = entry.extend({ const subagent = entry.extend({ description: z.string().optional(), entryPath: z.string(), + effective: z.object({ + connections: z.object({ + inherited: z.boolean(), + owned: z.number(), + }), + sandbox: z.enum(["default", "inherited", "owned"]), + }), + inherit: z.object({ + connections: z.boolean(), + sandbox: z.boolean(), + }), nodeId: z.string(), rootPath: z.string(), summary: z.object({ diff --git a/packages/eve/src/compiler/extension-compatibility.ts b/packages/eve/src/compiler/extension-compatibility.ts index 704f06ac6..2d0ee7974 100644 --- a/packages/eve/src/compiler/extension-compatibility.ts +++ b/packages/eve/src/compiler/extension-compatibility.ts @@ -21,14 +21,14 @@ interface ExtensionCapabilityContract { const EXTENSION_CAPABILITY_CONTRACTS = { extension: { current: 1, supported: [1], dropped: {} }, - tool: { current: 3, supported: [1, 2, 3], dropped: {} }, - dynamicTool: { current: 6, supported: [1, 2, 3, 4, 5, 6], dropped: {} }, + tool: { current: 4, supported: [1, 2, 3, 4], dropped: {} }, + dynamicTool: { current: 7, supported: [1, 2, 3, 4, 5, 6, 7], dropped: {} }, connection: { current: 2, supported: [1, 2], dropped: {} }, - hook: { current: 4, supported: [1, 2, 3, 4], dropped: {} }, + hook: { current: 5, supported: [1, 2, 3, 4, 5], dropped: {} }, skill: { current: 1, supported: [1], dropped: {} }, - dynamicSkill: { current: 3, supported: [1, 2, 3], dropped: {} }, + dynamicSkill: { current: 4, supported: [1, 2, 3, 4], dropped: {} }, instructions: { current: 1, supported: [1], dropped: {} }, - dynamicInstructions: { current: 3, supported: [1, 2, 3], dropped: {} }, + dynamicInstructions: { current: 4, supported: [1, 2, 3, 4], dropped: {} }, config: { current: 1, supported: [1], dropped: {} }, state: { current: 2, supported: [1, 2], dropped: {} }, } as const satisfies Record; diff --git a/packages/eve/src/compiler/manifest.test.ts b/packages/eve/src/compiler/manifest.test.ts index 215c1f901..b4b42175f 100644 --- a/packages/eve/src/compiler/manifest.test.ts +++ b/packages/eve/src/compiler/manifest.test.ts @@ -213,4 +213,26 @@ describe("compiledAgentManifestSchema", () => { expect(parsed.success).toBe(true); expect(manifest.config.experimental?.workflow).toEqual({ world: "@acme/eve-world" }); }); + + it("preserves explicit capability inheritance configuration", () => { + const manifest = createCompiledAgentManifest({ + agentRoot: "/app/agent", + appRoot: "/app", + config: { + inherit: { + connections: true, + sandbox: true, + }, + model: { id: "openai/gpt-5.5", routing: classifyModelRouting("openai/gpt-5.5") }, + name: "app", + }, + }); + + const parsed = compiledAgentManifestSchema.parse(manifest); + + expect(parsed.config.inherit).toEqual({ + connections: true, + sandbox: true, + }); + }); }); diff --git a/packages/eve/src/compiler/manifest.ts b/packages/eve/src/compiler/manifest.ts index 7b6c35f22..bf721e613 100644 --- a/packages/eve/src/compiler/manifest.ts +++ b/packages/eve/src/compiler/manifest.ts @@ -25,6 +25,7 @@ import type { InternalAgentModelDefinition, InternalAgentCompactionDefinition, AgentBuildDefinition, + AgentInheritanceDefinition, ModelRouting, } from "#shared/agent-definition.js"; import type { InternalToolDefinition } from "#shared/tool-definition.js"; @@ -122,6 +123,8 @@ type CompiledAgentCompactionDefinition = Omit = z + .object({ + connections: z.boolean().optional(), + sandbox: z.boolean().optional(), + }) + .strict(); + const compiledAgentConfigSchema: z.ZodType = z .object({ build: compiledAgentBuildDefinitionSchema.optional(), @@ -432,6 +442,7 @@ const compiledAgentConfigSchema: z.ZodType = z }) .strict() .optional(), + inherit: compiledAgentInheritanceDefinitionSchema.optional(), model: compiledRuntimeModelReferenceSchema, name: z.string(), outputSchema: jsonObjectSchema.optional(), @@ -835,6 +846,13 @@ export function createCompiledAgentNodeManifest(input: { world: input.config.experimental.workflow.world, }, }, + inherit: + input.config.inherit === undefined + ? undefined + : { + connections: input.config.inherit.connections, + sandbox: input.config.inherit.sandbox, + }, model: cloneCompiledRuntimeModelReference(input.config.model), name: input.config.name, outputSchema: input.config.outputSchema, @@ -949,6 +967,7 @@ export function createCompiledAgentManifest(input: { readonly instructions?: CompiledInstructionsDefinition; readonly tools?: readonly CompiledToolDefinition[]; readonly extensionMounts?: readonly CompiledExtensionMount[]; + readonly workspaceResourceRoot?: CompiledWorkspaceResourceRoot; }): CompiledAgentManifest { return { ...createCompiledAgentNodeManifest(input), diff --git a/packages/eve/src/compiler/normalize-agent-config.test.ts b/packages/eve/src/compiler/normalize-agent-config.test.ts index 98023eddb..d1e15bec2 100644 --- a/packages/eve/src/compiler/normalize-agent-config.test.ts +++ b/packages/eve/src/compiler/normalize-agent-config.test.ts @@ -57,6 +57,32 @@ describe("compileAgentConfig", () => { }); }); + it("preserves explicit capability inheritance config", async () => { + mocks.loadModuleBackedDefinition.mockResolvedValue({ + inherit: { connections: true, sandbox: true }, + model: "openai/gpt-5.5", + }); + + const manifest = createAgentSourceManifest({ + agentId: "research", + agentRoot: "/app/agent/subagents/research", + appRoot: "/app", + configModule: createModuleSourceRef({ + logicalPath: "agent.ts", + sourceId: "subagent-config", + }), + }); + + const compiled = await compileAgentConfig(manifest, { + modelCatalog: createModelCatalog(), + }); + + expect(compiled.inherit).toEqual({ + connections: true, + sandbox: true, + }); + }); + it("compiles an injected dynamic-subagent placeholder without reloading agent.ts", async () => { const manifest = createAgentSourceManifest({ agentId: "researcher", diff --git a/packages/eve/src/compiler/normalize-agent-config.ts b/packages/eve/src/compiler/normalize-agent-config.ts index 710f6fc68..c30e63123 100644 --- a/packages/eve/src/compiler/normalize-agent-config.ts +++ b/packages/eve/src/compiler/normalize-agent-config.ts @@ -80,6 +80,7 @@ export async function compileAgentConfig( description?: string; dynamicModel?: CompiledAgentDefinition["dynamicModel"]; experimental?: CompiledAgentDefinition["experimental"]; + inherit?: CompiledAgentDefinition["inherit"]; model: CompiledRuntimeModelReference; name: string; outputSchema?: JsonObject; @@ -114,6 +115,13 @@ export async function compileAgentConfig( compiledConfig.experimental = experimental; } + if (definition.inherit !== undefined) { + compiledConfig.inherit = { + connections: definition.inherit.connections, + sandbox: definition.inherit.sandbox, + }; + } + if (definition.build !== undefined) { compiledConfig.build = { externalDependencies: diff --git a/packages/eve/src/compiler/normalize-manifest.test.ts b/packages/eve/src/compiler/normalize-manifest.test.ts index b4b316f72..20dbcaa64 100644 --- a/packages/eve/src/compiler/normalize-manifest.test.ts +++ b/packages/eve/src/compiler/normalize-manifest.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentSourceManifest } from "#discover/manifest.js"; import { createAgentSourceManifest, + createConnectionSourceRef, createLocalSubagentSourceRef, createModuleSourceRef, } from "#discover/manifest.js"; @@ -15,6 +16,7 @@ import { DEFAULT_AGENT_MODEL_ID } from "#shared/default-agent-model.js"; const mocks = vi.hoisted(() => ({ compileAgentConfig: vi.fn(), + compileConnectionDefinition: vi.fn(), loadModuleBackedDefinition: vi.fn(), })); @@ -26,9 +28,14 @@ vi.mock("#compiler/normalize-helpers.js", () => ({ loadModuleBackedDefinition: mocks.loadModuleBackedDefinition, })); +vi.mock("#compiler/normalize-connection.js", () => ({ + compileConnectionDefinition: mocks.compileConnectionDefinition, +})); + describe("compileAgentManifest", () => { beforeEach(() => { mocks.compileAgentConfig.mockReset(); + mocks.compileConnectionDefinition.mockReset(); mocks.loadModuleBackedDefinition.mockReset(); }); @@ -96,6 +103,293 @@ describe("compileAgentManifest", () => { expect(compiled.workflowTool).toEqual({ maxSubagents: 6 }); }); + it("rejects capability inheritance on root agent configs", async () => { + const manifest = createAgentSourceManifest({ + agentId: "root", + agentRoot: "/app/agent", + appRoot: "/app", + }); + + mocks.compileAgentConfig.mockResolvedValue( + createConfig({ + inherit: { + connections: true, + }, + name: "root", + }), + ); + + await expect(compileAgentManifest(manifest)).rejects.toThrow( + 'Capability inheritance is only supported on declared subagent configs. Remove "inherit" from "root".', + ); + }); + + it("preserves capability inheritance on declared subagent configs", async () => { + const subagentManifest = createAgentSourceManifest({ + agentId: "research", + agentRoot: "/app/agent/subagents/research", + appRoot: "/app", + configModule: createModuleSourceRef({ + logicalPath: "agent.ts", + }), + }); + const manifest = createAgentSourceManifest({ + agentId: "root", + agentRoot: "/app/agent", + appRoot: "/app", + subagents: [ + createLocalSubagentSourceRef({ + entryPath: "subagents/research/agent.ts", + logicalPath: "subagents/research", + manifest: subagentManifest, + rootPath: "/app/agent/subagents/research", + subagentId: "research", + }), + ], + }); + + mocks.compileAgentConfig.mockImplementation(async (input: AgentSourceManifest) => + input.agentId === "research" + ? createConfig({ + description: "Research subagent", + inherit: { + connections: true, + sandbox: true, + }, + name: "research", + }) + : createConfig({ name: "root" }), + ); + mocks.loadModuleBackedDefinition.mockResolvedValue({ + description: "Research subagent", + model: "openai/gpt-5.5", + }); + + const compiled = await compileAgentManifest(manifest); + + expect(compiled.subagents[0]?.agent.config.inherit).toEqual({ + connections: true, + sandbox: true, + }); + }); + + it("rejects subagents that inherit and own a sandbox", async () => { + const subagentManifest = createAgentSourceManifest({ + agentId: "research", + agentRoot: "/app/agent/subagents/research", + appRoot: "/app", + configModule: createModuleSourceRef({ + logicalPath: "agent.ts", + }), + sandbox: createModuleSourceRef({ + logicalPath: "sandbox/sandbox.ts", + }), + }); + const manifest = createAgentSourceManifest({ + agentId: "root", + agentRoot: "/app/agent", + appRoot: "/app", + subagents: [ + createLocalSubagentSourceRef({ + entryPath: "subagents/research/agent.ts", + logicalPath: "subagents/research", + manifest: subagentManifest, + rootPath: "/app/agent/subagents/research", + subagentId: "research", + }), + ], + }); + + mocks.compileAgentConfig.mockImplementation(async (input: AgentSourceManifest) => + input.agentId === "research" + ? createConfig({ + description: "Research subagent", + inherit: { + sandbox: true, + }, + name: "research", + }) + : createConfig({ name: "root" }), + ); + mocks.loadModuleBackedDefinition.mockResolvedValue({ + description: "Research subagent", + model: "openai/gpt-5.5", + }); + + await expect(compileAgentManifest(manifest)).rejects.toThrow( + 'Subagent "research" cannot both inherit the parent sandbox and define its own sandbox.', + ); + }); + + it("rejects subagents that inherit connections and own a colliding connection name", async () => { + const subagentManifest = createAgentSourceManifest({ + agentId: "reviewer", + agentRoot: "/app/agent/subagents/reviewer", + appRoot: "/app", + configModule: createModuleSourceRef({ + logicalPath: "agent.ts", + }), + connections: [ + createConnectionSourceRef({ + connectionName: "github", + logicalPath: "connections/github.ts", + }), + ], + }); + const manifest = createAgentSourceManifest({ + agentId: "root", + agentRoot: "/app/agent", + appRoot: "/app", + connections: [ + createConnectionSourceRef({ + connectionName: "github", + logicalPath: "connections/github.ts", + }), + ], + subagents: [ + createLocalSubagentSourceRef({ + entryPath: "subagents/reviewer/agent.ts", + logicalPath: "subagents/reviewer", + manifest: subagentManifest, + rootPath: "/app/agent/subagents/reviewer", + subagentId: "reviewer", + }), + ], + }); + + mocks.compileAgentConfig.mockImplementation(async (input: AgentSourceManifest) => + input.agentId === "reviewer" + ? createConfig({ + description: "Reviewer subagent", + inherit: { + connections: true, + }, + name: "reviewer", + }) + : createConfig({ name: "root" }), + ); + mocks.compileConnectionDefinition.mockImplementation( + async (_agentRoot: string, source: { readonly connectionName: string }) => ({ + connectionName: source.connectionName, + description: `${source.connectionName} connection`, + logicalPath: `connections/${source.connectionName}.ts`, + protocol: "mcp", + sourceId: `connections/${source.connectionName}`, + sourceKind: "module", + url: "https://example.com", + }), + ); + mocks.loadModuleBackedDefinition.mockResolvedValue({ + description: "Reviewer subagent", + model: "openai/gpt-5.5", + }); + + await expect(compileAgentManifest(manifest)).rejects.toThrow( + 'Subagent "reviewer" inherits connection "github" but also defines a connection with that name.', + ); + }); + + it("rejects nested subagents that collide with an effective inherited parent connection name", async () => { + // root owns github → mid inherits connections → leaf inherits and also + // defines github. Effective parent names for leaf include root's github. + const leafManifest = createAgentSourceManifest({ + agentId: "leaf", + agentRoot: "/app/agent/subagents/mid/subagents/leaf", + appRoot: "/app", + configModule: createModuleSourceRef({ + logicalPath: "agent.ts", + }), + connections: [ + createConnectionSourceRef({ + connectionName: "github", + logicalPath: "connections/github.ts", + }), + ], + }); + const midManifest = createAgentSourceManifest({ + agentId: "mid", + agentRoot: "/app/agent/subagents/mid", + appRoot: "/app", + configModule: createModuleSourceRef({ + logicalPath: "agent.ts", + }), + subagents: [ + createLocalSubagentSourceRef({ + entryPath: "subagents/leaf/agent.ts", + logicalPath: "subagents/leaf", + manifest: leafManifest, + rootPath: "/app/agent/subagents/mid/subagents/leaf", + subagentId: "leaf", + }), + ], + }); + const manifest = createAgentSourceManifest({ + agentId: "root", + agentRoot: "/app/agent", + appRoot: "/app", + connections: [ + createConnectionSourceRef({ + connectionName: "github", + logicalPath: "connections/github.ts", + }), + ], + subagents: [ + createLocalSubagentSourceRef({ + entryPath: "subagents/mid/agent.ts", + logicalPath: "subagents/mid", + manifest: midManifest, + rootPath: "/app/agent/subagents/mid", + subagentId: "mid", + }), + ], + }); + + mocks.compileAgentConfig.mockImplementation(async (input: AgentSourceManifest) => { + if (input.agentId === "leaf") { + return createConfig({ + description: "Leaf subagent", + inherit: { connections: true }, + name: "leaf", + }); + } + if (input.agentId === "mid") { + return createConfig({ + description: "Mid subagent", + inherit: { connections: true }, + name: "mid", + }); + } + return createConfig({ name: "root" }); + }); + mocks.compileConnectionDefinition.mockImplementation( + async (_agentRoot: string, source: { readonly connectionName: string }) => ({ + connectionName: source.connectionName, + description: `${source.connectionName} connection`, + logicalPath: `connections/${source.connectionName}.ts`, + protocol: "mcp", + sourceId: `connections/${source.connectionName}`, + sourceKind: "module", + url: "https://example.com", + }), + ); + mocks.loadModuleBackedDefinition.mockImplementation(async (input: { kind: string }) => { + if (input.kind === "subagent config") { + return { + description: "Nested subagent", + model: "openai/gpt-5.5", + }; + } + return { + description: "Nested subagent", + model: "openai/gpt-5.5", + }; + }); + + await expect(compileAgentManifest(manifest)).rejects.toThrow( + 'Subagent "leaf" inherits connection "github" but also defines a connection with that name.', + ); + }); + it("compiles a dynamic subagent manifest without resolving an agent config", async () => { const dynamic = defineDynamic({ events: { @@ -188,7 +482,7 @@ function createManifestWithSubagent(): AgentSourceManifest { function createConfig( input: Pick & - Partial>, + Partial>, ): CompiledAgentDefinition { const config: CompiledAgentDefinition = { model: { @@ -204,6 +498,9 @@ function createConfig( if (input.experimental !== undefined) { config.experimental = input.experimental; } + if (input.inherit !== undefined) { + config.inherit = input.inherit; + } return config; } diff --git a/packages/eve/src/compiler/normalize-manifest.ts b/packages/eve/src/compiler/normalize-manifest.ts index 47574cc41..6f9b2f1da 100644 --- a/packages/eve/src/compiler/normalize-manifest.ts +++ b/packages/eve/src/compiler/normalize-manifest.ts @@ -44,6 +44,7 @@ export async function compileAgentManifest( compileAgentNodeManifest, context, externalDependencies: compiledNode.config.build?.externalDependencies ?? [], + parentConnectionNames: compiledNode.connections.map((connection) => connection.connectionName), parentNodeId: ROOT_COMPILED_AGENT_NODE_ID, subagents: manifest.subagents, }); @@ -77,6 +78,7 @@ async function compileAgentNodeManifest( options: { readonly agentConfigDefinition?: unknown; readonly externalDependencies?: readonly string[]; + readonly allowInheritanceConfig?: boolean; readonly allowWorkflowConfig?: boolean; } = {}, ): Promise { @@ -85,11 +87,24 @@ async function compileAgentNodeManifest( definition: options.agentConfigDefinition, }) : await compileAgentConfig(manifest, context); + if (options.allowInheritanceConfig !== true && rawConfig.inherit !== undefined) { + throw new Error( + `Capability inheritance is only supported on declared subagent configs. Remove "inherit" from "${manifest.agentId}".`, + ); + } if (options.allowWorkflowConfig === false && rawConfig.experimental?.workflow !== undefined) { throw new Error( `Workflow runtime configuration is only supported on the root agent config. Remove "experimental.workflow" from "${manifest.agentId}".`, ); } + if ( + rawConfig.inherit?.sandbox === true && + (manifest.sandbox !== null || manifest.sandboxWorkspaces.length > 0) + ) { + throw new Error( + `Subagent "${manifest.agentId}" cannot both inherit the parent sandbox and define its own sandbox. Remove "inherit.sandbox" or the subagent sandbox entries.`, + ); + } const externalDependencies = mergeExternalDependencies( options.externalDependencies, rawConfig.build?.externalDependencies, diff --git a/packages/eve/src/compiler/normalize-subagent.ts b/packages/eve/src/compiler/normalize-subagent.ts index 17a4fbb9e..ace4ed71f 100644 --- a/packages/eve/src/compiler/normalize-subagent.ts +++ b/packages/eve/src/compiler/normalize-subagent.ts @@ -44,6 +44,7 @@ export type CompileAgentNodeManifestFn = ( options?: { readonly agentConfigDefinition?: unknown; readonly externalDependencies?: readonly string[]; + readonly allowInheritanceConfig?: boolean; readonly allowWorkflowConfig?: boolean; }, ) => Promise; @@ -61,6 +62,12 @@ export async function compileSubagentGraph(input: { readonly compileAgentNodeManifest: CompileAgentNodeManifestFn; readonly context: ManifestCompileContext; readonly externalDependencies?: readonly string[]; + /** + * Effective connection names available on the parent node after its own + * inheritance is applied. Used to fail fast when a child both inherits + * connections and owns a colliding name. + */ + readonly parentConnectionNames?: readonly string[]; readonly parentNodeId: string; readonly subagents: readonly LocalSubagentSourceRef[]; }): Promise<{ @@ -78,6 +85,7 @@ export async function compileSubagentGraph(input: { compileAgentNodeManifest: input.compileAgentNodeManifest, context: input.context, externalDependencies: input.externalDependencies, + parentConnectionNames: input.parentConnectionNames ?? [], parentNodeId: input.parentNodeId, source: subagentSource, }); @@ -109,6 +117,7 @@ async function compileSubagentDefinition(input: { readonly compileAgentNodeManifest: CompileAgentNodeManifestFn; readonly context: ManifestCompileContext; readonly externalDependencies?: readonly string[]; + readonly parentConnectionNames: readonly string[]; readonly parentNodeId: string; readonly source: LocalSubagentSourceRef; }): Promise< @@ -171,6 +180,7 @@ async function compileSubagent(input: { readonly agentConfigDefinition?: unknown; readonly dynamic?: { readonly eventNames: readonly string[] }; readonly externalDependencies?: readonly string[]; + readonly parentConnectionNames: readonly string[]; readonly parentNodeId: string; readonly source: LocalSubagentSourceRef; }): Promise<{ @@ -191,6 +201,7 @@ async function compileSubagent(input: { input.context, { agentConfigDefinition: input.agentConfigDefinition, + allowInheritanceConfig: true, allowWorkflowConfig: false, externalDependencies: input.externalDependencies, }, @@ -216,11 +227,34 @@ async function compileSubagent(input: { variant = { description }; } + if (agent.config.inherit?.connections === true) { + const parentConnectionNames = new Set(input.parentConnectionNames); + const duplicateConnection = agent.connections.find((connection) => + parentConnectionNames.has(connection.connectionName), + ); + if (duplicateConnection !== undefined) { + throw new Error( + `Subagent "${subagentName}" inherits connection "${duplicateConnection.connectionName}" but also defines a connection with that name.`, + ); + } + } + + // Descendants that inherit connections receive this node's effective set: + // owned names plus any names this node itself inherits from its parent. + const effectiveConnectionNames = + agent.config.inherit?.connections === true + ? [ + ...input.parentConnectionNames, + ...agent.connections.map((connection) => connection.connectionName), + ] + : agent.connections.map((connection) => connection.connectionName); + const descendants = await compileSubagentGraph({ appRoot: input.appRoot, compileAgentNodeManifest: input.compileAgentNodeManifest, context: input.context, externalDependencies: agent.config.build?.externalDependencies, + parentConnectionNames: effectiveConnectionNames, parentNodeId: nodeId, subagents: input.source.manifest.subagents, }); diff --git a/packages/eve/src/compiler/subagent-capabilities.test.ts b/packages/eve/src/compiler/subagent-capabilities.test.ts new file mode 100644 index 000000000..7d18d1471 --- /dev/null +++ b/packages/eve/src/compiler/subagent-capabilities.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { + type ProjectableSubagentCapabilities, + projectSubagentCapabilities, +} from "#compiler/subagent-capabilities.js"; + +function createSubagent( + input: { + readonly connections?: number; + readonly inherit?: { readonly connections?: boolean; readonly sandbox?: boolean }; + readonly sandbox?: unknown | null; + readonly sandboxWorkspaces?: number; + } = {}, +): ProjectableSubagentCapabilities { + const config: { + inherit?: { readonly connections?: boolean; readonly sandbox?: boolean }; + } = {}; + if (input.inherit !== undefined) { + config.inherit = input.inherit; + } + + return { + agent: { + config, + connections: Array.from({ length: input.connections ?? 0 }, () => ({})), + sandbox: input.sandbox === undefined ? null : input.sandbox, + sandboxWorkspaces: Array.from({ length: input.sandboxWorkspaces ?? 0 }, () => ({})), + }, + }; +} + +describe("projectSubagentCapabilities", () => { + it("projects isolated defaults when inherit is unset", () => { + expect(projectSubagentCapabilities(createSubagent())).toEqual({ + effective: { + connections: { inherited: false, owned: 0 }, + sandbox: "default", + }, + inherit: { connections: false, sandbox: false }, + }); + }); + + it("projects inherited sandbox and connections", () => { + expect( + projectSubagentCapabilities( + createSubagent({ + connections: 1, + inherit: { connections: true, sandbox: true }, + }), + ), + ).toEqual({ + effective: { + connections: { inherited: true, owned: 1 }, + sandbox: "inherited", + }, + inherit: { connections: true, sandbox: true }, + }); + }); + + it("projects owned sandbox when the subagent defines one", () => { + expect( + projectSubagentCapabilities( + createSubagent({ + sandbox: { + logicalPath: "sandbox/sandbox.ts", + sourceId: "sandbox/sandbox", + sourceKind: "module", + }, + }), + ), + ).toMatchObject({ + effective: { sandbox: "owned" }, + inherit: { sandbox: false }, + }); + }); +}); diff --git a/packages/eve/src/compiler/subagent-capabilities.ts b/packages/eve/src/compiler/subagent-capabilities.ts new file mode 100644 index 000000000..9de40c208 --- /dev/null +++ b/packages/eve/src/compiler/subagent-capabilities.ts @@ -0,0 +1,65 @@ +/** + * Structural input for {@link projectSubagentCapabilities}. Matches the + * compiled subagent fields needed for inherit/effective projection without + * depending on the full compiled manifest type graph. + */ +export interface ProjectableSubagentCapabilities { + readonly agent: { + readonly config: { + readonly inherit?: { + readonly connections?: boolean; + readonly sandbox?: boolean; + }; + }; + readonly connections: readonly unknown[]; + readonly sandbox: unknown | null; + readonly sandboxWorkspaces: readonly unknown[]; + }; +} + +/** + * Projected inherit flags and effective capability summary for one compiled + * subagent. Shared by `eve info` and agent-info HTTP surfaces. + */ +export interface ProjectedSubagentCapabilities { + readonly effective: { + readonly connections: { + readonly inherited: boolean; + readonly owned: number; + }; + readonly sandbox: "default" | "inherited" | "owned"; + }; + readonly inherit: { + readonly connections: boolean; + readonly sandbox: boolean; + }; +} + +/** + * Derives inherit flags and effective connection/sandbox capability labels + * for a compiled subagent node. + */ +export function projectSubagentCapabilities( + subagent: ProjectableSubagentCapabilities, +): ProjectedSubagentCapabilities { + const inheritsConnections = subagent.agent.config.inherit?.connections === true; + const inheritsSandbox = subagent.agent.config.inherit?.sandbox === true; + + return { + effective: { + connections: { + inherited: inheritsConnections, + owned: subagent.agent.connections.length, + }, + sandbox: inheritsSandbox + ? "inherited" + : subagent.agent.sandbox === null && subagent.agent.sandboxWorkspaces.length === 0 + ? "default" + : "owned", + }, + inherit: { + connections: inheritsConnections, + sandbox: inheritsSandbox, + }, + }; +} diff --git a/packages/eve/src/context/available-skills.ts b/packages/eve/src/context/available-skills.ts new file mode 100644 index 000000000..d1531f8b0 --- /dev/null +++ b/packages/eve/src/context/available-skills.ts @@ -0,0 +1,19 @@ +import { DynamicSkillManifestKey, StaticSkillNamesKey } from "#context/keys.js"; +import type { ContextReader } from "#context/provider.js"; + +/** + * Returns the skill names visible to the active agent. + * + * Static names are seeded from the active runtime node at run bootstrap. + * Dynamic names come from the durable dynamic skill manifest. The sandbox may + * contain additional files when a child inherits a parent sandbox, so callers + * must use this list as the authority for skill access. + */ +export function getAvailableSkillNames(ctx: ContextReader): string[] { + const staticNames = ctx.get(StaticSkillNamesKey) ?? []; + const dynamicNames = Object.values(ctx.get(DynamicSkillManifestKey) ?? {}) + .flat() + .map((skill) => skill.name); + + return [...new Set([...staticNames, ...dynamicNames])].sort(); +} diff --git a/packages/eve/src/context/build-callback-context.test.ts b/packages/eve/src/context/build-callback-context.test.ts new file mode 100644 index 000000000..d8c631b3a --- /dev/null +++ b/packages/eve/src/context/build-callback-context.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { buildCallbackContext } from "#context/build-callback-context.js"; +import { ContextContainer, contextStorage } from "#context/container.js"; +import { SandboxKey, SessionKey, StaticSkillNamesKey } from "#context/keys.js"; +import { mockSandbox } from "#internal/testing/mocks/mock-sandbox.js"; + +const HOME_PROBE_COMMAND = `printf '%s\\n' "$HOME"`; + +function createContext() { + const ctx = new ContextContainer(); + ctx.set(SessionKey, { + auth: { current: null, initiator: null }, + sessionId: "session-1", + turn: { id: "turn-1", sequence: 0 }, + }); + ctx.set( + SandboxKey, + mockSandbox({ + commands: { + [HOME_PROBE_COMMAND]: { exitCode: 0, stderr: "", stdout: "/home/agent\n" }, + }, + initialFiles: { + "/home/agent/.agents/skills/child-only/SKILL.md": "# Child\n", + "/home/agent/.agents/skills/parent-only/SKILL.md": "# Parent\n", + }, + }).access, + ); + ctx.set(StaticSkillNamesKey, ["parent-only"]); + return ctx; +} + +describe("buildCallbackContext", () => { + it("allows authored code to read skills visible to the active agent", async () => { + const ctx = createContext(); + + const handle = contextStorage.run(ctx, () => buildCallbackContext().getSkill("parent-only")); + + await expect(handle.file("SKILL.md").text()).resolves.toBe("# Parent\n"); + }); + + it("rejects authored skill handles hidden from the active agent", () => { + const ctx = createContext(); + + expect(() => + contextStorage.run(ctx, () => buildCallbackContext().getSkill("child-only")), + ).toThrow( + 'Skill "child-only" is not available to the active agent. Available skills: parent-only.', + ); + }); +}); diff --git a/packages/eve/src/context/build-callback-context.ts b/packages/eve/src/context/build-callback-context.ts index 74defda28..8244a3c5e 100644 --- a/packages/eve/src/context/build-callback-context.ts +++ b/packages/eve/src/context/build-callback-context.ts @@ -1,6 +1,7 @@ import type { SessionContext } from "#public/definitions/callback-context.js"; import type { SkillHandle } from "#execution/skills/types.js"; import type { SandboxSession } from "#shared/sandbox-session.js"; +import { getAvailableSkillNames } from "#context/available-skills.js"; import { createSandboxSkillHandle } from "#runtime/skills/sandbox-access.js"; import { loadContext } from "#context/container.js"; import { SandboxKey, SessionKey } from "#context/keys.js"; @@ -47,7 +48,10 @@ export function buildCallbackContext(): SessionContext { "Call ctx.getSkill() only from authored runtime functions such as tools, hooks, and channel events.", ); } - return createSandboxSkillHandle(access, identifier); + return createSandboxSkillHandle(access, identifier, { + availableSkillNames: getAvailableSkillNames(ctx), + enforceAvailableSkills: true, + }); }, }; } diff --git a/packages/eve/src/context/dynamic-skill-lifecycle.test.ts b/packages/eve/src/context/dynamic-skill-lifecycle.test.ts index 7c7b938e5..086e868d2 100644 --- a/packages/eve/src/context/dynamic-skill-lifecycle.test.ts +++ b/packages/eve/src/context/dynamic-skill-lifecycle.test.ts @@ -5,7 +5,14 @@ import { PendingSkillAnnouncementKey, dispatchDynamicSkillEvent, } from "#context/dynamic-skill-lifecycle.js"; -import { DynamicSkillManifestKey, SessionIdKey, SandboxKey } from "#context/keys.js"; +import { + DynamicSkillManifestKey, + InheritedSandboxKey, + SandboxKey, + SandboxOwnerDynamicSkillNamesKey, + SandboxOwnerStaticSkillNamesKey, + SessionIdKey, +} from "#context/keys.js"; import { mockSandbox } from "#internal/testing/mocks/mock-sandbox.js"; import type { UnstampedMessageStreamEvent } from "#protocol/message.js"; import { defineSkill } from "#public/definitions/skill.js"; @@ -211,6 +218,96 @@ describe("dispatchDynamicSkillEvent", () => { ).toBe(true); }); + it("rejects dynamic skill writes that would overwrite inherited sandbox owner static skills", async () => { + const { ctx, sandbox } = createCtx(); + ctx.setVirtualContext(InheritedSandboxKey, true); + ctx.setVirtualContext(SandboxOwnerStaticSkillNamesKey, ["parent-only"]); + const resolver = createResolver("custom", () => ({ + "parent-only": makeSkill("Dynamic parent collision", "Child content."), + })); + + await expect( + dispatchDynamicSkillEvent({ + ctx, + event: makeEvent(), + messages: [], + resolvers: [resolver], + }), + ).rejects.toThrow(/cannot be written in an inherited sandbox/u); + + expect(sandbox.writes).toEqual([]); + expect(ctx.get(DynamicSkillManifestKey)).toBeUndefined(); + }); + + it("rejects dynamic skill removals that would delete inherited sandbox owner static skills", async () => { + const { ctx, sandbox } = createCtx(); + ctx.setVirtualContext(InheritedSandboxKey, true); + ctx.setVirtualContext(SandboxOwnerStaticSkillNamesKey, ["parent-only"]); + ctx.set(DynamicSkillManifestKey, { + custom: [{ description: "Dynamic parent collision", name: "parent-only" }], + }); + const resolver = createResolver("custom", () => null); + + await expect( + dispatchDynamicSkillEvent({ + ctx, + event: makeEvent(), + messages: [], + resolvers: [resolver], + }), + ).rejects.toThrow(/cannot be removed from an inherited sandbox/u); + + expect(sandbox.removedPaths).toEqual([]); + expect(ctx.get(DynamicSkillManifestKey)).toEqual({ + custom: [{ description: "Dynamic parent collision", name: "parent-only" }], + }); + }); + + it("rejects dynamic skill writes that would overwrite inherited sandbox owner dynamic skills", async () => { + const { ctx, sandbox } = createCtx(); + ctx.setVirtualContext(InheritedSandboxKey, true); + ctx.setVirtualContext(SandboxOwnerDynamicSkillNamesKey, ["parent-dynamic"]); + const resolver = createResolver("custom", () => ({ + "parent-dynamic": makeSkill("Dynamic parent collision", "Child content."), + })); + + await expect( + dispatchDynamicSkillEvent({ + ctx, + event: makeEvent(), + messages: [], + resolvers: [resolver], + }), + ).rejects.toThrow(/cannot be written in an inherited sandbox/u); + + expect(sandbox.writes).toEqual([]); + expect(ctx.get(DynamicSkillManifestKey)).toBeUndefined(); + }); + + it("rejects dynamic skill removals that would delete inherited sandbox owner dynamic skills", async () => { + const { ctx, sandbox } = createCtx(); + ctx.setVirtualContext(InheritedSandboxKey, true); + ctx.setVirtualContext(SandboxOwnerDynamicSkillNamesKey, ["parent-dynamic"]); + ctx.set(DynamicSkillManifestKey, { + custom: [{ description: "Dynamic parent collision", name: "parent-dynamic" }], + }); + const resolver = createResolver("custom", () => null); + + await expect( + dispatchDynamicSkillEvent({ + ctx, + event: makeEvent(), + messages: [], + resolvers: [resolver], + }), + ).rejects.toThrow(/cannot be removed from an inherited sandbox/u); + + expect(sandbox.removedPaths).toEqual([]); + expect(ctx.get(DynamicSkillManifestKey)).toEqual({ + custom: [{ description: "Dynamic parent collision", name: "parent-dynamic" }], + }); + }); + it("collapses a directly-returned single defineSkill to the bare slug", async () => { const { ctx, sandbox } = createCtx(); const resolver = createResolver("tenant", () => makeSkill("Tenant policy")); diff --git a/packages/eve/src/context/dynamic-skill-lifecycle.ts b/packages/eve/src/context/dynamic-skill-lifecycle.ts index 1861bb100..1df570550 100644 --- a/packages/eve/src/context/dynamic-skill-lifecycle.ts +++ b/packages/eve/src/context/dynamic-skill-lifecycle.ts @@ -20,7 +20,10 @@ import type { ContextContainer } from "#context/container.js"; import { type DurableDynamicSkillMetadata, DynamicSkillManifestKey, + InheritedSandboxKey, SandboxKey, + SandboxOwnerDynamicSkillNamesKey, + SandboxOwnerStaticSkillNamesKey, } from "#context/keys.js"; import { buildResolveContext } from "#context/dynamic-resolve-context.js"; import { resolveSandboxSkillRoot } from "#shared/skill-paths.js"; @@ -224,6 +227,12 @@ export async function dispatchDynamicSkillEvent(input: { } } + assertInheritedSandboxSkillSafe({ + ctx, + removedSkillNames, + updates, + }); + for (const name of removedSkillNames) { await removeSkillPackageFromSandbox({ name, sandbox }); } @@ -241,3 +250,33 @@ export async function dispatchDynamicSkillEvent(input: { await formatDynamicSkillAnnouncement({ ctx, manifest: newManifest }), ); } + +function assertInheritedSandboxSkillSafe(input: { + readonly ctx: ContextContainer; + readonly removedSkillNames: ReadonlySet; + readonly updates: readonly DynamicSkillUpdate[]; +}): void { + if (input.ctx.get(InheritedSandboxKey) !== true) return; + + const protectedSkillNames = new Set([ + ...(input.ctx.get(SandboxOwnerStaticSkillNamesKey) ?? []), + ...(input.ctx.get(SandboxOwnerDynamicSkillNamesKey) ?? []), + ]); + if (protectedSkillNames.size === 0) return; + + for (const { skills } of input.updates) { + for (const skill of skills) { + if (!protectedSkillNames.has(skill.name)) continue; + throw new Error( + `Dynamic skill "${skill.name}" cannot be written in an inherited sandbox because it collides with a skill already materialized by the sandbox session. Namespace the dynamic skill name before materializing it.`, + ); + } + } + + for (const name of input.removedSkillNames) { + if (!protectedSkillNames.has(name)) continue; + throw new Error( + `Dynamic skill "${name}" cannot be removed from an inherited sandbox because it collides with a skill already materialized by the sandbox session. Namespace the dynamic skill name before materializing it.`, + ); + } +} diff --git a/packages/eve/src/context/keys.ts b/packages/eve/src/context/keys.ts index 8723a2018..1132c7578 100644 --- a/packages/eve/src/context/keys.ts +++ b/packages/eve/src/context/keys.ts @@ -94,6 +94,34 @@ export const SessionCallbackKey = new ContextKey("eve.sessionCa export const SessionKey = new ContextKey("eve.session"); export const SandboxKey = new ContextKey("eve.sandbox"); +/** + * True when the active session is writing to a sandbox owned by an ancestor or + * different runtime node. Used by framework internals to avoid corrupting the + * owner's filesystem-level resources while preserving ordinary owned-sandbox + * behavior. + */ +export const InheritedSandboxKey = new ContextKey("eve.inheritedSandbox"); + +/** + * Static skill names seeded into the sandbox being used. + * + * This may differ from the active agent's {@link StaticSkillNamesKey} when a + * subagent inherits its parent's sandbox because the inherited sandbox can + * merge static skill files from multiple descendants. + */ +export const SandboxOwnerStaticSkillNamesKey = new ContextKey( + "eve.sandboxOwnerStaticSkillNames", +); + +/** + * Dynamic skill names already materialized in an inherited sandbox before the + * active session started using it. The active session may manage its own + * dynamic skills, but must not overwrite or remove inherited ones. + */ +export const SandboxOwnerDynamicSkillNamesKey = new ContextKey( + "eve.sandboxOwnerDynamicSkillNames", +); + // --------------------------------------------------------------------------- // Dynamic model keys // --------------------------------------------------------------------------- @@ -192,6 +220,15 @@ export const DynamicSubagentAgentConfigKey = new ContextKey("eve.staticSkillNames"); + /** * Durable metadata for one session-scoped dynamic skill. */ diff --git a/packages/eve/src/context/providers/sandbox.test.ts b/packages/eve/src/context/providers/sandbox.test.ts index 79c6bbd7e..e3baad722 100644 --- a/packages/eve/src/context/providers/sandbox.test.ts +++ b/packages/eve/src/context/providers/sandbox.test.ts @@ -3,14 +3,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ensureSandboxAccess } from "#execution/sandbox/ensure.js"; import type { HarnessSession } from "#harness/types.js"; import { createBundledRuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js"; +import type { ResolvedRuntimeAgentNode } from "#runtime/graph.js"; import type { RuntimeSandboxRegistry } from "#runtime/sandbox/registry.js"; -import { SessionIdKey } from "#context/keys.js"; +import { + InheritedSandboxKey, + SandboxKey, + SandboxOwnerDynamicSkillNamesKey, + SandboxOwnerStaticSkillNamesKey, + SessionIdKey, + SessionKey, + StaticSkillNamesKey, +} from "#context/keys.js"; import { BundleKey, ChannelKey, type CompiledBundle, } from "#runtime/sessions/runtime-context-keys.js"; -import { ContextContainer } from "#context/container.js"; +import { ContextContainer, loadContext } from "#context/container.js"; import { sandboxProvider } from "#context/providers/sandbox.js"; import { createStubSandboxRegistry } from "#internal/testing/stub-sandbox-registry.js"; @@ -37,26 +46,159 @@ function createHarnessSession(): HarnessSession { function createBundle(input: { readonly agentName: string; + readonly extraNodes?: readonly { + readonly agentName: string; + readonly nodeId: string; + readonly registry: RuntimeSandboxRegistry; + readonly skillNames?: readonly string[]; + readonly workspaceResourceRoot?: ResolvedRuntimeAgentNode["agent"]["workspaceResourceRoot"]; + }[]; + readonly parent?: { + readonly agentName: string; + readonly nodeId: string; + readonly registry: RuntimeSandboxRegistry; + readonly skillNames?: readonly string[]; + readonly workspaceResourceRoot?: ResolvedRuntimeAgentNode["agent"]["workspaceResourceRoot"]; + }; readonly registry: RuntimeSandboxRegistry; + readonly skillNames?: readonly string[]; + readonly workspaceResourceRoot?: ResolvedRuntimeAgentNode["agent"]["workspaceResourceRoot"]; }): CompiledBundle { + const root = createRuntimeNode({ + agentName: input.agentName, + nodeId: "__root__", + registry: input.registry, + skillNames: input.skillNames, + workspaceResourceRoot: input.workspaceResourceRoot, + }); + const nodesByNodeId = new Map([[root.nodeId, root]]); + if (input.parent !== undefined) { + nodesByNodeId.set( + input.parent.nodeId, + createRuntimeNode({ + agentName: input.parent.agentName, + nodeId: input.parent.nodeId, + registry: input.parent.registry, + skillNames: input.parent.skillNames, + workspaceResourceRoot: input.parent.workspaceResourceRoot, + }), + ); + } + for (const node of input.extraNodes ?? []) { + nodesByNodeId.set( + node.nodeId, + createRuntimeNode({ + agentName: node.agentName, + nodeId: node.nodeId, + registry: node.registry, + skillNames: node.skillNames, + workspaceResourceRoot: node.workspaceResourceRoot, + }), + ); + } + const adapterRegistry = { adaptersByKind: new Map() }; + const hookRegistry = { streamEventsByType: new Map(), streamEventsWildcard: [] }; + const subagentRegistry = { + dynamicNodeIds: new Set(), + dynamicResolvers: [], + preparedTools: [], + subagentsByName: new Map(), + subagentsByNodeId: new Map(), + }; + const toolRegistry = { preparedTools: [], toolsByName: new Map() }; return { + adapterRegistry, compiledArtifactsSource: createBundledRuntimeCompiledArtifactsSource(), graph: { - root: { - agent: { - config: { - name: input.agentName, - }, - }, - nodeId: "__root__", - sandboxRegistry: input.registry, - }, + nodesByNodeId, + root, + }, + hookRegistry, + moduleMap: { nodes: {} }, + resolvedAgent: root.agent, + subagentRegistry, + toolRegistry, + turnAgent: root.turnAgent, + }; +} + +function createRuntimeNode(input: { + readonly agentName: string; + readonly nodeId: string; + readonly registry: RuntimeSandboxRegistry; + readonly skillNames?: readonly string[]; + readonly workspaceResourceRoot?: ResolvedRuntimeAgentNode["agent"]["workspaceResourceRoot"]; +}): ResolvedRuntimeAgentNode { + const model = { id: "openai/gpt-5.4" }; + const agent = { + channels: [], + config: { + model, + name: input.agentName, + }, + connections: [], + disabledFrameworkChannels: [], + disabledFrameworkTools: [], + dynamicInstructionsResolvers: [], + dynamicSkillResolvers: [], + dynamicToolResolvers: [], + hooks: [], + metadata: { + agentRoot: "/app/agent", + appRoot: "/app", + diagnosticsSummary: { errors: 0, warnings: 0 }, + }, + sandbox: null, + skills: (input.skillNames ?? []).map((name) => ({ name })) as never, + tools: [], + workflowEnabled: false, + workspaceResourceRoot: + input.workspaceResourceRoot ?? + input.registry.sandbox.workspaceResourceRoots[0] ?? + input.registry.sandbox.workspaceResourceRoot, + workspaceSpec: { rootEntries: [] }, + }; + return { + agent, + channels: [], + hookRegistry: { streamEventsByType: new Map(), streamEventsWildcard: [] }, + nodeId: input.nodeId, + sandboxRegistry: input.registry, + subagentRegistry: { + dynamicNodeIds: new Set(), + dynamicResolvers: [], + preparedTools: [], + subagentsByName: new Map(), + subagentsByNodeId: new Map(), + }, + toolRegistry: { preparedTools: [], toolsByName: new Map() }, + turnAgent: { + id: input.agentName, + instructions: [], + model, + nodeId: input.nodeId, + tools: [], + workspaceSpec: { rootEntries: [] }, + }, + }; +} + +function createRegistryWithRoots( + roots: readonly ResolvedRuntimeAgentNode["agent"]["workspaceResourceRoot"][], +): RuntimeSandboxRegistry { + const base = createStubSandboxRegistry(); + return { + sandbox: { + ...base.sandbox, + workspaceResourceRoot: roots[0] ?? base.sandbox.workspaceResourceRoot, + workspaceResourceRoots: roots, }, - } as CompiledBundle; + }; } describe("sandboxProvider", () => { beforeEach(() => { + vi.mocked(ensureSandboxAccess).mockReset(); vi.mocked(ensureSandboxAccess).mockResolvedValue({ captureState: vi.fn().mockResolvedValue({ initialized: false, session: null }), get: vi.fn().mockResolvedValue(null), @@ -83,4 +225,256 @@ describe("sandboxProvider", () => { }), ); }); + + it("uses an inherited sandbox node and parent session from subagent adapter state", async () => { + const ctx = new ContextContainer(); + const childRegistry: RuntimeSandboxRegistry = createStubSandboxRegistry(); + const parentRegistry: RuntimeSandboxRegistry = createStubSandboxRegistry(); + + ctx.set( + BundleKey, + createBundle({ + agentName: "reviewer", + parent: { + agentName: "researcher", + nodeId: "subagents/researcher", + registry: parentRegistry, + }, + registry: childRegistry, + }), + ); + ctx.set(ChannelKey, { + kind: "subagent", + state: { + sandboxOwnerDynamicSkillNames: ["parent-dynamic"], + sandboxNodeId: "subagents/researcher", + sandboxSessionId: "parent-session", + }, + }); + ctx.set(SessionIdKey, "child-session"); + + await sandboxProvider.create(ctx, createHarnessSession()); + + expect(ensureSandboxAccess).toHaveBeenCalledWith( + expect.objectContaining({ + nodeId: "subagents/researcher", + registry: parentRegistry, + sessionId: "parent-session", + tags: { + agent: "researcher", + channel: "subagent", + sessionId: "child-session", + }, + }), + ); + }); + + it("throws when an inherited sandbox node id is missing from the runtime graph", async () => { + const ctx = new ContextContainer(); + const childRegistry: RuntimeSandboxRegistry = createStubSandboxRegistry(); + + ctx.set( + BundleKey, + createBundle({ + agentName: "reviewer", + registry: childRegistry, + }), + ); + ctx.set(ChannelKey, { + kind: "subagent", + state: { + sandboxNodeId: "subagents/missing-parent", + sandboxSessionId: "parent-session", + }, + }); + ctx.set(SessionIdKey, "child-session"); + + await expect(sandboxProvider.create(ctx, createHarnessSession())).rejects.toThrow( + 'Inherited sandbox node "subagents/missing-parent" is not present in the runtime agent graph.', + ); + expect(ensureSandboxAccess).not.toHaveBeenCalled(); + }); + + it("falls back to the active node when sandboxNodeId is absent or empty", async () => { + const ctx = new ContextContainer(); + const registry: RuntimeSandboxRegistry = createStubSandboxRegistry(); + + ctx.set(BundleKey, createBundle({ agentName: "weather-agent", registry })); + ctx.set(ChannelKey, { + kind: "subagent", + state: { + sandboxNodeId: "", + sandboxSessionId: "parent-session", + }, + }); + ctx.set(SessionIdKey, "child-session"); + + await sandboxProvider.create(ctx, createHarnessSession()); + + expect(ensureSandboxAccess).toHaveBeenCalledWith( + expect.objectContaining({ + nodeId: "__root__", + registry, + sessionId: "parent-session", + }), + ); + }); + + it("runs inherited sandbox session hooks inside the sandbox owner context", async () => { + const ctx = new ContextContainer(); + const childRoot = { + logicalPath: "workspace-resources/subagents/reviewer", + rootEntries: [], + }; + const parentRoot = { + logicalPath: "workspace-resources/subagents/researcher", + rootEntries: [], + }; + const childRegistry: RuntimeSandboxRegistry = createRegistryWithRoots([childRoot]); + const parentRegistry: RuntimeSandboxRegistry = createRegistryWithRoots([parentRoot]); + const liveSandbox = { id: "parent-sandbox" }; + + ctx.set( + BundleKey, + createBundle({ + agentName: "reviewer", + parent: { + agentName: "researcher", + nodeId: "subagents/researcher", + registry: parentRegistry, + skillNames: ["parent-skill"], + workspaceResourceRoot: parentRoot, + }, + registry: childRegistry, + skillNames: ["child-skill"], + workspaceResourceRoot: childRoot, + }), + ); + ctx.set(ChannelKey, { + kind: "subagent", + state: { + sandboxOwnerDynamicSkillNames: ["parent-dynamic"], + sandboxNodeId: "subagents/researcher", + sandboxSessionId: "parent-session", + }, + }); + ctx.set(SessionIdKey, "child-session"); + ctx.setVirtualContext(SessionKey, { + auth: { current: null, initiator: null }, + parent: { + callId: "call-1", + rootSessionId: "parent-session", + sessionId: "parent-session", + turn: { id: "parent-turn", sequence: 4 }, + }, + sessionId: "child-session", + turn: { id: "child-turn", sequence: 1 }, + }); + + await sandboxProvider.create(ctx, createHarnessSession()); + + expect(ctx.require(InheritedSandboxKey)).toBe(true); + expect(ctx.require(SandboxOwnerDynamicSkillNamesKey)).toEqual(["parent-dynamic"]); + expect(ctx.require(SandboxOwnerStaticSkillNamesKey)).toEqual(["parent-skill"]); + + const runOnSession = vi.mocked(ensureSandboxAccess).mock.calls.at(-1)?.[0].runOnSession; + if (runOnSession === undefined) throw new Error("runOnSession was not passed"); + + let observed: + | { + nodeId: string; + parent: unknown; + sandbox: unknown; + sessionId: string; + sessionSeed: string; + skillNames: readonly string[]; + turn: { id: string; sequence: number }; + } + | undefined; + + await runOnSession( + async () => { + const active = loadContext(); + const session = active.require(SessionKey); + observed = { + nodeId: active.require(BundleKey).graph.root.nodeId, + parent: session.parent, + sandbox: await active.require(SandboxKey).get(), + sessionId: session.sessionId, + sessionSeed: active.require(SessionIdKey), + skillNames: active.require(StaticSkillNamesKey), + turn: session.turn, + }; + }, + { + captureState: async () => ({ backendName: "test", metadata: {}, sessionKey: "parent" }), + session: liveSandbox, + shutdown: async () => {}, + useSessionFn: async () => liveSandbox, + } as never, + ); + + expect(observed).toEqual({ + nodeId: "subagents/researcher", + parent: undefined, + sandbox: liveSandbox, + sessionId: "parent-session", + sessionSeed: "parent-session", + skillNames: ["parent-skill"], + turn: { id: "parent-turn", sequence: 4 }, + }); + }); + + it("tracks every static skill seeded into an inherited sandbox", async () => { + const ctx = new ContextContainer(); + const parentRoot = { + logicalPath: "workspace-resources/subagents/researcher", + rootEntries: [], + }; + const auditorRoot = { + logicalPath: "workspace-resources/subagents/researcher::subagents/auditor", + rootEntries: [], + }; + const childRegistry: RuntimeSandboxRegistry = createStubSandboxRegistry(); + const parentRegistry: RuntimeSandboxRegistry = createRegistryWithRoots([ + parentRoot, + auditorRoot, + ]); + + ctx.set( + BundleKey, + createBundle({ + agentName: "reviewer", + extraNodes: [ + { + agentName: "auditor", + nodeId: "subagents/researcher/subagents/auditor", + registry: createStubSandboxRegistry(), + skillNames: ["auditor-skill"], + workspaceResourceRoot: auditorRoot, + }, + ], + parent: { + agentName: "researcher", + nodeId: "subagents/researcher", + registry: parentRegistry, + skillNames: ["parent-skill"], + workspaceResourceRoot: parentRoot, + }, + registry: childRegistry, + }), + ); + ctx.set(ChannelKey, { + kind: "subagent", + state: { + sandboxNodeId: "subagents/researcher", + sandboxSessionId: "parent-session", + }, + }); + ctx.set(SessionIdKey, "child-session"); + + await sandboxProvider.create(ctx, createHarnessSession()); + + expect(ctx.require(SandboxOwnerStaticSkillNamesKey)).toEqual(["auditor-skill", "parent-skill"]); + }); }); diff --git a/packages/eve/src/context/providers/sandbox.ts b/packages/eve/src/context/providers/sandbox.ts index e1167250b..eb56a48ce 100644 --- a/packages/eve/src/context/providers/sandbox.ts +++ b/packages/eve/src/context/providers/sandbox.ts @@ -3,8 +3,17 @@ import type { HarnessSession } from "#harness/types.js"; import type { SandboxAccess, SandboxState } from "#sandbox/state.js"; import { type ChannelAdapter, getAdapterKind } from "#channel/adapter.js"; import type { ContextContainer } from "#context/container.js"; -import { contextStorage } from "#context/container.js"; -import { SandboxKey, SessionIdKey } from "#context/keys.js"; +import { ContextContainer as MutableContextContainer, contextStorage } from "#context/container.js"; +import { + InheritedSandboxKey, + SandboxKey, + SandboxOwnerDynamicSkillNamesKey, + SandboxOwnerStaticSkillNamesKey, + type Session, + SessionIdKey, + SessionKey, + StaticSkillNamesKey, +} from "#context/keys.js"; import { BundleKey, ChannelKey, @@ -12,6 +21,10 @@ import { } from "#runtime/sessions/runtime-context-keys.js"; import { getActiveRuntimeNode } from "#context/node.js"; import type { FrameworkContextProvider } from "#context/provider.js"; +import { ROOT_RUNTIME_AGENT_NODE_ID, type ResolvedRuntimeAgentNode } from "#runtime/graph.js"; +import type { SandboxBackendHandle } from "#public/definitions/sandbox-backend.js"; + +const INHERITED_SANDBOX_FALLBACK_TURN = Object.freeze({ id: "sandbox-session", sequence: 0 }); export const sandboxProvider: FrameworkContextProvider = { key: SandboxKey, @@ -20,23 +33,50 @@ export const sandboxProvider: FrameworkContextProvider = { const bundle = ctx.get(BundleKey); if (bundle === undefined) return undefined; const node = getActiveRuntimeNode(ctx); - const registry = node.sandboxRegistry; const sessionId = ctx.require(SessionIdKey); const channel = ctx.get(ChannelKey); const adapterState = channel?.state as Record | undefined; const sandboxSessionId = (adapterState?.sandboxSessionId as string | undefined) ?? sessionId; const parentSandboxState = adapterState?.parentSandboxState as SandboxState | undefined; + const sandboxNode = resolveSandboxRuntimeNode({ + bundle, + node, + sandboxNodeId: adapterState?.sandboxNodeId, + }); + const registry = sandboxNode.sandboxRegistry; + ctx.setVirtualContext( + SandboxOwnerStaticSkillNamesKey, + resolveSandboxStaticSkillNames({ bundle, sandboxNode }), + ); + ctx.setVirtualContext( + SandboxOwnerDynamicSkillNamesKey, + readSandboxOwnerDynamicSkillNames(adapterState?.sandboxOwnerDynamicSkillNames), + ); + ctx.setVirtualContext( + InheritedSandboxKey, + sandboxSessionId !== sessionId || sandboxNode.nodeId !== node.nodeId, + ); return { value: await ensureSandboxAccess({ compiledArtifactsSource: bundle.compiledArtifactsSource, - nodeId: node.nodeId, + nodeId: sandboxNode.nodeId, registry, - runOnSession: async (callback) => await contextStorage.run(ctx, callback), + runOnSession: async (callback, handle) => + await contextStorage.run( + createSandboxOwnerContext({ + bundle, + ctx, + handle, + node: sandboxNode, + sessionId: sandboxSessionId, + }), + callback, + ), sessionId: sandboxSessionId, state: session.sandboxState ?? parentSandboxState ?? null, tags: { - agent: resolveTagAgentName({ bundle, node }), + agent: resolveTagAgentName({ bundle, node: sandboxNode }), channel: resolveTagChannelKind(channel), sessionId, }, @@ -50,6 +90,146 @@ export const sandboxProvider: FrameworkContextProvider = { }, }; +function resolveSandboxRuntimeNode(input: { + readonly bundle: CompiledBundle; + readonly node: ReturnType; + readonly sandboxNodeId: unknown; +}): ReturnType { + // Absent / empty means the active node owns the sandbox (isolation path). + if (typeof input.sandboxNodeId !== "string" || input.sandboxNodeId === "") { + return input.node; + } + + const resolved = input.bundle.graph.nodesByNodeId.get(input.sandboxNodeId); + if (resolved === undefined) { + throw new Error( + `Inherited sandbox node "${input.sandboxNodeId}" is not present in the runtime agent graph.`, + ); + } + return resolved; +} + +function createSandboxOwnerContext(input: { + readonly bundle: CompiledBundle; + readonly ctx: ContextContainer; + readonly handle: SandboxBackendHandle; + readonly node: ResolvedRuntimeAgentNode; + readonly sessionId: string; +}): ContextContainer { + const ownerContext = cloneDurableContext(input.ctx); + const ownerBundle = createBundleForNode(input.bundle, input.node); + ownerContext.set(BundleKey, ownerBundle); + ownerContext.set(SessionIdKey, input.sessionId); + ownerContext.set( + StaticSkillNamesKey, + input.node.agent.skills.map((skill) => skill.name), + ); + + const activeSession = input.ctx.get(SessionKey); + if (activeSession !== undefined) { + ownerContext.setVirtualContext( + SessionKey, + createSandboxOwnerSession(activeSession, input.sessionId), + ); + } + + ownerContext.setVirtualContext(SandboxKey, { + captureState: async () => ({ + initialized: true, + session: await input.handle.captureState(), + }), + get: async () => input.handle.session, + }); + + return ownerContext; +} + +function resolveSandboxStaticSkillNames(input: { + readonly bundle: CompiledBundle; + readonly sandboxNode: ResolvedRuntimeAgentNode; +}): readonly string[] { + const sandbox = input.sandboxNode.sandboxRegistry.sandbox as + | ResolvedRuntimeAgentNode["sandboxRegistry"]["sandbox"] + | null; + if (sandbox === null) { + return input.sandboxNode.agent?.skills?.map((skill) => skill.name) ?? []; + } + + const mergedResourceRoots = new Set( + sandbox.workspaceResourceRoots.map((root) => root.logicalPath), + ); + const names = new Set(); + + for (const node of input.bundle.graph.nodesByNodeId.values()) { + const logicalPath = node.agent?.workspaceResourceRoot?.logicalPath; + if (logicalPath === undefined || !mergedResourceRoots.has(logicalPath)) { + continue; + } + + for (const skill of node.agent.skills ?? []) { + names.add(skill.name); + } + } + + return [...names].sort((left, right) => left.localeCompare(right)); +} + +function readSandboxOwnerDynamicSkillNames(value: unknown): readonly string[] { + if (!Array.isArray(value)) { + return []; + } + + const names = new Set(); + for (const name of value) { + if (typeof name === "string" && name.length > 0) { + names.add(name); + } + } + return [...names].sort((left, right) => left.localeCompare(right)); +} + +function createSandboxOwnerSession(activeSession: Session, ownerSessionId: string): Session { + if (activeSession.sessionId === ownerSessionId) { + return activeSession; + } + + return { + auth: activeSession.auth, + sessionId: ownerSessionId, + turn: + activeSession.parent?.sessionId === ownerSessionId + ? activeSession.parent.turn + : INHERITED_SANDBOX_FALLBACK_TURN, + }; +} + +function cloneDurableContext(ctx: ContextContainer): ContextContainer { + const clone = new MutableContextContainer(); + for (const [key, value] of ctx.entries()) { + clone.set(key, value); + } + return clone; +} + +function createBundleForNode( + bundle: CompiledBundle, + node: ResolvedRuntimeAgentNode, +): CompiledBundle { + return { + ...bundle, + graph: { + nodesByNodeId: bundle.graph.nodesByNodeId, + root: node, + }, + hookRegistry: node.hookRegistry, + nodeId: node.nodeId === ROOT_RUNTIME_AGENT_NODE_ID ? undefined : node.nodeId, + resolvedAgent: node.agent, + subagentRegistry: node.subagentRegistry, + toolRegistry: node.toolRegistry, + turnAgent: node.turnAgent, + }; +} + function resolveTagAgentName(input: { readonly bundle: CompiledBundle; readonly node: ReturnType; diff --git a/packages/eve/src/execution/delegated-parent-notification.test.ts b/packages/eve/src/execution/delegated-parent-notification.test.ts index 10583564f..c8ebd2659 100644 --- a/packages/eve/src/execution/delegated-parent-notification.test.ts +++ b/packages/eve/src/execution/delegated-parent-notification.test.ts @@ -4,11 +4,13 @@ import { ContextContainer } from "#context/container.js"; import { serializeContext } from "#context/serialize.js"; import { BundleKey, ChannelKey } from "#runtime/sessions/runtime-context-keys.js"; import { getCompiledRuntimeAgentBundle } from "#runtime/sessions/compiled-agent-cache.js"; +import { createDurableSessionState } from "#execution/durable-session-store.js"; import { notifyDelegatedParentStep } from "#execution/delegated-parent-notification.js"; import { SUBAGENT_ADAPTER } from "#execution/subagent-adapter.js"; import { SUBAGENT_ADAPTER_KIND } from "#execution/subagent-adapter-state.js"; import { resumeHook } from "#internal/workflow/runtime.js"; import type { RuntimeSubagentResultActionResult } from "#runtime/actions/types.js"; +import type { HarnessSession } from "#harness/types.js"; vi.mock("../runtime/sessions/compiled-agent-cache.js", () => ({ getCompiledRuntimeAgentBundle: vi.fn(), @@ -31,7 +33,9 @@ function createSuccessResult(): RuntimeSubagentResultActionResult { }; } -function createSerializedContext(): Record { +function createSerializedContext( + adapterState: Readonly> = {}, +): Record { const bundle = { adapterRegistry: { adaptersByKind: new Map([[SUBAGENT_ADAPTER_KIND, SUBAGENT_ADAPTER]]), @@ -50,11 +54,29 @@ function createSerializedContext(): Record { parentContinuationToken: "parent-tok", parentSessionId: "parent-session", subagentName: "research", + ...adapterState, }, }); return serializeContext(ctx); } +function createSessionState(overrides: Partial = {}) { + return createDurableSessionState({ + session: { + agent: { + modelReference: { id: "test-model" }, + system: "", + tools: [], + }, + compaction: { recentWindowSize: 10, threshold: 100_000 }, + continuationToken: "child-token", + history: [], + sessionId: "child-session", + ...overrides, + }, + }); +} + describe("notifyDelegatedParentStep", () => { beforeEach(() => { resumeHookMock.mockReset(); @@ -114,4 +136,124 @@ describe("notifyDelegatedParentStep", () => { results: [errorResult], }); }); + + it("attaches inherited sandbox state captured by the child session", async () => { + const sandboxState = { + initialized: true, + session: { + backendName: "test-sandbox", + metadata: { workspace: "repo" }, + sessionKey: "sandbox-session-key", + }, + }; + + await notifyDelegatedParentStep({ + result: createSuccessResult(), + serializedContext: createSerializedContext({ + sandboxNodeId: "__root__", + sandboxSessionId: "parent-session", + }), + sessionState: createSessionState({ sandboxState }), + }); + + expect(resumeHookMock).toHaveBeenCalledWith("parent-tok", { + kind: "runtime-action-result", + results: [ + { + callId: "call-1", + inheritedSandbox: { + nodeId: "__root__", + sessionId: "parent-session", + state: sandboxState, + }, + kind: "subagent-result", + output: "done", + subagentName: "research", + }, + ], + }); + }); + + it("preserves inherited sandbox state when the child fails", async () => { + const sandboxState = { + initialized: true, + session: { + backendName: "test-sandbox", + metadata: { workspace: "repo" }, + sessionKey: "sandbox-session-key", + }, + }; + const errorResult: RuntimeSubagentResultActionResult = { + callId: "call-1", + isError: true, + kind: "subagent-result", + output: { code: "SUBAGENT_EXECUTION_FAILED", message: "boom" }, + subagentName: "research", + }; + + await notifyDelegatedParentStep({ + result: errorResult, + serializedContext: createSerializedContext({ + sandboxNodeId: "__root__", + sandboxSessionId: "parent-session", + }), + sessionState: createSessionState({ sandboxState }), + usage: USAGE, + }); + + expect(resumeHookMock).toHaveBeenCalledWith("parent-tok", { + kind: "runtime-action-result", + results: [ + { + ...errorResult, + inheritedSandbox: { + nodeId: "__root__", + sessionId: "parent-session", + state: sandboxState, + }, + }, + ], + }); + }); + + it("addresses nested shared-sandbox backfill to the immediate parent, not the owner", async () => { + // Nested chain: root owns the sandbox; leaf's adapter keeps + // sandboxSessionId=root while parentSessionId=mid. The result must + // target mid so applyInheritedSandboxResults on mid accepts it. + const sandboxState = { + initialized: true, + session: { + backendName: "test-sandbox", + metadata: { workspace: "repo" }, + sessionKey: "sandbox-session-key", + }, + }; + + await notifyDelegatedParentStep({ + result: createSuccessResult(), + serializedContext: createSerializedContext({ + parentSessionId: "mid-session", + sandboxNodeId: "__root__", + sandboxSessionId: "root-session", + }), + sessionState: createSessionState({ sandboxState }), + }); + + expect(resumeHookMock).toHaveBeenCalledWith("parent-tok", { + kind: "runtime-action-result", + results: [ + { + callId: "call-1", + inheritedSandbox: { + nodeId: "__root__", + sessionId: "mid-session", + state: sandboxState, + }, + kind: "subagent-result", + output: "done", + subagentName: "research", + }, + ], + }); + }); }); diff --git a/packages/eve/src/execution/delegated-parent-notification.ts b/packages/eve/src/execution/delegated-parent-notification.ts index 2e74c1655..ac11130bd 100644 --- a/packages/eve/src/execution/delegated-parent-notification.ts +++ b/packages/eve/src/execution/delegated-parent-notification.ts @@ -7,7 +7,11 @@ import { ChannelKey } from "#runtime/sessions/runtime-context-keys.js"; import { deserializeContext } from "#context/serialize.js"; -import type { RuntimeSubagentResultActionResult } from "#runtime/actions/types.js"; +import { + type RuntimeInheritedSandboxResult, + type RuntimeSubagentResultActionResult, +} from "#runtime/actions/types.js"; +import { type DurableSessionState, readDurableSession } from "#execution/durable-session-store.js"; import { SUBAGENT_ADAPTER_KIND } from "#execution/subagent-adapter-state.js"; import type { TokenUsage } from "#shared/token-usage.js"; import { resumeHook } from "#internal/workflow/runtime.js"; @@ -23,6 +27,7 @@ import { resumeHook } from "#internal/workflow/runtime.js"; export async function notifyDelegatedParentStep(input: { readonly result: RuntimeSubagentResultActionResult | undefined; readonly serializedContext: Record; + readonly sessionState?: DurableSessionState; readonly usage?: TokenUsage; }): Promise { "use step"; @@ -43,13 +48,87 @@ export async function notifyDelegatedParentStep(input: { return; } - const result = + const resultWithUsage = input.usage === undefined || input.result.isError === true ? input.result : { ...input.result, usage: input.usage }; + const result = await attachInheritedSandboxResult({ + adapterState: adapter.state, + result: resultWithUsage, + sessionState: input.sessionState, + }); await resumeHook(parentContinuationToken, { kind: "runtime-action-result", results: [result], }); } + +async function attachInheritedSandboxResult(input: { + readonly adapterState: Readonly> | undefined; + readonly result: RuntimeSubagentResultActionResult; + readonly sessionState: DurableSessionState | undefined; +}): Promise { + if (input.sessionState === undefined) { + return input.result; + } + + const inheritedSandbox = await readInheritedSandboxResult({ + adapterState: input.adapterState, + sessionState: input.sessionState, + }); + + if (inheritedSandbox === undefined) { + return input.result; + } + + return { ...input.result, inheritedSandbox }; +} + +async function readInheritedSandboxResult(input: { + readonly adapterState: Readonly> | undefined; + readonly sessionState: DurableSessionState; +}): Promise { + // Only children that share a sandbox carry sandboxSessionId. Isolation + // paths omit it and must not backfill parent sandbox state. + const sandboxSessionId = readNonEmptyString(input.adapterState?.sandboxSessionId); + if (sandboxSessionId === undefined) { + return undefined; + } + + // sessionId is the *intended recipient* (immediate parent), not the + // sandbox owner. Nested inherit.sandbox chains keep sandboxSessionId as + // the root owner while parentSessionId points at the waiting parent; + // applyInheritedSandboxResults matches this against the receiving + // harness session id so mid-chain parents accept the backfill. + const parentSessionId = readNonEmptyString(input.adapterState?.parentSessionId); + if (parentSessionId === undefined) { + return undefined; + } + + const session = await readDurableSession(input.sessionState); + + if (session.sandboxState === undefined) { + return undefined; + } + + const inheritedSandbox: { + nodeId?: string; + sessionId: string; + state: NonNullable; + } = { + sessionId: parentSessionId, + state: session.sandboxState, + }; + const sandboxNodeId = readNonEmptyString(input.adapterState?.sandboxNodeId); + + if (sandboxNodeId !== undefined) { + inheritedSandbox.nodeId = sandboxNodeId; + } + + return inheritedSandbox; +} + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value !== "" ? value : undefined; +} diff --git a/packages/eve/src/execution/dispatch-runtime-actions-step.ts b/packages/eve/src/execution/dispatch-runtime-actions-step.ts index 06493bccc..05e493658 100644 --- a/packages/eve/src/execution/dispatch-runtime-actions-step.ts +++ b/packages/eve/src/execution/dispatch-runtime-actions-step.ts @@ -13,6 +13,7 @@ import { AuthKey, CapabilitiesKey, ChannelInstrumentationKey, + DynamicSkillManifestKey, InitiatorAuthKey, } from "#context/keys.js"; import { BundleKey, ChannelKey } from "#runtime/sessions/runtime-context-keys.js"; @@ -21,6 +22,7 @@ import { getPendingRuntimeActionBatch, recordPendingSubagentChild, } from "#harness/runtime-actions.js"; +import type { HarnessSession } from "#harness/types.js"; import { createSubagentCalledEvent, encodeMessageStreamEvent, @@ -37,6 +39,7 @@ import { type DurableSessionState, readDurableSession, } from "#execution/durable-session-store.js"; +import { ROOT_RUNTIME_AGENT_NODE_ID } from "#runtime/graph.js"; import { resolveRemoteAgentForAction, startRemoteAgentSession, @@ -87,6 +90,9 @@ export async function dispatchRuntimeActionsStep(input: { const auth = ctx.get(AuthKey) ?? null; const capabilities = ctx.get(CapabilitiesKey); const channelMetadata = ctx.get(ChannelInstrumentationKey); + const currentDynamicSkillNames = resolveCurrentDynamicSkillNames( + ctx.get(DynamicSkillManifestKey), + ); const initiatorAuth = ctx.get(InitiatorAuthKey) ?? null; const writer = input.parentWritable.getWriter(); @@ -145,13 +151,63 @@ export async function dispatchRuntimeActionsStep(input: { switch (action.kind) { case "subagent-call": { const registered = bundle.subagentRegistry.subagentsByNodeId.get(action.nodeId); + const parentNodeId = bundle.nodeId ?? ROOT_RUNTIME_AGENT_NODE_ID; + const localDefinition = + registered?.definition.kind === "subagent" ? registered.definition : undefined; const description = - dynamicSubagentSelection?.agentConfig.description ?? - (registered?.definition.kind === "subagent" - ? registered.definition.description - : undefined); - const source: SubagentInputSource = - description !== undefined ? { description, type: "local" } : { type: "runtime" }; + dynamicSubagentSelection?.agentConfig.description ?? localDefinition?.description; + // Only attach sandbox sharing payload when the child will share: + // local declared inherit.sandbox, or built-in runtime self-agent. + const willShareSandbox = + localDefinition !== undefined + ? localDefinition.inherit?.sandbox === true + : action.subagentName === "agent"; + const effectiveSandbox = willShareSandbox + ? createEffectiveSandboxSource({ + adapterState: adapter.state, + parentNodeId, + protectedDynamicSkillNames: currentDynamicSkillNames, + session, + }) + : undefined; + let source: SubagentInputSource; + if (description !== undefined) { + const localSource: { + description: string; + effectiveSandbox?: NonNullable< + Extract["effectiveSandbox"] + >; + inherit?: NonNullable["inherit"]; + parentNodeId?: string; + type: "local"; + } = { + description, + type: "local", + }; + if (localDefinition?.inherit !== undefined) { + localSource.inherit = localDefinition.inherit; + } + if (effectiveSandbox !== undefined) { + localSource.effectiveSandbox = effectiveSandbox; + } + if (willShareSandbox) { + localSource.parentNodeId = parentNodeId; + } + source = localSource; + } else { + const runtimeSource: { + effectiveSandbox?: NonNullable< + Extract["effectiveSandbox"] + >; + type: "runtime"; + } = { + type: "runtime", + }; + if (effectiveSandbox !== undefined) { + runtimeSource.effectiveSandbox = effectiveSandbox; + } + source = runtimeSource; + } const childRuntime = createWorkflowRuntime({ compiledArtifactsSource: bundle.compiledArtifactsSource, dynamicSubagentAgentConfig: dynamicSubagentSelection?.agentConfig, @@ -274,6 +330,83 @@ export async function dispatchRuntimeActionsStep(input: { return { results, sessionState: nextState }; } +function createEffectiveSandboxSource(input: { + readonly adapterState?: Record; + readonly parentNodeId: string; + readonly protectedDynamicSkillNames?: readonly string[]; + readonly session: HarnessSession; +}): Extract["effectiveSandbox"] { + // Match resolveSandboxRuntimeNode / readInheritedSandboxResult: empty + // strings are absent, not meaningful identities. + const inheritedSandboxNodeId = readNonEmptyString(input.adapterState?.sandboxNodeId); + const inheritedSandboxSessionId = readNonEmptyString(input.adapterState?.sandboxSessionId); + const parentSandboxState = input.adapterState?.parentSandboxState as + | HarnessSession["sandboxState"] + | undefined; + const effectiveSandbox: { + parentSandboxState?: HarnessSession["sandboxState"]; + sandboxNodeId: string; + sandboxOwnerDynamicSkillNames?: readonly string[]; + sandboxSessionId: string; + } = { + sandboxNodeId: inheritedSandboxNodeId ?? input.parentNodeId, + sandboxSessionId: inheritedSandboxSessionId ?? input.session.sessionId, + }; + + if (parentSandboxState !== undefined) { + effectiveSandbox.parentSandboxState = parentSandboxState; + } + const protectedDynamicSkillNames = resolveProtectedDynamicSkillNames({ + adapterState: input.adapterState, + localDynamicSkillNames: input.protectedDynamicSkillNames ?? [], + }); + if (protectedDynamicSkillNames.length > 0) { + effectiveSandbox.sandboxOwnerDynamicSkillNames = protectedDynamicSkillNames; + } + + return effectiveSandbox; +} + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value !== "" ? value : undefined; +} + +function resolveCurrentDynamicSkillNames( + manifest: Record | undefined, +): readonly string[] { + if (manifest === undefined || manifest === null || typeof manifest !== "object") { + return []; + } + const names = new Set(); + for (const skills of Object.values( + manifest as Record, + )) { + if (!Array.isArray(skills)) continue; + for (const skill of skills) { + if (typeof skill.name === "string" && skill.name.length > 0) { + names.add(skill.name); + } + } + } + return [...names].sort((left, right) => left.localeCompare(right)); +} + +function resolveProtectedDynamicSkillNames(input: { + readonly adapterState?: Record; + readonly localDynamicSkillNames: readonly string[]; +}): readonly string[] { + const names = new Set(input.localDynamicSkillNames); + const inheritedNames = input.adapterState?.sandboxOwnerDynamicSkillNames; + if (Array.isArray(inheritedNames)) { + for (const name of inheritedNames) { + if (typeof name === "string" && name.length > 0) { + names.add(name); + } + } + } + return [...names].sort((left, right) => left.localeCompare(right)); +} + function createUnavailableDynamicSubagentResult( action: RuntimeSubagentCallActionRequest | RuntimeRemoteAgentCallActionRequest, ): RuntimeSubagentResultActionResult { diff --git a/packages/eve/src/execution/runtime-context.ts b/packages/eve/src/execution/runtime-context.ts index 13d4a8938..d7865185d 100644 --- a/packages/eve/src/execution/runtime-context.ts +++ b/packages/eve/src/execution/runtime-context.ts @@ -13,6 +13,7 @@ import { ParentSessionKey, ParentTraceContextKey, SessionCallbackKey, + StaticSkillNamesKey, SubagentDepthKey, } from "#context/keys.js"; import { BundleKey, type CompiledBundle } from "#runtime/sessions/runtime-context-keys.js"; @@ -31,6 +32,7 @@ export function buildRunContext(input: { const auth: SessionAuthContext | null = run.auth; ctx.set(BundleKey, bundle); + ctx.set(StaticSkillNamesKey, bundle.resolvedAgent?.skills?.map((skill) => skill.name) ?? []); setChannelContext(ctx, run.adapter, { channelName: run.channelName }); if (run.channelMetadata !== undefined) { diff --git a/packages/eve/src/execution/sandbox/ensure.test.ts b/packages/eve/src/execution/sandbox/ensure.test.ts index e5b0c8c94..41450114e 100644 --- a/packages/eve/src/execution/sandbox/ensure.test.ts +++ b/packages/eve/src/execution/sandbox/ensure.test.ts @@ -21,7 +21,10 @@ import { countActiveSandboxHandles, shutdownActiveSandboxHandles, } from "#execution/sandbox/active-handles.js"; -import { ensureSandboxAccess } from "#execution/sandbox/ensure.js"; +import { + clearSandboxSessionInitializationStateForTest, + ensureSandboxAccess, +} from "#execution/sandbox/ensure.js"; import type { SandboxState } from "#sandbox/state.js"; const mocks = vi.hoisted(() => ({ @@ -44,6 +47,7 @@ function createTestRegistry( definition: Partial, backend: SandboxBackend, ): RuntimeSandboxRegistry { + const workspaceResourceRoot = { logicalPath: "", rootEntries: [] }; const resolved: ResolvedSandboxDefinition = { backend, logicalPath: "agent/sandbox/sandbox.ts", @@ -56,7 +60,8 @@ function createTestRegistry( return { sandbox: { definition: resolved, - workspaceResourceRoot: { logicalPath: "", rootEntries: [] }, + workspaceResourceRoot, + workspaceResourceRoots: [workspaceResourceRoot], }, }; } @@ -117,6 +122,7 @@ function createSession(): Session { describe("ensureSandboxAccess", () => { beforeEach(() => { + clearSandboxSessionInitializationStateForTest(); mocks.prewarmAppSandboxes.mockReset(); mocks.prewarmAppSandboxes.mockResolvedValue(undefined); mocks.waitForSandboxTemplatePrewarmLock.mockReset(); @@ -314,6 +320,89 @@ describe("ensureSandboxAccess", () => { expect(vi.mocked(backend.create).mock.calls[0]?.[0].existingMetadata).toBeUndefined(); }); + it("serializes first creation and onSession for concurrent access to one session key", async () => { + const ctx = new ContextContainer(); + ctx.set(SessionKey, createSession()); + const runOnSession = async (callback: () => Promise) => + await contextStorage.run(ctx, callback); + const createEntered = createDeferred(); + const releaseCreate = createDeferred(); + const sandbox = mockSandbox({ id: "sbx_shared" }); + const create = vi.fn(async (input: SandboxBackendCreateInput) => { + createEntered.resolve(); + await releaseCreate.promise; + return { + captureState: async () => ({ + backendName: "test", + metadata: {}, + sessionKey: input.sessionKey, + }), + useSessionFn: async () => sandbox.session, + shutdown: async () => {}, + session: sandbox.session, + }; + }); + const backend: SandboxBackend = { create, name: "test", prewarm: vi.fn() }; + const onSession = vi.fn(); + const registry = createTestRegistry({ onSession }, backend); + + const firstAccess = await ensure({ registry, runOnSession }); + const secondAccess = await ensure({ registry, runOnSession }); + const firstGet = firstAccess.get(); + const secondGet = secondAccess.get(); + + await createEntered.promise; + await vi.waitFor(() => { + expect(create).toHaveBeenCalledTimes(1); + }); + releaseCreate.resolve(); + + const [firstSession, secondSession] = await Promise.all([firstGet, secondGet]); + + expect(firstSession).toBe(sandbox.session); + expect(secondSession).toBe(sandbox.session); + expect(create).toHaveBeenCalledTimes(1); + expect(onSession).toHaveBeenCalledTimes(1); + await expect(firstAccess.captureState()).resolves.toMatchObject({ initialized: true }); + await expect(secondAccess.captureState()).resolves.toMatchObject({ initialized: true }); + }); + + it("does not re-run onSession when a second open starts after the first completed", async () => { + const ctx = new ContextContainer(); + ctx.set(SessionKey, createSession()); + const runOnSession = async (callback: () => Promise) => + await contextStorage.run(ctx, callback); + const onSession = vi.fn(); + const backend = createBackend(); + const registry = createTestRegistry({ onSession }, backend); + + const first = await ensure({ registry, runOnSession }); + await first.get(); + const firstState = await first.captureState(); + expect(firstState).toMatchObject({ initialized: true }); + expect(onSession).toHaveBeenCalledTimes(1); + expect(backend.create).toHaveBeenCalledTimes(1); + const firstSessionKey = vi.mocked(backend.create).mock.calls[0]?.[0].sessionKey; + expect(typeof firstSessionKey).toBe("string"); + + // Simulate an inheriting child that starts after the first open finished + // but before durable parentSandboxState is available (state null / + // initialized false). Process-lifetime tracking must still skip onSession. + // The second open still creates a backend handle (open vs init split); + // it must reuse the same sessionKey without re-running onSession. + const second = await ensure({ + registry, + runOnSession, + state: { initialized: false, session: null }, + }); + await second.get(); + + expect(onSession).toHaveBeenCalledTimes(1); + expect(backend.create).toHaveBeenCalledTimes(2); + expect(vi.mocked(backend.create).mock.calls[1]?.[0].sessionKey).toBe(firstSessionKey); + await expect(second.captureState()).resolves.toMatchObject({ initialized: true }); + }); + it("does not pass bootstrap or seed files to runtime create", async () => { const bootstrap = vi.fn(); const backend = createBackend(); diff --git a/packages/eve/src/execution/sandbox/ensure.ts b/packages/eve/src/execution/sandbox/ensure.ts index 3659a74f6..5ab637c38 100644 --- a/packages/eve/src/execution/sandbox/ensure.ts +++ b/packages/eve/src/execution/sandbox/ensure.ts @@ -29,11 +29,41 @@ export interface EnsureSandboxAccessInput { readonly nodeId: string; readonly registry: RuntimeSandboxRegistry; readonly sessionId: string; - readonly runOnSession?: (callback: () => Promise) => Promise; + readonly runOnSession?: ( + callback: () => Promise, + handle: SandboxBackendHandle, + ) => Promise; readonly state: SandboxState | null; readonly tags?: SandboxBackendTags; } +const sandboxSessionInitializationLocks = new Map>(); + +/** + * Session keys whose `onSession` already completed successfully in this + * process. Kept for process lifetime (same as the in-flight lock map): + * locks are cleared when the init promise settles, so sequential + * inheriting children that start after the first finishes still need a + * durable "already initialized" signal when durable parent state has not + * been written back yet. + * + * Growth bound: one entry per distinct backend/appRoot/sessionKey triple + * seen by this process. Entries are never pruned (no cheap session-end + * hook today). Long-lived multi-tenant hosts that mint many unique sandbox + * session keys will accumulate set entries for the process lifetime; that + * is intentional for correctness over the shared-init race and matches + * other process-scoped maps in this module. + */ +const sandboxSessionInitializedKeys = new Set(); + +/** + * Clears process-lifetime sandbox init tracking. Test-only. + */ +export function clearSandboxSessionInitializationStateForTest(): void { + sandboxSessionInitializationLocks.clear(); + sandboxSessionInitializedKeys.clear(); +} + /** * Creates or reattaches the live sandbox from the compiled agent bundle's * registry and persisted session state, returning a {@link SandboxAccess} @@ -127,36 +157,83 @@ export async function ensureSandboxAccess(input: EnsureSandboxAccessInput): Prom templateKey: keys.templateKey, }; - const handle = await withDevelopmentSandboxProgress( - `eve: opening sandbox session "${formatNodeLabel(input.nodeId)}" on backend "${backend.name}"...`, - `eve: opening sandbox session "${formatNodeLabel(input.nodeId)}" on backend "${backend.name}"`, - async () => - await createBackendHandleWithPrewarmRetry({ - appRoot, - backend, - compiledArtifactsSource: input.compiledArtifactsSource, - createInput, - }), - ); - trackActiveSandboxHandle({ + async function openHandle(): Promise { + const handle = await withDevelopmentSandboxProgress( + `eve: opening sandbox session "${formatNodeLabel(input.nodeId)}" on backend "${backend.name}"...`, + `eve: opening sandbox session "${formatNodeLabel(input.nodeId)}" on backend "${backend.name}"`, + async () => + await createBackendHandleWithPrewarmRetry({ + appRoot, + backend, + compiledArtifactsSource: input.compiledArtifactsSource, + createInput, + }), + ); + trackActiveSandboxHandle({ + backendName: backend.name, + handle, + sessionKey: keys.sessionKey, + }); + return handle; + } + + async function openAndInitializeHandle(): Promise { + const handle = await openHandle(); + + if (!initialized) { + await runOnSession(async () => { + await definition.onSession?.({ + ctx: buildCallbackContext(), + use: handle.useSessionFn, + }); + }, handle); + initialized = true; + } + + return handle; + } + + const lockKey = createSandboxSessionInitializationLockKey({ + appRoot, backendName: backend.name, - handle, sessionKey: keys.sessionKey, }); - if (!initialized) { - await runOnSession(async () => { - await definition.onSession?.({ ctx: buildCallbackContext(), use: handle.useSessionFn }); - }); + // Durable session state, or a prior successful init in this process + // (including a sibling that finished before this open started). + if (initialized || sandboxSessionInitializedKeys.has(lockKey)) { initialized = true; + sandboxSessionInitializedKeys.add(lockKey); + return await openHandle(); } - return handle; + const existingLock = sandboxSessionInitializationLocks.get(lockKey); + if (existingLock !== undefined) { + const handle = await existingLock; + initialized = true; + sandboxSessionInitializedKeys.add(lockKey); + return handle; + } + + const lock = openAndInitializeHandle(); + sandboxSessionInitializationLocks.set(lockKey, lock); + try { + const handle = await lock; + sandboxSessionInitializedKeys.add(lockKey); + return handle; + } finally { + if (sandboxSessionInitializationLocks.get(lockKey) === lock) { + sandboxSessionInitializationLocks.delete(lockKey); + } + } } - async function runOnSession(callback: () => Promise): Promise { + async function runOnSession( + callback: () => Promise, + handle: SandboxBackendHandle, + ): Promise { if (input.runOnSession !== undefined) { - await input.runOnSession(callback); + await input.runOnSession(callback, handle); return; } await callback(); @@ -183,6 +260,14 @@ export async function ensureSandboxAccess(input: EnsureSandboxAccessInput): Prom }; } +function createSandboxSessionInitializationLockKey(input: { + readonly appRoot: string; + readonly backendName: string; + readonly sessionKey: string; +}): string { + return `${input.backendName}\0${input.appRoot}\0${input.sessionKey}`; +} + async function createBackendHandleWithPrewarmRetry(input: { readonly appRoot: string; readonly backend: SandboxBackend; diff --git a/packages/eve/src/execution/sandbox/prewarm.test.ts b/packages/eve/src/execution/sandbox/prewarm.test.ts index 182029f31..cf107f6f4 100644 --- a/packages/eve/src/execution/sandbox/prewarm.test.ts +++ b/packages/eve/src/execution/sandbox/prewarm.test.ts @@ -9,6 +9,7 @@ import type { import { createDiskRuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js"; import { ROOT_RUNTIME_AGENT_NODE_ID, type ResolvedAgentGraphBundle } from "#runtime/graph.js"; import type { ResolvedSandboxDefinition } from "#runtime/types.js"; +import { materializeWorkspaceDirectory } from "#runtime/workspace/seed-files.js"; vi.mock("#execution/sandbox/template-prewarm-lock.js", () => ({ withSandboxTemplatePrewarmLock: async (_input: unknown, callback: () => Promise) => @@ -20,6 +21,7 @@ vi.mock("#runtime/workspace/seed-files.js", () => ({ describe("prewarmAppSandboxes", () => { afterEach(() => { + vi.clearAllMocks(); vi.unstubAllEnvs(); }); @@ -81,6 +83,98 @@ describe("prewarmAppSandboxes", () => { expect(signatures).toHaveLength(1); }); + it("seeds every workspace resource root registered to one sandbox", async () => { + const appRoot = process.cwd(); + const inputs: SandboxBackendPrewarmInput[] = []; + const rootResource = { + contentHash: "root-resource-hash", + logicalPath: "workspace-resources/__root__", + rootEntries: ["root-notes.md"], + }; + const childResource = { + contentHash: "child-resource-hash", + logicalPath: "workspace-resources/subagents/researcher", + rootEntries: ["research-notes.md"], + }; + + vi.mocked(materializeWorkspaceDirectory).mockImplementation(async (path) => [ + { + content: Buffer.from(path), + path: path.includes("subagents/researcher") + ? "$HOME/.agents/skills/researcher/SKILL.md" + : "/workspace/root-notes.md", + }, + ]); + + await prewarmAppSandboxes({ + appRoot, + compiledArtifactsSource: createDiskRuntimeCompiledArtifactsSource(appRoot), + dispatch: recordPrewarmInputs(inputs), + loadAgentGraph: async () => + createGraph({ + workspaceResourceRoot: { + contentHash: "merged-resource-hash", + logicalPath: rootResource.logicalPath, + rootEntries: [...rootResource.rootEntries, ...childResource.rootEntries], + }, + workspaceResourceRoots: [rootResource, childResource], + }), + }); + + expect(materializeWorkspaceDirectory).toHaveBeenCalledWith( + `${appRoot}/.eve/compile/${rootResource.logicalPath}`, + ); + expect(materializeWorkspaceDirectory).toHaveBeenCalledWith( + `${appRoot}/.eve/compile/${childResource.logicalPath}`, + ); + expect(inputs[0]?.seedFiles.map((file) => file.path)).toEqual([ + "/workspace/root-notes.md", + "$HOME/.agents/skills/researcher/SKILL.md", + ]); + }); + + it("rejects duplicate seed paths across inherited workspace resource roots", async () => { + const appRoot = process.cwd(); + const inputs: SandboxBackendPrewarmInput[] = []; + const rootResource = { + contentHash: "root-resource-hash", + logicalPath: "workspace-resources/__root__", + rootEntries: ["shared-skill"], + }; + const childResource = { + contentHash: "child-resource-hash", + logicalPath: "workspace-resources/subagents/researcher", + rootEntries: ["shared-skill"], + }; + + vi.mocked(materializeWorkspaceDirectory).mockResolvedValue([ + { + content: Buffer.from("shared skill"), + path: "$HOME/.agents/skills/shared/SKILL.md", + }, + ]); + + await expect( + prewarmAppSandboxes({ + appRoot, + compiledArtifactsSource: createDiskRuntimeCompiledArtifactsSource(appRoot), + dispatch: recordPrewarmInputs(inputs), + loadAgentGraph: async () => + createGraph({ + workspaceResourceRoot: { + contentHash: "merged-resource-hash", + logicalPath: rootResource.logicalPath, + rootEntries: [...rootResource.rootEntries, ...childResource.rootEntries], + }, + workspaceResourceRoots: [rootResource, childResource], + }), + }), + ).rejects.toThrow( + 'Cannot merge inherited sandbox resources because "workspace-resources/__root__" and "workspace-resources/subagents/researcher" both seed "$HOME/.agents/skills/shared/SKILL.md".', + ); + expect(inputs).toHaveLength(0); + }); + it.each(["docker", "microsandbox"])( "explains that %s is unavailable during Vercel prewarm", async (backendName) => { @@ -142,6 +236,11 @@ function createGraph( readonly logicalPath: string; readonly rootEntries: readonly string[]; }; + readonly workspaceResourceRoots?: readonly { + readonly contentHash?: string; + readonly logicalPath: string; + readonly rootEntries: readonly string[]; + }[]; } = {}, ): ResolvedAgentGraphBundle { const backend: SandboxBackend = { @@ -171,6 +270,12 @@ function createGraph( logicalPath: "", rootEntries: [], }, + workspaceResourceRoots: input.workspaceResourceRoots ?? [ + input.workspaceResourceRoot ?? { + logicalPath: "", + rootEntries: [], + }, + ], }, }, }; diff --git a/packages/eve/src/execution/sandbox/prewarm.ts b/packages/eve/src/execution/sandbox/prewarm.ts index 0a08204d4..a1e7279be 100644 --- a/packages/eve/src/execution/sandbox/prewarm.ts +++ b/packages/eve/src/execution/sandbox/prewarm.ts @@ -248,38 +248,40 @@ async function collectPrewarmTargets(input: { const targets: PrewarmTarget[] = []; await Promise.all( - collectNodeSandboxes(input.graph).map(async ({ definition, nodeId, workspaceResourceRoot }) => { - const templatePlan = createRuntimeSandboxTemplatePlan({ - definition, - workspaceResourceRoot, - }); - const templateKey = await createRuntimeSandboxTemplateKey({ - backendName: definition.backend.name, - compiledArtifactsSource: input.compiledArtifactsSource, - nodeId, - sourceId: definition.sourceId, - templatePlan, - }); + collectNodeSandboxes(input.graph).map( + async ({ definition, nodeId, workspaceResourceRoot, workspaceResourceRoots }) => { + const templatePlan = createRuntimeSandboxTemplatePlan({ + definition, + workspaceResourceRoot, + }); + const templateKey = await createRuntimeSandboxTemplateKey({ + backendName: definition.backend.name, + compiledArtifactsSource: input.compiledArtifactsSource, + nodeId, + sourceId: definition.sourceId, + templatePlan, + }); - if (templateKey === null) { - return; - } + if (templateKey === null) { + return; + } - targets.push({ - backend: definition.backend, - label: formatLabel(nodeId), - input: { - bootstrap: definition.bootstrap, - seedFiles: await loadResourceRootSeedFiles({ - compileDirectoryPath: input.compileDirectoryPath, - workspaceResourceRoot, - }), - runtimeContext, - templateKey, - }, - signature: `${definition.backend.name}:${nodeId}:${templateKey}`, - }); - }), + targets.push({ + backend: definition.backend, + label: formatLabel(nodeId), + input: { + bootstrap: definition.bootstrap, + seedFiles: await loadResourceRootSeedFiles({ + compileDirectoryPath: input.compileDirectoryPath, + workspaceResourceRoots, + }), + runtimeContext, + templateKey, + }, + signature: `${definition.backend.name}:${nodeId}:${templateKey}`, + }); + }, + ), ); // Template keys factor in nodeId (see runtime/sandbox/keys.ts), so each @@ -297,21 +299,38 @@ async function collectPrewarmTargets(input: { */ async function loadResourceRootSeedFiles(input: { readonly compileDirectoryPath: string; - readonly workspaceResourceRoot: CompiledWorkspaceResourceRoot; + readonly workspaceResourceRoots: readonly CompiledWorkspaceResourceRoot[]; }): Promise { - if ( - input.workspaceResourceRoot.contentHash === undefined && - input.workspaceResourceRoot.rootEntries.length === 0 - ) { - return []; + const seedFiles: SandboxSeedFile[] = []; + const seedPathSources = new Map(); + + for (const workspaceResourceRoot of input.workspaceResourceRoots) { + if ( + workspaceResourceRoot.contentHash === undefined && + workspaceResourceRoot.rootEntries.length === 0 + ) { + continue; + } + const materialized = await materializeWorkspaceDirectory( + `${input.compileDirectoryPath}/${workspaceResourceRoot.logicalPath}`, + ); + for (const file of materialized) { + const existingSource = seedPathSources.get(file.path); + if (existingSource !== undefined) { + throw new Error( + `Cannot merge inherited sandbox resources because "${existingSource}" and "${workspaceResourceRoot.logicalPath}" both seed "${file.path}". Rename one static skill or avoid inheriting the parent sandbox for this subagent.`, + ); + } + + seedPathSources.set(file.path, workspaceResourceRoot.logicalPath); + seedFiles.push({ + content: file.content, + path: file.path, + }); + } } - const materialized = await materializeWorkspaceDirectory( - `${input.compileDirectoryPath}/${input.workspaceResourceRoot.logicalPath}`, - ); - return materialized.map((file) => ({ - content: file.content, - path: file.path, - })); + + return seedFiles; } async function loadGraphFromArtifacts(input: { diff --git a/packages/eve/src/execution/session-reset.integration.test.ts b/packages/eve/src/execution/session-reset.integration.test.ts index 2369f8687..fba1d3391 100644 --- a/packages/eve/src/execution/session-reset.integration.test.ts +++ b/packages/eve/src/execution/session-reset.integration.test.ts @@ -95,10 +95,12 @@ function createSessionSandboxHarness() { sourceId: "agent/sandbox/sandbox", sourceKind: "module", }; + const workspaceResourceRoot = { logicalPath: "", rootEntries: [] as const }; const registry: RuntimeSandboxRegistry = { sandbox: { definition, - workspaceResourceRoot: { logicalPath: "", rootEntries: [] }, + workspaceResourceRoot, + workspaceResourceRoots: [workspaceResourceRoot], }, }; diff --git a/packages/eve/src/execution/subagent-tool.test.ts b/packages/eve/src/execution/subagent-tool.test.ts index 2f059802d..261efe9f4 100644 --- a/packages/eve/src/execution/subagent-tool.test.ts +++ b/packages/eve/src/execution/subagent-tool.test.ts @@ -335,6 +335,70 @@ describe("buildSubagentRunInput", () => { }); }); + it("preserves inherited sandbox identity for nested self-delegation", () => { + const sandboxState = { initialized: true, session: null }; + const action: RuntimeSubagentCallActionRequest = { + ...makeAction(), + subagentName: "agent", + }; + const { runInput } = buildSubagentRunInput({ + action, + auth: null, + batchEvent: { sequence: 0, turnId: "turn-0" }, + initiatorAuth: null, + session: { + ...makeSession(), + sessionId: "researcher-session", + }, + source: { + effectiveSandbox: { + parentSandboxState: sandboxState, + sandboxNodeId: "__root__", + sandboxOwnerDynamicSkillNames: ["parent-dynamic"], + sandboxSessionId: "parent-session", + }, + type: "runtime", + }, + }); + + expect(runInput.adapter.state).toMatchObject({ + parentSandboxState: sandboxState, + sandboxNodeId: "__root__", + sandboxOwnerDynamicSkillNames: ["parent-dynamic"], + sandboxSessionId: "parent-session", + }); + }); + + it("shares inherited runtime sandbox identity before state has been captured", () => { + const action: RuntimeSubagentCallActionRequest = { + ...makeAction(), + subagentName: "agent", + }; + const { runInput } = buildSubagentRunInput({ + action, + auth: null, + batchEvent: { sequence: 0, turnId: "turn-0" }, + initiatorAuth: null, + session: { + ...makeSession(), + sessionId: "researcher-session", + }, + source: { + effectiveSandbox: { + sandboxNodeId: "__root__", + sandboxSessionId: "parent-session", + }, + type: "runtime", + }, + }); + + expect(runInput.adapter.state).toMatchObject({ + sandboxNodeId: "__root__", + sandboxSessionId: "parent-session", + }); + expect(runInput.adapter.state).not.toHaveProperty("parentSandboxState"); + }); + it("does not include sandbox sharing fields for normal subagents", () => { const sandboxState = { initialized: true, session: null }; const session = { ...makeSession(), sandboxState }; @@ -349,4 +413,182 @@ describe("buildSubagentRunInput", () => { expect(runInput.adapter.state).not.toHaveProperty("parentSandboxState"); expect(runInput.adapter.state).not.toHaveProperty("sandboxSessionId"); }); + + it("does not treat a declared agent subagent as self-delegation", () => { + const sandboxState = { initialized: true, session: null }; + const action: RuntimeSubagentCallActionRequest = { + ...makeAction(), + name: "agent", + nodeId: "subagents/agent", + subagentName: "agent", + }; + const { runInput } = buildSubagentRunInput({ + action, + auth: null, + batchEvent: { sequence: 0, turnId: "turn-0" }, + initiatorAuth: null, + session: { + ...makeSession(), + sessionId: "researcher-session", + }, + source: { + description: "Declared local agent subagent.", + effectiveSandbox: { + parentSandboxState: sandboxState, + sandboxNodeId: "__root__", + sandboxOwnerDynamicSkillNames: ["parent-dynamic"], + sandboxSessionId: "parent-session", + }, + type: "local", + }, + }); + + expect(runInput.adapter.state).not.toHaveProperty("parentSandboxState"); + expect(runInput.adapter.state).not.toHaveProperty("sandboxNodeId"); + expect(runInput.adapter.state).not.toHaveProperty("sandboxSessionId"); + }); + + it("includes sandbox sharing fields for local subagents that inherit the parent sandbox", () => { + const sandboxState = { initialized: true, session: null }; + const session = { ...makeSession(), sandboxState }; + const { runInput } = buildSubagentRunInput({ + action: makeAction(), + auth: null, + batchEvent: { sequence: 0, turnId: "turn-0" }, + initiatorAuth: null, + session, + source: { + description: "Use the parent's checkout.", + inherit: { sandbox: true }, + parentNodeId: "__root__", + type: "local", + }, + }); + + expect(runInput.adapter.state).toMatchObject({ + parentSandboxState: sandboxState, + sandboxNodeId: "__root__", + sandboxSessionId: "parent-session", + }); + }); + + it("allows a declared agent subagent to explicitly inherit the parent sandbox", () => { + const sandboxState = { initialized: true, session: null }; + const action: RuntimeSubagentCallActionRequest = { + ...makeAction(), + name: "agent", + nodeId: "subagents/agent", + subagentName: "agent", + }; + const { runInput } = buildSubagentRunInput({ + action, + auth: null, + batchEvent: { sequence: 0, turnId: "turn-0" }, + initiatorAuth: null, + session: { + ...makeSession(), + sessionId: "researcher-session", + }, + source: { + description: "Declared local agent subagent.", + effectiveSandbox: { + parentSandboxState: sandboxState, + sandboxNodeId: "__root__", + sandboxOwnerDynamicSkillNames: ["parent-dynamic"], + sandboxSessionId: "parent-session", + }, + inherit: { sandbox: true }, + parentNodeId: "__root__", + type: "local", + }, + }); + + expect(runInput.adapter.state).toMatchObject({ + parentSandboxState: sandboxState, + sandboxNodeId: "__root__", + sandboxSessionId: "parent-session", + }); + }); + + it("shares the parent sandbox session even before parent state has been captured", () => { + const { runInput } = buildSubagentRunInput({ + action: makeAction(), + auth: null, + batchEvent: { sequence: 0, turnId: "turn-0" }, + initiatorAuth: null, + session: makeSession(), + source: { + description: "Use the parent's checkout.", + inherit: { sandbox: true }, + parentNodeId: "subagents/researcher", + type: "local", + }, + }); + + expect(runInput.adapter.state).toMatchObject({ + sandboxNodeId: "subagents/researcher", + sandboxSessionId: "parent-session", + }); + expect(runInput.adapter.state).not.toHaveProperty("parentSandboxState"); + }); + + it("preserves the effective sandbox identity for nested inherited subagents", () => { + const sandboxState = { initialized: true, session: null }; + const { runInput } = buildSubagentRunInput({ + action: makeAction(), + auth: null, + batchEvent: { sequence: 0, turnId: "turn-0" }, + initiatorAuth: null, + session: { + ...makeSession(), + sessionId: "researcher-session", + }, + source: { + description: "Use the original parent's checkout.", + effectiveSandbox: { + parentSandboxState: sandboxState, + sandboxNodeId: "__root__", + sandboxOwnerDynamicSkillNames: ["parent-dynamic"], + sandboxSessionId: "parent-session", + }, + inherit: { sandbox: true }, + parentNodeId: "subagents/researcher", + type: "local", + }, + }); + + expect(runInput.adapter.state).toMatchObject({ + parentSandboxState: sandboxState, + sandboxNodeId: "__root__", + sandboxOwnerDynamicSkillNames: ["parent-dynamic"], + sandboxSessionId: "parent-session", + }); + }); + + it("passes protected dynamic skill names to local subagents that inherit a sandbox", () => { + const { runInput } = buildSubagentRunInput({ + action: makeAction(), + auth: null, + batchEvent: { sequence: 0, turnId: "turn-0" }, + initiatorAuth: null, + session: makeSession(), + source: { + description: "Use the parent's checkout.", + effectiveSandbox: { + sandboxNodeId: "__root__", + sandboxOwnerDynamicSkillNames: ["parent-dynamic"], + sandboxSessionId: "parent-session", + }, + inherit: { sandbox: true }, + parentNodeId: "__root__", + type: "local", + }, + }); + + expect(runInput.adapter.state).toMatchObject({ + sandboxNodeId: "__root__", + sandboxOwnerDynamicSkillNames: ["parent-dynamic"], + sandboxSessionId: "parent-session", + }); + }); }); diff --git a/packages/eve/src/execution/subagent-tool.ts b/packages/eve/src/execution/subagent-tool.ts index 64e49af32..3bc0e2106 100644 --- a/packages/eve/src/execution/subagent-tool.ts +++ b/packages/eve/src/execution/subagent-tool.ts @@ -12,6 +12,7 @@ import type { SessionTraceContext, } from "#channel/types.js"; import type { HarnessSession } from "#harness/types.js"; +import type { AgentInheritanceDefinition } from "#shared/agent-definition.js"; import type { RuntimeSubagentCallActionRequest } from "#runtime/actions/types.js"; import { mintSubagentContinuationToken } from "#execution/session.js"; import { resolveSubagentDepth } from "#harness/subagent-depth.js"; @@ -28,12 +29,23 @@ interface BatchEventMetadata { export type SubagentInputSource = | { readonly description: string; + readonly effectiveSandbox?: EffectiveSandboxSource; + readonly inherit?: AgentInheritanceDefinition; + readonly parentNodeId?: string; readonly type: "local"; } | { + readonly effectiveSandbox?: EffectiveSandboxSource; readonly type: "runtime"; }; +interface EffectiveSandboxSource { + readonly parentSandboxState?: HarnessSession["sandboxState"]; + readonly sandboxNodeId?: string; + readonly sandboxOwnerDynamicSkillNames?: readonly string[]; + readonly sandboxSessionId?: string; +} + /** * Result of {@link buildSubagentRunInput}. * @@ -98,6 +110,11 @@ export function buildSubagentRunInput(input: { // children. const rootSessionId = session.rootSessionId ?? session.sessionId; const subagentDepth = resolveSubagentDepth(session); + const sharedSandboxState = createSharedSandboxAdapterState({ + session, + source, + subagentName: action.subagentName, + }); const inheritedLimits: { -readonly [K in keyof RunSessionLimits]: RunSessionLimits[K]; } = resolveRemainingSessionTokenLimits(session, input.fanoutSize); @@ -113,9 +130,7 @@ export function buildSubagentRunInput(input: { parentContinuationToken: input.parentContinuationToken ?? session.continuationToken, parentSessionId: session.sessionId, subagentName: action.subagentName, - ...(action.subagentName === "agent" && session.sandboxState - ? { parentSandboxState: session.sandboxState, sandboxSessionId: session.sessionId } - : {}), + ...sharedSandboxState, }, }, auth, @@ -145,6 +160,71 @@ export function buildSubagentRunInput(input: { return { childContinuationToken, runInput }; } +function createSharedSandboxAdapterState(input: { + readonly session: HarnessSession; + readonly source: SubagentInputSource; + readonly subagentName: string; +}): { + readonly parentSandboxState?: HarnessSession["sandboxState"]; + readonly sandboxNodeId?: string; + readonly sandboxOwnerDynamicSkillNames?: readonly string[]; + readonly sandboxSessionId?: string; +} { + const shouldShare = + (input.source.type === "runtime" && input.subagentName === "agent") || + (input.source.type === "local" && input.source.inherit?.sandbox === true); + if (!shouldShare) { + return {}; + } + + const effectiveSandbox = input.source.effectiveSandbox; + const parentSandboxState = input.session.sandboxState ?? effectiveSandbox?.parentSandboxState; + + // Local inherit always pins the parent session id. Built-in runtime + // self-delegation only shares when parent state or an explicit + // effective identity is already available (share-before-first-open + // still works when effectiveSandbox carries sandboxSessionId). + const sandboxSessionId = + effectiveSandbox?.sandboxSessionId ?? + (input.source.type === "local" + ? input.session.sessionId + : parentSandboxState === undefined + ? undefined + : input.session.sessionId); + + if (sandboxSessionId === undefined) { + return {}; + } + + const sandboxNodeId = + effectiveSandbox?.sandboxNodeId ?? + (input.source.type === "local" ? input.source.parentNodeId : undefined); + + const state: { + parentSandboxState?: HarnessSession["sandboxState"]; + sandboxNodeId?: string; + sandboxOwnerDynamicSkillNames?: readonly string[]; + sandboxSessionId: string; + } = { + sandboxSessionId, + }; + + if (parentSandboxState !== undefined) { + state.parentSandboxState = parentSandboxState; + } + if (sandboxNodeId !== undefined) { + state.sandboxNodeId = sandboxNodeId; + } + if ( + effectiveSandbox?.sandboxOwnerDynamicSkillNames !== undefined && + effectiveSandbox.sandboxOwnerDynamicSkillNames.length > 0 + ) { + state.sandboxOwnerDynamicSkillNames = effectiveSandbox.sandboxOwnerDynamicSkillNames; + } + + return state; +} + /** * Formats the synthesized child input message for one delegated subagent call. */ diff --git a/packages/eve/src/execution/workflow-entry.test.ts b/packages/eve/src/execution/workflow-entry.test.ts index ef792c081..c67855c94 100644 --- a/packages/eve/src/execution/workflow-entry.test.ts +++ b/packages/eve/src/execution/workflow-entry.test.ts @@ -498,7 +498,57 @@ describe("workflowEntry", () => { subagentName: "researcher", }, serializedContext, + sessionState, + }); + }); + + it("notifies a delegated parent with the latest parked state when a later turn throws", async () => { + const initialState = createBaseSessionState({ + emissionState: { sequence: 0, sessionStarted: false, stepIndex: 0, turnId: "initial" }, + }); + const parkedState = createBaseSessionState({ + emissionState: { sequence: 7, sessionStarted: true, stepIndex: 3, turnId: "parked" }, }); + vi.mocked(createSessionStep).mockResolvedValue(createSessionStepResultForMock(initialState)); + installHookMocks({ + deliveryHooks: [ + { + token: "http:test", + values: [{ kind: "deliver", payloads: [{ message: "resume after park" }] }], + }, + ], + turnControls: [ + turnResult({ action: "park", sessionState: parkedState }), + { + error: { message: "later turn failed", name: "Error" }, + kind: "turn-error", + }, + ], + }); + const serializedContext = createSerializedContext({ + "eve.channel": { + kind: "subagent", + state: { + callId: "call-1", + parentContinuationToken: "parent-token", + sandboxSessionId: "parent-session", + subagentName: "researcher", + }, + }, + }); + + await expect( + workflowEntry({ + input: { message: "delegate" }, + serializedContext, + }), + ).rejects.toThrow("Agent workflow failed. Inspect the private session trace for details."); + + expect(notifyDelegatedParentStep).toHaveBeenCalledWith( + expect.objectContaining({ + sessionState: parkedState, + }), + ); }); it("passes the resumed channel request id to the next turn", async () => { diff --git a/packages/eve/src/execution/workflow-entry.ts b/packages/eve/src/execution/workflow-entry.ts index e91a2b4f4..895176fe2 100644 --- a/packages/eve/src/execution/workflow-entry.ts +++ b/packages/eve/src/execution/workflow-entry.ts @@ -111,6 +111,7 @@ export async function workflowEntry(input: WorkflowEntryInput): Promise(); + let sessionStateForFailure: DurableSessionState | undefined; try { // Derived once and reused for createSession + tag emission so the @@ -132,6 +133,7 @@ export async function workflowEntry(input: WorkflowEntryInput): Promise { + sessionStateForFailure = state; + }, serializedContext: input.serializedContext, sessionState, sessionTimeoutDeadline: @@ -179,10 +184,19 @@ export async function workflowEntry(input: WorkflowEntryInput): Promise; readonly initialInput: HookPayload; readonly mode: RunMode; + readonly onSessionStateChange?: (state: DurableSessionState) => void; readonly serializedContext: Record; readonly sessionState: DurableSessionState; readonly sessionTimeoutDeadline?: Date; @@ -257,6 +272,7 @@ async function runDriverLoop(input: { serializedContext: input.serializedContext, sessionState: input.sessionState, }); + input.onSessionStateChange?.(action.sessionState); while (true) { if (action.kind === "done") { @@ -323,6 +339,7 @@ async function runDriverLoop(input: { serializedContext: action.serializedContext, sessionState: action.sessionState, }); + input.onSessionStateChange?.(action.sessionState); continue; } @@ -392,6 +409,7 @@ async function runDriverLoop(input: { serializedContext: action.serializedContext, sessionState: action.sessionState, }); + input.onSessionStateChange?.(action.sessionState); } } finally { await disposeSettledTurnControl?.(); @@ -520,6 +538,7 @@ async function finalizeDone(input: { ? createDelegatedSubagentErrorResult(serializedContext, output) : createDelegatedSubagentSuccessResult(serializedContext, output), serializedContext, + sessionState: input.action.sessionState, usage: failed ? undefined : input.action.usage, }); return { output }; diff --git a/packages/eve/src/execution/workflow-steps.test.ts b/packages/eve/src/execution/workflow-steps.test.ts index c6d42b6d4..86184b6ae 100644 --- a/packages/eve/src/execution/workflow-steps.test.ts +++ b/packages/eve/src/execution/workflow-steps.test.ts @@ -7,6 +7,7 @@ import { ContextKey } from "#context/key.js"; import { AuthKey, ContinuationTokenKey, + DynamicSkillManifestKey, DynamicSubagentAgentConfigKey, ModeKey, SessionDynamicToolMetadataKey, @@ -484,6 +485,108 @@ describe("dispatchRuntimeActionsStep", () => { ); }); + it("passes protected dynamic skill names to inherited sandbox children", async () => { + const compiledBundle = { + adapterRegistry: { + adaptersByKind: new Map([[threadContextAdapter.kind, threadContextAdapter]]), + }, + compiledArtifactsSource: {}, + graph: { + nodesByNodeId: new Map(), + root: { + sandboxRegistry: { sandbox: null }, + turnAgent: TestTurnAgent, + }, + }, + hookRegistry: createEmptyHookRegistry(), + resolvedAgent: { config: {} }, + subagentRegistry: { + subagentsByNodeId: new Map([ + [ + "subagents/delegate", + { + definition: { + description: "Local delegate child description.", + inherit: { sandbox: true }, + kind: "subagent", + }, + }, + ], + ]), + }, + toolRegistry: {}, + turnAgent: TestTurnAgent, + } as never; + vi.mocked(getCompiledRuntimeAgentBundle).mockResolvedValue(compiledBundle); + startMock.mockResolvedValue({ runId: "child-run" }); + getRunMock.mockReturnValue({ + getReadable: () => + new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + }); + + const session = setPendingRuntimeActionBatch({ + actions: [ + { + callId: "call-1", + description: "Runtime action event description.", + input: { message: "investigate latest routing" }, + kind: "subagent-call", + name: "delegate", + nodeId: "subagents/delegate", + subagentName: "delegate", + }, + ], + event: { sequence: 0, stepIndex: 0, turnId: "turn_0" }, + responseMessages: [], + session: createStubSession({ + continuationToken: "http:parent", + sessionId: "parent-session", + }), + }); + installSessionStoreMocks([session]); + + const serializedContext = createSerializedContext(); + serializedContext[DynamicSkillManifestKey.name] = { + tenant: [{ description: "Tenant policy", name: "tenant-policy" }], + }; + (serializedContext[ChannelKey.name] as { state: Record }).state = { + sandboxOwnerDynamicSkillNames: ["upstream-policy"], + }; + + await dispatchRuntimeActionsStep({ + parentContinuationToken: "turn-inbox", + parentWritable: createTestWritable(), + serializedContext, + sessionState: createStubSessionState({ + continuationToken: "http:parent", + sessionId: "parent-session", + }), + }); + + expect(startMock).toHaveBeenCalledWith( + workflowEntryReference, + [ + expect.objectContaining({ + serializedContext: expect.objectContaining({ + "eve.channel": expect.objectContaining({ + kind: "subagent", + state: expect.objectContaining({ + sandboxNodeId: "__root__", + sandboxOwnerDynamicSkillNames: ["tenant-policy", "upstream-policy"], + sandboxSessionId: "parent-session", + }), + }), + }), + }), + ], + expect.any(Object), + ); + }); + it("returns a failed subagent result when remote session creation fails", async () => { const remote = { definition: { diff --git a/packages/eve/src/harness/runtime-actions.test.ts b/packages/eve/src/harness/runtime-actions.test.ts index f811bf3a6..6276378a2 100644 --- a/packages/eve/src/harness/runtime-actions.test.ts +++ b/packages/eve/src/harness/runtime-actions.test.ts @@ -105,6 +105,316 @@ describe("resolvePendingRuntimeActions", () => { outputTokens: 100, }); }); + + it("backfills inherited sandbox state without exposing internal metadata", async () => { + const session = createParkedSession(); + const events: unknown[] = []; + const sandboxState = { + initialized: true, + session: { + backendName: "test-sandbox", + metadata: { workspace: "repo" }, + sessionKey: "sandbox-session-key", + }, + }; + + const resolved = await resolvePendingRuntimeActions({ + emit: async (event) => { + events.push(event); + }, + session, + stepInput: { + runtimeActionResults: [ + { + callId: "call-1", + inheritedSandbox: { + nodeId: "__root__", + sessionId: "test-session", + state: sandboxState, + }, + kind: "subagent-result", + output: "done", + subagentName: "researcher", + }, + ], + }, + }); + + const actionResult = events.find(isActionResultEvent); + + expect(resolved.outcome).toBe("resolved"); + expect(resolved.session.sandboxState).toEqual(sandboxState); + expect(actionResult?.data.result).toMatchObject({ + callId: "call-1", + kind: "subagent-result", + output: "done", + subagentName: "researcher", + }); + expect(actionResult?.data.result).not.toHaveProperty("inheritedSandbox"); + }); + + it("backfills inherited sandbox state from failed subagent results", async () => { + const session = createParkedSession(); + const sandboxState = { + initialized: true, + session: { + backendName: "test-sandbox", + metadata: { workspace: "repo" }, + sessionKey: "sandbox-session-key", + }, + }; + + const resolved = await resolvePendingRuntimeActions({ + session, + stepInput: { + runtimeActionResults: [ + { + callId: "call-1", + inheritedSandbox: { + nodeId: "__root__", + sessionId: "test-session", + state: sandboxState, + }, + isError: true, + kind: "subagent-result", + output: { code: "SUBAGENT_EXECUTION_FAILED", message: "boom" }, + subagentName: "researcher", + }, + ], + }, + }); + + expect(resolved.outcome).toBe("resolved"); + expect(resolved.session.sandboxState).toEqual(sandboxState); + }); + + it("prefers initialized inherited sandbox state over a later uninitialized capture", async () => { + let session = createParkedSession(); + session = setPendingRuntimeActionBatch({ + actions: [ + { + callId: "call-1", + description: "research subagent", + input: { message: "go" }, + kind: "subagent-call", + name: "researcher", + nodeId: "subagents/researcher", + subagentName: "researcher", + }, + { + callId: "call-2", + description: "reviewer subagent", + input: { message: "review" }, + kind: "subagent-call", + name: "reviewer", + nodeId: "subagents/reviewer", + subagentName: "reviewer", + }, + ], + event: { sequence: 0, stepIndex: 0, turnId: "turn_0" }, + responseMessages: [], + session, + }); + + const initializedState = { + initialized: true, + session: { + backendName: "test-sandbox", + metadata: { workspace: "repo" }, + sessionKey: "sandbox-session-key", + }, + }; + const uninitializedState = { + initialized: false, + session: null, + }; + + const resolved = await resolvePendingRuntimeActions({ + session, + stepInput: { + runtimeActionResults: [ + { + callId: "call-1", + inheritedSandbox: { + nodeId: "__root__", + sessionId: "test-session", + state: initializedState, + }, + kind: "subagent-result", + output: "done", + subagentName: "researcher", + }, + { + callId: "call-2", + inheritedSandbox: { + nodeId: "__root__", + sessionId: "test-session", + state: uninitializedState, + }, + kind: "subagent-result", + output: "done", + subagentName: "reviewer", + }, + ], + }, + }); + + expect(resolved.outcome).toBe("resolved"); + expect(resolved.session.sandboxState).toEqual(initializedState); + }); + + it("ignores inherited sandbox results that do not match the parent session id", async () => { + const session = createParkedSession(); + const sandboxState = { + initialized: true, + session: { + backendName: "test-sandbox", + metadata: { workspace: "repo" }, + sessionKey: "sandbox-session-key", + }, + }; + + const resolved = await resolvePendingRuntimeActions({ + session, + stepInput: { + runtimeActionResults: [ + { + callId: "call-1", + inheritedSandbox: { + nodeId: "__root__", + sessionId: "other-session", + state: sandboxState, + }, + kind: "subagent-result", + output: "done", + subagentName: "researcher", + }, + ], + }, + }); + + expect(resolved.outcome).toBe("resolved"); + expect(resolved.session.sandboxState).toBeUndefined(); + }); + + it("accepts nested shared-sandbox backfill addressed to the mid-chain parent", async () => { + // Leaf kept sandboxSessionId=root owner, but inheritedSandbox.sessionId is + // the immediate parent (mid). Mid must accept and store the capture so it + // can later forward to root. + const midSession = setPendingRuntimeActionBatch({ + actions: [ + { + callId: "call-leaf", + description: "leaf worker", + input: { message: "work" }, + kind: "subagent-call", + name: "worker", + nodeId: "subagents/researcher/subagents/worker", + subagentName: "worker", + }, + ], + event: { sequence: 0, stepIndex: 0, turnId: "turn_0" }, + responseMessages: [], + session: { + agent: { modelReference: { id: "test-model" }, system: "", tools: [] }, + compaction: { recentWindowSize: 10, threshold: 100_000 }, + continuationToken: "http:mid-session", + history: [{ content: "delegate", role: "user" }], + sessionId: "mid-session", + }, + }); + + const leafCapture = { + initialized: true, + session: { + backendName: "test-sandbox", + metadata: { workspace: "repo" }, + sessionKey: "sandbox-session-key", + }, + }; + + const resolved = await resolvePendingRuntimeActions({ + session: midSession, + stepInput: { + runtimeActionResults: [ + { + callId: "call-leaf", + inheritedSandbox: { + nodeId: "__root__", + sessionId: "mid-session", + state: leafCapture, + }, + kind: "subagent-result", + output: "done", + subagentName: "worker", + }, + ], + }, + }); + + expect(resolved.outcome).toBe("resolved"); + expect(resolved.session.sandboxState).toEqual(leafCapture); + }); + + it("does not clobber reattach metadata with an initialized capture that has null session", async () => { + let session = createParkedSession(); + session = { + ...session, + sandboxState: { + initialized: true, + session: { + backendName: "test-sandbox", + metadata: { workspace: "repo" }, + sessionKey: "sandbox-session-key", + }, + }, + }; + session = setPendingRuntimeActionBatch({ + actions: [ + { + callId: "call-1", + description: "research subagent", + input: { message: "go" }, + kind: "subagent-call", + name: "researcher", + nodeId: "subagents/researcher", + subagentName: "researcher", + }, + ], + event: { sequence: 0, stepIndex: 0, turnId: "turn_0" }, + responseMessages: [], + session, + }); + + const resolved = await resolvePendingRuntimeActions({ + session, + stepInput: { + runtimeActionResults: [ + { + callId: "call-1", + inheritedSandbox: { + nodeId: "__root__", + sessionId: "test-session", + state: { initialized: true, session: null }, + }, + kind: "subagent-result", + output: "done", + subagentName: "researcher", + }, + ], + }, + }); + + expect(resolved.outcome).toBe("resolved"); + expect(resolved.session.sandboxState).toEqual({ + initialized: true, + session: { + backendName: "test-sandbox", + metadata: { workspace: "repo" }, + sessionKey: "sandbox-session-key", + }, + }); + }); }); describe("pending subagent child adoption", () => { @@ -181,3 +491,16 @@ describe("resolveToolCallInputObject", () => { ); }); }); + +function isActionResultEvent(event: unknown): event is { + readonly data: { readonly result: Record }; + readonly type: "action.result"; +} { + return ( + typeof event === "object" && + event !== null && + (event as { readonly type?: unknown }).type === "action.result" && + typeof (event as { readonly data?: unknown }).data === "object" && + (event as { readonly data?: unknown }).data !== null + ); +} diff --git a/packages/eve/src/harness/runtime-actions.ts b/packages/eve/src/harness/runtime-actions.ts index 41d3a8cef..01ebc9d77 100644 --- a/packages/eve/src/harness/runtime-actions.ts +++ b/packages/eve/src/harness/runtime-actions.ts @@ -2,7 +2,11 @@ import type { ModelMessage, ToolSet, TypedToolCall } from "ai"; import { createActionResultEvent, type UnstampedMessageStreamEvent } from "#protocol/message.js"; import { getRuntimeActionRequestKey, getRuntimeActionResultKey } from "#runtime/actions/keys.js"; -import type { RuntimeActionRequest, RuntimeActionResult } from "#runtime/actions/types.js"; +import type { + RuntimeActionRequest, + RuntimeActionResult, + RuntimeSubagentResultActionResult, +} from "#runtime/actions/types.js"; import { parseJsonObject, type JsonObject } from "#shared/json.js"; import { clearProxyInputRequestsForChild } from "#harness/proxy-input-requests.js"; import { @@ -273,7 +277,7 @@ export async function resolvePendingRuntimeActions(input: { await input.emit( createActionResultEvent({ - result, + result: toPublicRuntimeActionResult(result), sequence: batch.event.sequence, stepIndex: batch.event.stepIndex, turnId: batch.event.turnId, @@ -290,6 +294,11 @@ export async function resolvePendingRuntimeActions(input: { state: Object.keys(state).length > 0 ? state : undefined, }; + nextSession = applyInheritedSandboxResults({ + results: readyResults, + session: nextSession, + }); + // Clear proxy-input entries for completed children so future // deliveries don't route responses to a dead child. const childTokens = batch.childContinuationTokens; @@ -366,6 +375,85 @@ export async function resolvePendingRuntimeActions(input: { }; } +function applyInheritedSandboxResults(input: { + readonly results: readonly RuntimeActionResult[]; + readonly session: HarnessSession; +}): HarnessSession { + let nextSession = input.session; + + for (const result of input.results) { + if (result.kind !== "subagent-result" || result.inheritedSandbox === undefined) { + continue; + } + + const inherited = result.inheritedSandbox; + // inherited.sessionId is the intended recipient (immediate parent), + // set from adapter parentSessionId. Ignore results aimed at another + // session so a stale/mismatched child cannot clobber parent state. + // Nested chains still work: leaf→mid uses mid's sessionId even when + // sandboxSessionId remains the root owner. + if (inherited.sessionId !== input.session.sessionId) { + continue; + } + + nextSession = pickPreferredSandboxState(nextSession, inherited.state); + } + + return nextSession; +} + +function pickPreferredSandboxState( + current: HarnessSession, + candidate: NonNullable, +): HarnessSession { + const currentState = current.sandboxState; + + // Prefer an already-initialized capture over a later uninitialized one. + if (currentState?.initialized === true && candidate.initialized !== true) { + return current; + } + + // Do not wipe reattach metadata with a capture that has no session + // record (e.g. initialized:true + session:null). Keep the richer + // existing session when the candidate omits it. + if (candidate.session === null && currentState?.session != null) { + return { + ...current, + sandboxState: { + initialized: candidate.initialized || currentState.initialized, + session: currentState.session, + }, + }; + } + + return { + ...current, + sandboxState: candidate, + }; +} + +function toPublicRuntimeActionResult(result: RuntimeActionResult): RuntimeActionResult { + if (result.kind !== "subagent-result" || result.inheritedSandbox === undefined) { + return result; + } + + const publicResult: RuntimeSubagentResultActionResult = { + callId: result.callId, + kind: "subagent-result", + output: result.output, + subagentName: result.subagentName, + }; + + if (result.isError !== undefined) { + publicResult.isError = result.isError; + } + if (result.usage !== undefined) { + publicResult.usage = result.usage; + } + + return publicResult; +} + /** * Projects one AI SDK tool call into the eve runtime-action contract. */ diff --git a/packages/eve/src/internal/authored-definition/core.test.ts b/packages/eve/src/internal/authored-definition/core.test.ts index cc22eb334..8c8fd2e69 100644 --- a/packages/eve/src/internal/authored-definition/core.test.ts +++ b/packages/eve/src/internal/authored-definition/core.test.ts @@ -140,6 +140,53 @@ describe("normalizeAgentDefinition", () => { ).toThrow(FAILURE_MESSAGE); }); + it("accepts explicit subagent capability inheritance flags", () => { + const definition = normalizeAgentDefinition( + { + model: "openai/gpt-5.5", + inherit: { + connections: true, + sandbox: true, + }, + }, + FAILURE_MESSAGE, + ); + + expect(definition.inherit).toEqual({ + connections: true, + sandbox: true, + }); + }); + + it.each([ + ["connections", "yes"], + ["sandbox", 1], + ])("rejects non-boolean inheritance flag %s=%j", (key, value) => { + expect(() => + normalizeAgentDefinition( + { + model: "openai/gpt-5.5", + inherit: { [key]: value }, + }, + FAILURE_MESSAGE, + ), + ).toThrow(FAILURE_MESSAGE); + }); + + it("rejects unknown inheritance keys", () => { + expect(() => + normalizeAgentDefinition( + { + model: "openai/gpt-5.5", + inherit: { + tools: true, + }, + }, + FAILURE_MESSAGE, + ), + ).toThrow('Unknown key "tools"'); + }); + it("rejects the removed agent-level workflow max subagents limit", () => { expect(() => normalizeAgentDefinition( diff --git a/packages/eve/src/internal/authored-definition/core.ts b/packages/eve/src/internal/authored-definition/core.ts index 348ed5a2a..210a5e0a6 100644 --- a/packages/eve/src/internal/authored-definition/core.ts +++ b/packages/eve/src/internal/authored-definition/core.ts @@ -7,6 +7,7 @@ import type { ScheduleDefinition, ScheduleRunHandler } from "#public/definitions import type { SkillDefinition, SkillFileContent } from "#public/definitions/skill.js"; import type { InstructionsDefinition } from "#public/definitions/instructions.js"; import { + expectBoolean, expectFunction, expectObjectRecord, expectOnlyKnownKeys, @@ -51,6 +52,7 @@ export function normalizeAgentDefinition( "compaction", "description", "experimental", + "inherit", "limits", "model", "modelContextWindowTokens", @@ -84,6 +86,10 @@ export function normalizeAgentDefinition( definition.experimental = normalizeAgentExperimentalDefinition(record.experimental, message); } + if (record.inherit !== undefined) { + definition.inherit = normalizeAgentInheritanceDefinition(record.inherit, message); + } + if (record.modelOptions !== undefined) { definition.modelOptions = normalizeAgentModelOptions(record.modelOptions, message); } @@ -110,6 +116,24 @@ export function normalizeAgentDefinition( return definition as Readonly; } +function normalizeAgentInheritanceDefinition( + value: unknown, + message: string, +): NonNullable { + const record = expectObjectRecord(value, message); + expectOnlyKnownKeys(record, ["connections", "sandbox"], message); + const normalizedDefinition: Mutable> = {}; + + if (record.connections !== undefined) { + normalizedDefinition.connections = expectBoolean(record.connections, message); + } + if (record.sandbox !== undefined) { + normalizedDefinition.sandbox = expectBoolean(record.sandbox, message); + } + + return normalizedDefinition; +} + function normalizeAgentReasoningDefinition( value: unknown, message: string, diff --git a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.test.ts b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.test.ts index 5898486fe..cc3150b2e 100644 --- a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.test.ts +++ b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { buildFrameworkToolInfo } from "#internal/nitro/routes/agent-info/build-agent-info-response.js"; +import { createCompiledAgentNodeManifest } from "#compiler/manifest.js"; +import { classifyModelRouting } from "#internal/classify-model-routing.js"; +import { + buildFrameworkToolInfo, + renderSubagent, +} from "#internal/nitro/routes/agent-info/build-agent-info-response.js"; describe("buildFrameworkToolInfo", () => { it("reports the built-in agent action as active and available by default", () => { @@ -57,3 +62,54 @@ describe("buildFrameworkToolInfo", () => { }); }); }); + +describe("renderSubagent", () => { + it("reports inherited and owned subagent capabilities", () => { + const subagent = renderSubagent({ + agent: createCompiledAgentNodeManifest({ + agentRoot: "/app/agent/subagents/researcher", + appRoot: "/app", + config: { + description: "Research one task.", + inherit: { + connections: true, + sandbox: true, + }, + model: { id: "openai/gpt-5.5", routing: classifyModelRouting("openai/gpt-5.5") }, + name: "researcher", + }, + connections: [ + { + connectionName: "linear", + description: "Use Linear.", + logicalPath: "connections/linear.ts", + protocol: "mcp", + sourceId: "connections/linear.ts", + sourceKind: "module", + url: "https://mcp.linear.example", + }, + ], + }), + description: "Research one task.", + entryPath: "subagents/researcher/agent.ts", + logicalPath: "subagents/researcher", + name: "researcher", + nodeId: "subagents/researcher", + rootPath: "/app/agent/subagents/researcher", + sourceId: "subagents/researcher", + sourceKind: "module", + }); + + expect(subagent.inherit).toEqual({ + connections: true, + sandbox: true, + }); + expect(subagent.effective).toEqual({ + connections: { + inherited: true, + owned: 1, + }, + sandbox: "inherited", + }); + }); +}); diff --git a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.ts b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.ts index f94270fcf..d568d0499 100644 --- a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.ts +++ b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.ts @@ -1,4 +1,5 @@ import { ROOT_COMPILED_AGENT_NODE_ID } from "#compiler/manifest.js"; +import { projectSubagentCapabilities } from "#compiler/subagent-capabilities.js"; import { getAllFrameworkToolDefinitions, getAllFrameworkToolNames, @@ -97,6 +98,17 @@ export interface AgentInfoScheduleEntry extends AgentInfoSource { export interface AgentInfoSubagentEntry extends AgentInfoSource { readonly description?: string; readonly entryPath: string; + readonly effective: { + readonly connections: { + readonly inherited: boolean; + readonly owned: number; + }; + readonly sandbox: "default" | "inherited" | "owned"; + }; + readonly inherit: { + readonly connections: boolean; + readonly sandbox: boolean; + }; readonly name: string; readonly nodeId: string; readonly rootPath: string; @@ -518,10 +530,14 @@ function renderSandbox(sandbox: ResolvedSandboxDefinition | null): AgentInfoSand } export function renderSubagent(subagent: CompiledSubagentNode): AgentInfoSubagentEntry { + const capabilities = projectSubagentCapabilities(subagent); + return { ...toSource(subagent), description: subagent.description, entryPath: subagent.entryPath, + effective: capabilities.effective, + inherit: capabilities.inherit, name: subagent.name, nodeId: subagent.nodeId, rootPath: subagent.rootPath, diff --git a/packages/eve/src/internal/testing/active-session-context.ts b/packages/eve/src/internal/testing/active-session-context.ts index 576bd6de4..7c8beffea 100644 --- a/packages/eve/src/internal/testing/active-session-context.ts +++ b/packages/eve/src/internal/testing/active-session-context.ts @@ -6,6 +6,7 @@ import { SessionKey, type SessionParent, type SessionTurn, + StaticSkillNamesKey, } from "#context/keys.js"; import { setChannelContext } from "#execution/channel-context.js"; import type { SandboxAccess } from "#sandbox/state.js"; @@ -23,6 +24,12 @@ export interface ActiveSessionInit { readonly turn: SessionTurn; readonly parent?: SessionParent; readonly sandbox?: SandboxAccess; + /** + * Static skills visible to the active authored agent. The production runtime + * seeds this from the compiled bundle in `runtime-context.ts`; tests that + * bypass the full run context need to provide the same boundary explicitly. + */ + readonly staticSkillNames?: readonly string[]; /** * Optional channel adapter to bind in the active channel context. Used by * staging-layer integration tests that exercise the attachment ref @@ -47,6 +54,10 @@ export function buildActiveSessionContext(init: ActiveSessionInit): ContextConta ctx.set(SandboxKey, init.sandbox); } + if (init.staticSkillNames !== undefined) { + ctx.set(StaticSkillNamesKey, init.staticSkillNames); + } + if (init.channel !== undefined) { setChannelContext(ctx, init.channel); } diff --git a/packages/eve/src/internal/testing/app-harness.ts b/packages/eve/src/internal/testing/app-harness.ts index 4f102a71c..0c5bb8e36 100644 --- a/packages/eve/src/internal/testing/app-harness.ts +++ b/packages/eve/src/internal/testing/app-harness.ts @@ -203,7 +203,9 @@ export function createTestRuntime(descriptor: TestAppDescriptor = {}): TestRunti init: RunAsSessionInit | undefined, fn: () => Promise | T, ): Promise { - const sessionInit: ActiveSessionInit = buildActiveSessionContextInit(init); + const sessionInit: ActiveSessionInit = buildActiveSessionContextInit(init, { + staticSkillNames: skills.map((skill) => skill.name), + }); return await run(async () => { return await runWithActiveSessionContext(sessionInit, fn); @@ -255,7 +257,10 @@ export function createTestRuntime(descriptor: TestAppDescriptor = {}): TestRunti // Exported for the internal active-session-context helper. export { buildActiveSessionContext }; -function buildActiveSessionContextInit(init: RunAsSessionInit | undefined): ActiveSessionInit { +function buildActiveSessionContextInit( + init: RunAsSessionInit | undefined, + options: { readonly staticSkillNames?: readonly string[] } = {}, +): ActiveSessionInit { const sessionId = init?.sessionId ?? "session_test"; const turn = init?.turn ?? { id: "turn_test_001", sequence: 1 }; const sandboxAccess = init?.sandboxAccess ?? init?.sandbox?.access; @@ -266,6 +271,7 @@ function buildActiveSessionContextInit(init: RunAsSessionInit | undefined): Acti parent?: SessionParent; sandbox?: SandboxAccess; channel?: ChannelAdapter; + staticSkillNames?: readonly string[]; } = { sessionId, turn, @@ -283,5 +289,9 @@ function buildActiveSessionContextInit(init: RunAsSessionInit | undefined): Acti mutable.channel = init.channel; } + if (options.staticSkillNames !== undefined) { + mutable.staticSkillNames = options.staticSkillNames; + } + return mutable; } diff --git a/packages/eve/src/internal/testing/stub-sandbox-registry.ts b/packages/eve/src/internal/testing/stub-sandbox-registry.ts index 29bf27808..da75e8ec9 100644 --- a/packages/eve/src/internal/testing/stub-sandbox-registry.ts +++ b/packages/eve/src/internal/testing/stub-sandbox-registry.ts @@ -20,6 +20,12 @@ export function createStubSandboxRegistry(): RuntimeSandboxRegistry { logicalPath: "test:stub-sandbox/workspace", rootEntries: [], }, + workspaceResourceRoots: [ + { + logicalPath: "test:stub-sandbox/workspace", + rootEntries: [], + }, + ], }, }; } diff --git a/packages/eve/src/public/definitions/agent.ts b/packages/eve/src/public/definitions/agent.ts index 38de0ba32..e0bc52dcd 100644 --- a/packages/eve/src/public/definitions/agent.ts +++ b/packages/eve/src/public/definitions/agent.ts @@ -25,6 +25,7 @@ export type { PublicAgentModelDefinition as AgentModelDefinition, PublicAgentStaticModelDefinition as AgentStaticModelDefinition, PublicAgentCompactionDefinition as AgentCompactionDefinition, + AgentInheritanceDefinition, } from "#shared/agent-definition.js"; /** diff --git a/packages/eve/src/public/index.ts b/packages/eve/src/public/index.ts index c2906e651..75cb5acec 100644 --- a/packages/eve/src/public/index.ts +++ b/packages/eve/src/public/index.ts @@ -6,6 +6,7 @@ export { type AgentCompactionDefinition, type AgentDefinition, type AgentExperimentalDefinition, + type AgentInheritanceDefinition, type AgentLimitsDefinition, type AgentModelDefinition, type AgentModelOptionsDefinition, diff --git a/packages/eve/src/runtime/actions/types.ts b/packages/eve/src/runtime/actions/types.ts index 0e17dd66c..f35e9b2f4 100644 --- a/packages/eve/src/runtime/actions/types.ts +++ b/packages/eve/src/runtime/actions/types.ts @@ -1,6 +1,7 @@ import { z } from "#compiled/zod/index.js"; import { jsonObjectSchema, jsonValueSchema } from "#shared/json-schemas.js"; +import type { SandboxState } from "#sandbox/state.js"; import { tokenUsageSchema } from "#shared/token-usage.js"; /** @@ -135,12 +136,50 @@ export type RuntimeSubagentResultActionResult = z.infer< typeof runtimeSubagentResultActionResultSchema >; +/** + * Eve-internal inherited sandbox state carried from an inheriting child back + * to its waiting parent. Runtime stream projections strip this before emitting + * public `action.result` events. + * + * `sessionId` is the intended recipient (immediate parent harness session), + * not the sandbox owner. Nested `inherit.sandbox` chains keep the owner id on + * adapter `sandboxSessionId` / `nodeId` while addressing backfill to each hop + * via `parentSessionId`. + */ +export interface RuntimeInheritedSandboxResult { + readonly nodeId?: string; + /** Immediate parent harness session id that should apply this backfill. */ + readonly sessionId: string; + readonly state: SandboxState; +} + +const runtimeInheritedSandboxResultSchema = z + .object({ + nodeId: z.string().optional(), + sessionId: z.string(), + state: z + .object({ + initialized: z.boolean(), + session: z + .object({ + backendName: z.string(), + metadata: z.record(z.string(), z.unknown()), + sessionKey: z.string(), + }) + .strict() + .nullable(), + }) + .strict(), + }) + .strict(); + /** * Zod schema for one runtime-owned subagent result action result. */ const runtimeSubagentResultActionResultSchema = z .object({ callId: z.string(), + inheritedSandbox: runtimeInheritedSandboxResultSchema.optional(), isError: z.boolean().optional(), kind: z.literal("subagent-result"), output: jsonValueSchema, diff --git a/packages/eve/src/runtime/framework-tools/skill.ts b/packages/eve/src/runtime/framework-tools/skill.ts index afd768f56..07881526a 100644 --- a/packages/eve/src/runtime/framework-tools/skill.ts +++ b/packages/eve/src/runtime/framework-tools/skill.ts @@ -37,7 +37,10 @@ async function executeLoadSkillTool( `The dynamic skill "${skill}" requires sandbox access on the runtime context.`, ); } - return await loadSkillFromSandbox(sandbox, skill, availableSkills); + return await loadSkillFromSandbox(sandbox, skill, { + availableSkillNames: availableSkills, + enforceAvailableSkills: true, + }); } const authoredSkill = authoredSkills.find((entry) => entry.name === skill); diff --git a/packages/eve/src/runtime/resolve-agent-graph.ts b/packages/eve/src/runtime/resolve-agent-graph.ts index 58b625c12..ebfef3939 100644 --- a/packages/eve/src/runtime/resolve-agent-graph.ts +++ b/packages/eve/src/runtime/resolve-agent-graph.ts @@ -3,6 +3,7 @@ import type { CompiledAgentNodeManifest, CompiledRemoteAgentNode, CompiledSubagentNode, + CompiledWorkspaceResourceRoot, } from "#compiler/manifest.js"; import { ROOT_COMPILED_AGENT_NODE_ID } from "#compiler/manifest.js"; import type { CompiledModuleMap } from "#compiler/module-map.js"; @@ -29,7 +30,9 @@ import { createRuntimeSubagentRegistry } from "#runtime/subagents/registry.js"; import { createRuntimeToolRegistry } from "#runtime/tools/registry.js"; import { WORKFLOW_TOOL_NAME } from "#shared/workflow-sandbox.js"; import type { + ResolvedAgent, ResolvedChannelDefinition, + ResolvedConnectionDefinition, ResolvedDynamicSubagentDefinition, ResolvedRuntimeDelegationNode, ResolvedRuntimeRemoteAgentNode, @@ -110,6 +113,7 @@ interface ResolveRuntimeAgentNodeInput { readonly childNodeIdsByParentNodeId: ReadonlyMap; readonly manifest: CompiledAgentNodeManifest; readonly moduleMap: CompiledModuleMap; + readonly inheritedConnections?: readonly ResolvedConnectionDefinition[]; readonly nodeId: string; readonly nodesByNodeId: Map; readonly sourceId?: string; @@ -131,11 +135,17 @@ async function resolveRuntimeAgentNode( ); } - const agent = await resolveAgent({ + const authoredAgent = await resolveAgent({ manifest: input.manifest, moduleMap: input.moduleMap, nodeId: input.nodeId, }); + const agent = applyInheritedConnections({ + agent: authoredAgent, + inheritedConnections: input.inheritedConnections, + nodeId, + sourceId: input.sourceId, + }); const frameworkTools = getFrameworkToolDefinitions({ authoredSkills: agent.skills, }); @@ -213,6 +223,12 @@ async function resolveRuntimeAgentNode( const sandboxRegistry = createRuntimeSandboxRegistry({ authoredSandbox: agent.sandbox, workspaceResourceRoot: agent.workspaceResourceRoot, + workspaceResourceRoots: collectInheritedSandboxWorkspaceResourceRoots({ + childNodeIdsByParentNodeId: input.childNodeIdsByParentNodeId, + manifest: input.manifest, + nodeId: input.nodeId, + subagentNodesById: input.subagentNodesById, + }), }); const subagentRegistry = createRuntimeSubagentRegistry({ reservedToolNames: [ @@ -224,6 +240,7 @@ async function resolveRuntimeAgentNode( manifest: input.manifest, moduleMap: input.moduleMap, nodesByNodeId: input.nodesByNodeId, + parentConnections: agent.connections, parentNodeId: input.nodeId, subagentNodesById: input.subagentNodesById, }), @@ -254,11 +271,74 @@ async function resolveRuntimeAgentNode( return node; } +function collectInheritedSandboxWorkspaceResourceRoots(input: { + readonly childNodeIdsByParentNodeId: ReadonlyMap; + readonly manifest: CompiledAgentNodeManifest; + readonly nodeId: string; + readonly subagentNodesById: ReadonlyMap; +}): readonly CompiledWorkspaceResourceRoot[] { + const roots: CompiledWorkspaceResourceRoot[] = [input.manifest.workspaceResourceRoot]; + const childNodeIds = input.childNodeIdsByParentNodeId.get(input.nodeId) ?? []; + + for (const childNodeId of childNodeIds) { + const child = input.subagentNodesById.get(childNodeId); + if (child?.agent.config.inherit?.sandbox !== true) { + continue; + } + + roots.push( + ...collectInheritedSandboxWorkspaceResourceRoots({ + childNodeIdsByParentNodeId: input.childNodeIdsByParentNodeId, + manifest: child.agent, + nodeId: child.nodeId, + subagentNodesById: input.subagentNodesById, + }), + ); + } + + return roots; +} + +function applyInheritedConnections(input: { + readonly agent: ResolvedAgent; + readonly inheritedConnections?: readonly ResolvedConnectionDefinition[]; + readonly nodeId: string; + readonly sourceId?: string; +}): ResolvedAgent { + const inheritedConnections = input.inheritedConnections ?? []; + if (inheritedConnections.length === 0) { + return input.agent; + } + + const ownedConnectionNames = new Set( + input.agent.connections.map((connection) => connection.connectionName), + ); + const duplicateConnection = inheritedConnections.find((connection) => + ownedConnectionNames.has(connection.connectionName), + ); + + if (duplicateConnection !== undefined) { + throw new ResolveRuntimeAgentGraphError( + `Subagent node "${input.nodeId}" inherits connection "${duplicateConnection.connectionName}" but also defines a connection with that name.`, + { + nodeId: input.nodeId, + sourceId: input.sourceId, + }, + ); + } + + return { + ...input.agent, + connections: [...inheritedConnections, ...input.agent.connections], + }; +} + async function resolveRuntimeSubagents(input: { readonly childNodeIdsByParentNodeId: ReadonlyMap; readonly manifest: CompiledAgentNodeManifest; readonly moduleMap: CompiledModuleMap; readonly nodesByNodeId: Map; + readonly parentConnections: readonly ResolvedConnectionDefinition[]; readonly parentNodeId: string; readonly subagentNodesById: ReadonlyMap; }): Promise { @@ -283,6 +363,7 @@ async function resolveRuntimeSubagents(input: { childNodeIdsByParentNodeId: input.childNodeIdsByParentNodeId, moduleMap: input.moduleMap, nodesByNodeId: input.nodesByNodeId, + parentConnections: input.parentConnections, sourceRef, subagentNodesById: input.subagentNodesById, }), @@ -306,6 +387,7 @@ async function resolveRuntimeSubagent(input: { readonly childNodeIdsByParentNodeId: ReadonlyMap; readonly moduleMap: CompiledModuleMap; readonly nodesByNodeId: Map; + readonly parentConnections: readonly ResolvedConnectionDefinition[]; readonly sourceRef: CompiledSubagentNode; readonly subagentNodesById: ReadonlyMap; }): Promise { @@ -336,6 +418,7 @@ async function resolveRuntimeSubagent(input: { }; const resolvedSubagent: ResolvedRuntimeSubagentNode = { ...variant, + inherit: input.sourceRef.agent.config.inherit, kind: "subagent", logicalPath: input.sourceRef.logicalPath, name: input.sourceRef.name, @@ -345,6 +428,10 @@ async function resolveRuntimeSubagent(input: { }; await resolveRuntimeAgentNode({ childNodeIdsByParentNodeId: input.childNodeIdsByParentNodeId, + inheritedConnections: + input.sourceRef.agent.config.inherit?.connections === true + ? input.parentConnections + : undefined, manifest: input.sourceRef.agent, moduleMap: input.moduleMap, nodeId: input.sourceRef.nodeId, diff --git a/packages/eve/src/runtime/resolve-agent.ts b/packages/eve/src/runtime/resolve-agent.ts index 22a61940b..6d54edd33 100644 --- a/packages/eve/src/runtime/resolve-agent.ts +++ b/packages/eve/src/runtime/resolve-agent.ts @@ -161,6 +161,7 @@ function createResolvedAgentConfig(manifest: CompiledAgentNodeManifest): Resolve reasoning?: ResolvedAgent["config"]["reasoning"]; source?: ResolvedAgent["config"]["source"]; limits?: ResolvedAgent["config"]["limits"]; + inherit?: ResolvedAgent["config"]["inherit"]; } = { model: manifest.config.model.source === undefined @@ -234,6 +235,13 @@ function createResolvedAgentConfig(manifest: CompiledAgentNodeManifest): Resolve }; } + if (manifest.config.inherit !== undefined) { + config.inherit = { + connections: manifest.config.inherit.connections, + sandbox: manifest.config.inherit.sandbox, + }; + } + if (manifest.config.outputSchema !== undefined) { config.outputSchema = manifest.config.outputSchema; } diff --git a/packages/eve/src/runtime/sandbox/registry.ts b/packages/eve/src/runtime/sandbox/registry.ts index fee842ff7..3a84103f4 100644 --- a/packages/eve/src/runtime/sandbox/registry.ts +++ b/packages/eve/src/runtime/sandbox/registry.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + import type { CompiledWorkspaceResourceRoot } from "#compiler/manifest.js"; import { defaultSandbox } from "#public/sandbox/backends/default.js"; import type { ResolvedSandboxDefinition } from "#runtime/types.js"; @@ -27,6 +29,7 @@ export const DEFAULT_SANDBOX_SOURCE_ID = "eve:default-sandbox"; export interface RuntimeRegisteredSandbox { readonly definition: ResolvedSandboxDefinition; readonly workspaceResourceRoot: CompiledWorkspaceResourceRoot; + readonly workspaceResourceRoots: readonly CompiledWorkspaceResourceRoot[]; } /** @@ -51,16 +54,60 @@ export interface RuntimeSandboxRegistry { export function createRuntimeSandboxRegistry(input: { readonly authoredSandbox: ResolvedSandboxDefinition | null; readonly workspaceResourceRoot: CompiledWorkspaceResourceRoot; + readonly workspaceResourceRoots?: readonly CompiledWorkspaceResourceRoot[]; }): RuntimeSandboxRegistry { const definition = input.authoredSandbox ?? createFrameworkSandboxDefinition(); + const workspaceResourceRoots = input.workspaceResourceRoots ?? [input.workspaceResourceRoot]; return { sandbox: { definition, - workspaceResourceRoot: input.workspaceResourceRoot, + workspaceResourceRoot: mergeWorkspaceResourceRoots(workspaceResourceRoots), + workspaceResourceRoots, }, }; } +function mergeWorkspaceResourceRoots( + roots: readonly CompiledWorkspaceResourceRoot[], +): CompiledWorkspaceResourceRoot { + if (roots.length === 0) { + return { + logicalPath: "", + rootEntries: [], + }; + } + + if (roots.length === 1) { + return roots[0]!; + } + + const hash = createHash("sha256"); + const rootEntries = new Set(); + let hasContent = false; + + hash.update("eve-merged-workspace-resource-root-v1\0"); + for (const root of roots) { + hash.update(root.logicalPath); + hash.update("\0"); + hash.update(root.contentHash ?? ""); + hash.update("\0"); + for (const entry of root.rootEntries) { + rootEntries.add(entry); + hash.update(entry); + hash.update("\0"); + } + if (root.contentHash !== undefined || root.rootEntries.length > 0) { + hasContent = true; + } + } + + return { + contentHash: hasContent ? hash.digest("hex") : undefined, + logicalPath: roots[0]!.logicalPath, + rootEntries: [...rootEntries].sort((left, right) => left.localeCompare(right)), + }; +} + /** * Builds the framework default sandbox definition used when no agent * authored override is present. diff --git a/packages/eve/src/runtime/skills/sandbox-access.test.ts b/packages/eve/src/runtime/skills/sandbox-access.test.ts index a70da8aef..2e8958fe5 100644 --- a/packages/eve/src/runtime/skills/sandbox-access.test.ts +++ b/packages/eve/src/runtime/skills/sandbox-access.test.ts @@ -37,6 +37,26 @@ describe("loadSkillFromSandbox", () => { await expect(loadSkillFromSandbox(sandbox.access, "research")).resolves.toBe("# Research\n"); }); + it("rejects physically present skills outside an enforced active allowlist", async () => { + const sandbox = mockSandbox({ + commands: { + [HOME_PROBE_COMMAND]: { exitCode: 0, stderr: "", stdout: "/home/agent\n" }, + }, + initialFiles: { + "/home/agent/.agents/skills/child-only/SKILL.md": "# Child\n", + }, + }); + + await expect( + loadSkillFromSandbox(sandbox.access, "child-only", { + availableSkillNames: ["parent-only"], + enforceAvailableSkills: true, + }), + ).rejects.toThrow( + 'Skill "child-only" is not available to the active agent. Available skills: parent-only.', + ); + }); + it("does not read an ordinary workspace skills subtree when HOME is usable", async () => { const sandbox = mockSandbox({ commands: { @@ -100,4 +120,17 @@ describe("createSandboxSkillHandle", () => { Buffer.from("entities: []\n"), ); }); + + it("rejects handles outside an enforced active allowlist", () => { + const sandbox = mockSandbox(); + + expect(() => + createSandboxSkillHandle(sandbox.access, "child-only", { + availableSkillNames: ["parent-only"], + enforceAvailableSkills: true, + }), + ).toThrow( + 'Skill "child-only" is not available to the active agent. Available skills: parent-only.', + ); + }); }); diff --git a/packages/eve/src/runtime/skills/sandbox-access.ts b/packages/eve/src/runtime/skills/sandbox-access.ts index 1bb965075..5e885ccc8 100644 --- a/packages/eve/src/runtime/skills/sandbox-access.ts +++ b/packages/eve/src/runtime/skills/sandbox-access.ts @@ -5,6 +5,11 @@ import { resolveSandboxSkillReadPaths } from "#shared/skill-paths.js"; const FRONTMATTER_PATTERN = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/; +export interface SandboxSkillAccessOptions { + readonly availableSkillNames?: readonly string[]; + readonly enforceAvailableSkills?: boolean; +} + /** * Validates a skill id before it is used as one path segment under * the sandbox skill root. @@ -37,9 +42,11 @@ export function assertSafeSkillId(id: string): asserts id is string { export async function loadSkillFromSandbox( access: SandboxAccess, id: string, - availableNames: readonly string[] = [], + options: readonly string[] | SandboxSkillAccessOptions = {}, ): Promise { assertSafeSkillId(id); + const accessOptions = normalizeSkillAccessOptions(options); + assertSkillIsAvailable(id, accessOptions); const sandbox = await requireSandboxSession(access); const paths = await resolveSandboxSkillReadPaths({ name: id, @@ -54,7 +61,10 @@ export async function loadSkillFromSandbox( } } - const hint = availableNames.length > 0 ? ` Available skills: ${availableNames.join(", ")}.` : ""; + const hint = + accessOptions.availableSkillNames.length > 0 + ? ` Available skills: ${accessOptions.availableSkillNames.join(", ")}.` + : ""; throw new Error(`No skill named "${id}" at ${paths[0]}.${hint}`); } @@ -62,8 +72,14 @@ export async function loadSkillFromSandbox( * Creates the public runtime skill handle. Existence is checked lazily by * each file read against the sandbox. */ -export function createSandboxSkillHandle(access: SandboxAccess, id: string): SkillHandle { +export function createSandboxSkillHandle( + access: SandboxAccess, + id: string, + options: SandboxSkillAccessOptions = {}, +): SkillHandle { assertSafeSkillId(id); + const accessOptions = normalizeSkillAccessOptions(options); + assertSkillIsAvailable(id, accessOptions); return { name: id, @@ -110,6 +126,35 @@ export function createSandboxSkillHandle(access: SandboxAccess, id: string): Ski }; } +function normalizeSkillAccessOptions( + options: readonly string[] | SandboxSkillAccessOptions, +): Required { + if (Array.isArray(options)) { + return { + availableSkillNames: [...(options as readonly string[])], + enforceAvailableSkills: false, + }; + } + + const accessOptions = options as SandboxSkillAccessOptions; + return { + availableSkillNames: [...(accessOptions.availableSkillNames ?? [])], + enforceAvailableSkills: accessOptions.enforceAvailableSkills ?? false, + }; +} + +function assertSkillIsAvailable(id: string, options: Required): void { + if (!options.enforceAvailableSkills || options.availableSkillNames.includes(id)) { + return; + } + + const hint = + options.availableSkillNames.length > 0 + ? ` Available skills: ${options.availableSkillNames.join(", ")}.` + : ""; + throw new Error(`Skill "${id}" is not available to the active agent.${hint}`); +} + function assertSafeSkillRelativePath(relativePath: string): void { if ( relativePath.length === 0 || diff --git a/packages/eve/src/runtime/types.ts b/packages/eve/src/runtime/types.ts index ffc1a3fac..96b0fb0bf 100644 --- a/packages/eve/src/runtime/types.ts +++ b/packages/eve/src/runtime/types.ts @@ -30,7 +30,10 @@ import type { MarkdownSourceRef, } from "#shared/source-ref.js"; import type { NamedSkillDefinition } from "#shared/skill-definition.js"; -import type { InternalAgentDefinition } from "#shared/agent-definition.js"; +import type { + AgentInheritanceDefinition, + InternalAgentDefinition, +} from "#shared/agent-definition.js"; import type { RuntimeDynamicModelReference } from "#runtime/agent/bootstrap.js"; import type { InternalToolDefinitionWithExecuteFn } from "#shared/tool-definition.js"; import type { SandboxBackend } from "#shared/sandbox-backend.js"; @@ -268,6 +271,7 @@ export interface ResolvedChannelDefinition extends ResolvedModuleSourceRef { export type ResolvedRuntimeSubagentNode = Readonly< ModuleSourceRef & Node & { + inherit?: AgentInheritanceDefinition; kind: "subagent"; name: string; } & ( diff --git a/packages/eve/src/shared/agent-definition.ts b/packages/eve/src/shared/agent-definition.ts index e79311fb7..d191bdc18 100644 --- a/packages/eve/src/shared/agent-definition.ts +++ b/packages/eve/src/shared/agent-definition.ts @@ -190,6 +190,27 @@ export interface AgentLimitsDefinition { readonly maxOutputTokensPerSession?: number | false; } +/** + * Explicit capability inheritance for declared subagents. + * + * Only subagent configs may use this. Root agents always own their capability + * slots directly, while subagents stay isolated unless one of these flags is + * set. + */ +export interface AgentInheritanceDefinition { + /** + * Share the immediate parent's live sandbox session with this declared + * subagent. The child keeps its own instructions, tools, skills, and state. + */ + readonly sandbox?: boolean; + /** + * Reuse the immediate parent's resolved connection definitions from this + * declared subagent. Auth still resolves through the normal per-session + * connection flow; credentials are not copied into prompts or durable state. + */ + readonly connections?: boolean; +} + /** * Experimental, opt-in agent capabilities authored in `agent.ts`. * @@ -259,6 +280,7 @@ export type InternalAgentDefinition = { build?: AgentBuildDefinition; compaction?: InternalAgentCompactionDefinition; experimental?: AgentExperimentalDefinition; + inherit?: AgentInheritanceDefinition; model: InternalAgentModelDefinition; outputSchema?: JsonObject; reasoning?: AgentReasoningDefinition; @@ -287,6 +309,14 @@ export type PublicAgentDefinition = { * {@link AgentExperimentalDefinition}. */ readonly experimental?: AgentExperimentalDefinition; + /** + * Explicitly inherit selected capabilities from the immediate parent when + * this config is authored under `agent/subagents//agent.ts`. + * + * Root agents cannot use this field. Declared subagents remain isolated by + * default. + */ + readonly inherit?: AgentInheritanceDefinition; /** * Language model used for agent turns. Accepts an AI Gateway model ID, any AI * SDK-compatible language model, or `defineDynamic({ fallback, events })` for diff --git a/packages/eve/test/runtime-agent-graph.test.ts b/packages/eve/test/runtime-agent-graph.test.ts index cdf06d668..ebc0ab9f0 100644 --- a/packages/eve/test/runtime-agent-graph.test.ts +++ b/packages/eve/test/runtime-agent-graph.test.ts @@ -725,6 +725,386 @@ describe("resolveRuntimeAgentGraph", () => { }); }); + it("inherits parent connections only when a declared subagent opts in", async () => { + const appRoot = "/app"; + const agentRoot = "/app/agent"; + const researcherRoot = "/app/agent/subagents/researcher"; + const reviewerRoot = "/app/agent/subagents/reviewer"; + const inheritedConnection = { + connectionName: "github", + description: "Use GitHub", + logicalPath: "connections/github.ts", + protocol: "mcp" as const, + sourceId: "connections/github.ts", + sourceKind: "module" as const, + url: "https://mcp.github.example", + }; + const researcherManifest = createCompiledAgentNodeManifest({ + agentRoot: researcherRoot, + appRoot, + config: { + description: "Research with inherited GitHub context.", + inherit: { + connections: true, + }, + model: { + id: TEST_DEFAULT_MODEL_ID, + routing: { kind: "gateway", target: "openai" }, + }, + name: "researcher", + }, + }); + const reviewerManifest = createCompiledAgentNodeManifest({ + agentRoot: reviewerRoot, + appRoot, + config: { + description: "Review without inherited connections.", + model: { + id: TEST_DEFAULT_MODEL_ID, + routing: { kind: "gateway", target: "openai" }, + }, + name: "reviewer", + }, + }); + const manifest = createCompiledAgentManifest({ + agentRoot, + appRoot, + config: { + model: { + id: TEST_DEFAULT_MODEL_ID, + routing: { kind: "gateway", target: "openai" }, + }, + name: "router", + }, + connections: [inheritedConnection], + subagentEdges: [ + { + childNodeId: "subagents/researcher", + parentNodeId: ROOT_COMPILED_AGENT_NODE_ID, + }, + { + childNodeId: "subagents/reviewer", + parentNodeId: ROOT_COMPILED_AGENT_NODE_ID, + }, + ], + subagents: [ + { + agent: researcherManifest, + description: "Research with inherited GitHub context.", + entryPath: researcherRoot, + logicalPath: "subagents/researcher", + name: "researcher", + nodeId: "subagents/researcher", + rootPath: researcherRoot, + sourceId: "subagents/researcher", + sourceKind: "module", + }, + { + agent: reviewerManifest, + description: "Review without inherited connections.", + entryPath: reviewerRoot, + logicalPath: "subagents/reviewer", + name: "reviewer", + nodeId: "subagents/reviewer", + rootPath: reviewerRoot, + sourceId: "subagents/reviewer", + sourceKind: "module", + }, + ], + }); + const graph = await resolveRuntimeAgentGraph({ + manifest, + moduleMap: { + nodes: { + [ROOT_COMPILED_AGENT_NODE_ID]: { + modules: { + "connections/github.ts": { + default: { + url: "https://mcp.github.example", + }, + }, + }, + }, + "subagents/researcher": { + modules: {}, + }, + "subagents/reviewer": { + modules: {}, + }, + }, + }, + }); + + expect(graph.root.agent.connections.map((connection) => connection.connectionName)).toEqual([ + "github", + ]); + expect( + graph.nodesByNodeId + .get("subagents/researcher") + ?.agent.connections.map((connection) => connection.connectionName), + ).toEqual(["github"]); + expect( + graph.nodesByNodeId + .get("subagents/reviewer") + ?.agent.connections.map((connection) => connection.connectionName), + ).toEqual([]); + expect(graph.nodesByNodeId.get("subagents/researcher")?.agent.config.inherit).toEqual({ + connections: true, + sandbox: undefined, + }); + }); + + it("seeds inherited sandbox templates with inheriting child workspace resources", async () => { + const appRoot = "/app"; + const agentRoot = "/app/agent"; + const researcherRoot = "/app/agent/subagents/researcher"; + const auditorRoot = "/app/agent/subagents/researcher/subagents/auditor"; + const reviewerRoot = "/app/agent/subagents/reviewer"; + const auditorManifest = createCompiledAgentNodeManifest({ + agentRoot: auditorRoot, + appRoot, + config: { + description: "Audit inside the research sandbox.", + inherit: { + sandbox: true, + }, + model: { + id: TEST_DEFAULT_MODEL_ID, + routing: { kind: "gateway", target: "openai" }, + }, + name: "auditor", + }, + workspaceResourceRoot: { + contentHash: "auditor-resource-hash", + logicalPath: "workspace-resources/subagents/researcher::subagents/auditor", + rootEntries: ["audit-notes.md"], + }, + }); + const researcherManifest = createCompiledAgentNodeManifest({ + agentRoot: researcherRoot, + appRoot, + config: { + description: "Research in the parent sandbox.", + inherit: { + sandbox: true, + }, + model: { + id: TEST_DEFAULT_MODEL_ID, + routing: { kind: "gateway", target: "openai" }, + }, + name: "researcher", + }, + workspaceResourceRoot: { + contentHash: "researcher-resource-hash", + logicalPath: "workspace-resources/subagents/researcher", + rootEntries: ["research-notes.md"], + }, + }); + const reviewerManifest = createCompiledAgentNodeManifest({ + agentRoot: reviewerRoot, + appRoot, + config: { + description: "Review in an isolated sandbox.", + model: { + id: TEST_DEFAULT_MODEL_ID, + routing: { kind: "gateway", target: "openai" }, + }, + name: "reviewer", + }, + workspaceResourceRoot: { + contentHash: "reviewer-resource-hash", + logicalPath: "workspace-resources/subagents/reviewer", + rootEntries: ["review-notes.md"], + }, + }); + const manifest = createCompiledAgentManifest({ + agentRoot, + appRoot, + config: { + model: { + id: TEST_DEFAULT_MODEL_ID, + routing: { kind: "gateway", target: "openai" }, + }, + name: "router", + }, + subagentEdges: [ + { + childNodeId: "subagents/researcher", + parentNodeId: ROOT_COMPILED_AGENT_NODE_ID, + }, + { + childNodeId: "subagents/researcher::subagents/auditor", + parentNodeId: "subagents/researcher", + }, + { + childNodeId: "subagents/reviewer", + parentNodeId: ROOT_COMPILED_AGENT_NODE_ID, + }, + ], + subagents: [ + { + agent: researcherManifest, + description: "Research in the parent sandbox.", + entryPath: researcherRoot, + logicalPath: "subagents/researcher", + name: "researcher", + nodeId: "subagents/researcher", + rootPath: researcherRoot, + sourceId: "subagents/researcher", + sourceKind: "module", + }, + { + agent: auditorManifest, + description: "Audit inside the research sandbox.", + entryPath: auditorRoot, + logicalPath: "subagents/auditor", + name: "auditor", + nodeId: "subagents/researcher::subagents/auditor", + rootPath: auditorRoot, + sourceId: "subagents/auditor", + sourceKind: "module", + }, + { + agent: reviewerManifest, + description: "Review in an isolated sandbox.", + entryPath: reviewerRoot, + logicalPath: "subagents/reviewer", + name: "reviewer", + nodeId: "subagents/reviewer", + rootPath: reviewerRoot, + sourceId: "subagents/reviewer", + sourceKind: "module", + }, + ], + workspaceResourceRoot: { + contentHash: "root-resource-hash", + logicalPath: "workspace-resources/__root__", + rootEntries: ["root-notes.md"], + }, + }); + const graph = await resolveRuntimeAgentGraph({ + manifest, + moduleMap: { + nodes: { + [ROOT_COMPILED_AGENT_NODE_ID]: { modules: {} }, + "subagents/researcher": { modules: {} }, + "subagents/researcher::subagents/auditor": { modules: {} }, + "subagents/reviewer": { modules: {} }, + }, + }, + }); + + expect( + graph.root.sandboxRegistry.sandbox?.workspaceResourceRoots.map((root) => root.logicalPath), + ).toEqual([ + "workspace-resources/__root__", + "workspace-resources/subagents/researcher", + "workspace-resources/subagents/researcher::subagents/auditor", + ]); + expect(graph.root.sandboxRegistry.sandbox?.workspaceResourceRoot.rootEntries).toEqual([ + "audit-notes.md", + "research-notes.md", + "root-notes.md", + ]); + expect( + graph.nodesByNodeId + .get("subagents/reviewer") + ?.sandboxRegistry.sandbox?.workspaceResourceRoots.map((root) => root.logicalPath), + ).toEqual(["workspace-resources/subagents/reviewer"]); + }); + + it("rejects inherited connections that collide with a subagent-owned connection", async () => { + const appRoot = "/app"; + const agentRoot = "/app/agent"; + const researcherRoot = "/app/agent/subagents/researcher"; + const githubConnection = { + connectionName: "github", + description: "Use GitHub", + logicalPath: "connections/github.ts", + protocol: "mcp" as const, + sourceId: "connections/github.ts", + sourceKind: "module" as const, + url: "https://mcp.github.example", + }; + const researcherManifest = createCompiledAgentNodeManifest({ + agentRoot: researcherRoot, + appRoot, + config: { + description: "Research with inherited GitHub context.", + inherit: { + connections: true, + }, + model: { + id: TEST_DEFAULT_MODEL_ID, + routing: { kind: "gateway", target: "openai" }, + }, + name: "researcher", + }, + connections: [githubConnection], + }); + const manifest = createCompiledAgentManifest({ + agentRoot, + appRoot, + config: { + model: { + id: TEST_DEFAULT_MODEL_ID, + routing: { kind: "gateway", target: "openai" }, + }, + name: "router", + }, + connections: [githubConnection], + subagentEdges: [ + { + childNodeId: "subagents/researcher", + parentNodeId: ROOT_COMPILED_AGENT_NODE_ID, + }, + ], + subagents: [ + { + agent: researcherManifest, + description: "Research with inherited GitHub context.", + entryPath: researcherRoot, + logicalPath: "subagents/researcher", + name: "researcher", + nodeId: "subagents/researcher", + rootPath: researcherRoot, + sourceId: "subagents/researcher", + sourceKind: "module", + }, + ], + }); + + await expect( + resolveRuntimeAgentGraph({ + manifest, + moduleMap: { + nodes: { + [ROOT_COMPILED_AGENT_NODE_ID]: { + modules: { + "connections/github.ts": { + default: { + url: "https://mcp.github.example", + }, + }, + }, + }, + "subagents/researcher": { + modules: { + "connections/github.ts": { + default: { + url: "https://mcp.github.example", + }, + }, + }, + }, + }, + }, + }), + ).rejects.toThrow( + 'Subagent node "subagents/researcher" inherits connection "github" but also defines a connection with that name.', + ); + }); + it("lets an authored tool replace a framework tool by name collision", async () => { const manifest = createCompiledAgentManifest({ agentRoot: "/app/agent",