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/docs/subagents.mdx b/docs/subagents.mdx index 99a500b6f..ff0abe540 100644 --- a/docs/subagents.mdx +++ b/docs/subagents.mdx @@ -1,6 +1,6 @@ --- title: "Subagents" -description: "Delegate work to child agents, either a copy of the agent itself or declared specialists with their own sandbox and skills." +description: "Delegate work to child agents, either a copy of the agent itself or declared specialists with isolated or explicitly inherited capabilities." --- A subagent is a child agent that one agent delegates a focused subtask to. Split work into one to run it in parallel, to give the child a narrower set of tools, or to give a specialist its own identity. There are two kinds, the built-in `agent` tool (a copy of the agent itself) and declared subagents (specialists with their own directory). @@ -53,26 +53,53 @@ 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 | Built-in `agent` tool | Declared subagent | -| ------------ | ----------------------------- | -------------------------------------- | -| Instructions | Inherited (copy of the agent) | Own `instructions.{md,ts}`, optional | -| Tools | Inherited | 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 | Built-in `agent` tool | Declared subagent | +| ------------ | ----------------------------- | ------------------------------------------------------ | +| Instructions | Inherited (copy of the agent) | Own `instructions.{md,ts}`, optional | +| Tools | Inherited | 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 built-in `agent` tool is the exception. Its children share the parent'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. + +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 (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/src/cli/commands/info.ts b/packages/eve/src/cli/commands/info.ts index 6221bb84c..39d5d5edb 100644 --- a/packages/eve/src/cli/commands/info.ts +++ b/packages/eve/src/cli/commands/info.ts @@ -21,6 +21,20 @@ export interface ApplicationInfoJson { model: string | null; instructions: string | null; skills: string[]; + subagents: { + name: string; + effective: { + connections: { + inherited: boolean; + owned: number; + }; + sandbox: "default" | "inherited" | "owned"; + }; + inherit: { + connections: boolean; + sandbox: boolean; + }; + }[]; tools: string[]; channels: { name: string; kind: string | null; method: string | null; urlPath: string | null }[]; messaging: { create: string; continue: string; stream: string }; @@ -54,6 +68,28 @@ export function buildApplicationInfoJson(inspection: ApplicationInspection): App model: compiledState?.manifest.config.model.id ?? null, instructions: compiledState?.manifest.instructions?.logicalPath ?? null, skills: (compiledState?.manifest.skills ?? []).map((skill) => skill.name), + subagents: (compiledState?.manifest.subagents ?? []).map((subagent) => { + const inheritsConnections = subagent.agent.config.inherit?.connections === true; + const inheritsSandbox = subagent.agent.config.inherit?.sandbox === true; + return { + name: subagent.name, + 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, + }, + }; + }), tools: (compiledState?.manifest.tools ?? []).map((tool) => tool.name), channels: (compiledState?.manifest.channels ?? []).map((channel) => channel.kind === "channel" @@ -90,6 +126,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": @@ -118,6 +167,7 @@ export async function printApplicationInfo( const compiledState = inspection.compiledState; const info = inspection.application; + const applicationInfoJson = buildApplicationInfoJson(inspection); const theme = createCliTheme(); const applicationRows: CliRow[] = [ { @@ -136,6 +186,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( @@ -240,6 +298,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 2ceda11d4..bfd07ac2a 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(), 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/manifest.test.ts b/packages/eve/src/compiler/manifest.test.ts index c3a1423bf..eb8b905f0 100644 --- a/packages/eve/src/compiler/manifest.test.ts +++ b/packages/eve/src/compiler/manifest.test.ts @@ -112,4 +112,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 3e7745920..318a1cba3 100644 --- a/packages/eve/src/compiler/manifest.ts +++ b/packages/eve/src/compiler/manifest.ts @@ -24,6 +24,7 @@ import type { InternalAgentModelDefinition, InternalAgentCompactionDefinition, AgentBuildDefinition, + AgentInheritanceDefinition, ModelRouting, } from "#shared/agent-definition.js"; import type { InternalToolDefinition } from "#shared/tool-definition.js"; @@ -121,6 +122,8 @@ type CompiledAgentCompactionDefinition = Omit = z + .object({ + connections: z.boolean().optional(), + sandbox: z.boolean().optional(), + }) + .strict(); + const compiledAgentConfigSchema: z.ZodType = z .object({ build: compiledAgentBuildDefinitionSchema.optional(), @@ -413,6 +423,7 @@ const compiledAgentConfigSchema: z.ZodType = z }) .strict() .optional(), + inherit: compiledAgentInheritanceDefinitionSchema.optional(), model: compiledRuntimeModelReferenceSchema, name: z.string(), outputSchema: jsonObjectSchema.optional(), @@ -804,6 +815,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, @@ -916,6 +934,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 7c75c0df5..5d769fcf5 100644 --- a/packages/eve/src/compiler/normalize-agent-config.test.ts +++ b/packages/eve/src/compiler/normalize-agent-config.test.ts @@ -56,6 +56,32 @@ describe("compileAgentConfig", () => { sourceKind: "module", }); }); + + 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, + }); + }); }); function createModelCatalog(): ManifestCompileContext["modelCatalog"] { diff --git a/packages/eve/src/compiler/normalize-agent-config.ts b/packages/eve/src/compiler/normalize-agent-config.ts index 3ef0e3e98..1f55e8c51 100644 --- a/packages/eve/src/compiler/normalize-agent-config.ts +++ b/packages/eve/src/compiler/normalize-agent-config.ts @@ -74,6 +74,7 @@ export async function compileAgentConfig( description?: string; dynamicModel?: CompiledAgentDefinition["dynamicModel"]; experimental?: CompiledAgentDefinition["experimental"]; + inherit?: CompiledAgentDefinition["inherit"]; model: CompiledRuntimeModelReference; name: string; outputSchema?: JsonObject; @@ -108,6 +109,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 38cc0b3a9..e82f325ca 100644 --- a/packages/eve/src/compiler/normalize-manifest.test.ts +++ b/packages/eve/src/compiler/normalize-manifest.test.ts @@ -77,11 +77,129 @@ describe("compileAgentManifest", () => { 'Remove "experimental.workflow" from "research"', ); }); + + 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.', + ); + }); }); function createConfig( input: Pick & - Partial>, + Partial>, ): CompiledAgentDefinition { const config: CompiledAgentDefinition = { model: { @@ -97,6 +215,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 f1268f418..13b45edab 100644 --- a/packages/eve/src/compiler/normalize-manifest.ts +++ b/packages/eve/src/compiler/normalize-manifest.ts @@ -75,15 +75,29 @@ async function compileAgentNodeManifest( context: ManifestCompileContext, options: { readonly externalDependencies?: readonly string[]; + readonly allowInheritanceConfig?: boolean; readonly allowWorkflowConfig?: boolean; } = {}, ): Promise { const rawConfig = 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 11458ffdf..a16c9ec6b 100644 --- a/packages/eve/src/compiler/normalize-subagent.ts +++ b/packages/eve/src/compiler/normalize-subagent.ts @@ -34,6 +34,7 @@ export type CompileAgentNodeManifestFn = ( context: ManifestCompileContext, options?: { readonly externalDependencies?: readonly string[]; + readonly allowInheritanceConfig?: boolean; readonly allowWorkflowConfig?: boolean; }, ) => Promise; @@ -170,7 +171,11 @@ async function compileSubagent(input: { appRoot: input.appRoot, }, input.context, - { allowWorkflowConfig: false, externalDependencies: input.externalDependencies }, + { + allowInheritanceConfig: true, + allowWorkflowConfig: false, + externalDependencies: input.externalDependencies, + }, ); const description = agent.config.description; 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 a2cf12f65..6d47002f3 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 { HandleMessageStreamEvent } 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 26b730745..015e22852 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 05f07015a..ac09ff122 100644 --- a/packages/eve/src/context/keys.ts +++ b/packages/eve/src/context/keys.ts @@ -88,6 +88,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 // --------------------------------------------------------------------------- @@ -159,6 +187,15 @@ export const LiveStepToolsKey = new ContextKey< // Dynamic skill keys // --------------------------------------------------------------------------- +/** + * Static skill names visible to the active agent node. + * + * Seeded from the compiled runtime bundle when the run context is created so + * callback/harness code can enforce skill visibility without importing + * runtime-tier bundle keys. + */ +export const StaticSkillNamesKey = 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..4c5fa4438 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,22 +46,150 @@ 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 = { + 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, }, - } as CompiledBundle; + 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: { + 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, + }, + }; } describe("sandboxProvider", () => { @@ -83,4 +220,205 @@ 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("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..d38314907 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,139 @@ export const sandboxProvider: FrameworkContextProvider = { }, }; +function resolveSandboxRuntimeNode(input: { + readonly bundle: CompiledBundle; + readonly node: ReturnType; + readonly sandboxNodeId: unknown; +}): ReturnType { + if (typeof input.sandboxNodeId !== "string") { + return input.node; + } + + return input.bundle.graph.nodesByNodeId.get(input.sandboxNodeId) ?? input.node; +} + +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 c4969edf5..35bb2b3e3 100644 --- a/packages/eve/src/execution/delegated-parent-notification.test.ts +++ b/packages/eve/src/execution/delegated-parent-notification.test.ts @@ -4,10 +4,12 @@ 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, SUBAGENT_ADAPTER_KIND } from "#execution/subagent-adapter.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(), @@ -30,7 +32,9 @@ function createSuccessResult(): RuntimeSubagentResultActionResult { }; } -function createSerializedContext(): Record { +function createSerializedContext( + adapterState: Readonly> = {}, +): Record { const bundle = { adapterRegistry: { adaptersByKind: new Map([[SUBAGENT_ADAPTER_KIND, SUBAGENT_ADAPTER]]), @@ -49,11 +53,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(); @@ -113,4 +135,83 @@ 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, + }, + }, + ], + }); + }); }); diff --git a/packages/eve/src/execution/delegated-parent-notification.ts b/packages/eve/src/execution/delegated-parent-notification.ts index 7fde04201..79f1359f9 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.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,76 @@ 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 { + const sandboxSessionId = readNonEmptyString(input.adapterState?.sandboxSessionId); + + if (sandboxSessionId === undefined) { + return undefined; + } + + const session = await readDurableSession(input.sessionState); + + if (session.sandboxState === undefined) { + return undefined; + } + + const inheritedSandbox: { + nodeId?: string; + sessionId: string; + state: NonNullable; + } = { + sessionId: sandboxSessionId, + 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 2b6a1c546..ddc9c7fa6 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"; @@ -35,6 +36,7 @@ import { type DurableSessionState, readDurableSession, } from "#execution/durable-session-store.js"; +import { ROOT_RUNTIME_AGENT_NODE_ID } from "#runtime/graph.js"; import { resolveRemoteAgentForAction, startRemoteAgentSession, @@ -51,6 +53,7 @@ import { resolveSubagentDelegationLimit, type SubagentDelegationLimit, } from "#harness/subagent-depth.js"; +import type { HarnessSession } from "#harness/types.js"; const log = createLogger("execution.dispatch-runtime-actions"); @@ -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(); @@ -123,10 +129,30 @@ 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 source: SubagentInputSource = registered?.definition.kind === "subagent" - ? { description: registered.definition.description, type: "local" } - : { type: "runtime" }; + ? { + description: registered.definition.description, + effectiveSandbox: createEffectiveSandboxSource({ + adapterState: adapter.state, + parentNodeId, + protectedDynamicSkillNames: currentDynamicSkillNames, + session, + }), + inherit: registered.definition.inherit, + parentNodeId, + type: "local", + } + : { + effectiveSandbox: createEffectiveSandboxSource({ + adapterState: adapter.state, + parentNodeId, + protectedDynamicSkillNames: currentDynamicSkillNames, + session, + }), + type: "runtime", + }; const childRuntime = createWorkflowRuntime({ compiledArtifactsSource: bundle.compiledArtifactsSource, nodeId: action.nodeId, @@ -217,6 +243,81 @@ 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"] { + const inheritedSandboxNodeId = input.adapterState?.sandboxNodeId; + const inheritedSandboxSessionId = 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: + typeof inheritedSandboxNodeId === "string" ? inheritedSandboxNodeId : input.parentNodeId, + sandboxSessionId: + typeof inheritedSandboxSessionId === "string" + ? 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 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 createRemoteAgentStartFailureResult(input: { readonly action: RuntimeRemoteAgentCallActionRequest; readonly error: unknown; diff --git a/packages/eve/src/execution/runtime-context.ts b/packages/eve/src/execution/runtime-context.ts index db18fd854..39aaca56b 100644 --- a/packages/eve/src/execution/runtime-context.ts +++ b/packages/eve/src/execution/runtime-context.ts @@ -11,6 +11,7 @@ import { ModeKey, ParentSessionKey, SessionCallbackKey, + StaticSkillNamesKey, SubagentDepthKey, } from "#context/keys.js"; import { BundleKey, type CompiledBundle } from "#runtime/sessions/runtime-context-keys.js"; @@ -27,6 +28,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 db07d14fc..9799fa5fa 100644 --- a/packages/eve/src/execution/sandbox/ensure.test.ts +++ b/packages/eve/src/execution/sandbox/ensure.test.ts @@ -49,6 +49,7 @@ function createTestRegistry( definition: Partial, backend: SandboxBackend, ): RuntimeSandboxRegistry { + const workspaceResourceRoot = { logicalPath: "", rootEntries: [] }; const resolved: ResolvedSandboxDefinition = { backend, logicalPath: "agent/sandbox/sandbox.ts", @@ -61,7 +62,8 @@ function createTestRegistry( return { sandbox: { definition: resolved, - workspaceResourceRoot: { logicalPath: "", rootEntries: [] }, + workspaceResourceRoot, + workspaceResourceRoots: [workspaceResourceRoot], }, }; } @@ -319,6 +321,53 @@ 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 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 6e63e3065..817cbe506 100644 --- a/packages/eve/src/execution/sandbox/ensure.ts +++ b/packages/eve/src/execution/sandbox/ensure.ts @@ -30,11 +30,16 @@ 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>(); + /** * Creates or reattaches the live sandbox from the compiled agent bundle's * registry and persisted session state, returning a {@link SandboxAccess} @@ -128,37 +133,76 @@ 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, - }), - ); - markDevelopmentSandboxBackendInitialized(backend.name); - 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, + }), + ); + markDevelopmentSandboxBackendInitialized(backend.name); + 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; + } + + if (initialized) { + return await openHandle(); + } + + const lockKey = createSandboxSessionInitializationLockKey({ + appRoot, backendName: backend.name, - handle, sessionKey: keys.sessionKey, }); - - if (!initialized) { - await runOnSession(async () => { - await definition.onSession?.({ ctx: buildCallbackContext(), use: handle.useSessionFn }); - }); + const existingLock = sandboxSessionInitializationLocks.get(lockKey); + if (existingLock !== undefined) { + const handle = await existingLock; initialized = true; + return handle; } - return handle; + const lock = openAndInitializeHandle(); + sandboxSessionInitializationLocks.set(lockKey, lock); + try { + return await lock; + } 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(); @@ -185,6 +229,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 db16a5c29..a501d2322 100644 --- a/packages/eve/src/execution/sandbox/prewarm.ts +++ b/packages/eve/src/execution/sandbox/prewarm.ts @@ -237,38 +237,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, - 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, + workspaceResourceRoots, + }), + runtimeContext, + templateKey, + }, + signature: `${definition.backend.name}:${nodeId}:${templateKey}`, + }); + }, + ), ); // Template keys factor in nodeId (see runtime/sandbox/keys.ts), so each @@ -286,21 +288,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/subagent-tool.test.ts b/packages/eve/src/execution/subagent-tool.test.ts index fff6bd97d..e12035b69 100644 --- a/packages/eve/src/execution/subagent-tool.test.ts +++ b/packages/eve/src/execution/subagent-tool.test.ts @@ -303,6 +303,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 }; @@ -317,4 +381,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 083fed779..c74cf39a8 100644 --- a/packages/eve/src/execution/subagent-tool.ts +++ b/packages/eve/src/execution/subagent-tool.ts @@ -8,6 +8,7 @@ import type { SessionCapabilities, } from "#channel/types.js"; import type { HarnessSession } from "#harness/types.js"; +import type { AgentInheritanceDefinition } from "#shared/agent-definition.js"; import type { JsonObject } from "#shared/json.js"; import type { RuntimeSubagentCallActionRequest } from "#runtime/actions/types.js"; import { mintSubagentContinuationToken } from "#execution/session.js"; @@ -25,12 +26,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}. * @@ -94,6 +106,11 @@ export function buildSubagentRunInput(input: { // children. const rootSessionId = session.rootSessionId ?? session.sessionId; const delegationLimit = resolveSubagentDelegationLimit(session); + const sharedSandboxState = createSharedSandboxAdapterState({ + session, + source, + subagentName: action.subagentName, + }); const inheritedLimits: { -readonly [K in keyof RunSessionLimits]: RunSessionLimits[K]; } = resolveRemainingSessionTokenLimits(session, input.fanoutSize); @@ -114,9 +131,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,84 @@ 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; +} { + if (input.source.type === "runtime" && input.subagentName === "agent") { + const effectiveSandbox = input.source.effectiveSandbox; + const parentSandboxState = input.session.sandboxState ?? effectiveSandbox?.parentSandboxState; + const sandboxSessionId = + effectiveSandbox?.sandboxSessionId ?? + (parentSandboxState === undefined ? undefined : input.session.sessionId); + + if (sandboxSessionId === undefined) { + return {}; + } + + const state: { + parentSandboxState?: HarnessSession["sandboxState"]; + sandboxNodeId?: string; + sandboxOwnerDynamicSkillNames?: readonly string[]; + sandboxSessionId: string; + } = { + sandboxSessionId, + }; + + if (parentSandboxState !== undefined) { + state.parentSandboxState = parentSandboxState; + } + if (effectiveSandbox?.sandboxNodeId !== undefined) { + state.sandboxNodeId = effectiveSandbox.sandboxNodeId; + } + if ( + effectiveSandbox?.sandboxOwnerDynamicSkillNames !== undefined && + effectiveSandbox.sandboxOwnerDynamicSkillNames.length > 0 + ) { + state.sandboxOwnerDynamicSkillNames = effectiveSandbox.sandboxOwnerDynamicSkillNames; + } + + return state; + } + + if (input.source.type !== "local" || input.source.inherit?.sandbox !== true) { + return {}; + } + + const effectiveSandbox = input.source.effectiveSandbox; + const parentSandboxState = input.session.sandboxState ?? effectiveSandbox?.parentSandboxState; + const sandboxNodeId = effectiveSandbox?.sandboxNodeId ?? input.source.parentNodeId; + const state: { + parentSandboxState?: HarnessSession["sandboxState"]; + sandboxNodeId?: string; + sandboxOwnerDynamicSkillNames?: readonly string[]; + sandboxSessionId: string; + } = { + sandboxSessionId: effectiveSandbox?.sandboxSessionId ?? input.session.sessionId, + }; + + 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 2f9d439cc..fd9fd8f03 100644 --- a/packages/eve/src/execution/workflow-entry.test.ts +++ b/packages/eve/src/execution/workflow-entry.test.ts @@ -302,9 +302,59 @@ 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("later turn failed"); + + expect(notifyDelegatedParentStep).toHaveBeenCalledWith( + expect.objectContaining({ + sessionState: parkedState, + }), + ); + }); + it("passes the resumed channel request id to the next turn", async () => { const sessionState = createBaseSessionState(); vi.mocked(createSessionStep).mockResolvedValue(createSessionStepResultForMock(sessionState)); diff --git a/packages/eve/src/execution/workflow-entry.ts b/packages/eve/src/execution/workflow-entry.ts index 7b10b55e6..45b071d00 100644 --- a/packages/eve/src/execution/workflow-entry.ts +++ b/packages/eve/src/execution/workflow-entry.ts @@ -83,6 +83,7 @@ export async function workflowEntry(input: WorkflowEntryInput): Promise(); + let sessionStateForFailure: DurableSessionState | undefined; try { // Derived once and reused for createSession + tag emission so the @@ -100,6 +101,7 @@ export async function workflowEntry(input: WorkflowEntryInput): Promise { + sessionStateForFailure = state; + }, serializedContext: input.serializedContext, sessionState, }); @@ -134,10 +139,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; }): Promise { @@ -183,6 +198,7 @@ async function runDriverLoop(input: { serializedContext: input.serializedContext, sessionState: input.sessionState, }); + input.onSessionStateChange?.(action.sessionState); while (true) { if (action.kind === "done") { @@ -238,6 +254,7 @@ async function runDriverLoop(input: { serializedContext: action.serializedContext, sessionState: action.sessionState, }); + input.onSessionStateChange?.(action.sessionState); continue; } @@ -278,6 +295,7 @@ async function runDriverLoop(input: { serializedContext: action.serializedContext, sessionState: action.sessionState, }); + input.onSessionStateChange?.(action.sessionState); } } finally { await deliveryHook.dispose(); @@ -305,6 +323,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 ef46e3c2c..6811ecdff 100644 --- a/packages/eve/src/execution/workflow-steps.test.ts +++ b/packages/eve/src/execution/workflow-steps.test.ts @@ -4,7 +4,13 @@ import type { ChannelAdapter, ChannelAdapterContext } from "#channel/adapter.js" import type { DeliverPayload, SubagentInputRequestHookPayload } from "#channel/types.js"; import { ContextContainer } from "#context/container.js"; import { ContextKey } from "#context/key.js"; -import { AuthKey, ContinuationTokenKey, ModeKey, SessionIdKey } from "#context/keys.js"; +import { + AuthKey, + ContinuationTokenKey, + DynamicSkillManifestKey, + ModeKey, + SessionIdKey, +} from "#context/keys.js"; import { BundleKey, ChannelKey } from "#runtime/sessions/runtime-context-keys.js"; import { serializeContext } from "#context/serialize.js"; import { setPendingRuntimeActionBatch } from "#harness/runtime-actions.js"; @@ -414,6 +420,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 ae8229a53..5463c1a8a 100644 --- a/packages/eve/src/harness/runtime-actions.test.ts +++ b/packages/eve/src/harness/runtime-actions.test.ts @@ -102,4 +102,99 @@ 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); + }); }); + +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 90cefd100..97b4a6e6a 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 HandleMessageStreamEvent } 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 { @@ -258,7 +262,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, @@ -275,6 +279,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; @@ -351,6 +360,48 @@ 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; + } + + nextSession = { + ...nextSession, + sandboxState: result.inheritedSandbox.state, + }; + } + + return nextSession; +} + +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 f1ce81d45..04b340f94 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.each([0, 1.5, -1, "4"])("rejects invalid subagent max depth %j", (maxSubagentDepth) => { expect(() => normalizeAgentDefinition( diff --git a/packages/eve/src/internal/authored-definition/core.ts b/packages/eve/src/internal/authored-definition/core.ts index 57917cf26..c6d7fb531 100644 --- a/packages/eve/src/internal/authored-definition/core.ts +++ b/packages/eve/src/internal/authored-definition/core.ts @@ -50,6 +50,7 @@ export function normalizeAgentDefinition( "compaction", "description", "experimental", + "inherit", "limits", "model", "modelContextWindowTokens", @@ -83,6 +84,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); } @@ -109,6 +114,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, @@ -137,6 +160,14 @@ function expectPositiveInteger(value: unknown, message: string): number { return value; } +function expectBoolean(value: unknown, message: string): boolean { + if (typeof value !== "boolean") { + throw new Error(message); + } + + return value; +} + function normalizeAgentModelDefinition( 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 new file mode 100644 index 000000000..11caef06e --- /dev/null +++ b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { createCompiledAgentNodeManifest } from "#compiler/manifest.js"; +import { classifyModelRouting } from "#internal/classify-model-routing.js"; + +import { renderSubagent } from "./build-agent-info-response.js"; + +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 f0fcf6d39..34a390ed6 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 @@ -94,6 +94,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; @@ -483,10 +494,28 @@ function renderSandbox(sandbox: ResolvedSandboxDefinition | null): AgentInfoSand } export function renderSubagent(subagent: CompiledSubagentNode): AgentInfoSubagentEntry { + const inheritsSandbox = subagent.agent.config.inherit?.sandbox === true; + const inheritsConnections = subagent.agent.config.inherit?.connections === true; + return { ...toSource(subagent), description: subagent.description, entryPath: subagent.entryPath, + 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, + }, 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 f10f75951..b34f51737 100644 --- a/packages/eve/src/internal/testing/app-harness.ts +++ b/packages/eve/src/internal/testing/app-harness.ts @@ -191,7 +191,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); @@ -243,7 +245,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; @@ -254,6 +259,7 @@ function buildActiveSessionContextInit(init: RunAsSessionInit | undefined): Acti parent?: SessionParent; sandbox?: SandboxAccess; channel?: ChannelAdapter; + staticSkillNames?: readonly string[]; } = { sessionId, turn, @@ -271,5 +277,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 cf7b5bbf4..badc0f8df 100644 --- a/packages/eve/src/public/definitions/agent.ts +++ b/packages/eve/src/public/definitions/agent.ts @@ -17,6 +17,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 89c12df7f..9cd4f2baf 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..47284ce69 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,44 @@ 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. + */ +export interface RuntimeInheritedSandboxResult { + readonly nodeId?: string; + 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.test.ts b/packages/eve/src/runtime/framework-tools/skill.test.ts index be7ebb141..2311512f5 100644 --- a/packages/eve/src/runtime/framework-tools/skill.test.ts +++ b/packages/eve/src/runtime/framework-tools/skill.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vitest"; import { ContextContainer, contextStorage } from "#context/container.js"; -import { DynamicSkillManifestKey, SandboxKey } from "#context/keys.js"; +import { DynamicSkillManifestKey, SandboxKey, StaticSkillNamesKey } from "#context/keys.js"; import { ConnectionRegistryKey } from "#context/providers/connection-key.js"; import { mockSandbox } from "#internal/testing/mocks/mock-sandbox.js"; import type { ConnectionRegistry } from "#runtime/connections/types.js"; import { SKILL_TOOL_DEFINITION } from "#runtime/framework-tools/skill.js"; +const HOME_PROBE_COMMAND = `printf '%s\\n' "$HOME"`; + describe("SKILL_TOOL_DEFINITION", () => { it("describes when skill loading should be used", () => { expect(SKILL_TOOL_DEFINITION.description).toContain( @@ -22,6 +24,58 @@ describe("SKILL_TOOL_DEFINITION", () => { }); describe("load_skill executor", () => { + it("loads a static skill visible to the active agent", async () => { + const ctx = new ContextContainer(); + ctx.set( + SandboxKey, + mockSandbox({ + commands: { + [HOME_PROBE_COMMAND]: { exitCode: 0, stderr: "", stdout: "/home/agent\n" }, + }, + initialFiles: { + "/home/agent/.agents/skills/parent-only/SKILL.md": "# Parent\n", + }, + }).access, + ); + ctx.set(StaticSkillNamesKey, ["parent-only"]); + + const execute = SKILL_TOOL_DEFINITION.execute; + if (execute === undefined) throw new Error("load_skill tool is missing an execute function"); + + await expect( + contextStorage.run(ctx, () => + execute({ skill: "parent-only" }, { messages: [], toolCallId: "call_1" }), + ), + ).resolves.toBe("# Parent\n"); + }); + + it("does not load static skills hidden from the active agent", async () => { + const ctx = new ContextContainer(); + 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", + }, + }).access, + ); + ctx.set(StaticSkillNamesKey, ["parent-only"]); + + const execute = SKILL_TOOL_DEFINITION.execute; + if (execute === undefined) throw new Error("load_skill tool is missing an execute function"); + + await expect( + contextStorage.run(ctx, () => + execute({ skill: "child-only" }, { messages: [], toolCallId: "call_1" }), + ), + ).rejects.toThrow( + 'Skill "child-only" is not available to the active agent. Available skills: parent-only.', + ); + }); + it("surfaces dynamic skill names when the requested id is missing", async () => { const ctx = new ContextContainer(); ctx.set(SandboxKey, mockSandbox().access); diff --git a/packages/eve/src/runtime/framework-tools/skill.ts b/packages/eve/src/runtime/framework-tools/skill.ts index 21f32c16b..448884d00 100644 --- a/packages/eve/src/runtime/framework-tools/skill.ts +++ b/packages/eve/src/runtime/framework-tools/skill.ts @@ -1,6 +1,7 @@ import { loadContext } from "#context/container.js"; -import { DynamicSkillManifestKey, SandboxKey } from "#context/keys.js"; +import { SandboxKey } from "#context/keys.js"; import { ConnectionRegistryKey } from "#context/providers/connection-key.js"; +import { getAvailableSkillNames } from "#context/available-skills.js"; import { loadSkillFromSandbox } from "#runtime/skills/sandbox-access.js"; import type { ResolvedToolDefinition } from "#runtime/types.js"; import type { JsonObject } from "#shared/json.js"; @@ -32,7 +33,10 @@ async function executeLoadSkillTool(args: LoadSkillInput): Promise { const { skill } = args; const availableSkills = availableSkillNames(ctx); try { - return await loadSkillFromSandbox(sandbox, skill, availableSkills); + return await loadSkillFromSandbox(sandbox, skill, { + availableSkillNames: availableSkills, + enforceAvailableSkills: true, + }); } catch (error) { const connectionName = ctx .get(ConnectionRegistryKey) @@ -49,14 +53,8 @@ async function executeLoadSkillTool(args: LoadSkillInput): Promise { } } -// Dynamic skill names for load_skill's not-found hint. Dynamic-only on purpose: -// importing the bundle (for authored skills) would cycle through the -// framework-tools barrel. function availableSkillNames(ctx: ReturnType): string[] { - const dynamic = Object.values(ctx.get(DynamicSkillManifestKey) ?? {}) - .flat() - .map((s) => s.name); - return [...new Set(dynamic)].sort(); + return getAvailableSkillNames(ctx); } // --------------------------------------------------------------------------- diff --git a/packages/eve/src/runtime/resolve-agent-graph.ts b/packages/eve/src/runtime/resolve-agent-graph.ts index 9d11ea9c9..3609e3efa 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"; @@ -28,7 +29,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, ResolvedRuntimeDelegationNode, ResolvedRuntimeRemoteAgentNode, ResolvedRuntimeSubagentNode, @@ -108,6 +111,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; @@ -129,11 +133,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 hasConnections = agent.connections.length > 0; const frameworkTools = getFrameworkToolDefinitions({ hasConnections }); const frameworkToolNames = new Set(frameworkTools.map((t) => t.name)); @@ -210,6 +220,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: [ @@ -221,6 +237,7 @@ async function resolveRuntimeAgentNode( manifest: input.manifest, moduleMap: input.moduleMap, nodesByNodeId: input.nodesByNodeId, + parentConnections: agent.connections, parentNodeId: input.nodeId, subagentNodesById: input.subagentNodesById, }), @@ -253,11 +270,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 { @@ -282,6 +362,7 @@ async function resolveRuntimeSubagents(input: { childNodeIdsByParentNodeId: input.childNodeIdsByParentNodeId, moduleMap: input.moduleMap, nodesByNodeId: input.nodesByNodeId, + parentConnections: input.parentConnections, sourceRef, subagentNodesById: input.subagentNodesById, }), @@ -305,11 +386,13 @@ async function resolveRuntimeSubagent(input: { readonly childNodeIdsByParentNodeId: ReadonlyMap; readonly moduleMap: CompiledModuleMap; readonly nodesByNodeId: Map; + readonly parentConnections: readonly ResolvedConnectionDefinition[]; readonly sourceRef: CompiledSubagentNode; readonly subagentNodesById: ReadonlyMap; }): Promise { const resolvedSubagent: ResolvedRuntimeSubagentNode = { description: input.sourceRef.description, + inherit: input.sourceRef.agent.config.inherit, kind: "subagent", logicalPath: input.sourceRef.logicalPath, name: input.sourceRef.name, @@ -319,6 +402,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 89801eb20..08dcd0d60 100644 --- a/packages/eve/src/runtime/resolve-agent.ts +++ b/packages/eve/src/runtime/resolve-agent.ts @@ -158,6 +158,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 @@ -230,6 +231,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 12986ed1c..d7d3f7dc4 100644 --- a/packages/eve/src/runtime/types.ts +++ b/packages/eve/src/runtime/types.ts @@ -32,7 +32,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 type ResolvedRuntimeSubagentNode = Readonly< ModuleSourceRef & Node & { description: string; + 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 a4ecddb55..7a0e80f85 100644 --- a/packages/eve/src/shared/agent-definition.ts +++ b/packages/eve/src/shared/agent-definition.ts @@ -206,6 +206,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`. * @@ -268,6 +289,7 @@ export type InternalAgentDefinition = { build?: AgentBuildDefinition; compaction?: InternalAgentCompactionDefinition; experimental?: AgentExperimentalDefinition; + inherit?: AgentInheritanceDefinition; model: InternalAgentModelDefinition; outputSchema?: JsonObject; reasoning?: AgentReasoningDefinition; @@ -296,6 +318,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 b0d7dac0d..d0a8167a4 100644 --- a/packages/eve/test/runtime-agent-graph.test.ts +++ b/packages/eve/test/runtime-agent-graph.test.ts @@ -641,6 +641,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",