From 8b036a7fb364978370e0fbbcee4542ca0ec1aa90 Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Sat, 22 Aug 2026 11:46:29 -0400 Subject: [PATCH 01/12] refactor(eve): compile framework defaults as agent sources Route the default sandbox and core workspace tools through the same composition, compilation, and module-loading pipeline as application-authored modules. Signed-off-by: Andrew Barba --- .changeset/warm-framework-sources.md | 5 + .../compose-framework-sources.test.ts | 60 +++++++++ .../src/compiler/compose-framework-sources.ts | 101 +++++++++++++++ packages/eve/src/compiler/module-map.test.ts | 35 +++++ packages/eve/src/compiler/module-map.ts | 16 ++- .../compiler/normalize-agent-config.test.ts | 27 ++-- .../src/compiler/normalize-agent-config.ts | 2 + .../eve/src/compiler/normalize-channel.ts | 2 + .../eve/src/compiler/normalize-connection.ts | 2 + .../eve/src/compiler/normalize-helpers.ts | 2 + .../src/compiler/normalize-instructions.ts | 2 + .../src/compiler/normalize-manifest.test.ts | 113 +++++++++++++--- .../eve/src/compiler/normalize-manifest.ts | 122 ++++++++++++++---- .../eve/src/compiler/normalize-sandbox.ts | 16 ++- .../eve/src/compiler/normalize-schedule.ts | 2 + packages/eve/src/compiler/normalize-skill.ts | 2 + .../eve/src/compiler/normalize-subagent.ts | 42 ++++-- packages/eve/src/compiler/normalize-tool.ts | 2 + .../eve/src/framework-sources/constants.ts | 1 + .../eve/src/framework-sources/registry.ts | 25 ++++ packages/eve/src/framework-sources/sandbox.ts | 3 + .../eve/src/framework-sources/tools/bash.ts | 1 + .../src/framework-sources/tools/read_file.ts | 1 + .../eve/src/framework-sources/tools/todo.ts | 1 + .../src/framework-sources/tools/web_fetch.ts | 1 + .../src/framework-sources/tools/write_file.ts | 1 + .../internal/authored-module-map-loader.ts | 12 +- packages/eve/src/public/tools/defaults.ts | 108 ++++++++++++++-- 28 files changed, 620 insertions(+), 87 deletions(-) create mode 100644 .changeset/warm-framework-sources.md create mode 100644 packages/eve/src/compiler/compose-framework-sources.test.ts create mode 100644 packages/eve/src/compiler/compose-framework-sources.ts create mode 100644 packages/eve/src/framework-sources/constants.ts create mode 100644 packages/eve/src/framework-sources/registry.ts create mode 100644 packages/eve/src/framework-sources/sandbox.ts create mode 100644 packages/eve/src/framework-sources/tools/bash.ts create mode 100644 packages/eve/src/framework-sources/tools/read_file.ts create mode 100644 packages/eve/src/framework-sources/tools/todo.ts create mode 100644 packages/eve/src/framework-sources/tools/web_fetch.ts create mode 100644 packages/eve/src/framework-sources/tools/write_file.ts diff --git a/.changeset/warm-framework-sources.md b/.changeset/warm-framework-sources.md new file mode 100644 index 0000000000..39b5717c74 --- /dev/null +++ b/.changeset/warm-framework-sources.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Compile the default sandbox and core workspace tools through the same module-source pipeline as application-authored definitions. Application files continue to override defaults by their path-derived identity. diff --git a/packages/eve/src/compiler/compose-framework-sources.test.ts b/packages/eve/src/compiler/compose-framework-sources.test.ts new file mode 100644 index 0000000000..1d2fa9b4cf --- /dev/null +++ b/packages/eve/src/compiler/compose-framework-sources.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { composeFrameworkSources } from "#compiler/compose-framework-sources.js"; +import { createAgentSourceManifest, createModuleSourceRef } from "#discover/manifest.js"; +import { frameworkAgentSourceRegistry } from "#framework-sources/registry.js"; + +describe("composeFrameworkSources", () => { + it("uses application tools and sandbox modules for matching canonical slots", () => { + const result = composeFrameworkSources({ + isRoot: true, + manifest: createAgentSourceManifest({ + agentId: "root", + agentRoot: "/app/agent", + appRoot: "/app", + sandbox: createModuleSourceRef({ logicalPath: "sandbox/sandbox.ts" }), + tools: [createModuleSourceRef({ logicalPath: "tools/bash.ts" })], + }), + nodeId: "root", + registry: frameworkAgentSourceRegistry, + }); + + expect( + result.manifest.tools.find((tool) => tool.logicalPath === "tools/bash.ts")?.sourceId, + ).toBe("tools/bash.ts"); + expect(result.manifest.sandbox?.sourceId).toBe("sandbox/sandbox.ts"); + expect(result.bindings["eve.framework-defaults:tools/bash.ts"]).toBeUndefined(); + expect(result.bindings["eve.framework-defaults:sandbox.ts"]).toBeUndefined(); + }); + + it("binds non-overridden defaults to their programmatic owners", () => { + const result = composeFrameworkSources({ + isRoot: false, + manifest: createAgentSourceManifest({ + agentId: "child", + agentRoot: "/app/agent/subagents/child", + appRoot: "/app", + }), + nodeId: "subagents/child", + registry: frameworkAgentSourceRegistry, + }); + + expect(result.manifest.tools.map((tool) => tool.logicalPath)).toEqual([ + "tools/bash.ts", + "tools/read_file.ts", + "tools/todo.ts", + "tools/web_fetch.ts", + "tools/write_file.ts", + ]); + expect(result.manifest.sandbox?.logicalPath).toBe("sandbox.ts"); + expect(result.bindings["eve.framework-defaults:tools/bash.ts"]).toEqual({ + backing: { + kind: "programmatic", + moduleId: "tools/bash.ts", + registryId: "eve.framework-defaults", + }, + logicalPath: "tools/bash.ts", + owner: { feature: "eve.framework-defaults", kind: "framework" }, + }); + }); +}); diff --git a/packages/eve/src/compiler/compose-framework-sources.ts b/packages/eve/src/compiler/compose-framework-sources.ts new file mode 100644 index 0000000000..d8a566b72a --- /dev/null +++ b/packages/eve/src/compiler/compose-framework-sources.ts @@ -0,0 +1,101 @@ +import { resolve } from "node:path"; + +import type { AgentModuleCandidate } from "#compiler/agent-module-candidate.js"; +import type { AgentSourceRegistry } from "#compiler/agent-source-registry.js"; +import { composeAgentModuleCandidates } from "#compiler/compose-agent-module-candidates.js"; +import type { CompiledModuleBinding } from "#compiler/module-binding.js"; +import { createProgrammaticModuleCandidates } from "#compiler/programmatic-module-candidates.js"; +import type { AgentSourceManifest, ToolSourceRef } from "#discover/manifest.js"; +import type { ModuleSourceRef } from "#shared/source-ref.js"; + +export interface ComposedFrameworkSources { + readonly bindings: Readonly>; + readonly manifest: AgentSourceManifest; +} + +export function composeFrameworkSources(input: { + readonly isRoot: boolean; + readonly manifest: AgentSourceManifest; + readonly nodeId: string; + readonly registry: AgentSourceRegistry; +}): ComposedFrameworkSources { + const applicationRefs = [ + ...input.manifest.tools, + ...(input.manifest.sandbox === null ? [] : [input.manifest.sandbox]), + ]; + const applicationCandidates = applicationRefs.map((source) => + createApplicationCandidate(input.manifest, input.nodeId, source), + ); + const frameworkCandidates = createProgrammaticModuleCandidates({ + isRoot: input.isRoot, + nodeId: input.nodeId, + registry: input.registry, + }).filter( + (candidate) => + candidate.logicalPath === "sandbox.ts" || candidate.logicalPath.startsWith("tools/"), + ); + const composition = composeAgentModuleCandidates([ + ...frameworkCandidates, + ...applicationCandidates, + ]); + const refsBySourceId = new Map(applicationRefs.map((source) => [source.sourceId, source])); + const bindings: Record = {}; + const tools: ToolSourceRef[] = []; + let sandbox: ModuleSourceRef | null = null; + + for (const winner of composition.winners) { + const source = + refsBySourceId.get(winner.sourceId) ?? createProgrammaticSourceRef(input.registry, winner); + if (winner.backing.kind === "programmatic") { + bindings[winner.sourceId] = { + backing: winner.backing, + logicalPath: winner.logicalPath, + owner: winner.owner, + }; + } + if (winner.logicalPath.startsWith("tools/")) tools.push(source); + if (winner.logicalPath === "sandbox.ts" || winner.logicalPath.startsWith("sandbox/")) { + sandbox = source; + } + } + + return { + bindings, + manifest: { ...input.manifest, sandbox, tools }, + }; +} + +function createApplicationCandidate( + manifest: AgentSourceManifest, + nodeId: string, + source: ModuleSourceRef, +): AgentModuleCandidate { + return { + backing: { + externalDependencies: [], + kind: "filesystem", + sourcePath: resolve(manifest.agentRoot, source.logicalPath), + }, + layer: "application", + logicalPath: source.logicalPath, + nodeId, + owner: { kind: "application" }, + sourceId: source.sourceId, + }; +} + +function createProgrammaticSourceRef( + registry: AgentSourceRegistry, + candidate: AgentModuleCandidate, +): ModuleSourceRef { + if (candidate.backing.kind !== "programmatic") { + throw new Error(`Expected "${candidate.sourceId}" to have a programmatic backing.`); + } + const module = registry.getModule(candidate.backing); + return { + exportName: module.exportName, + logicalPath: candidate.logicalPath, + sourceId: candidate.sourceId, + sourceKind: "module", + }; +} diff --git a/packages/eve/src/compiler/module-map.test.ts b/packages/eve/src/compiler/module-map.test.ts index 42e9999c50..de837a6eb2 100644 --- a/packages/eve/src/compiler/module-map.test.ts +++ b/packages/eve/src/compiler/module-map.test.ts @@ -108,6 +108,41 @@ describe("createCompiledModuleMapSource", () => { expect(source).not.toContain("/consumer/agent/tools/echo.ts"); }); + it("knows how to import the framework source registry", () => { + const manifest = createManifestWithTool("/consumer/agent"); + const sourceId = "eve.framework-defaults:tools/bash.ts"; + const source = createCompiledModuleMapSource({ + manifest: { + ...manifest, + bindings: { + [sourceId]: { + backing: { + kind: "programmatic", + moduleId: "tools/bash.ts", + registryId: "eve.framework-defaults", + }, + logicalPath: "tools/bash.ts", + owner: { feature: "eve.framework-defaults", kind: "framework" }, + }, + }, + tools: [ + { + ...manifest.tools[0]!, + logicalPath: "tools/bash.ts", + name: "bash", + sourceId, + }, + ], + }, + moduleMapPath: "/consumer/.eve/compile/module-map.mjs", + }); + + expect(source).toContain("frameworkAgentSourceRegistry as module_0"); + expect(source).toContain( + 'module_0.getModule({"kind":"programmatic","moduleId":"tools/bash.ts","registryId":"eve.framework-defaults"}).namespace', + ); + }); + it("imports the physical binding instead of reconstructing it from logical identity", () => { const manifest = createManifestWithTool("/consumer/agent"); const source = createCompiledModuleMapSource({ diff --git a/packages/eve/src/compiler/module-map.ts b/packages/eve/src/compiler/module-map.ts index c3c5c23c2d..2817b3c9de 100644 --- a/packages/eve/src/compiler/module-map.ts +++ b/packages/eve/src/compiler/module-map.ts @@ -1,3 +1,5 @@ +import { fileURLToPath } from "node:url"; + import { z } from "#compiled/zod/index.js"; import type { CompiledAgentManifest, @@ -9,6 +11,7 @@ import { assertTotalModuleBindings } from "#compiler/module-binding.js"; import { collectModuleRefsForManifest } from "#compiler/module-references.js"; import type { ModuleSourceRef } from "#shared/source-ref.js"; import { normalizeEsmImportSpecifier } from "#internal/application/import-specifier.js"; +import { FRAMEWORK_AGENT_SOURCE_ID } from "#framework-sources/constants.js"; /** * Compiled module ownership for one runtime graph node. @@ -71,6 +74,15 @@ interface CollectedModuleNodeScope { export function createCompiledModuleMapSource(input: CreateCompiledModuleMapSourceInput): string { const moduleMapDirectory = dirnameFilesystemPath(input.moduleMapPath); const importSpecifierStyle = input.importSpecifierStyle ?? "relative"; + const programmaticRegistryImports = { + [FRAMEWORK_AGENT_SOURCE_ID]: { + exportName: "frameworkAgentSourceRegistry", + importSpecifier: normalizeEsmImportSpecifier( + fileURLToPath(new URL("../framework-sources/registry.js", import.meta.url)), + ), + }, + ...input.programmaticRegistryImports, + }; let nextBindingIndex = 0; const collectedScopes: CollectedModuleNodeScope[] = [ collectModuleNodeScope({ @@ -81,7 +93,7 @@ export function createCompiledModuleMapSource(input: CreateCompiledModuleMapSour return `module_${nextBindingIndex++}`; }, nodeId: ROOT_COMPILED_AGENT_NODE_ID, - programmaticRegistryImports: input.programmaticRegistryImports, + programmaticRegistryImports, }), ...[...input.manifest.subagents] .sort((left, right) => left.nodeId.localeCompare(right.nodeId)) @@ -95,7 +107,7 @@ export function createCompiledModuleMapSource(input: CreateCompiledModuleMapSour return `module_${nextBindingIndex++}`; }, nodeId: subagent.nodeId, - programmaticRegistryImports: input.programmaticRegistryImports, + programmaticRegistryImports, }), ), ]; diff --git a/packages/eve/src/compiler/normalize-agent-config.test.ts b/packages/eve/src/compiler/normalize-agent-config.test.ts index 49a86469e2..b23685f2b2 100644 --- a/packages/eve/src/compiler/normalize-agent-config.test.ts +++ b/packages/eve/src/compiler/normalize-agent-config.test.ts @@ -5,6 +5,7 @@ import { defineDynamic } from "#public/definitions/tool.js"; import { chatgpt } from "#public/models/openai/index.js"; import { compileAgentConfig } from "#compiler/normalize-agent-config.js"; import type { ManifestCompileContext } from "#compiler/normalize-helpers.js"; +import { createAgentModuleNamespaceLoader } from "#compiler/module-namespace-loader.js"; const mocks = vi.hoisted(() => ({ loadModuleBackedDefinition: vi.fn(), @@ -41,7 +42,7 @@ describe("compileAgentConfig", () => { }); const modelCatalog = createModelCatalog(); - const compiled = await compileAgentConfig(manifest, { modelCatalog }); + const compiled = await compileAgentConfig(manifest, createContext(modelCatalog)); expect(compiled.model).toBeUndefined(); expect(compiled.dynamicModel).toEqual({ @@ -63,7 +64,7 @@ describe("compileAgentConfig", () => { }); const modelCatalog = createModelCatalog(); - const compiled = await compileAgentConfig(manifest, { modelCatalog }); + const compiled = await compileAgentConfig(manifest, createContext(modelCatalog)); expect(compiled.model).toEqual( expect.objectContaining({ @@ -87,15 +88,11 @@ describe("compileAgentConfig", () => { }), }); - const compiled = await compileAgentConfig( - manifest, - { modelCatalog: createModelCatalog() }, - { - definition: { - model: "openai/gpt-5.5", - }, + const compiled = await compileAgentConfig(manifest, createContext(createModelCatalog()), { + definition: { + model: "openai/gpt-5.5", }, - ); + }); expect(mocks.loadModuleBackedDefinition).not.toHaveBeenCalled(); expect(compiled.description).toBeUndefined(); @@ -109,3 +106,13 @@ function createModelCatalog(): ManifestCompileContext["modelCatalog"] { getModelLimits: vi.fn(async () => ({ contextWindowTokens: 256_000 })), }; } + +function createContext( + modelCatalog: ManifestCompileContext["modelCatalog"], +): ManifestCompileContext { + return { + bindingsByAgentRoot: new Map(), + modelCatalog, + moduleLoader: createAgentModuleNamespaceLoader(), + }; +} diff --git a/packages/eve/src/compiler/normalize-agent-config.ts b/packages/eve/src/compiler/normalize-agent-config.ts index e511a28630..76c0e3c7c0 100644 --- a/packages/eve/src/compiler/normalize-agent-config.ts +++ b/packages/eve/src/compiler/normalize-agent-config.ts @@ -47,8 +47,10 @@ export async function compileAgentConfig( ? { model: DEFAULT_AGENT_MODEL_ID } : await loadModuleBackedDefinition({ agentRoot: manifest.agentRoot, + binding: context.bindingsByAgentRoot.get(manifest.agentRoot)?.[configModule.sourceId], displayPath: configModulePath!, kind: "agent config", + moduleLoader: context.moduleLoader, source: configModule, }), configModule === undefined diff --git a/packages/eve/src/compiler/normalize-channel.ts b/packages/eve/src/compiler/normalize-channel.ts index d35021c849..4582054b53 100644 --- a/packages/eve/src/compiler/normalize-channel.ts +++ b/packages/eve/src/compiler/normalize-channel.ts @@ -28,8 +28,10 @@ export async function compileChannelDefinition( ): Promise { const rawValue = await loadModuleBackedDefinition({ agentRoot, + binding: options.binding, externalDependencies: options.externalDependencies, kind: "channel", + moduleLoader: options.moduleLoader, source, }); diff --git a/packages/eve/src/compiler/normalize-connection.ts b/packages/eve/src/compiler/normalize-connection.ts index a2684ae7f9..ae91360916 100644 --- a/packages/eve/src/compiler/normalize-connection.ts +++ b/packages/eve/src/compiler/normalize-connection.ts @@ -35,8 +35,10 @@ export async function compileConnectionDefinition( ): Promise { const loaded = await loadModuleBackedDefinition({ agentRoot, + binding: options.binding, externalDependencies: options.externalDependencies, kind: "connection", + moduleLoader: options.moduleLoader, source, }); const protocol = readConnectionProtocol(loaded); diff --git a/packages/eve/src/compiler/normalize-helpers.ts b/packages/eve/src/compiler/normalize-helpers.ts index e92a1969a0..3e269d44f7 100644 --- a/packages/eve/src/compiler/normalize-helpers.ts +++ b/packages/eve/src/compiler/normalize-helpers.ts @@ -25,7 +25,9 @@ const SANDBOX_PARENT_DEFINITION_MARKER = Symbol.for("eve.sandbox-parent-definiti * reuses the cache across all of its child compilations. */ export interface ManifestCompileContext { + readonly bindingsByAgentRoot: Map>>; readonly modelCatalog: CompiledRuntimeModelCatalogLoader; + readonly moduleLoader: AgentModuleNamespaceLoader; } export interface ModuleBackedDefinitionLoadOptions { diff --git a/packages/eve/src/compiler/normalize-instructions.ts b/packages/eve/src/compiler/normalize-instructions.ts index 773e8e27db..b4e05c0644 100644 --- a/packages/eve/src/compiler/normalize-instructions.ts +++ b/packages/eve/src/compiler/normalize-instructions.ts @@ -68,8 +68,10 @@ export async function compileInstructionsEntry( const exportValue = await loadModuleBackedDefinition({ agentRoot, + binding: options.binding, externalDependencies: options.externalDependencies, kind: "instructions", + moduleLoader: options.moduleLoader, source, }); diff --git a/packages/eve/src/compiler/normalize-manifest.test.ts b/packages/eve/src/compiler/normalize-manifest.test.ts index 67ca2912b5..dabf0dcd9a 100644 --- a/packages/eve/src/compiler/normalize-manifest.test.ts +++ b/packages/eve/src/compiler/normalize-manifest.test.ts @@ -17,6 +17,7 @@ import { defineTool, experimental_workflow } from "#public/definitions/tool.js"; import { webSearch } from "#public/tools/web-search.js"; const mocks = vi.hoisted(() => ({ + applicationDefinition: vi.fn(), compileAgentConfig: vi.fn(), loadModuleBackedDefinition: vi.fn(), })); @@ -31,8 +32,16 @@ vi.mock("#compiler/normalize-helpers.js", () => ({ describe("compileAgentManifest", () => { beforeEach(() => { + mocks.applicationDefinition.mockReset(); mocks.compileAgentConfig.mockReset(); mocks.loadModuleBackedDefinition.mockReset(); + mocks.loadModuleBackedDefinition.mockImplementation(async (input) => { + if (input.binding?.backing.kind !== "programmatic") { + return await mocks.applicationDefinition(input); + } + const namespace = await input.moduleLoader.load(input.binding.backing); + return namespace[input.source.exportName ?? "default"]; + }); }); it("rejects Workflow runtime configuration on subagents", async () => { @@ -74,7 +83,7 @@ describe("compileAgentManifest", () => { return createConfig({ name: "root" }); }); - mocks.loadModuleBackedDefinition.mockResolvedValue({ + mocks.applicationDefinition.mockResolvedValue({ description: "Research subagent", model: "openai/gpt-5.5", }); @@ -173,7 +182,7 @@ describe("compileAgentManifest", () => { ? createConfig({ name: input.agentId, description: "Research the request." }) : createConfig({ name: input.agentId }), ); - mocks.loadModuleBackedDefinition.mockResolvedValue({ + mocks.applicationDefinition.mockResolvedValue({ description: "Research the request.", model: "openai/gpt-5.5", }); @@ -232,7 +241,7 @@ describe("compileAgentManifest", () => { return createConfig({ name: "root" }); }); - mocks.loadModuleBackedDefinition.mockResolvedValue({ + mocks.applicationDefinition.mockResolvedValue({ description: "Research subagent", model: "openai/gpt-5.5", }); @@ -249,7 +258,7 @@ describe("compileAgentManifest", () => { appRoot: "/app", tools: [createModuleSourceRef({ logicalPath: "tools/export.ts" })], }); - mocks.loadModuleBackedDefinition.mockResolvedValue( + mocks.applicationDefinition.mockResolvedValue( defineTool({ description: "Starts an export.", execution: "background", @@ -266,9 +275,10 @@ describe("compileAgentManifest", () => { mocks.compileAgentConfig.mockResolvedValue( createConfig({ experimental: { tasks: true }, name: "root" }), ); - await expect(compileAgentManifest(manifest)).resolves.toMatchObject({ - tools: [expect.objectContaining({ execution: "background", name: "export" })], - }); + const compiled = await compileAgentManifest(manifest); + expect(compiled.tools).toContainEqual( + expect.objectContaining({ execution: "background", name: "export" }), + ); }); it("compiles experimental Workflow tool configuration", async () => { @@ -279,7 +289,7 @@ describe("compileAgentManifest", () => { tools: [createModuleSourceRef({ logicalPath: "tools/workflow.ts" })], }); mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); - mocks.loadModuleBackedDefinition.mockResolvedValue(experimental_workflow({ maxSubagents: 6 })); + mocks.applicationDefinition.mockResolvedValue(experimental_workflow({ maxSubagents: 6 })); const compiled = await compileAgentManifest(manifest); @@ -294,12 +304,79 @@ describe("compileAgentManifest", () => { tools: [createModuleSourceRef({ logicalPath: "tools/web_search.ts" })], }); mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); - mocks.loadModuleBackedDefinition.mockResolvedValue(webSearch({ provider: "exa" })); + mocks.applicationDefinition.mockResolvedValue(webSearch({ provider: "exa" })); const compiled = await compileAgentManifest(manifest); expect(compiled.webSearchProvider).toBe("exa"); - expect(compiled.tools).toEqual([]); + expect(compiled.tools.map((tool) => tool.name)).toEqual([ + "bash", + "read_file", + "todo", + "web_fetch", + "write_file", + ]); + }); + + it("compiles framework defaults through ordinary module bindings", async () => { + mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); + + const compiled = await compileAgentManifest( + createAgentSourceManifest({ + agentId: "root", + agentRoot: "/app/agent", + appRoot: "/app", + }), + ); + + expect(compiled.tools.map((tool) => tool.name)).toEqual([ + "bash", + "read_file", + "todo", + "web_fetch", + "write_file", + ]); + expect(compiled.sandbox).toMatchObject({ + logicalPath: "sandbox.ts", + sourceId: "eve.framework-defaults:sandbox.ts", + }); + expect(Object.values(compiled.bindings)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + backing: expect.objectContaining({ + kind: "programmatic", + registryId: "eve.framework-defaults", + }), + owner: { feature: "eve.framework-defaults", kind: "framework" }, + }), + ]), + ); + }); + + it("lets application tools replace framework defaults by logical path", async () => { + mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); + mocks.applicationDefinition.mockResolvedValue( + defineTool({ + description: "Application bash", + inputSchema: z.object({}), + execute: () => ({ ok: true }), + }), + ); + + const compiled = await compileAgentManifest( + createAgentSourceManifest({ + agentId: "root", + agentRoot: "/app/agent", + appRoot: "/app", + tools: [createModuleSourceRef({ logicalPath: "tools/bash.ts" })], + }), + ); + + expect(compiled.tools.find((tool) => tool.name === "bash")?.description).toBe( + "Application bash", + ); + expect(compiled.bindings["tools/bash.ts"]?.backing.kind).toBe("filesystem"); + expect(compiled.bindings["eve.framework-defaults:tools/bash.ts"]).toBeUndefined(); }); it("preserves ordered static instruction content, roles, and legacy definitions", async () => { @@ -313,7 +390,7 @@ describe("compileAgentManifest", () => { ], }); mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); - mocks.loadModuleBackedDefinition + mocks.applicationDefinition .mockResolvedValueOnce(defineInstructions({ content: "Account context.", role: "user" })) .mockResolvedValueOnce(defineInstructions({ markdown: "Legacy system context." })); @@ -341,7 +418,7 @@ describe("compileAgentManifest", () => { instructions: [createModuleSourceRef({ logicalPath: "instructions/dynamic.ts" })], }); mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); - mocks.loadModuleBackedDefinition.mockResolvedValue( + mocks.applicationDefinition.mockResolvedValue( defineDynamic({ events: { "step.started": () => defineInstructions({ content: "Too late." }), @@ -362,7 +439,7 @@ describe("compileAgentManifest", () => { tools: [createModuleSourceRef({ logicalPath: "tools/search.ts" })], }); mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); - mocks.loadModuleBackedDefinition.mockResolvedValue(webSearch({ provider: "exa" })); + mocks.applicationDefinition.mockResolvedValue(webSearch({ provider: "exa" })); await expect(compileAgentManifest(manifest)).rejects.toThrow( 'must be exported from "tools/web_search.ts"', @@ -384,7 +461,7 @@ describe("compileAgentManifest", () => { mocks.compileAgentConfig.mockImplementation(async (input: AgentSourceManifest) => createConfig({ name: input.agentId }), ); - mocks.loadModuleBackedDefinition.mockResolvedValue(dynamic); + mocks.applicationDefinition.mockResolvedValue(dynamic); const compiled = await compileAgentManifest(manifest); @@ -414,7 +491,7 @@ describe("compileAgentManifest", () => { mocks.compileAgentConfig.mockImplementation(async (input: AgentSourceManifest) => createConfig({ name: input.agentId }), ); - mocks.loadModuleBackedDefinition.mockResolvedValue(dynamic); + mocks.applicationDefinition.mockResolvedValue(dynamic); const compiled = await compileAgentManifest(manifest); @@ -431,7 +508,7 @@ describe("compileAgentManifest", () => { it("rejects invalid dynamic subagent build configuration", async () => { mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); - mocks.loadModuleBackedDefinition.mockResolvedValue({ + mocks.applicationDefinition.mockResolvedValue({ build: { externalDependencies: "just-bash" }, events: { "session.started": () => null }, kind: "eve:dynamic", @@ -444,7 +521,7 @@ describe("compileAgentManifest", () => { it("rejects fallback on a dynamic subagent", async () => { mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); - mocks.loadModuleBackedDefinition.mockResolvedValue({ + mocks.applicationDefinition.mockResolvedValue({ ...defineDynamic({ events: { "session.started": () => null, @@ -463,7 +540,7 @@ describe("compileAgentManifest", () => { it("rejects step-scoped dynamic subagents", async () => { mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); - mocks.loadModuleBackedDefinition.mockResolvedValue( + mocks.applicationDefinition.mockResolvedValue( defineDynamic({ events: { "step.started": () => diff --git a/packages/eve/src/compiler/normalize-manifest.ts b/packages/eve/src/compiler/normalize-manifest.ts index 45b3c15119..5df289696e 100644 --- a/packages/eve/src/compiler/normalize-manifest.ts +++ b/packages/eve/src/compiler/normalize-manifest.ts @@ -26,7 +26,10 @@ import { composeAgentSubagentSources, compileExtensionContributions, } from "#compiler/normalize-extension.js"; -import type { ManifestCompileContext } from "#compiler/normalize-helpers.js"; +import type { + ManifestCompileContext, + ModuleBackedDefinitionLoadOptions, +} from "#compiler/normalize-helpers.js"; import { compileHookEntry } from "#compiler/normalize-hook.js"; import { compileSandboxDefinition } from "#compiler/normalize-sandbox.js"; import { compileInstructionsEntry } from "#compiler/normalize-instructions.js"; @@ -35,6 +38,9 @@ import { compileSkillSource } from "#compiler/normalize-skill.js"; import { compileSubagentGraph } from "#compiler/normalize-subagent.js"; import { compileToolEntry } from "#compiler/normalize-tool.js"; import { createFilesystemModuleBindings } from "#compiler/module-binding.js"; +import { composeFrameworkSources } from "#compiler/compose-framework-sources.js"; +import { frameworkAgentSourceRegistry } from "#framework-sources/registry.js"; +import { createAgentModuleNamespaceLoader } from "#compiler/module-namespace-loader.js"; /** * Compiles one discovery manifest into the normalized manifest loaded by the runtime. @@ -43,9 +49,21 @@ export async function compileAgentManifest( manifest: AgentSourceManifest, ): Promise { const context: ManifestCompileContext = { + bindingsByAgentRoot: new Map(), modelCatalog: createCompiledRuntimeModelCatalogLoader(manifest.appRoot), + moduleLoader: createAgentModuleNamespaceLoader({ registry: frameworkAgentSourceRegistry }), }; - const compiledNode = await compileAgentNodeManifest(manifest, context); + const rootSources = composeFrameworkSources({ + isRoot: true, + manifest, + nodeId: ROOT_COMPILED_AGENT_NODE_ID, + registry: frameworkAgentSourceRegistry, + }); + context.bindingsByAgentRoot.set(manifest.agentRoot, rootSources.bindings); + const compiledNode = await compileAgentNodeManifest(rootSources.manifest, context, { + nodeId: ROOT_COMPILED_AGENT_NODE_ID, + sourcesComposed: true, + }); const subagentGraph = await compileSubagentGraph({ appRoot: manifest.appRoot, compileAgentNodeManifest, @@ -54,7 +72,7 @@ export async function compileAgentManifest( externalDependencies: compiledNode.config.build?.externalDependencies ?? [], parentAgentRoot: manifest.agentRoot, parentNodeId: ROOT_COMPILED_AGENT_NODE_ID, - subagents: composeAgentSubagentSources(manifest), + subagents: composeAgentSubagentSources(rootSources.manifest), }); const backgroundTool = [compiledNode, ...subagentGraph.nodes.map((node) => node.agent)] @@ -75,11 +93,11 @@ export async function compileAgentManifest( }); return { ...compiledManifest, - bindings: createFilesystemModuleBindings({ - agentRoot: compiledManifest.agentRoot, - externalDependencies: compiledManifest.config.build?.externalDependencies, - manifest: compiledManifest, - }), + bindings: createNodeBindings( + compiledManifest, + context, + compiledManifest.config.build?.externalDependencies, + ), }; } @@ -90,8 +108,20 @@ async function compileAgentNodeManifest( readonly agentConfigDefinition?: unknown; readonly externalDependencies?: readonly string[]; readonly allowRootOnlyConfig?: boolean; + readonly nodeId?: string; + readonly sourcesComposed?: boolean; } = {}, ): Promise { + const sources = options.sourcesComposed + ? { bindings: context.bindingsByAgentRoot.get(manifest.agentRoot) ?? {}, manifest } + : composeFrameworkSources({ + isRoot: false, + manifest, + nodeId: options.nodeId ?? manifest.agentId, + registry: frameworkAgentSourceRegistry, + }); + manifest = sources.manifest; + context.bindingsByAgentRoot.set(manifest.agentRoot, sources.bindings); const rawConfig = Object.hasOwn(options, "agentConfigDefinition") ? await compileAgentConfig(manifest, context, { definition: options.agentConfigDefinition, @@ -122,27 +152,46 @@ async function compileAgentNodeManifest( externalDependencies, }, }; - const resources = await compileAgentResources(manifest, context, { externalDependencies }); + const resources = await compileAgentResources(manifest, context, { + externalDependencies, + nodeId: options.nodeId, + sourcesComposed: true, + }); const compiledNode = createCompiledAgentNodeManifest({ ...resources, config }); return { ...compiledNode, - bindings: createFilesystemModuleBindings({ - agentRoot: compiledNode.agentRoot, - externalDependencies: config.build?.externalDependencies, - manifest: compiledNode, - }), + bindings: createNodeBindings(compiledNode, context, config.build?.externalDependencies), }; } async function compileAgentResources( manifest: AgentSourceManifest, context: ManifestCompileContext, - options: { readonly externalDependencies?: readonly string[] } = {}, + options: { + readonly externalDependencies?: readonly string[]; + readonly nodeId?: string; + readonly sourcesComposed?: boolean; + } = {}, ): Promise { + const sources = options.sourcesComposed + ? { bindings: context.bindingsByAgentRoot.get(manifest.agentRoot) ?? {}, manifest } + : composeFrameworkSources({ + isRoot: false, + manifest, + nodeId: options.nodeId ?? manifest.agentId, + registry: frameworkAgentSourceRegistry, + }); + manifest = sources.manifest; + context.bindingsByAgentRoot.set(manifest.agentRoot, sources.bindings); const externalDependencies = [...(options.externalDependencies ?? [])]; + const loadOptions = (sourceId: string): ModuleBackedDefinitionLoadOptions => ({ + binding: sources.bindings[sourceId], + externalDependencies, + moduleLoader: context.moduleLoader, + }); const compiledToolEntries = await Promise.all( manifest.tools.map((toolSource) => - compileToolEntry(manifest.agentRoot, toolSource, { externalDependencies }), + compileToolEntry(manifest.agentRoot, toolSource, loadOptions(toolSource.sourceId)), ), ); const tools: CompiledToolDefinition[] = []; @@ -167,7 +216,11 @@ async function compileAgentResources( const compiledChannelResults = await Promise.all( manifest.channels.map((channelSource) => - compileChannelDefinition(manifest.agentRoot, channelSource, { externalDependencies }), + compileChannelDefinition( + manifest.agentRoot, + channelSource, + loadOptions(channelSource.sourceId), + ), ), ); @@ -178,7 +231,7 @@ async function compileAgentResources( const compiledSkillEntries = await Promise.all( manifest.skills.map((skillSource) => - compileSkillSource(manifest.agentRoot, skillSource, { externalDependencies }), + compileSkillSource(manifest.agentRoot, skillSource, loadOptions(skillSource.sourceId)), ), ); const skills: CompiledSkillDefinition[] = []; @@ -194,7 +247,7 @@ async function compileAgentResources( const compiledInstructionsEntries = await Promise.all( manifest.instructions.map((source) => - compileInstructionsEntry(manifest.agentRoot, source, { externalDependencies }), + compileInstructionsEntry(manifest.agentRoot, source, loadOptions(source.sourceId)), ), ); const staticInstructions: CompiledInstructionsDefinition[] = []; @@ -210,13 +263,21 @@ async function compileAgentResources( const connections = await Promise.all( manifest.connections.map((connectionSource) => - compileConnectionDefinition(manifest.agentRoot, connectionSource, { externalDependencies }), + compileConnectionDefinition( + manifest.agentRoot, + connectionSource, + loadOptions(connectionSource.sourceId), + ), ), ); const hooks = manifest.hooks.map((hookSource) => compileHookEntry(hookSource)); const schedules = await Promise.all( manifest.schedules.map((scheduleSource) => - compileScheduleDefinition(manifest.agentRoot, scheduleSource, { externalDependencies }), + compileScheduleDefinition( + manifest.agentRoot, + scheduleSource, + loadOptions(scheduleSource.sourceId), + ), ), ); @@ -287,7 +348,7 @@ async function compileAgentResources( manifest.sandbox === null ? null : await compileSandboxDefinition(manifest.agentRoot, manifest.sandbox, { - externalDependencies, + ...loadOptions(manifest.sandbox.sourceId), }), sandboxWorkspaces: manifest.sandboxWorkspaces.map((workspace) => ({ logicalPath: workspace.logicalPath, @@ -303,11 +364,22 @@ async function compileAgentResources( }); return { ...resources, - bindings: createFilesystemModuleBindings({ - agentRoot: resources.agentRoot, + bindings: createNodeBindings(resources, context, externalDependencies), + }; +} + +function createNodeBindings( + manifest: CompiledAgentNodeManifest | CompiledAgentResources, + context: ManifestCompileContext, + externalDependencies?: readonly string[], +): CompiledAgentResources["bindings"] { + return { + ...createFilesystemModuleBindings({ + agentRoot: manifest.agentRoot, externalDependencies, - manifest: resources, + manifest, }), + ...context.bindingsByAgentRoot.get(manifest.agentRoot), }; } diff --git a/packages/eve/src/compiler/normalize-sandbox.ts b/packages/eve/src/compiler/normalize-sandbox.ts index 9311ab39fa..bf30274138 100644 --- a/packages/eve/src/compiler/normalize-sandbox.ts +++ b/packages/eve/src/compiler/normalize-sandbox.ts @@ -23,8 +23,10 @@ export async function compileSandboxDefinition( const message = `Expected the sandbox export "${source.exportName ?? "default"}" from "${source.logicalPath}" to match the public eve shape.`; const loaded = await loadModuleBackedDefinition({ agentRoot, + binding: options.binding, externalDependencies: options.externalDependencies, kind: "sandbox", + moduleLoader: options.moduleLoader, source, }); const inheritsParent = await resolveParentSandboxSelector(loaded, message); @@ -45,7 +47,7 @@ export async function compileSandboxDefinition( exportName: source.exportName, logicalPath: source.logicalPath, revalidationKey, - sourceHash: await resolveSandboxSourceHash(agentRoot, source), + sourceHash: await resolveSandboxSourceHash(agentRoot, source, options.binding), sourceId: source.sourceId, sourceKind: "module", }; @@ -129,7 +131,17 @@ async function resolveSandboxRevalidationKey(input: { async function resolveSandboxSourceHash( agentRoot: string, source: SandboxSourceRef, + binding: ModuleBackedDefinitionLoadOptions["binding"], ): Promise { - const content = await readFile(join(agentRoot, source.logicalPath)); + if (binding?.backing.kind === "programmatic") { + return createHash("sha256") + .update(`${binding.backing.registryId}:${binding.backing.moduleId}`) + .digest("hex"); + } + const content = await readFile( + binding?.backing.kind === "filesystem" + ? binding.backing.sourcePath + : join(agentRoot, source.logicalPath), + ); return createHash("sha256").update(content).digest("hex"); } diff --git a/packages/eve/src/compiler/normalize-schedule.ts b/packages/eve/src/compiler/normalize-schedule.ts index 458c962225..386c0bb853 100644 --- a/packages/eve/src/compiler/normalize-schedule.ts +++ b/packages/eve/src/compiler/normalize-schedule.ts @@ -33,8 +33,10 @@ export async function compileScheduleDefinition( : normalizeScheduleDefinition( await loadModuleBackedDefinition({ agentRoot, + binding: options.binding, externalDependencies: options.externalDependencies, kind: "schedule", + moduleLoader: options.moduleLoader, source, }), `Expected the schedule export "${source.exportName ?? "default"}" from "${source.logicalPath}" to match the public eve shape.`, diff --git a/packages/eve/src/compiler/normalize-skill.ts b/packages/eve/src/compiler/normalize-skill.ts index 32f3c9e10d..d7164cfd31 100644 --- a/packages/eve/src/compiler/normalize-skill.ts +++ b/packages/eve/src/compiler/normalize-skill.ts @@ -70,8 +70,10 @@ export async function compileSkillSource( // Module-backed skill — load the export and check for DynamicSentinel. const exportValue = await loadModuleBackedDefinition({ agentRoot, + binding: options.binding, externalDependencies: options.externalDependencies, kind: "skill", + moduleLoader: options.moduleLoader, source, }); diff --git a/packages/eve/src/compiler/normalize-subagent.ts b/packages/eve/src/compiler/normalize-subagent.ts index 3dcb79a5ee..ab84b39d01 100644 --- a/packages/eve/src/compiler/normalize-subagent.ts +++ b/packages/eve/src/compiler/normalize-subagent.ts @@ -50,13 +50,19 @@ export type CompileAgentNodeManifestFn = ( readonly agentConfigDefinition?: unknown; readonly externalDependencies?: readonly string[]; readonly allowRootOnlyConfig?: boolean; + readonly nodeId?: string; + readonly sourcesComposed?: boolean; }, ) => Promise; export type CompileAgentResourcesFn = ( manifest: AgentSourceManifest, context: ManifestCompileContext, - options?: { readonly externalDependencies?: readonly string[] }, + options?: { + readonly externalDependencies?: readonly string[]; + readonly nodeId?: string; + readonly sourcesComposed?: boolean; + }, ) => Promise; /** @@ -156,9 +162,13 @@ async function compileSubagentDefinition(input: { ); const definition = await loadModuleBackedDefinition({ agentRoot: input.source.manifest.agentRoot, + binding: input.context.bindingsByAgentRoot.get(input.source.manifest.agentRoot)?.[ + configModule.sourceId + ], displayPath: configModuleSource.logicalPath, externalDependencies: input.externalDependencies, kind: "subagent config", + moduleLoader: input.context.moduleLoader, source: configModule, }); const dynamic = normalizeDynamicSubagentDefinition( @@ -233,6 +243,7 @@ async function compileSubagent(input: { agentConfigDefinition: input.agentConfigDefinition, allowRootOnlyConfig: false, externalDependencies: inheritedExternalDependencies, + nodeId, }); const description = agent.config.description; if (!description) { @@ -259,11 +270,14 @@ async function compileSubagent(input: { ...nodeBase, agent: { ...compiledAgent, - bindings: createFilesystemModuleBindings({ - agentRoot: compiledAgent.agentRoot, - externalDependencies: compiledAgent.config.build?.externalDependencies, - manifest: compiledAgent, - }), + bindings: { + ...createFilesystemModuleBindings({ + agentRoot: compiledAgent.agentRoot, + externalDependencies: compiledAgent.config.build?.externalDependencies, + manifest: compiledAgent, + }), + ...compiledAgent.bindings, + }, }, description, }, @@ -272,6 +286,7 @@ async function compileSubagent(input: { const resources = await input.compileAgentResources(sourceManifest, input.context, { externalDependencies: inheritedExternalDependencies, + nodeId, }); const descendants = await compileSubagentGraph({ appRoot: input.appRoot, @@ -290,12 +305,15 @@ async function compileSubagent(input: { ...nodeBase, agent: { ...compiledResources, - bindings: createFilesystemModuleBindings({ - additionalRefs: [input.configResolver], - agentRoot: compiledResources.agentRoot, - externalDependencies: inheritedExternalDependencies, - manifest: compiledResources, - }), + bindings: { + ...createFilesystemModuleBindings({ + additionalRefs: [input.configResolver], + agentRoot: compiledResources.agentRoot, + externalDependencies: inheritedExternalDependencies, + manifest: compiledResources, + }), + ...compiledResources.bindings, + }, }, configResolver: input.configResolver, }, diff --git a/packages/eve/src/compiler/normalize-tool.ts b/packages/eve/src/compiler/normalize-tool.ts index 7b6a5e3d2a..15b267ef83 100644 --- a/packages/eve/src/compiler/normalize-tool.ts +++ b/packages/eve/src/compiler/normalize-tool.ts @@ -41,8 +41,10 @@ export async function compileToolEntry( const entry = normalizeToolDefinition( await loadModuleBackedDefinition({ agentRoot, + binding: options.binding, externalDependencies: options.externalDependencies, kind: "tool", + moduleLoader: options.moduleLoader, source, }), `Expected the tool export "${source.exportName ?? "default"}" from "${source.logicalPath}" to match the public eve shape.`, diff --git a/packages/eve/src/framework-sources/constants.ts b/packages/eve/src/framework-sources/constants.ts new file mode 100644 index 0000000000..f6cf04a9dd --- /dev/null +++ b/packages/eve/src/framework-sources/constants.ts @@ -0,0 +1 @@ +export const FRAMEWORK_AGENT_SOURCE_ID = "eve.framework-defaults"; diff --git a/packages/eve/src/framework-sources/registry.ts b/packages/eve/src/framework-sources/registry.ts new file mode 100644 index 0000000000..82f1a4dbc7 --- /dev/null +++ b/packages/eve/src/framework-sources/registry.ts @@ -0,0 +1,25 @@ +import * as bash from "./tools/bash.js"; +import * as readFile from "./tools/read_file.js"; +import * as sandbox from "./sandbox.js"; +import * as todo from "./tools/todo.js"; +import * as webFetch from "./tools/web_fetch.js"; +import * as writeFile from "./tools/write_file.js"; +import { createAgentSourceRegistry } from "#compiler/agent-source-registry.js"; +import { defineProgrammaticAgentSource } from "#compiler/programmatic-agent-source.js"; +import { FRAMEWORK_AGENT_SOURCE_ID } from "./constants.js"; + +const frameworkAgentSource = defineProgrammaticAgentSource({ + id: FRAMEWORK_AGENT_SOURCE_ID, + modules: [ + { logicalPath: "sandbox.ts", namespace: sandbox }, + { logicalPath: "tools/bash.ts", namespace: bash }, + { logicalPath: "tools/read_file.ts", namespace: readFile }, + { logicalPath: "tools/todo.ts", namespace: todo }, + { logicalPath: "tools/web_fetch.ts", namespace: webFetch }, + { logicalPath: "tools/write_file.ts", namespace: writeFile }, + ], +}); + +export const frameworkAgentSourceRegistry = createAgentSourceRegistry([ + { applyTo: "all-local-nodes", source: frameworkAgentSource }, +]); diff --git a/packages/eve/src/framework-sources/sandbox.ts b/packages/eve/src/framework-sources/sandbox.ts new file mode 100644 index 0000000000..9a57a016ee --- /dev/null +++ b/packages/eve/src/framework-sources/sandbox.ts @@ -0,0 +1,3 @@ +import { defineSandbox } from "#public/definitions/sandbox.js"; + +export default defineSandbox({}); diff --git a/packages/eve/src/framework-sources/tools/bash.ts b/packages/eve/src/framework-sources/tools/bash.ts new file mode 100644 index 0000000000..3c4a196e7b --- /dev/null +++ b/packages/eve/src/framework-sources/tools/bash.ts @@ -0,0 +1 @@ +export { bash as default } from "#public/tools/defaults.js"; diff --git a/packages/eve/src/framework-sources/tools/read_file.ts b/packages/eve/src/framework-sources/tools/read_file.ts new file mode 100644 index 0000000000..c53023ccbe --- /dev/null +++ b/packages/eve/src/framework-sources/tools/read_file.ts @@ -0,0 +1 @@ +export { readFile as default } from "#public/tools/defaults.js"; diff --git a/packages/eve/src/framework-sources/tools/todo.ts b/packages/eve/src/framework-sources/tools/todo.ts new file mode 100644 index 0000000000..fb51c1926d --- /dev/null +++ b/packages/eve/src/framework-sources/tools/todo.ts @@ -0,0 +1 @@ +export { todo as default } from "#public/tools/defaults.js"; diff --git a/packages/eve/src/framework-sources/tools/web_fetch.ts b/packages/eve/src/framework-sources/tools/web_fetch.ts new file mode 100644 index 0000000000..3bfa958827 --- /dev/null +++ b/packages/eve/src/framework-sources/tools/web_fetch.ts @@ -0,0 +1 @@ +export { webFetch as default } from "#public/tools/defaults.js"; diff --git a/packages/eve/src/framework-sources/tools/write_file.ts b/packages/eve/src/framework-sources/tools/write_file.ts new file mode 100644 index 0000000000..73a696d369 --- /dev/null +++ b/packages/eve/src/framework-sources/tools/write_file.ts @@ -0,0 +1 @@ +export { writeFile as default } from "#public/tools/defaults.js"; diff --git a/packages/eve/src/internal/authored-module-map-loader.ts b/packages/eve/src/internal/authored-module-map-loader.ts index 0255e2c5c8..60d4d5144e 100644 --- a/packages/eve/src/internal/authored-module-map-loader.ts +++ b/packages/eve/src/internal/authored-module-map-loader.ts @@ -16,6 +16,8 @@ import { loadCompiledManifest } from "#runtime/loaders/manifest.js"; import { formatValidationError } from "#runtime/validation.js"; import { loadAuthoredModuleNamespace } from "#internal/authored-module-loader.js"; import { readMaterializedAuthoredModuleIndex } from "#internal/materialized-authored-modules.js"; +import { createAgentModuleNamespaceLoader } from "#compiler/module-namespace-loader.js"; +import { frameworkAgentSourceRegistry } from "#framework-sources/registry.js"; /** * Ambient namespace read by `defineExtension` when it is evaluated from a module @@ -25,6 +27,9 @@ import { readMaterializedAuthoredModuleIndex } from "#internal/materialized-auth * leaks into consumer code. */ const EXT_CONFIG_SCOPE = Symbol.for("eve.ext-config-scope"); +const programmaticModuleLoader = createAgentModuleNamespaceLoader({ + registry: frameworkAgentSourceRegistry, +}); /** * Loads a disk-backed module map by hydrating authored modules directly from @@ -180,10 +185,9 @@ async function hydrateCompiledNodeScope(input: { for (const ref of refs) { const binding = input.manifest.bindings[ref.sourceId]!; - if (binding.backing.kind !== "filesystem") { - throw new Error( - `Cannot hydrate programmatic binding "${ref.sourceId}" from authored filesystem source.`, - ); + if (binding.backing.kind === "programmatic") { + modules[ref.sourceId] = await programmaticModuleLoader.load(binding.backing); + continue; } const modulePath = binding.backing.sourcePath; const extensionScopeNamespace = diff --git a/packages/eve/src/public/tools/defaults.ts b/packages/eve/src/public/tools/defaults.ts index 137ef40429..97479c818b 100644 --- a/packages/eve/src/public/tools/defaults.ts +++ b/packages/eve/src/public/tools/defaults.ts @@ -3,53 +3,106 @@ * values so authors can spread, wrap, or patch them inside their own * `agent/tools/*.ts` files. */ -import { BASH_TOOL_DEFINITION } from "#runtime/framework-tools/bash.js"; -import { GLOB_TOOL_DEFINITION } from "#runtime/framework-tools/glob.js"; -import { GREP_TOOL_DEFINITION } from "#runtime/framework-tools/grep.js"; -import { READ_FILE_TOOL_DEFINITION } from "#runtime/framework-tools/read-file.js"; import { SKILL_TOOL_DEFINITION } from "#runtime/framework-tools/skill.js"; -import { TODO_TOOL_DEFINITION } from "#runtime/framework-tools/todo.js"; -import { WEB_FETCH_TOOL_DEFINITION } from "#runtime/framework-tools/web-fetch.js"; -import { WRITE_FILE_TOOL_DEFINITION } from "#runtime/framework-tools/write-file.js"; +import { + TODO_INPUT_SCHEMA, + TODO_OUTPUT_SCHEMA, + executeTodoTool, + type TodoToolInput, +} from "#runtime/framework-tools/todo.js"; +import { + WEB_FETCH_INPUT_SCHEMA, + WEB_FETCH_OUTPUT_SCHEMA, +} from "#runtime/framework-tools/web-fetch.js"; +import { executeWebFetchTool, type WebFetchInput } from "#execution/web-fetch/tool.js"; import type { ToolDefinition } from "#public/definitions/tool.js"; import { toPublicToolDefinition } from "#public/tools/internal.js"; +import { defineBashTool } from "#public/tools/define-bash-tool.js"; +import { defineGlobTool } from "#public/tools/define-glob-tool.js"; +import { defineGrepTool } from "#public/tools/define-grep-tool.js"; +import { defineReadFileTool } from "#public/tools/define-read-file-tool.js"; +import { defineWriteFileTool } from "#public/tools/define-write-file-tool.js"; export type { ToolDefinition }; /** * Framework-provided shell execution tool. Spread or wrap to customize. */ -export const bash: ToolDefinition = toPublicToolDefinition(BASH_TOOL_DEFINITION); +export const bash: ToolDefinition = defineBashTool({ + description: "Execute a shell command in the shared workspace environment.", +}); /** * Framework-provided file search tool. Finds files by glob pattern. Spread * or wrap to customize. */ -export const glob: ToolDefinition = toPublicToolDefinition(GLOB_TOOL_DEFINITION); +export const glob: ToolDefinition = defineGlobTool(); /** * Framework-provided content search tool. Searches file contents by regex * pattern. Spread or wrap to customize. */ -export const grep: ToolDefinition = toPublicToolDefinition(GREP_TOOL_DEFINITION); +export const grep: ToolDefinition = defineGrepTool(); /** * Framework-provided file reader tool (`read_file`). Spread or wrap to * customize. The framework resets the durable read-before-write stamps on * context compaction automatically, regardless of how the reader is defined. */ -export const readFile: ToolDefinition = toPublicToolDefinition(READ_FILE_TOOL_DEFINITION); +export const readFile: ToolDefinition = defineReadFileTool({ + description: [ + "Read a file from the local filesystem. If the path does not exist, an error is returned.", + "", + "Usage:", + "- The filePath parameter should be an absolute path or begin with $HOME/.", + "- By default, this tool returns up to 2000 lines from the start of the file.", + "- The offset parameter is the line number to start from (1-indexed).", + "- To read later sections, call this tool again with a larger offset.", + '- Contents are returned with each line prefixed by its line number as `: `. For example, if a file has contents "foo\\n", you will receive "1: foo\\n".', + "- Any line longer than 2000 characters is truncated.", + "- Call this tool in parallel when you know there are multiple files you want to read.", + "- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.", + ].join("\n"), +}); /** * Framework-provided file writer tool. Spread or wrap to customize. * Enforces read-before-write for existing files and stale-read detection. */ -export const writeFile: ToolDefinition = toPublicToolDefinition(WRITE_FILE_TOOL_DEFINITION); +export const writeFile: ToolDefinition = defineWriteFileTool({ + description: [ + "Writes a file to the local filesystem.", + "", + "Usage:", + "- This tool will overwrite the existing file if there is one at the provided path.", + "- If this is an existing file, you MUST use the read_file tool first to read the file's contents. This tool will fail if you did not read the file first.", + "- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.", + "- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.", + "- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.", + ].join("\n"), +}); /** * Framework-provided HTTP fetch tool. Spread or wrap to customize. */ -export const webFetch: ToolDefinition = toPublicToolDefinition(WEB_FETCH_TOOL_DEFINITION); +export const webFetch: ToolDefinition = { + description: [ + "Fetch a webpage and return its content in the requested format. Use this to retrieve and analyze content from URLs.", + "", + "Usage notes:", + "- The URL must be a fully-formed valid URL starting with https://", + "- HTML responses are automatically converted to markdown or plain text based on the requested format", + '- Format options: "markdown" (default), "text", or "html"', + "- Default timeout is 30 seconds (max 120 seconds)", + "- Maximum response size is 5 MB; content is further capped at the shared tool-output budget (50 KB / 2000 lines)", + "- This tool is read-only and does not modify any files", + ].join("\n"), + async execute(input, ctx) { + return await executeWebFetchTool(input as WebFetchInput, { abortSignal: ctx.abortSignal }); + }, + inputSchema: WEB_FETCH_INPUT_SCHEMA, + outputSchema: WEB_FETCH_OUTPUT_SCHEMA, +}; /** * Framework-provided durable todo list tool. Spreading the default keeps its @@ -57,7 +110,34 @@ export const webFetch: ToolDefinition = toPublicToolDefinition(WEB_FETCH_TOOL_DE * framework's internal todo state. Replace with a fully custom executor (and * your own `ContextKey`) if you need different state semantics. */ -export const todo: ToolDefinition = toPublicToolDefinition(TODO_TOOL_DEFINITION); +export const todo: ToolDefinition = { + description: [ + "Use this tool to create and manage a structured task list for the current session.", + "This helps you track progress, organize complex tasks, and demonstrate thoroughness.", + "", + "When to use:", + "- Complex multistep tasks requiring 3 or more distinct steps", + "- When the user provides multiple tasks or a numbered list", + "- After receiving new instructions, to capture requirements", + "- After completing a task, to mark it complete and add follow-ups", + "", + "When NOT to use:", + "- Single, straightforward tasks that need no tracking", + "- Purely conversational or informational requests", + "", + "Usage:", + "- Call with `todos` to replace the entire list (full replacement write)", + "- Call without `todos` to read the current list", + "- Both return the full current list with status counts", + "- Mark tasks in_progress when you start, completed when done", + "- Only have ONE task in_progress at a time", + ].join("\n"), + async execute(input) { + return executeTodoTool((input ?? {}) as TodoToolInput); + }, + inputSchema: TODO_INPUT_SCHEMA, + outputSchema: TODO_OUTPUT_SCHEMA, +}; /** * Framework-provided skill loading tool (`load_skill`). Returns a named From ee08da4efcc729771ee025637d6ba638b0ff15b2 Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Sat, 22 Aug 2026 11:46:32 -0400 Subject: [PATCH 02/12] refactor(eve): compile connection search as an agent source Remove the separate framework dynamic-tool registry and treat connection_search like any other composed source. Keep discovered tool state in durable context instead of reconstructing it from model-facing history. Signed-off-by: Andrew Barba --- .changeset/fresh-connection-search.md | 5 + .../src/cli/dev/tui/tool-presentation.test.ts | 1 + .../compose-framework-sources.test.ts | 1 + .../src/compiler/normalize-manifest.test.ts | 38 +++ .../eve/src/framework-sources/registry.ts | 2 + .../tools/connection_search.ts | 1 + ...build-agent-info-response-from-manifest.ts | 21 +- .../agent-info/build-agent-info-response.ts | 21 +- .../connection-search-dynamic.test.ts | 236 +++++------------- .../connection-search-dynamic.ts | 57 +---- .../src/runtime/framework-tools/index.test.ts | 15 +- .../eve/src/runtime/framework-tools/index.ts | 49 +--- .../eve/src/runtime/resolve-agent-graph.ts | 6 +- 13 files changed, 139 insertions(+), 314 deletions(-) create mode 100644 .changeset/fresh-connection-search.md create mode 100644 packages/eve/src/framework-sources/tools/connection_search.ts diff --git a/.changeset/fresh-connection-search.md b/.changeset/fresh-connection-search.md new file mode 100644 index 0000000000..b27a6c368e --- /dev/null +++ b/.changeset/fresh-connection-search.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Compile `connection_search` as an ordinary dynamic tool source and keep discovered connection tools exclusively in durable context. Existing sessions without that context search again instead of reconstructing tools from message history. diff --git a/packages/eve/src/cli/dev/tui/tool-presentation.test.ts b/packages/eve/src/cli/dev/tui/tool-presentation.test.ts index 553ab73578..201449e671 100644 --- a/packages/eve/src/cli/dev/tui/tool-presentation.test.ts +++ b/packages/eve/src/cli/dev/tui/tool-presentation.test.ts @@ -174,6 +174,7 @@ describe("presentTool", () => { agent: { message: "audit the auth flow" }, ask_question: { prompt: "Which environment?" }, bash: { command: "ls" }, + connection_search: { keywords: "linear issues" }, glob: { pattern: "**/*.ts" }, grep: { pattern: "useEve" }, load_skill: { skill: "commit" }, diff --git a/packages/eve/src/compiler/compose-framework-sources.test.ts b/packages/eve/src/compiler/compose-framework-sources.test.ts index 1d2fa9b4cf..e0d5b8cf68 100644 --- a/packages/eve/src/compiler/compose-framework-sources.test.ts +++ b/packages/eve/src/compiler/compose-framework-sources.test.ts @@ -41,6 +41,7 @@ describe("composeFrameworkSources", () => { expect(result.manifest.tools.map((tool) => tool.logicalPath)).toEqual([ "tools/bash.ts", + "tools/connection_search.ts", "tools/read_file.ts", "tools/todo.ts", "tools/web_fetch.ts", diff --git a/packages/eve/src/compiler/normalize-manifest.test.ts b/packages/eve/src/compiler/normalize-manifest.test.ts index dabf0dcd9a..bccfe586aa 100644 --- a/packages/eve/src/compiler/normalize-manifest.test.ts +++ b/packages/eve/src/compiler/normalize-manifest.test.ts @@ -316,6 +316,13 @@ describe("compileAgentManifest", () => { "web_fetch", "write_file", ]); + expect(compiled.dynamicTools).toContainEqual( + expect.objectContaining({ + logicalPath: "tools/connection_search.ts", + slug: "connection_search", + sourceId: "eve.framework-defaults:tools/connection_search.ts", + }), + ); }); it("compiles framework defaults through ordinary module bindings", async () => { @@ -379,6 +386,37 @@ describe("compileAgentManifest", () => { expect(compiled.bindings["eve.framework-defaults:tools/bash.ts"]).toBeUndefined(); }); + it("lets an application tool replace the framework connection search resolver", async () => { + mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); + mocks.applicationDefinition.mockResolvedValue( + defineTool({ + description: "Search a fixed connection index", + inputSchema: z.object({}), + execute: () => [], + }), + ); + + const compiled = await compileAgentManifest( + createAgentSourceManifest({ + agentId: "root", + agentRoot: "/app/agent", + appRoot: "/app", + tools: [createModuleSourceRef({ logicalPath: "tools/connection_search.ts" })], + }), + ); + + expect(compiled.tools).toContainEqual( + expect.objectContaining({ + name: "connection_search", + sourceId: "tools/connection_search.ts", + }), + ); + expect(compiled.dynamicTools).not.toContainEqual( + expect.objectContaining({ slug: "connection_search" }), + ); + expect(compiled.bindings["eve.framework-defaults:tools/connection_search.ts"]).toBeUndefined(); + }); + it("preserves ordered static instruction content, roles, and legacy definitions", async () => { const manifest = createAgentSourceManifest({ agentId: "root", diff --git a/packages/eve/src/framework-sources/registry.ts b/packages/eve/src/framework-sources/registry.ts index 82f1a4dbc7..707bce0750 100644 --- a/packages/eve/src/framework-sources/registry.ts +++ b/packages/eve/src/framework-sources/registry.ts @@ -1,4 +1,5 @@ import * as bash from "./tools/bash.js"; +import * as connectionSearch from "./tools/connection_search.js"; import * as readFile from "./tools/read_file.js"; import * as sandbox from "./sandbox.js"; import * as todo from "./tools/todo.js"; @@ -13,6 +14,7 @@ const frameworkAgentSource = defineProgrammaticAgentSource({ modules: [ { logicalPath: "sandbox.ts", namespace: sandbox }, { logicalPath: "tools/bash.ts", namespace: bash }, + { logicalPath: "tools/connection_search.ts", namespace: connectionSearch }, { logicalPath: "tools/read_file.ts", namespace: readFile }, { logicalPath: "tools/todo.ts", namespace: todo }, { logicalPath: "tools/web_fetch.ts", namespace: webFetch }, diff --git a/packages/eve/src/framework-sources/tools/connection_search.ts b/packages/eve/src/framework-sources/tools/connection_search.ts new file mode 100644 index 0000000000..3d28c9781f --- /dev/null +++ b/packages/eve/src/framework-sources/tools/connection_search.ts @@ -0,0 +1 @@ +export { default } from "#runtime/framework-tools/connection-search-dynamic.js"; diff --git a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.ts b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.ts index b4ba86fa5c..d03007818f 100644 --- a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.ts +++ b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.ts @@ -1,7 +1,4 @@ -import { - getAllFrameworkToolNames, - getFrameworkDynamicToolResolvers, -} from "#runtime/framework-tools/index.js"; +import { getAllFrameworkToolNames } from "#runtime/framework-tools/index.js"; import { getAllFrameworkChannelNames, getFrameworkChannelDefinitions, @@ -221,14 +218,14 @@ export function buildAgentInfoResponseFromManifest( available: [...frameworkToolInfo.available, ...authoredTools], authored: authoredTools, disabledFramework: [...manifest.disabledFrameworkTools], - dynamic: [ - ...getFrameworkDynamicToolResolvers().map((resolver) => - renderDynamicResolver(resolver, { origin: "framework" }), - ), - ...manifest.dynamicTools.map((resolver) => - renderDynamicResolver(resolver, { origin: "authored" }), - ), - ], + dynamic: manifest.dynamicTools.map((resolver) => + renderDynamicResolver(resolver, { + origin: + manifest.bindings[resolver.sourceId]?.owner.kind === "framework" + ? "framework" + : "authored", + }), + ), framework: frameworkToolInfo.framework, reserved: [WORKFLOW_TOOL_NAME, LOAD_SKILL_TOOL_NAME], }, 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 f130f23e86..c705ba99d1 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 @@ -2,7 +2,6 @@ import { ROOT_COMPILED_AGENT_NODE_ID } from "#compiler/manifest.js"; import { getAllFrameworkToolDefinitions, getAllFrameworkToolNames, - getFrameworkDynamicToolResolvers, getOptInFrameworkToolNames, } from "#runtime/framework-tools/index.js"; import { @@ -239,7 +238,7 @@ export function buildAgentInfoResponse( if (config === undefined) { throw new Error("Cannot inspect unresolved dynamic subagent resources as a root agent."); } - const tools = buildToolInfo(agent, getRootDelegationToolNames(data.manifest)); + const tools = buildToolInfo(agent, getRootDelegationToolNames(data.manifest), data.manifest); return { agent: { @@ -384,11 +383,11 @@ function buildChannelInfo(agent: ResolvedAgent): AgentInfoChannels { function buildToolInfo( agent: ResolvedAgent, delegationToolNames: ReadonlySet, + manifest: CompiledAgentManifest, ): AgentInfoTools { const authoredToolNames = new Set(agent.tools.map((tool) => tool.name)); const disabledFrameworkTools = new Set(agent.disabledFrameworkTools); const allFrameworkToolNames = getAllFrameworkToolNames(); - const dynamicFrameworkResolvers = getFrameworkDynamicToolResolvers(); const authored = agent.tools.map((tool) => renderTool(tool, { origin: "authored", @@ -405,14 +404,14 @@ function buildToolInfo( available: [...frameworkInfo.available, ...authored], authored, disabledFramework: [...agent.disabledFrameworkTools], - dynamic: [ - ...dynamicFrameworkResolvers.map((resolver) => - renderDynamicResolver(resolver, { origin: "framework" }), - ), - ...agent.dynamicToolResolvers.map((resolver) => - renderDynamicResolver(resolver, { origin: "authored" }), - ), - ], + dynamic: agent.dynamicToolResolvers.map((resolver) => + renderDynamicResolver(resolver, { + origin: + manifest.bindings[resolver.sourceId]?.owner.kind === "framework" + ? "framework" + : "authored", + }), + ), framework: frameworkInfo.framework, reserved: [WORKFLOW_TOOL_NAME, LOAD_SKILL_TOOL_NAME], }; diff --git a/packages/eve/src/runtime/framework-tools/connection-search-dynamic.test.ts b/packages/eve/src/runtime/framework-tools/connection-search-dynamic.test.ts index 2ce85b3c6f..7b0b96a4d4 100644 --- a/packages/eve/src/runtime/framework-tools/connection-search-dynamic.test.ts +++ b/packages/eve/src/runtime/framework-tools/connection-search-dynamic.test.ts @@ -11,8 +11,10 @@ import { import { ConnectionAuthorizationRequiredError } from "#public/connections/errors.js"; import type { ToolContext } from "#public/definitions/tool.js"; import type { ConnectionRegistry, ConnectionToolMetadata } from "#runtime/connections/types.js"; -import { extractDiscoveredTools } from "#runtime/framework-tools/connection-search-dynamic.js"; -import { getFrameworkDynamicToolResolvers } from "#runtime/framework-tools/index.js"; +import connectionSearchDynamicDefinition, { + ConnectionSearchResultsKey, +} from "#runtime/framework-tools/connection-search-dynamic.js"; +import { resolveLoadedDynamicToolDefinition } from "#runtime/resolve-dynamic-tool.js"; import type { ResolvedConnectionDefinition } from "#runtime/types.js"; import { isBrandedToolEntry, @@ -21,9 +23,6 @@ import { } from "#shared/dynamic-tool-definition.js"; import { readDurableDynamicToolCallbacks } from "#shared/durable-dynamic-tool-callbacks.js"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Msg = any; - function connection(name: string): ResolvedConnectionDefinition { return { connectionName: name, @@ -58,7 +57,12 @@ async function executeConnectionSearch( } function getConnectionSearchResolver() { - return getFrameworkDynamicToolResolvers()[0]!; + return resolveLoadedDynamicToolDefinition(connectionSearchDynamicDefinition, { + logicalPath: "tools/connection_search.ts", + slug: "connection_search", + sourceId: "eve.framework-defaults:tools/connection_search.ts", + sourceKind: "module", + }); } function registry(input: { @@ -112,29 +116,17 @@ describe("connection dynamic tools", () => { connections: [linear], loadTools: { linear: async () => [] }, }); - const messages: Msg[] = [ - { - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "call-1", - toolName: "connection_search", - output: [ - { - connection: "linear", - description: "List issues", - inputSchema: { type: "object" }, - qualifiedName: "linear__list_issues", - tool: "list_issues", - }, - ], - }, - ], - }, - ]; const ctx = new ContextContainer(); ctx.set(ConnectionRegistryKey, connectionRegistry); + ctx.set(ConnectionSearchResultsKey, [ + { + connection: "linear", + description: "List issues", + inputSchema: { type: "object" }, + qualifiedName: "linear__list_issues", + tool: "list_issues", + }, + ]); const resolver = getConnectionSearchResolver(); const resolve = resolver.events["step.started"]!; @@ -143,7 +135,7 @@ describe("connection dynamic tools", () => { {}, { channel: {}, - messages, + messages: [], session: { auth: { current: null, initiator: null }, id: "test-session" }, }, )) as DynamicToolSet; @@ -153,6 +145,47 @@ describe("connection dynamic tools", () => { expect(Object.keys(tools)).toEqual(["connection_search", "linear__list_issues"]); expect(Object.values(tools).every(isBrandedToolEntry)).toBe(true); }); + + it("does not reconstruct discovered tools from message history", async () => { + const linear = connection("linear"); + const ctx = new ContextContainer(); + ctx.set( + ConnectionRegistryKey, + registry({ connections: [linear], loadTools: { linear: async () => [] } }), + ); + + const tools = await contextStorage.run(ctx, async () => + getConnectionSearchResolver().events["step.started"]!( + {}, + { + channel: {}, + messages: [ + { + content: [ + { + output: [ + { + connection: "linear", + description: "List issues", + qualifiedName: "linear__list_issues", + tool: "list_issues", + }, + ], + toolCallId: "call-1", + toolName: "connection_search", + type: "tool-result", + }, + ], + role: "tool", + }, + ], + session: { auth: { current: null, initiator: null }, id: "test-session" }, + }, + ), + ); + + expect(Object.keys(tools as DynamicToolSet)).toEqual(["connection_search"]); + }); }); describe("connection_search", () => { @@ -446,150 +479,3 @@ describe("connection_search", () => { expect(isAuthorizationSignal(result)).toBe(true); }); }); - -describe("extractDiscoveredTools", () => { - it("extracts tools from raw array output", () => { - const messages: Msg[] = [ - { role: "user", content: [{ type: "text", text: "search" }] }, - { - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "call-1", - toolName: "connection_search", - output: [ - { - connection: "linear", - tool: "list_issues", - qualifiedName: "linear__list_issues", - description: "List issues", - inputSchema: { type: "object" }, - outputSchema: { type: "object" }, - }, - ], - }, - ], - }, - ]; - - const result = extractDiscoveredTools(messages); - expect(result).toHaveLength(1); - expect(result[0]!.qualifiedName).toBe("linear__list_issues"); - expect(result[0]!.connection).toBe("linear"); - expect(result[0]!.tool).toBe("list_issues"); - expect(result[0]!.outputSchema).toEqual({ type: "object" }); - }); - - it("extracts tools from ToolResultOutput json wrapper", () => { - const messages: Msg[] = [ - { - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "call-1", - toolName: "connection_search", - output: { - type: "json", - value: [ - { - connection: "linear", - tool: "list_issues", - qualifiedName: "linear__list_issues", - description: "List issues", - inputSchema: { type: "object" }, - }, - ], - }, - }, - ], - }, - ]; - - const result = extractDiscoveredTools(messages); - expect(result).toHaveLength(1); - expect(result[0]!.qualifiedName).toBe("linear__list_issues"); - }); - - it("returns empty for no tool results", () => { - const messages: Msg[] = [{ role: "user", content: [{ type: "text", text: "hello" }] }]; - expect(extractDiscoveredTools(messages)).toHaveLength(0); - }); - - it("deduplicates by qualifiedName (latest wins)", () => { - const messages: Msg[] = [ - { - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "call-1", - toolName: "connection_search", - output: [ - { - connection: "linear", - tool: "list_issues", - qualifiedName: "linear__list_issues", - description: "Old description", - }, - ], - }, - ], - }, - { - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "call-2", - toolName: "connection_search", - output: [ - { - connection: "linear", - tool: "list_issues", - qualifiedName: "linear__list_issues", - description: "New description", - }, - ], - }, - ], - }, - ]; - - const result = extractDiscoveredTools(messages); - expect(result).toHaveLength(1); - expect(result[0]!.description).toBe("New description"); - }); - - it("skips items without tool or qualifiedName", () => { - const messages: Msg[] = [ - { - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "call-1", - toolName: "connection_search", - output: [ - { - connection: "linear", - description: "No tool or qualifiedName", - }, - { - connection: "linear", - tool: "list_issues", - qualifiedName: "linear__list_issues", - description: "Valid", - }, - ], - }, - ], - }, - ]; - - const result = extractDiscoveredTools(messages); - expect(result).toHaveLength(1); - expect(result[0]!.description).toBe("Valid"); - }); -}); diff --git a/packages/eve/src/runtime/framework-tools/connection-search-dynamic.ts b/packages/eve/src/runtime/framework-tools/connection-search-dynamic.ts index 20bdf4b733..3fb8493519 100644 --- a/packages/eve/src/runtime/framework-tools/connection-search-dynamic.ts +++ b/packages/eve/src/runtime/framework-tools/connection-search-dynamic.ts @@ -40,8 +40,6 @@ import { import type { ResolvedConnectionDefinition } from "#runtime/types.js"; import { createLogger } from "#internal/logging.js"; import { toError } from "#shared/errors.js"; -import type { ModelMessage } from "ai"; - import { ConnectionRegistryKey } from "#context/providers/connection-key.js"; const logger = createLogger("framework.connection-search-dynamic"); @@ -78,7 +76,7 @@ const CONNECTION_SEARCH_OUTPUT_SCHEMA = z.array(CONNECTION_SEARCH_RESULT_ITEM_SC * `executeConnectionSearch` so the resolver can find discovered tools without * relying on model-facing tool result history. */ -const ConnectionSearchResultsKey = new ContextKey( +export const ConnectionSearchResultsKey = new ContextKey( "eve.connectionSearchResults", ); @@ -363,44 +361,6 @@ async function executeConnectionSearch( return summaries; } -/** - * Extracts connection search results from conversation history. - * Scans tool-result messages for `connection_search` results and - * returns deduplicated tool metadata (latest result wins per qualifiedName). - */ -export function extractDiscoveredTools( - messages: readonly ModelMessage[], -): ConnectionSearchResultItem[] { - const byQualifiedName = new Map(); - - for (const msg of messages) { - if (msg.role !== "tool") continue; - const parts = msg.content as Array<{ - type: string; - toolName?: string; - output?: unknown; - }>; - for (const part of parts) { - if (part.type !== "tool-result" || part.toolName !== "connection_search") continue; - const output = part.output; - if (output === undefined || output === null) continue; - const items = ( - typeof output === "object" && "type" in output && "value" in output - ? (output as { value: unknown }).value - : output - ) as unknown; - if (!Array.isArray(items)) continue; - for (const item of items as ConnectionSearchResultItem[]) { - if (item.tool && item.qualifiedName) { - byQualifiedName.set(item.qualifiedName, item); - } - } - } - } - - return [...byQualifiedName.values()]; -} - function readDiscoveredToolClosure(closure: JsonObject): { readonly connectionName: string; readonly toolName: string; @@ -511,26 +471,15 @@ async function authorizeDiscoveredConnectionToolApproval( : await response(context); } -// The step-scoped definition re-derives its tools from conversation history. -// After compaction removes old search results, those tools naturally disappear. const connectionSearchDynamicDefinition = defineDynamic({ events: { - "step.started": async (_event, ctx) => { + "step.started": async () => { const registry = loadContext().get(ConnectionRegistryKey); if (!registry || registry.getConnections().length === 0) return null; const connections = registry.getConnections(); const connectionNames = connections.map((c) => c.connectionName); - const fromMessages = extractDiscoveredTools(ctx.messages); - const fromContext = loadContext().get(ConnectionSearchResultsKey) ?? []; - const mergedMap = new Map(); - for (const r of fromContext) { - if (r.qualifiedName) mergedMap.set(r.qualifiedName, r); - } - for (const r of fromMessages) { - if (r.qualifiedName) mergedMap.set(r.qualifiedName, r); - } - const discovered = [...mergedMap.values()]; + const discovered = loadContext().get(ConnectionSearchResultsKey) ?? []; const tools: Record = {}; diff --git a/packages/eve/src/runtime/framework-tools/index.test.ts b/packages/eve/src/runtime/framework-tools/index.test.ts index 848ea2520c..d115c81741 100644 --- a/packages/eve/src/runtime/framework-tools/index.test.ts +++ b/packages/eve/src/runtime/framework-tools/index.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import { getAllFrameworkToolDefinitions, getAllFrameworkToolNames, - getFrameworkDynamicToolResolvers, getFrameworkToolDefinitions, getOptInFrameworkToolNames, } from "#runtime/framework-tools/index.js"; @@ -26,8 +25,7 @@ describe("framework-tools/index", () => { expect(names.has("task_update")).toBe(true); expect(names.has("task_sleep")).toBe(false); expect(names.has("task_send")).toBe(false); - // connection_search is now a dynamic tool resolver, not a framework tool - expect(names.has("connection_search")).toBe(false); + expect(names.has("connection_search")).toBe(true); }); it("contains every framework tool exactly once", () => { @@ -78,15 +76,4 @@ describe("framework-tools/index", () => { expect(tool.outputSchema, `${tool.name} has outputSchema`).toBeDefined(); } }); - - it("registers connection search through the framework dynamic tool registry", () => { - expect(getFrameworkDynamicToolResolvers()).toMatchObject([ - { - eventNames: ["step.started"], - logicalPath: "eve:framework/connection-search-dynamic", - slug: "connection", - sourceId: "eve:connection-search-dynamic", - }, - ]); - }); }); diff --git a/packages/eve/src/runtime/framework-tools/index.ts b/packages/eve/src/runtime/framework-tools/index.ts index 1fb08685e2..c0b12a7402 100644 --- a/packages/eve/src/runtime/framework-tools/index.ts +++ b/packages/eve/src/runtime/framework-tools/index.ts @@ -13,8 +13,6 @@ import { TODO_TOOL_DEFINITION } from "#runtime/framework-tools/todo.js"; import { WEB_FETCH_TOOL_DEFINITION } from "#runtime/framework-tools/web-fetch.js"; import { WEB_SEARCH_TOOL_DEFINITION } from "#runtime/framework-tools/web-search.js"; import { WRITE_FILE_TOOL_DEFINITION } from "#runtime/framework-tools/write-file.js"; -import connectionSearchDynamicDefinition from "#runtime/framework-tools/connection-search-dynamic.js"; -import { resolveLoadedDynamicToolDefinition } from "#runtime/resolve-dynamic-tool.js"; export { ConnectionRegistryKey } from "#context/providers/connection-key.js"; export type { ReadFileStamp, ReadFileState } from "#runtime/framework-tools/file-state.js"; @@ -22,28 +20,7 @@ export { ReadFileStateKey } from "#runtime/framework-tools/file-state.js"; export type { TodoItem, TodoState } from "#runtime/framework-tools/todo.js"; export { TodoStateKey } from "#runtime/framework-tools/todo.js"; -import type { - ResolvedDynamicToolResolver, - ResolvedSkillDefinition, - ResolvedToolDefinition, -} from "#runtime/types.js"; -import type { DynamicSentinel } from "#shared/dynamic-tool-definition.js"; - -interface FrameworkDynamicToolDefinition { - readonly definition: DynamicSentinel; - readonly logicalPath: string; - readonly slug: string; - readonly sourceId: string; -} - -const REGISTERED_FRAMEWORK_DYNAMIC_TOOLS: readonly FrameworkDynamicToolDefinition[] = [ - { - definition: connectionSearchDynamicDefinition, - logicalPath: "eve:framework/connection-search-dynamic", - slug: "connection", - sourceId: "eve:connection-search-dynamic", - }, -]; +import type { ResolvedSkillDefinition, ResolvedToolDefinition } from "#runtime/types.js"; const REGISTERED_FRAMEWORK_TOOLS: readonly ResolvedToolDefinition[] = [ ASK_QUESTION_TOOL_DEFINITION, @@ -72,8 +49,7 @@ const ALL_FRAMEWORK_TOOLS: readonly ResolvedToolDefinition[] = [ * Returns framework-owned tool definitions registered in the tool registry * alongside authored tools during graph resolution. * - * `connection_search` is no longer in this list. The graph resolution path - * registers it as a framework dynamic tool resolver. + * Source-composed dynamic tools are not represented in this legacy catalog. */ export function getFrameworkToolDefinitions(config?: { readonly authoredSkills?: readonly ResolvedSkillDefinition[]; @@ -88,22 +64,6 @@ export function getFrameworkToolDefinitions(config?: { ); } -/** - * Returns framework-owned dynamic tool resolvers. - * Framework definitions use the public `defineDynamic()` contract and enter - * the same loaded-definition resolver path as authored dynamic tools. - */ -export function getFrameworkDynamicToolResolvers(): readonly ResolvedDynamicToolResolver[] { - return REGISTERED_FRAMEWORK_DYNAMIC_TOOLS.map((entry) => - resolveLoadedDynamicToolDefinition(entry.definition, { - logicalPath: entry.logicalPath, - slug: entry.slug, - sourceId: entry.sourceId, - sourceKind: "module", - }), - ); -} - /** * Returns every static framework-owned tool definition, including tools such * as `agent` that the runtime does not register in the tool registry. @@ -127,5 +87,8 @@ export function getOptInFrameworkToolNames(): ReadonlySet { * as an authoring error rather than silently dropping the request. */ export function getAllFrameworkToolNames(): ReadonlySet { - return new Set(ALL_FRAMEWORK_TOOLS.map((definition) => definition.name)); + return new Set([ + ...ALL_FRAMEWORK_TOOLS.map((definition) => definition.name), + "connection_search", + ]); } diff --git a/packages/eve/src/runtime/resolve-agent-graph.ts b/packages/eve/src/runtime/resolve-agent-graph.ts index 32d4ac8138..d3eb4c2d2c 100644 --- a/packages/eve/src/runtime/resolve-agent-graph.ts +++ b/packages/eve/src/runtime/resolve-agent-graph.ts @@ -16,7 +16,6 @@ import { } from "#runtime/framework-channels/index.js"; import { getAllFrameworkToolNames, - getFrameworkDynamicToolResolvers, getFrameworkToolDefinitions, } from "#runtime/framework-tools/index.js"; import { type ResolvedAgentGraphBundle, ROOT_RUNTIME_AGENT_NODE_ID } from "#runtime/graph.js"; @@ -238,10 +237,7 @@ async function resolveRuntimeAgentNode( subagentNodesById: input.subagentNodesById, }), }); - const resolvedAgent = { - ...agent, - dynamicToolResolvers: [...agent.dynamicToolResolvers, ...getFrameworkDynamicToolResolvers()], - }; + const resolvedAgent = agent; const node: ResolvedAgentGraphBundle["root"] = { agent: resolvedAgent, From d5f33083050c02ee9a227aeb7f6da64960237553 Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Sat, 22 Aug 2026 11:46:35 -0400 Subject: [PATCH 03/12] refactor(eve): compile skill loading as an agent source Replace the node-specific load_skill closure with a source-composed definition backed by derived runtime context. Preserve framework action semantics through explicit source ownership so application overrides remain ordinary tools. Signed-off-by: Andrew Barba --- .changeset/calm-skills-compose.md | 5 ++ .../compose-framework-sources.test.ts | 1 + .../src/compiler/normalize-manifest.test.ts | 2 + .../eve/src/context/providers/skill-key.ts | 6 ++ packages/eve/src/context/providers/skill.ts | 15 +++++ packages/eve/src/context/run-step.ts | 2 + packages/eve/src/execution/node-step.test.ts | 42 ++++++++++++ packages/eve/src/execution/node-step.ts | 15 +++-- .../eve/src/framework-sources/registry.ts | 2 + .../src/framework-sources/tools/load_skill.ts | 1 + packages/eve/src/public/tools/defaults.ts | 5 +- .../eve/src/runtime/framework-tools/index.ts | 20 ++---- .../src/runtime/framework-tools/skill.test.ts | 66 +++++++++---------- .../eve/src/runtime/framework-tools/skill.ts | 47 ++++++------- .../eve/src/runtime/resolve-agent-graph.ts | 4 +- packages/eve/src/runtime/resolve-agent.ts | 7 +- packages/eve/src/runtime/resolve-tool.ts | 3 + packages/eve/src/runtime/types.ts | 3 + 18 files changed, 155 insertions(+), 91 deletions(-) create mode 100644 .changeset/calm-skills-compose.md create mode 100644 packages/eve/src/context/providers/skill-key.ts create mode 100644 packages/eve/src/context/providers/skill.ts create mode 100644 packages/eve/src/framework-sources/tools/load_skill.ts diff --git a/.changeset/calm-skills-compose.md b/.changeset/calm-skills-compose.md new file mode 100644 index 0000000000..1c08b87230 --- /dev/null +++ b/.changeset/calm-skills-compose.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Compile the default `load_skill` tool as an ordinary agent source. The tool now reads the active node's skills from runtime context, so application overrides use the same path-based composition rules without a framework-only closure factory. diff --git a/packages/eve/src/compiler/compose-framework-sources.test.ts b/packages/eve/src/compiler/compose-framework-sources.test.ts index e0d5b8cf68..aa947613fc 100644 --- a/packages/eve/src/compiler/compose-framework-sources.test.ts +++ b/packages/eve/src/compiler/compose-framework-sources.test.ts @@ -42,6 +42,7 @@ describe("composeFrameworkSources", () => { expect(result.manifest.tools.map((tool) => tool.logicalPath)).toEqual([ "tools/bash.ts", "tools/connection_search.ts", + "tools/load_skill.ts", "tools/read_file.ts", "tools/todo.ts", "tools/web_fetch.ts", diff --git a/packages/eve/src/compiler/normalize-manifest.test.ts b/packages/eve/src/compiler/normalize-manifest.test.ts index bccfe586aa..1d81faedf6 100644 --- a/packages/eve/src/compiler/normalize-manifest.test.ts +++ b/packages/eve/src/compiler/normalize-manifest.test.ts @@ -311,6 +311,7 @@ describe("compileAgentManifest", () => { expect(compiled.webSearchProvider).toBe("exa"); expect(compiled.tools.map((tool) => tool.name)).toEqual([ "bash", + "load_skill", "read_file", "todo", "web_fetch", @@ -338,6 +339,7 @@ describe("compileAgentManifest", () => { expect(compiled.tools.map((tool) => tool.name)).toEqual([ "bash", + "load_skill", "read_file", "todo", "web_fetch", diff --git a/packages/eve/src/context/providers/skill-key.ts b/packages/eve/src/context/providers/skill-key.ts new file mode 100644 index 0000000000..2665d8c0b0 --- /dev/null +++ b/packages/eve/src/context/providers/skill-key.ts @@ -0,0 +1,6 @@ +import { ContextKey } from "#context/key.js"; +import type { ResolvedSkillDefinition } from "#runtime/types.js"; + +export const AuthoredSkillsKey = new ContextKey( + "eve.authoredSkills", +); diff --git a/packages/eve/src/context/providers/skill.ts b/packages/eve/src/context/providers/skill.ts new file mode 100644 index 0000000000..f8cb99d986 --- /dev/null +++ b/packages/eve/src/context/providers/skill.ts @@ -0,0 +1,15 @@ +import type { FrameworkContextProvider } from "#context/provider.js"; +import { AuthoredSkillsKey } from "#context/providers/skill-key.js"; +import { BundleKey } from "#runtime/sessions/runtime-context-keys.js"; +import type { ResolvedSkillDefinition } from "#runtime/types.js"; + +export const authoredSkillsProvider: FrameworkContextProvider = + { + key: AuthoredSkillsKey, + + create(ctx) { + const agent = ctx.get(BundleKey)?.graph.root.agent; + if (agent === undefined) return undefined; + return { value: agent.skills }; + }, + }; diff --git a/packages/eve/src/context/run-step.ts b/packages/eve/src/context/run-step.ts index efaeac6c98..973a8e67c6 100644 --- a/packages/eve/src/context/run-step.ts +++ b/packages/eve/src/context/run-step.ts @@ -4,6 +4,7 @@ import type { FrameworkContextProvider } from "#context/provider.js"; import { connectionProvider } from "#context/providers/connection.js"; import { sandboxProvider } from "#context/providers/sandbox.js"; import { sessionProvider } from "#context/providers/session.js"; +import { authoredSkillsProvider } from "#context/providers/skill.js"; /** * Framework providers in dependency order. @@ -13,6 +14,7 @@ import { sessionProvider } from "#context/providers/session.js"; */ const frameworkProviders: readonly FrameworkContextProvider[] = [ sessionProvider, + authoredSkillsProvider, connectionProvider, sandboxProvider, ]; diff --git a/packages/eve/src/execution/node-step.test.ts b/packages/eve/src/execution/node-step.test.ts index 5cfb115958..5f85e4f1b9 100644 --- a/packages/eve/src/execution/node-step.test.ts +++ b/packages/eve/src/execution/node-step.test.ts @@ -237,6 +237,48 @@ function createNoopRuntime(): Runtime { } describe("createNodeHarnessTools", () => { + it("classifies source-composed load_skill from binding ownership", async () => { + const definition = { + description: "Load a skill.", + execute: async () => "loaded", + inputSchema: toInputSchema({ type: "object" }), + logicalPath: "tools/load_skill.ts", + name: "load_skill", + sourceId: "eve.framework-defaults:tools/load_skill.ts", + sourceKind: "module" as const, + sourceOwner: { feature: "eve.framework-defaults", kind: "framework" as const }, + }; + const toolRegistry = await createRuntimeToolRegistry({ tools: [definition] }); + const tools = createNodeHarnessTools({ + node: createTestNode(createTestTurnAgent({ tools: toolRegistry.preparedTools }), { + toolRegistry, + }), + }); + + expect(tools.get("load_skill")?.frameworkAction).toBe("load-skill"); + }); + + it("keeps an application load_skill override as an ordinary tool", async () => { + const definition = { + description: "Application skill loader.", + execute: async () => "custom", + inputSchema: toInputSchema({ type: "object" }), + logicalPath: "tools/load_skill.ts", + name: "load_skill", + sourceId: "tools/load_skill.ts", + sourceKind: "module" as const, + sourceOwner: { kind: "application" as const }, + }; + const toolRegistry = await createRuntimeToolRegistry({ tools: [definition] }); + const tools = createNodeHarnessTools({ + node: createTestNode(createTestTurnAgent({ tools: toolRegistry.preparedTools }), { + toolRegistry, + }), + }); + + expect(tools.get("load_skill")?.frameworkAction).toBeUndefined(); + }); + it("guides the model to split large tasks across parallel agent calls", () => { const agentTool = createNodeHarnessTools({ node: createTestNode() }).get("agent"); diff --git a/packages/eve/src/execution/node-step.ts b/packages/eve/src/execution/node-step.ts index eb40d0ceeb..992aa5b9ce 100644 --- a/packages/eve/src/execution/node-step.ts +++ b/packages/eve/src/execution/node-step.ts @@ -280,7 +280,8 @@ function resolveHarnessToolDefinition(input: { } const def = registeredTool.definition; - const isFrameworkTool = def.sourceId.startsWith("eve:"); + const isNativeFrameworkTool = def.sourceOwner === undefined && def.sourceId.startsWith("eve:"); + const isFrameworkOwned = def.sourceOwner?.kind === "framework" || isNativeFrameworkTool; const rawExecute = def.execute; return { @@ -288,12 +289,12 @@ function resolveHarnessToolDefinition(input: { description: def.description, execution: def.execution, execute: resolveAuthoredExecute({ - isFrameworkTool, + isNativeFrameworkTool, rawExecute, scope: def.name, }), frameworkAction: - isFrameworkTool && def.name === LOAD_SKILL_TOOL_NAME ? "load-skill" : undefined, + isFrameworkOwned && def.name === LOAD_SKILL_TOOL_NAME ? "load-skill" : undefined, inputSchema: def.inputSchema ?? UNSPECIFIED_INPUT_SCHEMA, name: def.name, approval: def.approval, @@ -305,7 +306,7 @@ function resolveHarnessToolDefinition(input: { /** * Selects the harness-facing `execute` for one authored tool. * - * - Framework tools (`eve:` source) run their `execute` verbatim — they + * - Native framework tools run their `execute` verbatim — they * manage their own context and never receive an authored * {@link ToolContext}. * - Authored tools are wrapped by {@link createToolExecuteWithAuth}, @@ -314,15 +315,15 @@ function resolveHarnessToolDefinition(input: { * - Tools without `execute` (provider-managed) stay `undefined`. */ function resolveAuthoredExecute(input: { - readonly isFrameworkTool: boolean; + readonly isNativeFrameworkTool: boolean; readonly rawExecute: ResolvedToolDefinition["execute"]; readonly scope: string; }): HarnessToolDefinition["execute"] { - const { isFrameworkTool, rawExecute, scope } = input; + const { isNativeFrameworkTool, rawExecute, scope } = input; if (rawExecute === undefined) { return undefined; } - if (isFrameworkTool) { + if (isNativeFrameworkTool) { return rawExecute; } const authored = rawExecute as ( diff --git a/packages/eve/src/framework-sources/registry.ts b/packages/eve/src/framework-sources/registry.ts index 707bce0750..8581bd880f 100644 --- a/packages/eve/src/framework-sources/registry.ts +++ b/packages/eve/src/framework-sources/registry.ts @@ -1,5 +1,6 @@ import * as bash from "./tools/bash.js"; import * as connectionSearch from "./tools/connection_search.js"; +import * as loadSkill from "./tools/load_skill.js"; import * as readFile from "./tools/read_file.js"; import * as sandbox from "./sandbox.js"; import * as todo from "./tools/todo.js"; @@ -15,6 +16,7 @@ const frameworkAgentSource = defineProgrammaticAgentSource({ { logicalPath: "sandbox.ts", namespace: sandbox }, { logicalPath: "tools/bash.ts", namespace: bash }, { logicalPath: "tools/connection_search.ts", namespace: connectionSearch }, + { logicalPath: "tools/load_skill.ts", namespace: loadSkill }, { logicalPath: "tools/read_file.ts", namespace: readFile }, { logicalPath: "tools/todo.ts", namespace: todo }, { logicalPath: "tools/web_fetch.ts", namespace: webFetch }, diff --git a/packages/eve/src/framework-sources/tools/load_skill.ts b/packages/eve/src/framework-sources/tools/load_skill.ts new file mode 100644 index 0000000000..b54f0535a5 --- /dev/null +++ b/packages/eve/src/framework-sources/tools/load_skill.ts @@ -0,0 +1 @@ +export { loadSkill as default } from "#public/tools/defaults.js"; diff --git a/packages/eve/src/public/tools/defaults.ts b/packages/eve/src/public/tools/defaults.ts index 97479c818b..0586447bb1 100644 --- a/packages/eve/src/public/tools/defaults.ts +++ b/packages/eve/src/public/tools/defaults.ts @@ -3,7 +3,7 @@ * values so authors can spread, wrap, or patch them inside their own * `agent/tools/*.ts` files. */ -import { SKILL_TOOL_DEFINITION } from "#runtime/framework-tools/skill.js"; +import { loadSkillToolDefinition } from "#runtime/framework-tools/skill.js"; import { TODO_INPUT_SCHEMA, TODO_OUTPUT_SCHEMA, @@ -16,7 +16,6 @@ import { } from "#runtime/framework-tools/web-fetch.js"; import { executeWebFetchTool, type WebFetchInput } from "#execution/web-fetch/tool.js"; import type { ToolDefinition } from "#public/definitions/tool.js"; -import { toPublicToolDefinition } from "#public/tools/internal.js"; import { defineBashTool } from "#public/tools/define-bash-tool.js"; import { defineGlobTool } from "#public/tools/define-glob-tool.js"; import { defineGrepTool } from "#public/tools/define-grep-tool.js"; @@ -146,4 +145,4 @@ export const todo: ToolDefinition = { * framework does not surface skill descriptions to the model, so the model has * nothing to load. */ -export const loadSkill: ToolDefinition = toPublicToolDefinition(SKILL_TOOL_DEFINITION); +export const loadSkill: ToolDefinition = loadSkillToolDefinition; diff --git a/packages/eve/src/runtime/framework-tools/index.ts b/packages/eve/src/runtime/framework-tools/index.ts index c0b12a7402..d3930aa578 100644 --- a/packages/eve/src/runtime/framework-tools/index.ts +++ b/packages/eve/src/runtime/framework-tools/index.ts @@ -4,10 +4,7 @@ import { BASH_TOOL_DEFINITION } from "#runtime/framework-tools/bash.js"; import { GLOB_TOOL_DEFINITION } from "#runtime/framework-tools/glob.js"; import { GREP_TOOL_DEFINITION } from "#runtime/framework-tools/grep.js"; import { READ_FILE_TOOL_DEFINITION } from "#runtime/framework-tools/read-file.js"; -import { - createSkillToolDefinition, - SKILL_TOOL_DEFINITION, -} from "#runtime/framework-tools/skill.js"; +import { SKILL_TOOL_DEFINITION } from "#runtime/framework-tools/skill.js"; import { TASK_TOOL_DEFINITIONS } from "#runtime/framework-tools/tasks.js"; import { TODO_TOOL_DEFINITION } from "#runtime/framework-tools/todo.js"; import { WEB_FETCH_TOOL_DEFINITION } from "#runtime/framework-tools/web-fetch.js"; @@ -20,7 +17,7 @@ export { ReadFileStateKey } from "#runtime/framework-tools/file-state.js"; export type { TodoItem, TodoState } from "#runtime/framework-tools/todo.js"; export { TodoStateKey } from "#runtime/framework-tools/todo.js"; -import type { ResolvedSkillDefinition, ResolvedToolDefinition } from "#runtime/types.js"; +import type { ResolvedToolDefinition } from "#runtime/types.js"; const REGISTERED_FRAMEWORK_TOOLS: readonly ResolvedToolDefinition[] = [ ASK_QUESTION_TOOL_DEFINITION, @@ -51,17 +48,8 @@ const ALL_FRAMEWORK_TOOLS: readonly ResolvedToolDefinition[] = [ * * Source-composed dynamic tools are not represented in this legacy catalog. */ -export function getFrameworkToolDefinitions(config?: { - readonly authoredSkills?: readonly ResolvedSkillDefinition[]; -}): readonly ResolvedToolDefinition[] { - const authoredSkills = config?.authoredSkills; - if (authoredSkills === undefined) return REGISTERED_FRAMEWORK_TOOLS; - - return REGISTERED_FRAMEWORK_TOOLS.map((definition) => - definition.name === SKILL_TOOL_DEFINITION.name - ? createSkillToolDefinition(authoredSkills) - : definition, - ); +export function getFrameworkToolDefinitions(): readonly ResolvedToolDefinition[] { + return REGISTERED_FRAMEWORK_TOOLS; } /** diff --git a/packages/eve/src/runtime/framework-tools/skill.test.ts b/packages/eve/src/runtime/framework-tools/skill.test.ts index fa728ab233..055a1216ae 100644 --- a/packages/eve/src/runtime/framework-tools/skill.test.ts +++ b/packages/eve/src/runtime/framework-tools/skill.test.ts @@ -3,38 +3,41 @@ import { describe, expect, it, vi } from "vitest"; import { ContextContainer, contextStorage } from "#context/container.js"; import { DynamicSkillManifestKey, SandboxKey } from "#context/keys.js"; import { ConnectionRegistryKey } from "#context/providers/connection-key.js"; +import { AuthoredSkillsKey } from "#context/providers/skill-key.js"; import { mockSandbox } from "#internal/testing/mocks/mock-sandbox.js"; import type { ConnectionRegistry } from "#runtime/connections/types.js"; -import { - createSkillToolDefinition, - SKILL_TOOL_DEFINITION, -} from "#runtime/framework-tools/skill.js"; +import { loadSkill } from "#public/tools/defaults.js"; import { createSandboxSkillHandle } from "#runtime/skills/sandbox-access.js"; import type { ResolvedSkillDefinition } from "#runtime/types.js"; -function skillToolExecutor(skills: readonly ResolvedSkillDefinition[] = []) { - const execute = createSkillToolDefinition(skills).execute; +function skillToolExecutor() { + const execute = loadSkill.execute; if (execute === undefined) throw new Error("load_skill tool is missing an execute function"); return execute; } -describe("SKILL_TOOL_DEFINITION", () => { +function setAuthoredSkills( + ctx: ContextContainer, + skills: readonly ResolvedSkillDefinition[] = [], +): void { + ctx.set(AuthoredSkillsKey, skills); +} + +describe("loadSkill", () => { it("describes when skill loading should be used", () => { - expect(SKILL_TOOL_DEFINITION.description).toContain( - "request clearly matches a listed skill description", - ); - expect(SKILL_TOOL_DEFINITION.description).toContain( + expect(loadSkill.description).toContain("request clearly matches a listed skill description"); + expect(loadSkill.description).toContain( "Loading adds the skill instructions to the current turn.", ); - expect(SKILL_TOOL_DEFINITION.description).toContain("Available skills block"); - expect(SKILL_TOOL_DEFINITION.description).not.toContain("connection_search"); + expect(loadSkill.description).toContain("Available skills block"); + expect(loadSkill.description).not.toContain("connection_search"); }); }); describe("load_skill executor", () => { it("loads an authored markdown skill when no sandbox context is available", async () => { const ctx = new ContextContainer(); - const execute = skillToolExecutor([ + setAuthoredSkills(ctx, [ { description: "Research a topic systematically", logicalPath: "skills/research.md", @@ -52,11 +55,10 @@ describe("load_skill executor", () => { sourceKind: "markdown", }, ]); + const execute = skillToolExecutor(); await expect( - contextStorage.run(ctx, () => - execute({ skill: "research" }, { messages: [], toolCallId: "call_1" }), - ), + contextStorage.run(ctx, () => execute({ skill: "research" }, {} as never)), ).resolves.toBe("# Research\n\nFollow the evidence.\n"); }); @@ -74,7 +76,7 @@ describe("load_skill executor", () => { }; const ctx = new ContextContainer(); ctx.set(SandboxKey, access); - const execute = skillToolExecutor([ + setAuthoredSkills(ctx, [ { assetsPath: "/authored/skills/incident-response/assets", description: "Run the full incident response procedure", @@ -91,11 +93,10 @@ describe("load_skill executor", () => { sourceKind: "skill-package", }, ]); + const execute = skillToolExecutor(); await expect( - contextStorage.run(ctx, () => - execute({ skill: "incident-response" }, { messages: [], toolCallId: "call_2" }), - ), + contextStorage.run(ctx, () => execute({ skill: "incident-response" }, {} as never)), ).resolves.toBe( "# Incident response\n\nConsult `references/services/api/owners.md` when needed.\n", ); @@ -116,10 +117,7 @@ describe("load_skill executor", () => { }); const ctx = new ContextContainer(); ctx.set(SandboxKey, sandbox.access); - ctx.set(DynamicSkillManifestKey, { - policy: [{ description: "Apply the dynamic policy", name: "policy" }], - }); - const execute = skillToolExecutor([ + setAuthoredSkills(ctx, [ { description: "Apply the static policy", logicalPath: "skills/policy.md", @@ -129,17 +127,20 @@ describe("load_skill executor", () => { sourceKind: "markdown", }, ]); + ctx.set(DynamicSkillManifestKey, { + policy: [{ description: "Apply the dynamic policy", name: "policy" }], + }); + const execute = skillToolExecutor(); await expect( - contextStorage.run(ctx, () => - execute({ skill: "policy" }, { messages: [], toolCallId: "call_dynamic" }), - ), + contextStorage.run(ctx, () => execute({ skill: "policy" }, {} as never)), ).resolves.toBe("# Dynamic policy\n"); }); it("surfaces dynamic skill names when the requested id is missing", async () => { const ctx = new ContextContainer(); ctx.set(SandboxKey, mockSandbox().access); + setAuthoredSkills(ctx); ctx.set(DynamicSkillManifestKey, { custom: [ { description: "Talk like a dog", name: "custom__talk-like-a-dog" }, @@ -149,9 +150,7 @@ describe("load_skill executor", () => { const execute = skillToolExecutor(); await expect( - contextStorage.run(ctx, () => - execute({ skill: "talk-like-a-dog" }, { messages: [], toolCallId: "call_1" }), - ), + contextStorage.run(ctx, () => execute({ skill: "talk-like-a-dog" }, {} as never)), ).rejects.toThrow("Available skills: custom__bark, custom__talk-like-a-dog."); }); @@ -168,12 +167,11 @@ describe("load_skill executor", () => { const ctx = new ContextContainer(); ctx.set(SandboxKey, mockSandbox().access); ctx.set(ConnectionRegistryKey, registry); + setAuthoredSkills(ctx); const execute = skillToolExecutor(); await expect( - contextStorage.run(ctx, () => - execute({ skill: "linear" }, { messages: [], toolCallId: "call_1" }), - ), + contextStorage.run(ctx, () => execute({ skill: "linear" }, {} as never)), ).rejects.toThrow( '"linear" is an installed connection, not a skill. Use connection_search with connection "linear" to find its tools.', ); diff --git a/packages/eve/src/runtime/framework-tools/skill.ts b/packages/eve/src/runtime/framework-tools/skill.ts index d75f8f0992..2148a3540b 100644 --- a/packages/eve/src/runtime/framework-tools/skill.ts +++ b/packages/eve/src/runtime/framework-tools/skill.ts @@ -3,13 +3,15 @@ import { z } from "#compiled/zod/index.js"; import { loadContext } from "#context/container.js"; import { DynamicSkillManifestKey, SandboxKey } from "#context/keys.js"; import { ConnectionRegistryKey } from "#context/providers/connection-key.js"; +import { AuthoredSkillsKey } from "#context/providers/skill-key.js"; +import type { ToolDefinition } from "#public/definitions/tool.js"; import { loadSkillFromSandbox } from "#runtime/skills/sandbox-access.js"; -import type { ResolvedSkillDefinition, ResolvedToolDefinition } from "#runtime/types.js"; +import type { ResolvedToolDefinition } from "#runtime/types.js"; /** * Typed input accepted by {@link executeLoadSkillTool}. */ -type LoadSkillInput = z.infer; +export type LoadSkillInput = z.infer; /** * Executes the `load_skill` tool. @@ -18,11 +20,9 @@ type LoadSkillInput = z.infer; * Active dynamic skills take precedence and remain sandbox-backed because * their full package content is currently materialized there at runtime. */ -async function executeLoadSkillTool( - args: LoadSkillInput, - authoredSkills: readonly ResolvedSkillDefinition[], -): Promise { +export async function executeLoadSkillTool(args: LoadSkillInput): Promise { const ctx = loadContext(); + const authoredSkills = ctx.require(AuthoredSkillsKey); const { skill } = args; const dynamicSkillNames = availableDynamicSkillNames(ctx); const availableSkills = [ @@ -75,41 +75,34 @@ function formatSkillNotFoundError(skill: string, availableSkills: readonly strin return `No skill named "${skill}".${hint}`; } -// --------------------------------------------------------------------------- -// Tool definition -// --------------------------------------------------------------------------- - export const SKILL_INPUT_SCHEMA = z.strictObject({ skill: z.string().describe("Available skill name or id."), }); export const SKILL_OUTPUT_SCHEMA = z.string(); -const SKILL_TOOL_METADATA = { +export const loadSkillToolDefinition: ToolDefinition = { description: [ "Load the full instructions for one available skill by name or id.", "Use this tool when the request clearly matches a listed skill description or when the user explicitly asks for that skill.", "Loading adds the skill instructions to the current turn.", 'Choose the "skill" value from the Available skills block.', ].join(" "), + execute: async (input) => executeLoadSkillTool(input as LoadSkillInput), inputSchema: SKILL_INPUT_SCHEMA, - logicalPath: "eve:framework/load-skill", - name: "load_skill", outputSchema: SKILL_OUTPUT_SCHEMA, - sourceId: "eve:load-skill-tool", - sourceKind: "module" as const, }; /** - * Creates a node-specific `load_skill` definition with authored skills bound - * into its executor. + * Transitional runtime-catalog projection. Source-composed manifests replace + * this entry by canonical path; legacy in-memory graph fixtures still use it. */ -export function createSkillToolDefinition( - authoredSkills: readonly ResolvedSkillDefinition[], -): ResolvedToolDefinition { - return { - ...SKILL_TOOL_METADATA, - execute: (input) => executeLoadSkillTool(input as LoadSkillInput, authoredSkills), - }; -} - -export const SKILL_TOOL_DEFINITION = createSkillToolDefinition([]); +export const SKILL_TOOL_DEFINITION: ResolvedToolDefinition = { + description: loadSkillToolDefinition.description, + execute: (input) => executeLoadSkillTool(input as LoadSkillInput), + inputSchema: SKILL_INPUT_SCHEMA, + logicalPath: "eve:framework/load-skill", + name: "load_skill", + outputSchema: SKILL_OUTPUT_SCHEMA, + sourceId: "eve:load-skill-tool", + sourceKind: "module", +}; diff --git a/packages/eve/src/runtime/resolve-agent-graph.ts b/packages/eve/src/runtime/resolve-agent-graph.ts index d3eb4c2d2c..933a1c54a2 100644 --- a/packages/eve/src/runtime/resolve-agent-graph.ts +++ b/packages/eve/src/runtime/resolve-agent-graph.ts @@ -142,9 +142,7 @@ async function resolveRuntimeAgentNode( moduleMap: input.moduleMap, nodeId: input.nodeId, }); - const frameworkTools = getFrameworkToolDefinitions({ - authoredSkills: agent.skills, - }); + const frameworkTools = getFrameworkToolDefinitions(); const frameworkToolNames = new Set(frameworkTools.map((t) => t.name)); const allFrameworkToolNames = getAllFrameworkToolNames(); diff --git a/packages/eve/src/runtime/resolve-agent.ts b/packages/eve/src/runtime/resolve-agent.ts index d307163e76..e731681b70 100644 --- a/packages/eve/src/runtime/resolve-agent.ts +++ b/packages/eve/src/runtime/resolve-agent.ts @@ -65,7 +65,12 @@ export async function resolveAgent(input: ResolveAgentInput): Promise - resolveToolDefinition(toolDefinition, input.moduleMap, input.nodeId), + resolveToolDefinition( + toolDefinition, + input.moduleMap, + input.nodeId, + input.manifest.bindings[toolDefinition.sourceId]?.owner, + ), ), ); const resolvedDynamicInstructionsResolvers = await Promise.all( diff --git a/packages/eve/src/runtime/resolve-tool.ts b/packages/eve/src/runtime/resolve-tool.ts index 29bfc48261..c5f2cd0b2c 100644 --- a/packages/eve/src/runtime/resolve-tool.ts +++ b/packages/eve/src/runtime/resolve-tool.ts @@ -1,4 +1,5 @@ import type { CompiledToolDefinition } from "#compiler/manifest.js"; +import type { AgentSourceOwner } from "#compiler/module-binding.js"; import type { CompiledModuleMap } from "#compiler/module-map.js"; import { expectFunction, expectObjectRecord } from "#internal/authored-module.js"; import { normalizeApproval } from "#internal/authored-definition/approval.js"; @@ -21,6 +22,7 @@ export async function resolveToolDefinition( definition: CompiledToolDefinition, moduleMap: CompiledModuleMap, nodeId: string | undefined, + sourceOwner?: AgentSourceOwner, ): Promise { try { const resolvedExportValue = await loadResolvedModuleExport({ @@ -67,6 +69,7 @@ export async function resolveToolDefinition( outputSchema, sourceId: definition.sourceId, sourceKind: "module", + sourceOwner, ...extractOptionalHooks(resolvedRecord, definition), }; } catch (error) { diff --git a/packages/eve/src/runtime/types.ts b/packages/eve/src/runtime/types.ts index 3b7f87e80c..9d4633955a 100644 --- a/packages/eve/src/runtime/types.ts +++ b/packages/eve/src/runtime/types.ts @@ -20,6 +20,7 @@ import type { } from "#runtime/connections/types.js"; import type { OpenAPISpecSource } from "#public/definitions/connections/openapi.js"; import type { CompiledWorkspaceResourceRoot } from "#compiler/manifest.js"; +import type { AgentSourceOwner } from "#compiler/module-binding.js"; import type { WorkspaceRuntimeSpec } from "#runtime/workspace/types.js"; import type { JsonObject } from "#shared/json.js"; import type { Optional } from "#shared/optional.js"; @@ -161,6 +162,8 @@ export type ResolvedToolDefinition = Readonly< > > & ResolvedModuleSourceRef & { + /** Compiler-recorded owner of the source that won this logical tool slot. */ + readonly sourceOwner?: AgentSourceOwner; /** * Validated runtime input schema. Compiled and durable JSON Schemas are * rehydrated before entering this runtime-owned definition. From ea201d0aed65329f47fdc980d1792addbaf5adc5 Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Sat, 22 Aug 2026 11:46:37 -0400 Subject: [PATCH 04/12] refactor(eve): compose the default web search source Register the Exa web-search sentinel at the canonical tools/web_search.ts slot. Provider changes and full custom replacements now win through the same source composer as every other tool. Signed-off-by: Andrew Barba --- .changeset/light-web-search.md | 5 +++ .../compose-framework-sources.test.ts | 1 + .../src/compiler/normalize-manifest.test.ts | 35 +++++++++++++++++-- .../eve/src/framework-sources/registry.ts | 2 ++ .../src/framework-sources/tools/web_search.ts | 3 ++ 5 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 .changeset/light-web-search.md create mode 100644 packages/eve/src/framework-sources/tools/web_search.ts diff --git a/.changeset/light-web-search.md b/.changeset/light-web-search.md new file mode 100644 index 0000000000..d4bd60f37e --- /dev/null +++ b/.changeset/light-web-search.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Compose the default Exa-backed `web_search` configuration from the canonical `tools/web_search.ts` source slot, so application provider configuration and custom tool replacements override it through the ordinary source graph. diff --git a/packages/eve/src/compiler/compose-framework-sources.test.ts b/packages/eve/src/compiler/compose-framework-sources.test.ts index aa947613fc..8be8f12443 100644 --- a/packages/eve/src/compiler/compose-framework-sources.test.ts +++ b/packages/eve/src/compiler/compose-framework-sources.test.ts @@ -46,6 +46,7 @@ describe("composeFrameworkSources", () => { "tools/read_file.ts", "tools/todo.ts", "tools/web_fetch.ts", + "tools/web_search.ts", "tools/write_file.ts", ]); expect(result.manifest.sandbox?.logicalPath).toBe("sandbox.ts"); diff --git a/packages/eve/src/compiler/normalize-manifest.test.ts b/packages/eve/src/compiler/normalize-manifest.test.ts index 1d81faedf6..3082a75344 100644 --- a/packages/eve/src/compiler/normalize-manifest.test.ts +++ b/packages/eve/src/compiler/normalize-manifest.test.ts @@ -304,11 +304,11 @@ describe("compileAgentManifest", () => { tools: [createModuleSourceRef({ logicalPath: "tools/web_search.ts" })], }); mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); - mocks.applicationDefinition.mockResolvedValue(webSearch({ provider: "exa" })); + mocks.applicationDefinition.mockResolvedValue(webSearch({ provider: "parallel" })); const compiled = await compileAgentManifest(manifest); - expect(compiled.webSearchProvider).toBe("exa"); + expect(compiled.webSearchProvider).toBe("parallel"); expect(compiled.tools.map((tool) => tool.name)).toEqual([ "bash", "load_skill", @@ -326,6 +326,36 @@ describe("compileAgentManifest", () => { ); }); + it("lets an application tool replace the default web search sentinel", async () => { + mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); + mocks.applicationDefinition.mockResolvedValue( + defineTool({ + description: "Search an application index", + inputSchema: z.object({ query: z.string() }), + execute: () => [], + }), + ); + + const compiled = await compileAgentManifest( + createAgentSourceManifest({ + agentId: "root", + agentRoot: "/app/agent", + appRoot: "/app", + tools: [createModuleSourceRef({ logicalPath: "tools/web_search.ts" })], + }), + ); + + expect(compiled.webSearchProvider).toBeUndefined(); + expect(compiled.tools).toContainEqual( + expect.objectContaining({ + description: "Search an application index", + name: "web_search", + sourceId: "tools/web_search.ts", + }), + ); + expect(compiled.bindings["eve.framework-defaults:tools/web_search.ts"]).toBeUndefined(); + }); + it("compiles framework defaults through ordinary module bindings", async () => { mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); @@ -345,6 +375,7 @@ describe("compileAgentManifest", () => { "web_fetch", "write_file", ]); + expect(compiled.webSearchProvider).toBe("exa"); expect(compiled.sandbox).toMatchObject({ logicalPath: "sandbox.ts", sourceId: "eve.framework-defaults:sandbox.ts", diff --git a/packages/eve/src/framework-sources/registry.ts b/packages/eve/src/framework-sources/registry.ts index 8581bd880f..b6d55dc74f 100644 --- a/packages/eve/src/framework-sources/registry.ts +++ b/packages/eve/src/framework-sources/registry.ts @@ -5,6 +5,7 @@ import * as readFile from "./tools/read_file.js"; import * as sandbox from "./sandbox.js"; import * as todo from "./tools/todo.js"; import * as webFetch from "./tools/web_fetch.js"; +import * as webSearch from "./tools/web_search.js"; import * as writeFile from "./tools/write_file.js"; import { createAgentSourceRegistry } from "#compiler/agent-source-registry.js"; import { defineProgrammaticAgentSource } from "#compiler/programmatic-agent-source.js"; @@ -20,6 +21,7 @@ const frameworkAgentSource = defineProgrammaticAgentSource({ { logicalPath: "tools/read_file.ts", namespace: readFile }, { logicalPath: "tools/todo.ts", namespace: todo }, { logicalPath: "tools/web_fetch.ts", namespace: webFetch }, + { logicalPath: "tools/web_search.ts", namespace: webSearch }, { logicalPath: "tools/write_file.ts", namespace: writeFile }, ], }); diff --git a/packages/eve/src/framework-sources/tools/web_search.ts b/packages/eve/src/framework-sources/tools/web_search.ts new file mode 100644 index 0000000000..13f54d8ae8 --- /dev/null +++ b/packages/eve/src/framework-sources/tools/web_search.ts @@ -0,0 +1,3 @@ +import { webSearch } from "#public/tools/web-search.js"; + +export default webSearch({ provider: "exa" }); From 6f1273e30508e35a6f0051fd60c466e65093cb16 Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Sat, 22 Aug 2026 11:46:39 -0400 Subject: [PATCH 05/12] refactor(eve): compile framework channels as agent sources Register the default eve channel and six callback routes as root-only programmatic modules. Compose application channels and disable sentinels at the same canonical paths before ordinary channel normalization. Signed-off-by: Andrew Barba --- .changeset/true-framework-channels.md | 5 +++ .../compose-framework-sources.test.ts | 9 ++++- .../src/compiler/compose-framework-sources.ts | 11 ++++-- .../src/compiler/normalize-manifest.test.ts | 27 +++++++++++++- .../eve/src/framework-sources/channels/eve.ts | 7 ++++ .../channels/eve/v1/callback/post.ts | 8 +++++ .../eve/v1/connections/callback/get.ts | 8 +++++ .../eve/v1/connections/callback/legacy/get.ts | 10 ++++++ .../v1/connections/callback/legacy/post.ts | 10 ++++++ .../eve/v1/connections/callback/post.ts | 8 +++++ .../channels/eve/v1/task-input/post.ts | 8 +++++ .../eve/src/framework-sources/constants.ts | 1 + .../eve/src/framework-sources/registry.ts | 35 ++++++++++++++++++- .../src/runtime/connections/callback-route.ts | 2 +- 14 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 .changeset/true-framework-channels.md create mode 100644 packages/eve/src/framework-sources/channels/eve.ts create mode 100644 packages/eve/src/framework-sources/channels/eve/v1/callback/post.ts create mode 100644 packages/eve/src/framework-sources/channels/eve/v1/connections/callback/get.ts create mode 100644 packages/eve/src/framework-sources/channels/eve/v1/connections/callback/legacy/get.ts create mode 100644 packages/eve/src/framework-sources/channels/eve/v1/connections/callback/legacy/post.ts create mode 100644 packages/eve/src/framework-sources/channels/eve/v1/connections/callback/post.ts create mode 100644 packages/eve/src/framework-sources/channels/eve/v1/task-input/post.ts diff --git a/.changeset/true-framework-channels.md b/.changeset/true-framework-channels.md new file mode 100644 index 0000000000..0f27b4ad9f --- /dev/null +++ b/.changeset/true-framework-channels.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Compile the default eve channel and six runtime callback routes from root-only canonical channel sources. Application channel definitions and disable sentinels can now replace each framework route through ordinary path-based composition. diff --git a/packages/eve/src/compiler/compose-framework-sources.test.ts b/packages/eve/src/compiler/compose-framework-sources.test.ts index 8be8f12443..bb17c8b862 100644 --- a/packages/eve/src/compiler/compose-framework-sources.test.ts +++ b/packages/eve/src/compiler/compose-framework-sources.test.ts @@ -5,13 +5,14 @@ import { createAgentSourceManifest, createModuleSourceRef } from "#discover/mani import { frameworkAgentSourceRegistry } from "#framework-sources/registry.js"; describe("composeFrameworkSources", () => { - it("uses application tools and sandbox modules for matching canonical slots", () => { + it("uses application channels, tools, and sandbox modules for matching canonical slots", () => { const result = composeFrameworkSources({ isRoot: true, manifest: createAgentSourceManifest({ agentId: "root", agentRoot: "/app/agent", appRoot: "/app", + channels: [createModuleSourceRef({ logicalPath: "channels/eve.ts" })], sandbox: createModuleSourceRef({ logicalPath: "sandbox/sandbox.ts" }), tools: [createModuleSourceRef({ logicalPath: "tools/bash.ts" })], }), @@ -23,6 +24,11 @@ describe("composeFrameworkSources", () => { result.manifest.tools.find((tool) => tool.logicalPath === "tools/bash.ts")?.sourceId, ).toBe("tools/bash.ts"); expect(result.manifest.sandbox?.sourceId).toBe("sandbox/sandbox.ts"); + expect( + result.manifest.channels.find((channel) => channel.logicalPath === "channels/eve.ts") + ?.sourceId, + ).toBe("channels/eve.ts"); + expect(result.bindings["eve.framework-root:channels/eve.ts"]).toBeUndefined(); expect(result.bindings["eve.framework-defaults:tools/bash.ts"]).toBeUndefined(); expect(result.bindings["eve.framework-defaults:sandbox.ts"]).toBeUndefined(); }); @@ -49,6 +55,7 @@ describe("composeFrameworkSources", () => { "tools/web_search.ts", "tools/write_file.ts", ]); + expect(result.manifest.channels).toEqual([]); expect(result.manifest.sandbox?.logicalPath).toBe("sandbox.ts"); expect(result.bindings["eve.framework-defaults:tools/bash.ts"]).toEqual({ backing: { diff --git a/packages/eve/src/compiler/compose-framework-sources.ts b/packages/eve/src/compiler/compose-framework-sources.ts index d8a566b72a..30efb5efcf 100644 --- a/packages/eve/src/compiler/compose-framework-sources.ts +++ b/packages/eve/src/compiler/compose-framework-sources.ts @@ -5,7 +5,7 @@ import type { AgentSourceRegistry } from "#compiler/agent-source-registry.js"; import { composeAgentModuleCandidates } from "#compiler/compose-agent-module-candidates.js"; import type { CompiledModuleBinding } from "#compiler/module-binding.js"; import { createProgrammaticModuleCandidates } from "#compiler/programmatic-module-candidates.js"; -import type { AgentSourceManifest, ToolSourceRef } from "#discover/manifest.js"; +import type { AgentSourceManifest, ChannelSourceRef, ToolSourceRef } from "#discover/manifest.js"; import type { ModuleSourceRef } from "#shared/source-ref.js"; export interface ComposedFrameworkSources { @@ -20,6 +20,7 @@ export function composeFrameworkSources(input: { readonly registry: AgentSourceRegistry; }): ComposedFrameworkSources { const applicationRefs = [ + ...input.manifest.channels, ...input.manifest.tools, ...(input.manifest.sandbox === null ? [] : [input.manifest.sandbox]), ]; @@ -32,7 +33,9 @@ export function composeFrameworkSources(input: { registry: input.registry, }).filter( (candidate) => - candidate.logicalPath === "sandbox.ts" || candidate.logicalPath.startsWith("tools/"), + candidate.logicalPath.startsWith("channels/") || + candidate.logicalPath === "sandbox.ts" || + candidate.logicalPath.startsWith("tools/"), ); const composition = composeAgentModuleCandidates([ ...frameworkCandidates, @@ -40,6 +43,7 @@ export function composeFrameworkSources(input: { ]); const refsBySourceId = new Map(applicationRefs.map((source) => [source.sourceId, source])); const bindings: Record = {}; + const channels: ChannelSourceRef[] = []; const tools: ToolSourceRef[] = []; let sandbox: ModuleSourceRef | null = null; @@ -53,6 +57,7 @@ export function composeFrameworkSources(input: { owner: winner.owner, }; } + if (winner.logicalPath.startsWith("channels/")) channels.push(source); if (winner.logicalPath.startsWith("tools/")) tools.push(source); if (winner.logicalPath === "sandbox.ts" || winner.logicalPath.startsWith("sandbox/")) { sandbox = source; @@ -61,7 +66,7 @@ export function composeFrameworkSources(input: { return { bindings, - manifest: { ...input.manifest, sandbox, tools }, + manifest: { ...input.manifest, channels, sandbox, tools }, }; } diff --git a/packages/eve/src/compiler/normalize-manifest.test.ts b/packages/eve/src/compiler/normalize-manifest.test.ts index 3082a75344..9b1cd0ea72 100644 --- a/packages/eve/src/compiler/normalize-manifest.test.ts +++ b/packages/eve/src/compiler/normalize-manifest.test.ts @@ -40,7 +40,8 @@ describe("compileAgentManifest", () => { return await mocks.applicationDefinition(input); } const namespace = await input.moduleLoader.load(input.binding.backing); - return namespace[input.source.exportName ?? "default"]; + const value = namespace[input.source.exportName ?? "default"]; + return typeof value === "function" ? await value() : value; }); }); @@ -376,6 +377,30 @@ describe("compileAgentManifest", () => { "write_file", ]); expect(compiled.webSearchProvider).toBe("exa"); + expect(compiled.channels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + adapterKind: "http", + logicalPath: "channels/eve.ts", + name: "eve", + sourceId: "eve.framework-root:channels/eve.ts", + }), + expect.objectContaining({ + adapterKind: "http", + logicalPath: "channels/eve/v1/connections/callback/get.ts", + method: "GET", + name: "eve/v1/connections/callback/get", + sourceId: "eve.framework-root:channels/eve/v1/connections/callback/get.ts", + }), + expect.objectContaining({ + adapterKind: "http", + logicalPath: "channels/eve/v1/task-input/post.ts", + method: "POST", + name: "eve/v1/task-input/post", + sourceId: "eve.framework-root:channels/eve/v1/task-input/post.ts", + }), + ]), + ); expect(compiled.sandbox).toMatchObject({ logicalPath: "sandbox.ts", sourceId: "eve.framework-defaults:sandbox.ts", diff --git a/packages/eve/src/framework-sources/channels/eve.ts b/packages/eve/src/framework-sources/channels/eve.ts new file mode 100644 index 0000000000..33db0cf8df --- /dev/null +++ b/packages/eve/src/framework-sources/channels/eve.ts @@ -0,0 +1,7 @@ +import { localDev, placeholderAuth, vercelOidc } from "#public/channels/auth.js"; +import { eveChannel } from "#public/channels/eve.js"; + +export default () => + eveChannel({ + auth: [vercelOidc(), localDev(), placeholderAuth()], + }); diff --git a/packages/eve/src/framework-sources/channels/eve/v1/callback/post.ts b/packages/eve/src/framework-sources/channels/eve/v1/callback/post.ts new file mode 100644 index 0000000000..8596a12e56 --- /dev/null +++ b/packages/eve/src/framework-sources/channels/eve/v1/callback/post.ts @@ -0,0 +1,8 @@ +import { EVE_CALLBACK_ROUTE_PATTERN } from "#protocol/routes.js"; +import { defineChannel, POST } from "#public/definitions/channel.js"; +import { handleSessionCallbackRequest } from "#runtime/session-callback-route.js"; + +export default () => + defineChannel({ + routes: [POST(EVE_CALLBACK_ROUTE_PATTERN, handleSessionCallbackRequest)], + }); diff --git a/packages/eve/src/framework-sources/channels/eve/v1/connections/callback/get.ts b/packages/eve/src/framework-sources/channels/eve/v1/connections/callback/get.ts new file mode 100644 index 0000000000..012d2cb332 --- /dev/null +++ b/packages/eve/src/framework-sources/channels/eve/v1/connections/callback/get.ts @@ -0,0 +1,8 @@ +import { EVE_CONNECTION_CALLBACK_ROUTE_PATTERN } from "#protocol/routes.js"; +import { defineChannel, GET } from "#public/definitions/channel.js"; +import { handleConnectionCallbackRequest } from "#runtime/connections/callback-route.js"; + +export default () => + defineChannel({ + routes: [GET(EVE_CONNECTION_CALLBACK_ROUTE_PATTERN, handleConnectionCallbackRequest)], + }); diff --git a/packages/eve/src/framework-sources/channels/eve/v1/connections/callback/legacy/get.ts b/packages/eve/src/framework-sources/channels/eve/v1/connections/callback/legacy/get.ts new file mode 100644 index 0000000000..6b3c3f9a6e --- /dev/null +++ b/packages/eve/src/framework-sources/channels/eve/v1/connections/callback/legacy/get.ts @@ -0,0 +1,10 @@ +import { EVE_LEGACY_CONNECTION_CALLBACK_ROUTE_PATTERN } from "#protocol/routes.js"; +import { defineChannel, GET } from "#public/definitions/channel.js"; +import { handleLegacyConnectionCallbackRequest } from "#runtime/connections/callback-route.js"; + +export default () => + defineChannel({ + routes: [ + GET(EVE_LEGACY_CONNECTION_CALLBACK_ROUTE_PATTERN, handleLegacyConnectionCallbackRequest), + ], + }); diff --git a/packages/eve/src/framework-sources/channels/eve/v1/connections/callback/legacy/post.ts b/packages/eve/src/framework-sources/channels/eve/v1/connections/callback/legacy/post.ts new file mode 100644 index 0000000000..2adefa3c39 --- /dev/null +++ b/packages/eve/src/framework-sources/channels/eve/v1/connections/callback/legacy/post.ts @@ -0,0 +1,10 @@ +import { EVE_LEGACY_CONNECTION_CALLBACK_ROUTE_PATTERN } from "#protocol/routes.js"; +import { defineChannel, POST } from "#public/definitions/channel.js"; +import { handleLegacyConnectionCallbackRequest } from "#runtime/connections/callback-route.js"; + +export default () => + defineChannel({ + routes: [ + POST(EVE_LEGACY_CONNECTION_CALLBACK_ROUTE_PATTERN, handleLegacyConnectionCallbackRequest), + ], + }); diff --git a/packages/eve/src/framework-sources/channels/eve/v1/connections/callback/post.ts b/packages/eve/src/framework-sources/channels/eve/v1/connections/callback/post.ts new file mode 100644 index 0000000000..e10ef56085 --- /dev/null +++ b/packages/eve/src/framework-sources/channels/eve/v1/connections/callback/post.ts @@ -0,0 +1,8 @@ +import { EVE_CONNECTION_CALLBACK_ROUTE_PATTERN } from "#protocol/routes.js"; +import { defineChannel, POST } from "#public/definitions/channel.js"; +import { handleConnectionCallbackRequest } from "#runtime/connections/callback-route.js"; + +export default () => + defineChannel({ + routes: [POST(EVE_CONNECTION_CALLBACK_ROUTE_PATTERN, handleConnectionCallbackRequest)], + }); diff --git a/packages/eve/src/framework-sources/channels/eve/v1/task-input/post.ts b/packages/eve/src/framework-sources/channels/eve/v1/task-input/post.ts new file mode 100644 index 0000000000..5022798158 --- /dev/null +++ b/packages/eve/src/framework-sources/channels/eve/v1/task-input/post.ts @@ -0,0 +1,8 @@ +import { EVE_TASK_INPUT_ROUTE_PATTERN } from "#protocol/routes.js"; +import { defineChannel, POST } from "#public/definitions/channel.js"; +import { handleTaskInputResponseRequest } from "#runtime/task-input-response-route.js"; + +export default () => + defineChannel({ + routes: [POST(EVE_TASK_INPUT_ROUTE_PATTERN, handleTaskInputResponseRequest)], + }); diff --git a/packages/eve/src/framework-sources/constants.ts b/packages/eve/src/framework-sources/constants.ts index f6cf04a9dd..fbd98a6320 100644 --- a/packages/eve/src/framework-sources/constants.ts +++ b/packages/eve/src/framework-sources/constants.ts @@ -1 +1,2 @@ export const FRAMEWORK_AGENT_SOURCE_ID = "eve.framework-defaults"; +export const FRAMEWORK_ROOT_AGENT_SOURCE_ID = "eve.framework-root"; diff --git a/packages/eve/src/framework-sources/registry.ts b/packages/eve/src/framework-sources/registry.ts index b6d55dc74f..0eb2e0af64 100644 --- a/packages/eve/src/framework-sources/registry.ts +++ b/packages/eve/src/framework-sources/registry.ts @@ -9,7 +9,7 @@ import * as webSearch from "./tools/web_search.js"; import * as writeFile from "./tools/write_file.js"; import { createAgentSourceRegistry } from "#compiler/agent-source-registry.js"; import { defineProgrammaticAgentSource } from "#compiler/programmatic-agent-source.js"; -import { FRAMEWORK_AGENT_SOURCE_ID } from "./constants.js"; +import { FRAMEWORK_AGENT_SOURCE_ID, FRAMEWORK_ROOT_AGENT_SOURCE_ID } from "./constants.js"; const frameworkAgentSource = defineProgrammaticAgentSource({ id: FRAMEWORK_AGENT_SOURCE_ID, @@ -26,6 +26,39 @@ const frameworkAgentSource = defineProgrammaticAgentSource({ ], }); +const frameworkRootAgentSource = defineProgrammaticAgentSource({ + id: FRAMEWORK_ROOT_AGENT_SOURCE_ID, + modules: [ + { logicalPath: "channels/eve.ts", namespace: eveChannel }, + { logicalPath: "channels/eve/v1/callback/post.ts", namespace: sessionCallbackPost }, + { + logicalPath: "channels/eve/v1/connections/callback/get.ts", + namespace: connectionCallbackGet, + }, + { + logicalPath: "channels/eve/v1/connections/callback/legacy/get.ts", + namespace: legacyConnectionCallbackGet, + }, + { + logicalPath: "channels/eve/v1/connections/callback/legacy/post.ts", + namespace: legacyConnectionCallbackPost, + }, + { + logicalPath: "channels/eve/v1/connections/callback/post.ts", + namespace: connectionCallbackPost, + }, + { logicalPath: "channels/eve/v1/task-input/post.ts", namespace: taskInputPost }, + ], +}); + export const frameworkAgentSourceRegistry = createAgentSourceRegistry([ { applyTo: "all-local-nodes", source: frameworkAgentSource }, + { applyTo: "root", source: frameworkRootAgentSource }, ]); +import * as eveChannel from "./channels/eve.js"; +import * as sessionCallbackPost from "./channels/eve/v1/callback/post.js"; +import * as connectionCallbackGet from "./channels/eve/v1/connections/callback/get.js"; +import * as legacyConnectionCallbackGet from "./channels/eve/v1/connections/callback/legacy/get.js"; +import * as legacyConnectionCallbackPost from "./channels/eve/v1/connections/callback/legacy/post.js"; +import * as connectionCallbackPost from "./channels/eve/v1/connections/callback/post.js"; +import * as taskInputPost from "./channels/eve/v1/task-input/post.js"; diff --git a/packages/eve/src/runtime/connections/callback-route.ts b/packages/eve/src/runtime/connections/callback-route.ts index a84686d44a..2774f06c27 100644 --- a/packages/eve/src/runtime/connections/callback-route.ts +++ b/packages/eve/src/runtime/connections/callback-route.ts @@ -115,7 +115,7 @@ export async function handleConnectionCallbackRequest( return handleCallbackRequest(request, ctx, false); } -async function handleLegacyConnectionCallbackRequest( +export async function handleLegacyConnectionCallbackRequest( request: Request, ctx: RouteContext, ): Promise { From 2acce3cd92e9977fcf06b9ddf8c5f7ee4068495e Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Sat, 22 Aug 2026 11:46:41 -0400 Subject: [PATCH 06/12] fix(eve): load root framework sources in generated maps Teach generated module maps that the root-only framework source ID resolves through the static framework registry. Cover cold-start lookup for the default eve channel binding. Signed-off-by: Andrew Barba --- packages/eve/src/compiler/module-map.test.ts | 39 ++++++++++++++++++++ packages/eve/src/compiler/module-map.ts | 11 +++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/eve/src/compiler/module-map.test.ts b/packages/eve/src/compiler/module-map.test.ts index de837a6eb2..7076fc405b 100644 --- a/packages/eve/src/compiler/module-map.test.ts +++ b/packages/eve/src/compiler/module-map.test.ts @@ -143,6 +143,45 @@ describe("createCompiledModuleMapSource", () => { ); }); + it("imports root-only framework sources from the framework registry", () => { + const manifest = createManifestWithTool("/consumer/agent"); + const sourceId = "eve.framework-root:channels/eve.ts"; + const source = createCompiledModuleMapSource({ + manifest: { + ...manifest, + bindings: { + [sourceId]: { + backing: { + kind: "programmatic", + moduleId: "channels/eve.ts", + registryId: "eve.framework-root", + }, + logicalPath: "channels/eve.ts", + owner: { feature: "eve.framework-root", kind: "framework" }, + }, + }, + channels: [ + { + kind: "channel", + logicalPath: "channels/eve.ts", + method: "POST", + name: "eve", + sourceId, + sourceKind: "module", + urlPath: "/eve/v1/session", + }, + ], + tools: [], + }, + moduleMapPath: "/consumer/.eve/compile/module-map.mjs", + }); + + expect(source).toContain("frameworkAgentSourceRegistry as module_0"); + expect(source).toContain( + 'module_0.getModule({"kind":"programmatic","moduleId":"channels/eve.ts","registryId":"eve.framework-root"}).namespace', + ); + }); + it("imports the physical binding instead of reconstructing it from logical identity", () => { const manifest = createManifestWithTool("/consumer/agent"); const source = createCompiledModuleMapSource({ diff --git a/packages/eve/src/compiler/module-map.ts b/packages/eve/src/compiler/module-map.ts index 2817b3c9de..67ec862f4b 100644 --- a/packages/eve/src/compiler/module-map.ts +++ b/packages/eve/src/compiler/module-map.ts @@ -11,7 +11,10 @@ import { assertTotalModuleBindings } from "#compiler/module-binding.js"; import { collectModuleRefsForManifest } from "#compiler/module-references.js"; import type { ModuleSourceRef } from "#shared/source-ref.js"; import { normalizeEsmImportSpecifier } from "#internal/application/import-specifier.js"; -import { FRAMEWORK_AGENT_SOURCE_ID } from "#framework-sources/constants.js"; +import { + FRAMEWORK_AGENT_SOURCE_ID, + FRAMEWORK_ROOT_AGENT_SOURCE_ID, +} from "#framework-sources/constants.js"; /** * Compiled module ownership for one runtime graph node. @@ -81,6 +84,12 @@ export function createCompiledModuleMapSource(input: CreateCompiledModuleMapSour fileURLToPath(new URL("../framework-sources/registry.js", import.meta.url)), ), }, + [FRAMEWORK_ROOT_AGENT_SOURCE_ID]: { + exportName: "frameworkAgentSourceRegistry", + importSpecifier: normalizeEsmImportSpecifier( + fileURLToPath(new URL("../framework-sources/registry.js", import.meta.url)), + ), + }, ...input.programmaticRegistryImports, }; let nextBindingIndex = 0; From f0f4999f3425c91e5d3f212bc2e1e79e1e152bfd Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Sat, 22 Aug 2026 11:46:44 -0400 Subject: [PATCH 07/12] refactor(eve): project extensions into canonical source slots Signed-off-by: Andrew Barba --- .../compiler/compose-agent-sources.test.ts | 218 ++++++++ .../eve/src/compiler/compose-agent-sources.ts | 517 +++++++++++++++++ .../compose-framework-sources.test.ts | 70 --- .../src/compiler/compose-framework-sources.ts | 106 ---- .../compiler/normalize-agent-config.test.ts | 2 + .../src/compiler/normalize-extension.test.ts | 230 -------- .../eve/src/compiler/normalize-extension.ts | 527 ------------------ .../eve/src/compiler/normalize-helpers.ts | 4 + .../eve/src/compiler/normalize-manifest.ts | 269 +++++---- .../eve/src/compiler/normalize-subagent.ts | 114 ++-- .../src/discover/agent.integration.test.ts | 21 +- packages/eve/src/discover/discover-agent.ts | 72 +-- .../eve/src/discover/discover-subagent.ts | 14 - packages/eve/src/discover/extensions.ts | 8 - 14 files changed, 985 insertions(+), 1187 deletions(-) create mode 100644 packages/eve/src/compiler/compose-agent-sources.test.ts create mode 100644 packages/eve/src/compiler/compose-agent-sources.ts delete mode 100644 packages/eve/src/compiler/compose-framework-sources.test.ts delete mode 100644 packages/eve/src/compiler/compose-framework-sources.ts delete mode 100644 packages/eve/src/compiler/normalize-extension.test.ts delete mode 100644 packages/eve/src/compiler/normalize-extension.ts diff --git a/packages/eve/src/compiler/compose-agent-sources.test.ts b/packages/eve/src/compiler/compose-agent-sources.test.ts new file mode 100644 index 0000000000..ddef5f8501 --- /dev/null +++ b/packages/eve/src/compiler/compose-agent-sources.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from "vitest"; + +import { composeAgentSources, getSubagentSourceOrigin } from "#compiler/compose-agent-sources.js"; +import { + createAgentSourceManifest, + createConnectionSourceRef, + createLocalSubagentSourceRef, + createModuleSourceRef, +} from "#discover/manifest.js"; +import { frameworkAgentSourceRegistry } from "#framework-sources/registry.js"; + +describe("composeAgentSources", () => { + it("uses application channels, tools, and sandbox modules for matching canonical slots", () => { + const result = composeAgentSources({ + isRoot: true, + manifest: createAgentSourceManifest({ + agentId: "root", + agentRoot: "/app/agent", + appRoot: "/app", + channels: [createModuleSourceRef({ logicalPath: "channels/eve.ts" })], + sandbox: createModuleSourceRef({ logicalPath: "sandbox/sandbox.ts" }), + tools: [createModuleSourceRef({ logicalPath: "tools/bash.ts" })], + }), + nodeId: "root", + registry: frameworkAgentSourceRegistry, + }); + + expect( + result.manifest.tools.find((tool) => tool.logicalPath === "tools/bash.ts")?.sourceId, + ).toBe("tools/bash.ts"); + expect(result.manifest.sandbox?.sourceId).toBe("sandbox/sandbox.ts"); + expect( + result.manifest.channels.find((channel) => channel.logicalPath === "channels/eve.ts") + ?.sourceId, + ).toBe("channels/eve.ts"); + expect(result.bindings["eve.framework-root:channels/eve.ts"]).toBeUndefined(); + expect(result.bindings["eve.framework-defaults:tools/bash.ts"]).toBeUndefined(); + expect(result.bindings["eve.framework-defaults:sandbox.ts"]).toBeUndefined(); + }); + + it("binds non-overridden defaults to their programmatic owners", () => { + const result = composeAgentSources({ + isRoot: false, + manifest: createAgentSourceManifest({ + agentId: "child", + agentRoot: "/app/agent/subagents/child", + appRoot: "/app", + }), + nodeId: "subagents/child", + registry: frameworkAgentSourceRegistry, + }); + + expect(result.manifest.tools.map((tool) => tool.logicalPath)).toEqual([ + "tools/bash.ts", + "tools/connection_search.ts", + "tools/load_skill.ts", + "tools/read_file.ts", + "tools/todo.ts", + "tools/web_fetch.ts", + "tools/web_search.ts", + "tools/write_file.ts", + ]); + expect(result.manifest.channels).toEqual([]); + expect(result.manifest.sandbox?.logicalPath).toBe("sandbox.ts"); + expect(result.bindings["eve.framework-defaults:tools/bash.ts"]).toEqual({ + backing: { + kind: "programmatic", + moduleId: "tools/bash.ts", + registryId: "eve.framework-defaults", + }, + logicalPath: "tools/bash.ts", + owner: { feature: "eve.framework-defaults", kind: "framework" }, + }); + }); + + it("projects every extension primitive before selecting override and application winners", () => { + const extensionRoot = "/packages/crm/extension"; + const overrideRoot = "/app/agent/extensions/crm"; + const extensionManifest = createAgentSourceManifest({ + agentRoot: extensionRoot, + appRoot: "/packages/crm", + channels: [createModuleSourceRef({ logicalPath: "channels/webhooks/events.ts" })], + connections: [ + createConnectionSourceRef({ connectionName: "api", logicalPath: "connections/api.ts" }), + ], + hooks: [createModuleSourceRef({ logicalPath: "hooks/session/start.ts" })], + instructions: [ + { + definition: { content: "CRM instructions", role: "system" }, + logicalPath: "instructions.md", + sourceId: "instructions.md", + sourceKind: "markdown", + }, + ], + schedules: [createModuleSourceRef({ logicalPath: "schedules/daily/sync.ts" })], + skills: [createModuleSourceRef({ logicalPath: "skills/research.ts" })], + tools: [ + createModuleSourceRef({ logicalPath: "tools/search.ts" }), + createModuleSourceRef({ logicalPath: "tools/list.ts" }), + ], + }); + const overrides = createAgentSourceManifest({ + agentRoot: overrideRoot, + appRoot: "/app", + tools: [createModuleSourceRef({ logicalPath: "tools/search.ts" })], + }); + const result = composeAgentSources({ + isRoot: true, + manifest: createAgentSourceManifest({ + agentRoot: "/app/agent", + appRoot: "/app", + resolvedExtensions: [ + { + externalDependencies: ["@acme/sdk"], + manifest: extensionManifest, + namespace: "crm", + overrides, + packageName: "@acme/crm", + packageRoot: "/packages/crm", + sourceRoot: extensionRoot, + specifier: "@acme/crm", + }, + ], + tools: [createModuleSourceRef({ logicalPath: "tools/crm__list.ts" })], + }), + nodeId: "root", + registry: frameworkAgentSourceRegistry, + }); + + expect(result.manifest.channels.map((source) => source.logicalPath)).toContain( + "channels/crm__webhooks/events.ts", + ); + expect(result.manifest.connections).toEqual([ + expect.objectContaining({ + connectionName: "crm__api", + logicalPath: "connections/crm__api.ts", + }), + ]); + expect(result.manifest.hooks.map((source) => source.logicalPath)).toContain( + "hooks/crm__session/start.ts", + ); + expect(result.manifest.instructions.map((source) => source.logicalPath)).toContain( + "instructions/crm__instructions.md", + ); + expect(result.manifest.schedules.map((source) => source.logicalPath)).toContain( + "schedules/crm__daily/sync.ts", + ); + expect(result.manifest.skills.map((source) => source.logicalPath)).toContain( + "skills/crm__research.ts", + ); + expect(result.manifest.tools.map((source) => [source.logicalPath, source.sourceId])).toEqual( + expect.arrayContaining([ + ["tools/crm__list.ts", "tools/crm__list.ts"], + ["tools/crm__search.ts", "ext-override:crm:tools/search.ts"], + ]), + ); + expect(result.bindings["ext:crm:tools/list.ts"]).toBeUndefined(); + expect(result.bindings["ext-override:crm:tools/search.ts"]).toEqual({ + backing: { + externalDependencies: ["@acme/sdk"], + kind: "filesystem", + sourcePath: "/app/agent/extensions/crm/tools/search.ts", + }, + logicalPath: "tools/crm__search.ts", + owner: { kind: "application" }, + }); + }); + + it("projects extension subagents and preserves their source ownership", () => { + const childRoot = "/packages/crm/extension/subagents/reviewer"; + const child = createLocalSubagentSourceRef({ + entryPath: childRoot, + logicalPath: "subagents/reviewer", + manifest: createAgentSourceManifest({ + agentRoot: childRoot, + appRoot: "/packages/crm", + configModule: createModuleSourceRef({ logicalPath: "agent.ts" }), + }), + rootPath: childRoot, + subagentId: "reviewer", + }); + const result = composeAgentSources({ + isRoot: true, + manifest: createAgentSourceManifest({ + agentRoot: "/app/agent", + appRoot: "/app", + resolvedExtensions: [ + { + externalDependencies: [], + manifest: createAgentSourceManifest({ + agentRoot: "/packages/crm/extension", + appRoot: "/packages/crm", + subagents: [child], + }), + namespace: "crm", + packageName: "@acme/crm", + packageRoot: "/packages/crm", + sourceRoot: "/packages/crm/extension", + specifier: "@acme/crm", + }, + ], + }), + nodeId: "root", + registry: frameworkAgentSourceRegistry, + }); + + const projected = result.manifest.subagents[0]!; + expect(projected).toMatchObject({ + logicalPath: "subagents/crm__reviewer", + sourceId: "ext:crm:subagents/reviewer", + subagentId: "crm__reviewer", + }); + expect(getSubagentSourceOrigin(projected)).toMatchObject({ + layer: "extension-package", + owner: { kind: "extension", namespace: "crm", packageName: "@acme/crm" }, + }); + }); +}); diff --git a/packages/eve/src/compiler/compose-agent-sources.ts b/packages/eve/src/compiler/compose-agent-sources.ts new file mode 100644 index 0000000000..2c912a0f3d --- /dev/null +++ b/packages/eve/src/compiler/compose-agent-sources.ts @@ -0,0 +1,517 @@ +import { extname, resolve } from "node:path"; + +import type { AgentModuleCandidate, AgentSourceLayer } from "#compiler/agent-module-candidate.js"; +import type { AgentSourceRegistry } from "#compiler/agent-source-registry.js"; +import { + composeAgentModuleCandidates, + type AgentModuleComposition, +} from "#compiler/compose-agent-module-candidates.js"; +import type { + AgentSourceOwner, + CompiledModuleBacking, + CompiledModuleBinding, +} from "#compiler/module-binding.js"; +import { createProgrammaticModuleCandidates } from "#compiler/programmatic-module-candidates.js"; +import { packageStateNamespace } from "#discover/extensions.js"; +import { stripLogicalPathExtension } from "#discover/filesystem.js"; +import type { + AgentSourceManifest, + ConnectionSourceRef, + InstructionsSourceRef, + LocalSubagentSourceRef, + ResolvedExtensionMount, + ScheduleSourceRef, + SkillSourceRef, +} from "#discover/manifest.js"; +import type { ModuleSourceRef } from "#shared/source-ref.js"; + +type ComposableSourceRef = + | ConnectionSourceRef + | InstructionsSourceRef + | ModuleSourceRef + | ScheduleSourceRef + | SkillSourceRef; + +type ComposableSlot = + | "channels" + | "connections" + | "hooks" + | "instructions" + | "sandbox" + | "schedules" + | "skills" + | "tools"; + +export interface AgentSourceOrigin { + readonly backing: Omit, "sourcePath">; + readonly layer: Exclude; + readonly owner: AgentSourceOwner; + readonly sourceIdPrefix?: string; +} + +interface SourceCandidate { + readonly candidate: AgentModuleCandidate; + readonly slot: ComposableSlot; + readonly source: ComposableSourceRef; +} + +export interface ComposedAgentSources { + readonly bindings: Readonly>; + readonly composition: AgentModuleComposition; + readonly manifest: AgentSourceManifest; +} + +const projectedSubagentOrigins = new WeakMap(); + +/** + * Selects one effective source for every canonical slot before any authored + * definition executes. Extension sources are projected to their final + * consumer-visible paths here, while their bindings retain the physical file. + */ +export function composeAgentSources(input: { + readonly externalDependencies?: readonly string[]; + readonly isRoot: boolean; + readonly manifest: AgentSourceManifest; + readonly nodeId: string; + readonly origin?: AgentSourceOrigin; + readonly registry: AgentSourceRegistry; +}): ComposedAgentSources { + const applicationOrigin = + input.origin ?? + ({ + backing: { + externalDependencies: [...(input.externalDependencies ?? [])], + kind: "filesystem", + }, + layer: "application", + owner: { kind: "application" }, + } satisfies AgentSourceOrigin); + const candidates = [ + ...createManifestCandidates({ + manifest: input.manifest, + nodeId: input.nodeId, + origin: applicationOrigin, + }), + ...input.manifest.resolvedExtensions.flatMap((mount) => + createExtensionCandidates( + input.nodeId, + mount, + input.externalDependencies ?? mount.externalDependencies, + ), + ), + ...createFrameworkCandidates(input), + ]; + const composition = composeAgentModuleCandidates(candidates.map(({ candidate }) => candidate)); + const sourcesBySourceId = new Map( + candidates.map(({ candidate, slot, source }) => [candidate.sourceId, { slot, source }]), + ); + const bindings: Record = {}; + const selected = createEmptySelectedSources(); + + for (const winner of composition.winners) { + const entry = sourcesBySourceId.get(winner.sourceId); + if (entry === undefined) { + throw new Error(`Missing source metadata for candidate "${winner.sourceId}".`); + } + selected[entry.slot].push(entry.source as never); + if (entry.source.sourceKind === "module" && winner.layer !== "application") { + bindings[winner.sourceId] = { + backing: winner.backing, + logicalPath: winner.logicalPath, + owner: winner.owner, + }; + } + } + + const subagents = composeSubagentSources(input.manifest, input.nodeId, applicationOrigin); + Object.assign(bindings, subagents.bindings); + + return { + bindings, + composition, + manifest: { + ...input.manifest, + channels: selected.channels, + connections: selected.connections, + hooks: selected.hooks, + instructions: selected.instructions, + sandbox: selected.sandbox[0] ?? null, + schedules: selected.schedules, + skills: selected.skills, + subagents: subagents.sources, + tools: selected.tools, + }, + }; +} + +export function getSubagentSourceOrigin( + source: LocalSubagentSourceRef, +): AgentSourceOrigin | undefined { + return projectedSubagentOrigins.get(source); +} + +function createFrameworkCandidates(input: { + readonly isRoot: boolean; + readonly nodeId: string; + readonly registry: AgentSourceRegistry; +}): SourceCandidate[] { + return createProgrammaticModuleCandidates(input).map((candidate) => { + const slot = classifyComposablePath(candidate.logicalPath); + return { + candidate, + slot, + source: createProgrammaticSourceRef(input.registry, candidate), + }; + }); +} + +function createExtensionCandidates( + nodeId: string, + mount: ResolvedExtensionMount, + externalDependencies: readonly string[], +): SourceCandidate[] { + const extensionScope = { + namespace: packageStateNamespace(mount.packageName), + sourceRoot: mount.sourceRoot, + }; + const packageCandidates = createManifestCandidates({ + manifest: mount.manifest, + namespace: mount.namespace, + nodeId, + origin: { + backing: { + externalDependencies: [...externalDependencies], + extensionScope, + kind: "filesystem", + }, + layer: "extension-package", + owner: { + kind: "extension", + namespace: mount.namespace, + packageName: mount.packageName, + }, + sourceIdPrefix: `ext:${mount.namespace}`, + }, + }); + if (mount.overrides === undefined) return packageCandidates; + + return [ + ...packageCandidates, + ...createManifestCandidates({ + manifest: mount.overrides, + namespace: mount.namespace, + nodeId, + origin: { + backing: { + externalDependencies: [...externalDependencies], + kind: "filesystem", + }, + layer: "extension-override", + owner: { kind: "application" }, + sourceIdPrefix: `ext-override:${mount.namespace}`, + }, + }), + ]; +} + +function createManifestCandidates(input: { + readonly manifest: AgentSourceManifest; + readonly namespace?: string; + readonly nodeId: string; + readonly origin: AgentSourceOrigin; +}): SourceCandidate[] { + const sources: ReadonlyArray = [ + ...input.manifest.channels.map((source) => ["channels", source] as const), + ...input.manifest.connections.map((source) => ["connections", source] as const), + ...input.manifest.hooks.map((source) => ["hooks", source] as const), + ...input.manifest.instructions.map((source) => ["instructions", source] as const), + ...(input.manifest.sandbox === null ? [] : [["sandbox", input.manifest.sandbox] as const]), + ...input.manifest.schedules.map((source) => ["schedules", source] as const), + ...input.manifest.skills.map((source) => ["skills", source] as const), + ...input.manifest.tools.map((source) => ["tools", source] as const), + ]; + + return sources.map(([slot, original]) => { + const logicalPath = + input.namespace === undefined + ? original.logicalPath + : projectExtensionLogicalPath(original.logicalPath, slot, input.namespace); + const sourceId = + input.origin.sourceIdPrefix === undefined + ? original.sourceId + : `${input.origin.sourceIdPrefix}:${original.sourceId}`; + const source = projectSourceRef(original, slot, logicalPath, sourceId); + const sourcePath = physicalSourcePath(input.manifest, original); + return { + candidate: { + backing: { ...input.origin.backing, sourcePath }, + layer: input.origin.layer, + logicalPath, + nodeId: input.nodeId, + owner: input.origin.owner, + sourceId, + }, + slot, + source, + }; + }); +} + +function composeSubagentSources( + manifest: AgentSourceManifest, + nodeId: string, + applicationOrigin: AgentSourceOrigin, +): { + readonly bindings: Readonly>; + readonly sources: LocalSubagentSourceRef[]; +} { + const candidates: Array<{ + readonly candidate: AgentModuleCandidate; + readonly source: LocalSubagentSourceRef; + }> = manifest.subagents.map((source) => { + if (applicationOrigin.layer !== "application") { + projectedSubagentOrigins.set(source, applicationOrigin); + } + return { + candidate: createSubagentCandidate(nodeId, source, applicationOrigin), + source, + }; + }); + + for (const mount of manifest.resolvedExtensions) { + const externalDependencies = [ + ...new Set([ + ...applicationOrigin.backing.externalDependencies, + ...mount.externalDependencies, + ]), + ]; + const packageOrigin: AgentSourceOrigin = { + backing: { + externalDependencies, + extensionScope: { + namespace: packageStateNamespace(mount.packageName), + sourceRoot: mount.sourceRoot, + }, + kind: "filesystem", + }, + layer: "extension-package", + owner: { + kind: "extension", + namespace: mount.namespace, + packageName: mount.packageName, + }, + sourceIdPrefix: `ext:${mount.namespace}`, + }; + candidates.push( + ...mount.manifest.subagents.map((source) => { + const projected = projectRootExtensionSubagent(source, mount.namespace, packageOrigin); + return { + candidate: createSubagentCandidate(nodeId, projected, packageOrigin), + source: projected, + }; + }), + ); + + if (mount.overrides !== undefined) { + const overrideOrigin: AgentSourceOrigin = { + backing: { + externalDependencies, + kind: "filesystem", + }, + layer: "extension-override", + owner: { kind: "application" }, + sourceIdPrefix: `ext-override:${mount.namespace}`, + }; + candidates.push( + ...mount.overrides.subagents.map((source) => { + const projected = projectRootExtensionSubagent(source, mount.namespace, overrideOrigin); + return { + candidate: createSubagentCandidate(nodeId, projected, overrideOrigin), + source: projected, + }; + }), + ); + } + } + + const composition = composeAgentModuleCandidates(candidates.map(({ candidate }) => candidate)); + const sourcesById = new Map( + candidates.map(({ candidate, source }) => [candidate.sourceId, source]), + ); + const bindings: Record = {}; + for (const winner of composition.winners) { + if (winner.layer === "application") continue; + bindings[winner.sourceId] = { + backing: winner.backing, + logicalPath: winner.logicalPath, + owner: winner.owner, + }; + } + return { + bindings, + sources: composition.winners.map((winner) => sourcesById.get(winner.sourceId)!), + }; +} + +function createSubagentCandidate( + nodeId: string, + source: LocalSubagentSourceRef, + origin: AgentSourceOrigin, +): AgentModuleCandidate { + return { + backing: { ...origin.backing, sourcePath: source.entryPath }, + layer: origin.layer, + logicalPath: source.logicalPath, + nodeId, + owner: origin.owner, + sourceId: source.sourceId, + }; +} + +function projectRootExtensionSubagent( + source: LocalSubagentSourceRef, + namespace: string, + origin: AgentSourceOrigin, +): LocalSubagentSourceRef { + const projected = scopeSubagentSourceIds( + { + ...source, + logicalPath: projectExtensionLogicalPath(source.logicalPath, "subagents", namespace), + subagentId: `${namespace}__${source.subagentId}`, + }, + origin, + ); + return projected; +} + +function scopeSubagentSourceIds( + source: LocalSubagentSourceRef, + origin: AgentSourceOrigin, +): LocalSubagentSourceRef { + const prefix = origin.sourceIdPrefix; + if (prefix === undefined) return source; + const projected: LocalSubagentSourceRef = { + ...source, + manifest: { ...source.manifest }, + sourceId: `${prefix}:${source.sourceId}`, + }; + projectedSubagentOrigins.set(projected, origin); + for (const child of projected.manifest.subagents) { + projectedSubagentOrigins.set(child, origin); + } + return projected; +} + +function projectSourceRef( + source: T, + slot: ComposableSlot, + logicalPath: string, + sourceId: string, +): T { + const projected = { ...source, logicalPath, sourceId }; + if (slot === "connections") { + return { + ...projected, + connectionName: connectionNameFromLogicalPath(logicalPath), + } as T; + } + if (slot === "skills" && source.sourceKind === "skill-package") { + return { + ...projected, + name: stripLogicalPathExtension(logicalPath) + .replace(/^skills\//, "") + .replace(/\/SKILL$/i, ""), + } as T; + } + return projected as T; +} + +function projectExtensionLogicalPath( + logicalPath: string, + slot: ComposableSlot | "subagents", + namespace: string, +): string { + const prefix = `${slot}/`; + if (logicalPath.startsWith(prefix)) { + const relativePath = logicalPath.slice(prefix.length); + const separator = relativePath.indexOf("/"); + const firstSegment = separator === -1 ? relativePath : relativePath.slice(0, separator); + const rest = separator === -1 ? "" : relativePath.slice(separator); + return `${prefix}${namespace}__${firstSegment}${rest}`; + } + + if (slot === "instructions") { + const extension = extname(logicalPath); + const name = logicalPath.slice(0, extension.length === 0 ? undefined : -extension.length); + return `instructions/${namespace}__${name}${extension}`; + } + + throw new Error(`Cannot project extension ${slot} source "${logicalPath}".`); +} + +function physicalSourcePath(manifest: AgentSourceManifest, source: ComposableSourceRef): string { + if (source.sourceKind === "skill-package") return source.skillFilePath; + return resolve(manifest.agentRoot, source.logicalPath); +} + +function connectionNameFromLogicalPath(logicalPath: string): string { + const relativePath = stripLogicalPathExtension(logicalPath).replace(/^connections\//, ""); + return relativePath.endsWith("/connection") + ? relativePath.slice(0, -"/connection".length) + : relativePath; +} + +function classifyComposablePath(logicalPath: string): ComposableSlot { + if (logicalPath === "sandbox.ts" || logicalPath.startsWith("sandbox/")) return "sandbox"; + const slot = logicalPath.split("/", 1)[0]; + if ( + slot === "channels" || + slot === "connections" || + slot === "hooks" || + slot === "instructions" || + slot === "schedules" || + slot === "skills" || + slot === "tools" + ) { + return slot; + } + throw new Error(`Programmatic source "${logicalPath}" does not select a composable slot.`); +} + +function createProgrammaticSourceRef( + registry: AgentSourceRegistry, + candidate: AgentModuleCandidate, +): ModuleSourceRef { + if (candidate.backing.kind !== "programmatic") { + throw new Error(`Expected "${candidate.sourceId}" to have a programmatic backing.`); + } + const module = registry.getModule(candidate.backing); + return { + exportName: module.exportName, + logicalPath: candidate.logicalPath, + sourceId: candidate.sourceId, + sourceKind: "module", + }; +} + +function createEmptySelectedSources(): { + channels: ModuleSourceRef[]; + connections: ConnectionSourceRef[]; + hooks: ModuleSourceRef[]; + instructions: InstructionsSourceRef[]; + sandbox: ModuleSourceRef[]; + schedules: ScheduleSourceRef[]; + skills: SkillSourceRef[]; + tools: ModuleSourceRef[]; +} { + return { + channels: [], + connections: [], + hooks: [], + instructions: [], + sandbox: [], + schedules: [], + skills: [], + tools: [], + }; +} diff --git a/packages/eve/src/compiler/compose-framework-sources.test.ts b/packages/eve/src/compiler/compose-framework-sources.test.ts deleted file mode 100644 index bb17c8b862..0000000000 --- a/packages/eve/src/compiler/compose-framework-sources.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { composeFrameworkSources } from "#compiler/compose-framework-sources.js"; -import { createAgentSourceManifest, createModuleSourceRef } from "#discover/manifest.js"; -import { frameworkAgentSourceRegistry } from "#framework-sources/registry.js"; - -describe("composeFrameworkSources", () => { - it("uses application channels, tools, and sandbox modules for matching canonical slots", () => { - const result = composeFrameworkSources({ - isRoot: true, - manifest: createAgentSourceManifest({ - agentId: "root", - agentRoot: "/app/agent", - appRoot: "/app", - channels: [createModuleSourceRef({ logicalPath: "channels/eve.ts" })], - sandbox: createModuleSourceRef({ logicalPath: "sandbox/sandbox.ts" }), - tools: [createModuleSourceRef({ logicalPath: "tools/bash.ts" })], - }), - nodeId: "root", - registry: frameworkAgentSourceRegistry, - }); - - expect( - result.manifest.tools.find((tool) => tool.logicalPath === "tools/bash.ts")?.sourceId, - ).toBe("tools/bash.ts"); - expect(result.manifest.sandbox?.sourceId).toBe("sandbox/sandbox.ts"); - expect( - result.manifest.channels.find((channel) => channel.logicalPath === "channels/eve.ts") - ?.sourceId, - ).toBe("channels/eve.ts"); - expect(result.bindings["eve.framework-root:channels/eve.ts"]).toBeUndefined(); - expect(result.bindings["eve.framework-defaults:tools/bash.ts"]).toBeUndefined(); - expect(result.bindings["eve.framework-defaults:sandbox.ts"]).toBeUndefined(); - }); - - it("binds non-overridden defaults to their programmatic owners", () => { - const result = composeFrameworkSources({ - isRoot: false, - manifest: createAgentSourceManifest({ - agentId: "child", - agentRoot: "/app/agent/subagents/child", - appRoot: "/app", - }), - nodeId: "subagents/child", - registry: frameworkAgentSourceRegistry, - }); - - expect(result.manifest.tools.map((tool) => tool.logicalPath)).toEqual([ - "tools/bash.ts", - "tools/connection_search.ts", - "tools/load_skill.ts", - "tools/read_file.ts", - "tools/todo.ts", - "tools/web_fetch.ts", - "tools/web_search.ts", - "tools/write_file.ts", - ]); - expect(result.manifest.channels).toEqual([]); - expect(result.manifest.sandbox?.logicalPath).toBe("sandbox.ts"); - expect(result.bindings["eve.framework-defaults:tools/bash.ts"]).toEqual({ - backing: { - kind: "programmatic", - moduleId: "tools/bash.ts", - registryId: "eve.framework-defaults", - }, - logicalPath: "tools/bash.ts", - owner: { feature: "eve.framework-defaults", kind: "framework" }, - }); - }); -}); diff --git a/packages/eve/src/compiler/compose-framework-sources.ts b/packages/eve/src/compiler/compose-framework-sources.ts deleted file mode 100644 index 30efb5efcf..0000000000 --- a/packages/eve/src/compiler/compose-framework-sources.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { resolve } from "node:path"; - -import type { AgentModuleCandidate } from "#compiler/agent-module-candidate.js"; -import type { AgentSourceRegistry } from "#compiler/agent-source-registry.js"; -import { composeAgentModuleCandidates } from "#compiler/compose-agent-module-candidates.js"; -import type { CompiledModuleBinding } from "#compiler/module-binding.js"; -import { createProgrammaticModuleCandidates } from "#compiler/programmatic-module-candidates.js"; -import type { AgentSourceManifest, ChannelSourceRef, ToolSourceRef } from "#discover/manifest.js"; -import type { ModuleSourceRef } from "#shared/source-ref.js"; - -export interface ComposedFrameworkSources { - readonly bindings: Readonly>; - readonly manifest: AgentSourceManifest; -} - -export function composeFrameworkSources(input: { - readonly isRoot: boolean; - readonly manifest: AgentSourceManifest; - readonly nodeId: string; - readonly registry: AgentSourceRegistry; -}): ComposedFrameworkSources { - const applicationRefs = [ - ...input.manifest.channels, - ...input.manifest.tools, - ...(input.manifest.sandbox === null ? [] : [input.manifest.sandbox]), - ]; - const applicationCandidates = applicationRefs.map((source) => - createApplicationCandidate(input.manifest, input.nodeId, source), - ); - const frameworkCandidates = createProgrammaticModuleCandidates({ - isRoot: input.isRoot, - nodeId: input.nodeId, - registry: input.registry, - }).filter( - (candidate) => - candidate.logicalPath.startsWith("channels/") || - candidate.logicalPath === "sandbox.ts" || - candidate.logicalPath.startsWith("tools/"), - ); - const composition = composeAgentModuleCandidates([ - ...frameworkCandidates, - ...applicationCandidates, - ]); - const refsBySourceId = new Map(applicationRefs.map((source) => [source.sourceId, source])); - const bindings: Record = {}; - const channels: ChannelSourceRef[] = []; - const tools: ToolSourceRef[] = []; - let sandbox: ModuleSourceRef | null = null; - - for (const winner of composition.winners) { - const source = - refsBySourceId.get(winner.sourceId) ?? createProgrammaticSourceRef(input.registry, winner); - if (winner.backing.kind === "programmatic") { - bindings[winner.sourceId] = { - backing: winner.backing, - logicalPath: winner.logicalPath, - owner: winner.owner, - }; - } - if (winner.logicalPath.startsWith("channels/")) channels.push(source); - if (winner.logicalPath.startsWith("tools/")) tools.push(source); - if (winner.logicalPath === "sandbox.ts" || winner.logicalPath.startsWith("sandbox/")) { - sandbox = source; - } - } - - return { - bindings, - manifest: { ...input.manifest, channels, sandbox, tools }, - }; -} - -function createApplicationCandidate( - manifest: AgentSourceManifest, - nodeId: string, - source: ModuleSourceRef, -): AgentModuleCandidate { - return { - backing: { - externalDependencies: [], - kind: "filesystem", - sourcePath: resolve(manifest.agentRoot, source.logicalPath), - }, - layer: "application", - logicalPath: source.logicalPath, - nodeId, - owner: { kind: "application" }, - sourceId: source.sourceId, - }; -} - -function createProgrammaticSourceRef( - registry: AgentSourceRegistry, - candidate: AgentModuleCandidate, -): ModuleSourceRef { - if (candidate.backing.kind !== "programmatic") { - throw new Error(`Expected "${candidate.sourceId}" to have a programmatic backing.`); - } - const module = registry.getModule(candidate.backing); - return { - exportName: module.exportName, - logicalPath: candidate.logicalPath, - sourceId: candidate.sourceId, - sourceKind: "module", - }; -} diff --git a/packages/eve/src/compiler/normalize-agent-config.test.ts b/packages/eve/src/compiler/normalize-agent-config.test.ts index b23685f2b2..47aa44a69f 100644 --- a/packages/eve/src/compiler/normalize-agent-config.test.ts +++ b/packages/eve/src/compiler/normalize-agent-config.test.ts @@ -112,6 +112,8 @@ function createContext( ): ManifestCompileContext { return { bindingsByAgentRoot: new Map(), + compositionsByNodeId: new Map(), + manifestsByNodeId: new Map(), modelCatalog, moduleLoader: createAgentModuleNamespaceLoader(), }; diff --git a/packages/eve/src/compiler/normalize-extension.test.ts b/packages/eve/src/compiler/normalize-extension.test.ts deleted file mode 100644 index 9d57499496..0000000000 --- a/packages/eve/src/compiler/normalize-extension.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - applyOverrideDisables, - composeExtensionSubagentSources, - type CompiledExtensionContributions, - mergeContributions, -} from "#compiler/normalize-extension.js"; -import { - createAgentSourceManifest, - createLocalSubagentSourceRef, - createModuleSourceRef, -} from "#discover/manifest.js"; - -// mergeContributions only reads each named contribution's identifier for dedup, -// so minimal partial fixtures suffice. -function contributions( - overrides: Partial, -): CompiledExtensionContributions { - return { - channels: [], - tools: [], - dynamicTools: [], - hooks: [], - skills: [], - dynamicSkills: [], - dynamicInstructions: [], - connections: [], - instructions: [], - schedules: [], - ...overrides, - }; -} - -describe("mergeContributions", () => { - it("keeps the primary (consumer override) entry when a named contribution collides", () => { - const primary = contributions({ - channels: [ - { name: "crm__webhook", logicalPath: "override", method: "GET" }, - { name: "crm__webhook", logicalPath: "override", method: "POST" }, - ] as never, - tools: [{ name: "crm__search", logicalPath: "override" }] as never, - connections: [{ connectionName: "crm__api", logicalPath: "override" }] as never, - skills: [{ name: "crm__lookup", logicalPath: "override" }] as never, - dynamicTools: [{ slug: "crm__dynamic", logicalPath: "override" }] as never, - schedules: [{ name: "crm__sweep", logicalPath: "override" }] as never, - }); - const secondary = contributions({ - channels: [ - { name: "crm__webhook", logicalPath: "extension", method: "POST" }, - { name: "crm__status", logicalPath: "extension", method: "GET" }, - ] as never, - tools: [ - { name: "crm__search", logicalPath: "extension" }, - { name: "crm__list", logicalPath: "extension" }, - ] as never, - connections: [{ connectionName: "crm__api", logicalPath: "extension" }] as never, - skills: [{ name: "crm__lookup", logicalPath: "extension" }] as never, - dynamicTools: [{ slug: "crm__dynamic", logicalPath: "extension" }] as never, - schedules: [ - { name: "crm__sweep", logicalPath: "extension" }, - { name: "crm__digest", logicalPath: "extension" }, - ] as never, - }); - - const merged = mergeContributions(primary, secondary); - - expect(merged.channels).toEqual([ - { name: "crm__webhook", logicalPath: "override", method: "GET" }, - { name: "crm__webhook", logicalPath: "override", method: "POST" }, - { name: "crm__status", logicalPath: "extension", method: "GET" }, - ]); - expect(merged.tools).toEqual([ - { name: "crm__search", logicalPath: "override" }, - { name: "crm__list", logicalPath: "extension" }, - ]); - expect(merged.connections).toEqual([{ connectionName: "crm__api", logicalPath: "override" }]); - expect(merged.skills).toEqual([{ name: "crm__lookup", logicalPath: "override" }]); - expect(merged.dynamicTools).toEqual([{ slug: "crm__dynamic", logicalPath: "override" }]); - expect(merged.schedules).toEqual([ - { name: "crm__sweep", logicalPath: "override" }, - { name: "crm__digest", logicalPath: "extension" }, - ]); - }); - - it("concatenates unnamed contributions from both sets", () => { - const primary = contributions({ - hooks: [{ slug: "crm__before" }] as never, - instructions: [{ content: "override", role: "system" }] as never, - }); - const secondary = contributions({ - hooks: [{ slug: "crm__after" }] as never, - instructions: [{ content: "extension", role: "user" }] as never, - }); - - const merged = mergeContributions(primary, secondary); - - expect(merged.hooks).toEqual([{ slug: "crm__before" }, { slug: "crm__after" }]); - expect(merged.instructions).toEqual([ - { content: "override", role: "system" }, - { content: "extension", role: "user" }, - ]); - }); -}); - -describe("composeExtensionSubagentSources", () => { - it("namespaces extension subagents and lets a directory override win", () => { - const extensionRoot = "/packages/crm/dist/extension"; - const overrideRoot = "/app/agent/extensions/crm"; - const createSubagent = (root: string, subagentId: string) => { - const agentRoot = `${root}/subagents/${subagentId}`; - return createLocalSubagentSourceRef({ - entryPath: agentRoot, - logicalPath: `subagents/${subagentId}`, - manifest: createAgentSourceManifest({ - agentId: subagentId, - agentRoot, - appRoot: "/app", - configModule: createModuleSourceRef({ logicalPath: "agent.ts" }), - tools: [createModuleSourceRef({ logicalPath: "tools/search.ts" })], - }), - rootPath: agentRoot, - subagentId, - }); - }; - const extensionManifest = createAgentSourceManifest({ - agentRoot: extensionRoot, - appRoot: "/packages/crm", - subagents: [ - createSubagent(extensionRoot, "reviewer"), - createSubagent(extensionRoot, "analyst"), - ], - }); - const overrideManifest = createAgentSourceManifest({ - agentRoot: overrideRoot, - appRoot: "/app", - subagents: [createSubagent(overrideRoot, "reviewer")], - }); - - const result = composeExtensionSubagentSources({ - consumerAgentRoot: "/app/agent", - mount: { - namespace: "crm", - specifier: "@acme/crm", - packageName: "@acme/crm", - packageRoot: "/packages/crm", - sourceRoot: extensionRoot, - manifest: extensionManifest, - externalDependencies: [], - overrides: overrideManifest, - }, - }); - - expect(result.map((source) => [source.subagentId, source.sourceId])).toEqual([ - ["crm__reviewer", "ext-override:crm:subagents/reviewer"], - ["crm__analyst", "ext:crm:subagents/analyst"], - ]); - expect(result[1]?.manifest.tools[0]?.sourceId).toBe( - "ext:crm:subagents/analyst/tools/search.ts", - ); - }); -}); - -describe("applyOverrideDisables", () => { - it("removes the disabled static extension tool while keeping the rest", () => { - const merged = contributions({ - tools: [ - { name: "crm__search", logicalPath: "extension" }, - { name: "crm__list", logicalPath: "extension" }, - ] as never, - }); - - const result = applyOverrideDisables({ - merged, - disables: [{ name: "crm__search", logicalPath: "tools/search.ts" }], - extensionToolNames: new Set(["crm__search", "crm__list"]), - extensionDynamicToolSlugs: new Set(), - namespace: "crm", - }); - - expect(result.tools).toEqual([{ name: "crm__list", logicalPath: "extension" }]); - }); - - it("removes a disabled dynamic resolver slot by slug", () => { - const merged = contributions({ - tools: [{ name: "crm__list", logicalPath: "extension" }] as never, - dynamicTools: [{ slug: "crm__search", logicalPath: "extension" }] as never, - }); - - const result = applyOverrideDisables({ - merged, - disables: [{ name: "crm__search", logicalPath: "tools/search.ts" }], - extensionToolNames: new Set(["crm__list"]), - extensionDynamicToolSlugs: new Set(["crm__search"]), - namespace: "crm", - }); - - expect(result.dynamicTools).toEqual([]); - expect(result.tools).toEqual([{ name: "crm__list", logicalPath: "extension" }]); - }); - - it("throws, listing static and dynamic slots, when the disable targets neither", () => { - expect(() => - applyOverrideDisables({ - merged: contributions({ - tools: [{ name: "crm__list", logicalPath: "extension" }] as never, - dynamicTools: [{ slug: "crm__lookup", logicalPath: "extension" }] as never, - }), - disables: [{ name: "crm__search", logicalPath: "tools/search.ts" }], - extensionToolNames: new Set(["crm__list"]), - extensionDynamicToolSlugs: new Set(["crm__lookup"]), - namespace: "crm", - }), - ).toThrow(/no tool named "search"[\s\S]*It contributes: list, lookup/); - }); - - it("returns the merged set unchanged when nothing is disabled", () => { - const merged = contributions({ tools: [{ name: "crm__list" }] as never }); - - expect( - applyOverrideDisables({ - merged, - disables: [], - extensionToolNames: new Set(["crm__list"]), - extensionDynamicToolSlugs: new Set(), - namespace: "crm", - }), - ).toBe(merged); - }); -}); diff --git a/packages/eve/src/compiler/normalize-extension.ts b/packages/eve/src/compiler/normalize-extension.ts deleted file mode 100644 index a4cca1b874..0000000000 --- a/packages/eve/src/compiler/normalize-extension.ts +++ /dev/null @@ -1,527 +0,0 @@ -import { join as joinPath, relative as relativePath } from "node:path"; - -import { - createPathDerivedSourceId, - type AgentSourceManifest, - type LocalSubagentSourceRef, - type ResolvedExtensionMount, -} from "#discover/manifest.js"; -import type { - CompiledChannelEntry, - CompiledConnectionDefinition, - CompiledDynamicInstructionsDefinition, - CompiledDynamicSkillDefinition, - CompiledDynamicToolDefinition, - CompiledHookDefinition, - CompiledInstructionsDefinition, - CompiledScheduleDefinition, - CompiledSkillDefinition, - CompiledToolDefinition, -} from "#compiler/manifest.js"; -import { compileChannelDefinition } from "#compiler/normalize-channel.js"; -import { compileConnectionDefinition } from "#compiler/normalize-connection.js"; -import type { ManifestCompileContext } from "#compiler/normalize-helpers.js"; -import { compileHookEntry } from "#compiler/normalize-hook.js"; -import { compileInstructionsEntry } from "#compiler/normalize-instructions.js"; -import { compileScheduleDefinition } from "#compiler/normalize-schedule.js"; -import { compileSkillSource } from "#compiler/normalize-skill.js"; -import { compileToolEntry } from "#compiler/normalize-tool.js"; - -/** - * Contributions one mounted extension composes into the consuming agent, - * already namespaced by the mount and rebased onto the consumer's agent root. - */ -export interface CompiledExtensionContributions { - readonly channels: CompiledChannelEntry[]; - readonly tools: CompiledToolDefinition[]; - readonly dynamicTools: CompiledDynamicToolDefinition[]; - readonly hooks: CompiledHookDefinition[]; - readonly skills: CompiledSkillDefinition[]; - readonly dynamicSkills: CompiledDynamicSkillDefinition[]; - readonly dynamicInstructions: CompiledDynamicInstructionsDefinition[]; - readonly connections: CompiledConnectionDefinition[]; - readonly instructions: CompiledInstructionsDefinition[]; - readonly schedules: CompiledScheduleDefinition[]; -} - -/** - * Compiles one mounted extension's source tree and namespaces its - * contributions by the mount name. Module-backed contributions keep loading - * from the extension package because their `logicalPath` is rebased to a - * consumer-relative path — the module-map codegen resolves it against the - * consumer's agent root, reaching into the extension package unchanged. - * - * When the mount was authored as a directory (`extensions//`), any - * consumer-authored override slots are composed under the same namespace and - * win on name collision: an override tool `__search` shadows the - * extension's own `__search`. - */ -export async function compileExtensionContributions(input: { - readonly mount: ResolvedExtensionMount; - readonly context: ManifestCompileContext; - readonly consumerAgentRoot: string; - readonly externalDependencies: readonly string[]; -}): Promise { - const { mount, consumerAgentRoot } = input; - const options = { externalDependencies: input.externalDependencies }; - - const base = await composeManifestContributions({ - manifest: mount.manifest, - namespace: mount.namespace, - consumerAgentRoot, - options, - sourceIdScope: `ext:${mount.namespace}`, - role: "extension", - }); - - if (mount.overrides === undefined) { - return base.contributions; - } - - // Overrides are consumer-authored files, so they are NOT extension-scoped. The - // `ext-override:` prefix keeps their module-map keys distinct from the - // extension's own `ext::` modules while deliberately not matching the - // loader's `^ext::` scope pattern, so dev and prod both treat them unscoped. - const overrides = await composeManifestContributions({ - manifest: mount.overrides, - namespace: mount.namespace, - consumerAgentRoot, - options, - sourceIdScope: `ext-override:${mount.namespace}`, - role: "override", - }); - - // Consumer overrides win: list them first so first-registration-wins dedup - // keeps the override over the extension's same-named contribution. - const merged = mergeContributions(overrides.contributions, base.contributions); - - return applyOverrideDisables({ - merged, - disables: overrides.disabledToolTargets, - extensionToolNames: new Set(base.contributions.tools.map((tool) => tool.name)), - extensionDynamicToolSlugs: new Set(base.contributions.dynamicTools.map((tool) => tool.slug)), - namespace: mount.namespace, - }); -} - -/** Composes one mount's root-visible subagents under its namespace. */ -export function composeExtensionSubagentSources(input: { - readonly consumerAgentRoot: string; - readonly mount: ResolvedExtensionMount; -}): LocalSubagentSourceRef[] { - const base = scopeExtensionSubagents({ - consumerAgentRoot: input.consumerAgentRoot, - manifest: input.mount.manifest, - namespace: input.mount.namespace, - sourceIdScope: `ext:${input.mount.namespace}`, - sourceRoot: input.mount.sourceRoot, - }); - if (input.mount.overrides === undefined) { - return base; - } - - const overrides = scopeExtensionSubagents({ - consumerAgentRoot: input.consumerAgentRoot, - manifest: input.mount.overrides, - namespace: input.mount.namespace, - sourceIdScope: `ext-override:${input.mount.namespace}`, - sourceRoot: input.mount.overrides.agentRoot, - }); - return mergeExtensionSubagentSources(overrides, base); -} - -/** Returns authored and mounted-extension subagents visible from one agent node. */ -export function composeAgentSubagentSources( - manifest: AgentSourceManifest, -): LocalSubagentSourceRef[] { - return [ - ...manifest.subagents, - ...[...manifest.resolvedExtensions] - .sort((left, right) => left.namespace.localeCompare(right.namespace)) - .flatMap((mount) => - composeExtensionSubagentSources({ consumerAgentRoot: manifest.agentRoot, mount }), - ), - ]; -} - -/** Earlier entries win, matching directory-override precedence for other slots. */ -export function mergeExtensionSubagentSources( - primary: readonly LocalSubagentSourceRef[], - secondary: readonly LocalSubagentSourceRef[], -): LocalSubagentSourceRef[] { - return dedupeBy([...primary, ...secondary], (source) => source.subagentId); -} - -function scopeExtensionSubagents(input: { - readonly consumerAgentRoot: string; - readonly manifest: AgentSourceManifest; - readonly namespace: string; - readonly sourceIdScope: string; - readonly sourceRoot: string; -}): LocalSubagentSourceRef[] { - return input.manifest.subagents.map((source) => ({ - ...scopeExtensionSubagentSource(source, input.sourceRoot, input.sourceIdScope), - logicalPath: relativePath(input.consumerAgentRoot, source.entryPath).replaceAll("\\", "/"), - subagentId: `${input.namespace}__${source.subagentId}`, - })); -} - -function scopeExtensionSubagentSource( - source: LocalSubagentSourceRef, - sourceRoot: string, - sourceIdScope: string, -): LocalSubagentSourceRef { - return { - ...source, - manifest: scopeExtensionSubagentManifest(source.manifest, sourceRoot, sourceIdScope), - sourceId: scopeExtensionSourceId(sourceRoot, source.entryPath, sourceIdScope), - }; -} - -function scopeExtensionSubagentManifest( - manifest: AgentSourceManifest, - sourceRoot: string, - sourceIdScope: string, -): AgentSourceManifest { - const scopeRef = ( - source: T, - ): T => ({ - ...source, - sourceId: scopeExtensionSourceId( - sourceRoot, - joinPath(manifest.agentRoot, source.logicalPath), - sourceIdScope, - ), - }); - - return { - ...manifest, - channels: manifest.channels.map(scopeRef), - connections: manifest.connections.map(scopeRef), - ...(manifest.configModule === undefined - ? {} - : { configModule: scopeRef(manifest.configModule) }), - extensions: manifest.extensions.map(scopeRef), - hooks: manifest.hooks.map(scopeRef), - instructions: manifest.instructions.map(scopeRef), - lib: manifest.lib.map(scopeRef), - sandbox: manifest.sandbox === null ? null : scopeRef(manifest.sandbox), - sandboxWorkspaces: manifest.sandboxWorkspaces.map(scopeRef), - schedules: manifest.schedules.map(scopeRef), - skills: manifest.skills.map(scopeRef), - subagents: manifest.subagents.map((source) => - scopeExtensionSubagentSource(source, sourceRoot, sourceIdScope), - ), - tools: manifest.tools.map(scopeRef), - }; -} - -function scopeExtensionSourceId( - sourceRoot: string, - sourcePath: string, - sourceIdScope: string, -): string { - const logicalPath = relativePath(sourceRoot, sourcePath).replaceAll("\\", "/"); - return `${sourceIdScope}:${createPathDerivedSourceId(logicalPath)}`; -} - -export interface DisabledToolTarget { - /** Namespaced target, e.g. `crm__search`. */ - readonly name: string; - /** Override-relative authored path, e.g. `tools/search.ts`, for diagnostics. */ - readonly logicalPath: string; -} - -interface ComposedContributions { - readonly contributions: CompiledExtensionContributions; - readonly disabledToolTargets: readonly DisabledToolTarget[]; -} - -/** - * Removes the extension tools an override slot opted out of with `disableTool()`. - * A `disableTool()` targets a slot by name, so it removes the extension's - * same-named static tool or dynamic resolver — whichever kind occupies the slot. - * A disable that matches neither throws rather than silently disabling nothing. - * - * Exported for unit testing. - */ -export function applyOverrideDisables(input: { - readonly merged: CompiledExtensionContributions; - readonly disables: readonly DisabledToolTarget[]; - readonly extensionToolNames: ReadonlySet; - readonly extensionDynamicToolSlugs: ReadonlySet; - readonly namespace: string; -}): CompiledExtensionContributions { - if (input.disables.length === 0) { - return input.merged; - } - const prefixLength = input.namespace.length + 2; // strip the `__` prefix - const removed = new Set(); - for (const disable of input.disables) { - if ( - !input.extensionToolNames.has(disable.name) && - !input.extensionDynamicToolSlugs.has(disable.name) - ) { - const available = [...input.extensionToolNames, ...input.extensionDynamicToolSlugs] - .map((name) => name.slice(prefixLength)) - .sort(); - throw new Error( - `The override "agent/extensions/${input.namespace}/${disable.logicalPath}" calls disableTool(), ` + - `but the "${input.namespace}" extension contributes no tool named "${disable.name.slice(prefixLength)}". ` + - `It contributes: ${available.length > 0 ? available.join(", ") : "(no tools)"}.`, - ); - } - removed.add(disable.name); - } - return { - ...input.merged, - tools: input.merged.tools.filter((tool) => !removed.has(tool.name)), - dynamicTools: input.merged.dynamicTools.filter((tool) => !removed.has(tool.slug)), - }; -} - -interface ComposeOptions { - readonly externalDependencies: readonly string[]; -} - -/** - * Compiles one agent-shaped manifest into namespaced extension contributions - * rebased onto the consumer's agent root. Used for both the extension's own - * source tree and a directory mount's consumer override slots. - */ -async function composeManifestContributions(input: { - readonly manifest: AgentSourceManifest; - readonly namespace: string; - readonly consumerAgentRoot: string; - readonly options: ComposeOptions; - readonly sourceIdScope: string; - readonly role: "extension" | "override"; -}): Promise { - const { manifest, namespace, consumerAgentRoot, options, sourceIdScope, role } = input; - const sourceRoot = manifest.agentRoot; - const prefix = `${namespace}__`; - const scopeSourceId = (sourceId: string): string => `${sourceIdScope}:${sourceId}`; - const rebase = (logicalPath: string): string => - relativePath(consumerAgentRoot, joinPath(sourceRoot, logicalPath)).replaceAll("\\", "/"); - - const channels = ( - await Promise.all( - manifest.channels.map((source) => compileChannelDefinition(sourceRoot, source, options)), - ) - ) - .flat() - .map((channel): CompiledChannelEntry => - channel.kind === "disabled" - ? { - ...channel, - name: `${prefix}${channel.name}`, - logicalPath: rebase(channel.logicalPath), - } - : { - ...channel, - name: `${prefix}${channel.name}`, - sourceId: scopeSourceId(channel.sourceId), - logicalPath: rebase(channel.logicalPath), - }, - ); - - const tools: CompiledToolDefinition[] = []; - const dynamicTools: CompiledDynamicToolDefinition[] = []; - const disabledToolTargets: DisabledToolTarget[] = []; - for (const source of manifest.tools) { - const entry = await compileToolEntry(sourceRoot, source, options); - if (entry.kind === "tool") { - tools.push({ - ...entry.definition, - name: `${prefix}${entry.definition.name}`, - sourceId: scopeSourceId(entry.definition.sourceId), - logicalPath: rebase(entry.definition.logicalPath), - }); - } else if (entry.kind === "dynamic-tool") { - dynamicTools.push({ - ...entry.definition, - slug: `${prefix}${entry.definition.slug}`, - extensionNamespace: namespace, - sourceId: scopeSourceId(entry.definition.sourceId), - logicalPath: rebase(entry.definition.logicalPath), - }); - } else if (entry.kind === "workflow-tool") { - throw new Error( - `${describeExtensionSource(role, namespace, source.logicalPath)} enables the Workflow tool, ` + - `but the Workflow tool is the consuming agent's to enable, not an extension's. Remove it.`, - ); - } else if (entry.kind === "web-search-tool") { - throw new Error( - `${describeExtensionSource(role, namespace, source.logicalPath)} configures web search, ` + - `but the web search provider is the consuming agent's to configure, not an extension's. Remove it.`, - ); - } else if (role === "extension") { - throw new Error( - `${describeExtensionSource(role, namespace, source.logicalPath)} calls disableTool(), ` + - `but an extension cannot disable framework tools — that is the consuming agent's to own. Remove it.`, - ); - } else { - disabledToolTargets.push({ name: `${prefix}${entry.name}`, logicalPath: source.logicalPath }); - } - } - - const hooks: CompiledHookDefinition[] = manifest.hooks.map((source) => { - const hook = compileHookEntry(source); - return { - ...hook, - slug: `${prefix}${hook.slug}`, - sourceId: scopeSourceId(hook.sourceId), - logicalPath: rebase(hook.logicalPath), - }; - }); - - const skills: CompiledSkillDefinition[] = []; - const dynamicSkills: CompiledDynamicSkillDefinition[] = []; - for (const source of manifest.skills) { - const entry = await compileSkillSource(sourceRoot, source, options); - if (entry.kind === "skill") { - skills.push({ - ...entry.definition, - name: `${prefix}${entry.definition.name}`, - sourceId: scopeSourceId(entry.definition.sourceId), - logicalPath: rebase(entry.definition.logicalPath), - }); - } else { - dynamicSkills.push({ - ...entry.definition, - slug: `${prefix}${entry.definition.slug}`, - extensionNamespace: namespace, - sourceId: scopeSourceId(entry.definition.sourceId), - logicalPath: rebase(entry.definition.logicalPath), - }); - } - } - - const connections: CompiledConnectionDefinition[] = ( - await Promise.all( - manifest.connections.map((source) => - compileConnectionDefinition(sourceRoot, source, options), - ), - ) - ).map((connection) => ({ - ...connection, - connectionName: `${prefix}${connection.connectionName}`, - sourceId: scopeSourceId(connection.sourceId), - logicalPath: rebase(connection.logicalPath), - })); - - const dynamicInstructions: CompiledDynamicInstructionsDefinition[] = []; - const instructions: CompiledInstructionsDefinition[] = []; - for (const source of manifest.instructions) { - const entry = await compileInstructionsEntry(sourceRoot, source, options); - if (entry.kind === "instructions") { - instructions.push({ - ...entry.definition, - sourceId: scopeSourceId(entry.definition.sourceId), - logicalPath: rebase(entry.definition.logicalPath), - }); - } else { - dynamicInstructions.push({ - ...entry.definition, - slug: `${prefix}${entry.definition.slug}`, - sourceId: scopeSourceId(entry.definition.sourceId), - logicalPath: rebase(entry.definition.logicalPath), - }); - } - } - - const schedules = await Promise.all( - manifest.schedules.map(async (source) => { - const schedule = await compileScheduleDefinition(sourceRoot, source, options); - return { - ...schedule, - name: `${prefix}${schedule.name}`, - sourceId: scopeSourceId(schedule.sourceId), - logicalPath: rebase(schedule.logicalPath), - }; - }), - ); - - return { - contributions: { - channels, - tools, - dynamicTools, - hooks, - skills, - dynamicSkills, - dynamicInstructions, - connections, - instructions, - schedules, - }, - disabledToolTargets, - }; -} - -function describeExtensionSource( - role: "extension" | "override", - namespace: string, - logicalPath: string, -): string { - return role === "override" - ? `The override "agent/extensions/${namespace}/${logicalPath}"` - : `The "${namespace}" extension's "${logicalPath}"`; -} - -/** - * Merges two composed contribution sets with earlier-set-wins precedence per - * composed name. Named contributions dedup by their composed identifier so - * an override shadows the extension's same-named entry. Channel entries dedup - * as a group so every route from the winning channel is preserved. Unnamed - * contributions (hooks, dynamic skills, dynamic instructions, static - * instructions) simply concatenate. - * - * Exported for unit testing: passing the consumer overrides as `primary` and - * the extension's own contributions as `secondary` yields consumer-wins - * shadowing on name collision. - */ -export function mergeContributions( - primary: CompiledExtensionContributions, - secondary: CompiledExtensionContributions, -): CompiledExtensionContributions { - const primaryChannelNames = new Set(primary.channels.map((channel) => channel.name)); - return { - channels: [ - ...primary.channels, - ...secondary.channels.filter((channel) => !primaryChannelNames.has(channel.name)), - ], - tools: dedupeBy([...primary.tools, ...secondary.tools], (tool) => tool.name), - dynamicTools: dedupeBy( - [...primary.dynamicTools, ...secondary.dynamicTools], - (tool) => tool.slug, - ), - connections: dedupeBy( - [...primary.connections, ...secondary.connections], - (connection) => connection.connectionName, - ), - skills: dedupeBy([...primary.skills, ...secondary.skills], (skill) => skill.name), - schedules: dedupeBy( - [...primary.schedules, ...secondary.schedules], - (schedule) => schedule.name, - ), - hooks: [...primary.hooks, ...secondary.hooks], - dynamicSkills: [...primary.dynamicSkills, ...secondary.dynamicSkills], - dynamicInstructions: [...primary.dynamicInstructions, ...secondary.dynamicInstructions], - instructions: [...primary.instructions, ...secondary.instructions], - }; -} - -function dedupeBy(items: readonly T[], key: (item: T) => string): T[] { - const seen = new Set(); - const result: T[] = []; - for (const item of items) { - const identifier = key(item); - if (seen.has(identifier)) { - continue; - } - seen.add(identifier); - result.push(item); - } - return result; -} diff --git a/packages/eve/src/compiler/normalize-helpers.ts b/packages/eve/src/compiler/normalize-helpers.ts index 3e269d44f7..7c53b850ff 100644 --- a/packages/eve/src/compiler/normalize-helpers.ts +++ b/packages/eve/src/compiler/normalize-helpers.ts @@ -9,6 +9,8 @@ import { toErrorMessage } from "#shared/errors.js"; import type { ModuleSourceRef } from "#shared/source-ref.js"; import type { CompiledRuntimeModelCatalogLoader } from "#compiler/model-catalog.js"; import type { CompiledModuleBinding } from "#compiler/module-binding.js"; +import type { AgentModuleComposition } from "#compiler/compose-agent-module-candidates.js"; +import type { AgentSourceManifest } from "#discover/manifest.js"; import { createAgentModuleNamespaceLoader, type AgentModuleNamespaceLoader, @@ -26,6 +28,8 @@ const SANDBOX_PARENT_DEFINITION_MARKER = Symbol.for("eve.sandbox-parent-definiti */ export interface ManifestCompileContext { readonly bindingsByAgentRoot: Map>>; + readonly compositionsByNodeId: Map; + readonly manifestsByNodeId: Map; readonly modelCatalog: CompiledRuntimeModelCatalogLoader; readonly moduleLoader: AgentModuleNamespaceLoader; } diff --git a/packages/eve/src/compiler/normalize-manifest.ts b/packages/eve/src/compiler/normalize-manifest.ts index 5df289696e..1794c44c85 100644 --- a/packages/eve/src/compiler/normalize-manifest.ts +++ b/packages/eve/src/compiler/normalize-manifest.ts @@ -1,3 +1,5 @@ +import { resolve } from "node:path"; + import type { AgentSourceManifest } from "#discover/manifest.js"; import { mountRefNamespace, packageStateNamespace } from "#discover/extensions.js"; import { @@ -22,10 +24,6 @@ import { createCompiledRuntimeModelCatalogLoader } from "#compiler/model-catalog import { compileAgentConfig } from "#compiler/normalize-agent-config.js"; import { compileChannelDefinition } from "#compiler/normalize-channel.js"; import { compileConnectionDefinition } from "#compiler/normalize-connection.js"; -import { - composeAgentSubagentSources, - compileExtensionContributions, -} from "#compiler/normalize-extension.js"; import type { ManifestCompileContext, ModuleBackedDefinitionLoadOptions, @@ -37,8 +35,16 @@ import { compileScheduleDefinition } from "#compiler/normalize-schedule.js"; import { compileSkillSource } from "#compiler/normalize-skill.js"; import { compileSubagentGraph } from "#compiler/normalize-subagent.js"; import { compileToolEntry } from "#compiler/normalize-tool.js"; -import { createFilesystemModuleBindings } from "#compiler/module-binding.js"; -import { composeFrameworkSources } from "#compiler/compose-framework-sources.js"; +import { + createFilesystemModuleBindings, + type CompiledModuleBinding, +} from "#compiler/module-binding.js"; +import type { AgentSourceLayer } from "#compiler/agent-module-candidate.js"; +import { + composeAgentSources, + type AgentSourceOrigin, + type ComposedAgentSources, +} from "#compiler/compose-agent-sources.js"; import { frameworkAgentSourceRegistry } from "#framework-sources/registry.js"; import { createAgentModuleNamespaceLoader } from "#compiler/module-namespace-loader.js"; @@ -50,20 +56,15 @@ export async function compileAgentManifest( ): Promise { const context: ManifestCompileContext = { bindingsByAgentRoot: new Map(), + compositionsByNodeId: new Map(), + manifestsByNodeId: new Map(), modelCatalog: createCompiledRuntimeModelCatalogLoader(manifest.appRoot), moduleLoader: createAgentModuleNamespaceLoader({ registry: frameworkAgentSourceRegistry }), }; - const rootSources = composeFrameworkSources({ - isRoot: true, - manifest, + const compiledNode = await compileAgentNodeManifest(manifest, context, { nodeId: ROOT_COMPILED_AGENT_NODE_ID, - registry: frameworkAgentSourceRegistry, - }); - context.bindingsByAgentRoot.set(manifest.agentRoot, rootSources.bindings); - const compiledNode = await compileAgentNodeManifest(rootSources.manifest, context, { - nodeId: ROOT_COMPILED_AGENT_NODE_ID, - sourcesComposed: true, }); + const rootSources = context.manifestsByNodeId.get(ROOT_COMPILED_AGENT_NODE_ID) ?? manifest; const subagentGraph = await compileSubagentGraph({ appRoot: manifest.appRoot, compileAgentNodeManifest, @@ -72,7 +73,7 @@ export async function compileAgentManifest( externalDependencies: compiledNode.config.build?.externalDependencies ?? [], parentAgentRoot: manifest.agentRoot, parentNodeId: ROOT_COMPILED_AGENT_NODE_ID, - subagents: composeAgentSubagentSources(rootSources.manifest), + subagents: rootSources.subagents, }); const backgroundTool = [compiledNode, ...subagentGraph.nodes.map((node) => node.agent)] @@ -109,19 +110,11 @@ async function compileAgentNodeManifest( readonly externalDependencies?: readonly string[]; readonly allowRootOnlyConfig?: boolean; readonly nodeId?: string; + readonly sourceOrigin?: AgentSourceOrigin; readonly sourcesComposed?: boolean; } = {}, ): Promise { - const sources = options.sourcesComposed - ? { bindings: context.bindingsByAgentRoot.get(manifest.agentRoot) ?? {}, manifest } - : composeFrameworkSources({ - isRoot: false, - manifest, - nodeId: options.nodeId ?? manifest.agentId, - registry: frameworkAgentSourceRegistry, - }); - manifest = sources.manifest; - context.bindingsByAgentRoot.set(manifest.agentRoot, sources.bindings); + const nodeId = options.nodeId ?? manifest.agentId; const rawConfig = Object.hasOwn(options, "agentConfigDefinition") ? await compileAgentConfig(manifest, context, { definition: options.agentConfigDefinition, @@ -152,9 +145,24 @@ async function compileAgentNodeManifest( externalDependencies, }, }; + const sources = options.sourcesComposed + ? getComposedSources(manifest, context, nodeId) + : composeAgentSources({ + externalDependencies, + isRoot: nodeId === ROOT_COMPILED_AGENT_NODE_ID, + manifest, + nodeId, + origin: options.sourceOrigin, + registry: frameworkAgentSourceRegistry, + }); + manifest = sources.manifest; + const bindings = bindOriginConfigModule(sources.bindings, manifest, options.sourceOrigin); + context.bindingsByAgentRoot.set(manifest.agentRoot, bindings); + context.compositionsByNodeId.set(nodeId, sources.composition); + context.manifestsByNodeId.set(nodeId, manifest); const resources = await compileAgentResources(manifest, context, { externalDependencies, - nodeId: options.nodeId, + nodeId, sourcesComposed: true, }); const compiledNode = createCompiledAgentNodeManifest({ ...resources, config }); @@ -170,19 +178,25 @@ async function compileAgentResources( options: { readonly externalDependencies?: readonly string[]; readonly nodeId?: string; + readonly sourceOrigin?: AgentSourceOrigin; readonly sourcesComposed?: boolean; } = {}, ): Promise { + const nodeId = options.nodeId ?? manifest.agentId; const sources = options.sourcesComposed - ? { bindings: context.bindingsByAgentRoot.get(manifest.agentRoot) ?? {}, manifest } - : composeFrameworkSources({ - isRoot: false, + ? getComposedSources(manifest, context, nodeId) + : composeAgentSources({ + externalDependencies: options.externalDependencies, + isRoot: nodeId === ROOT_COMPILED_AGENT_NODE_ID, manifest, - nodeId: options.nodeId ?? manifest.agentId, + nodeId, + origin: options.sourceOrigin, registry: frameworkAgentSourceRegistry, }); manifest = sources.manifest; context.bindingsByAgentRoot.set(manifest.agentRoot, sources.bindings); + context.compositionsByNodeId.set(nodeId, sources.composition); + context.manifestsByNodeId.set(nodeId, manifest); const externalDependencies = [...(options.externalDependencies ?? [])]; const loadOptions = (sourceId: string): ModuleBackedDefinitionLoadOptions => ({ binding: sources.bindings[sourceId], @@ -190,9 +204,14 @@ async function compileAgentResources( moduleLoader: context.moduleLoader, }); const compiledToolEntries = await Promise.all( - manifest.tools.map((toolSource) => - compileToolEntry(manifest.agentRoot, toolSource, loadOptions(toolSource.sourceId)), - ), + manifest.tools.map(async (toolSource) => ({ + entry: await compileToolEntry( + manifest.agentRoot, + toolSource, + loadOptions(toolSource.sourceId), + ), + source: toolSource, + })), ); const tools: CompiledToolDefinition[] = []; const dynamicTools: CompiledDynamicToolDefinition[] = []; @@ -200,7 +219,9 @@ async function compileAgentResources( let workflowTool: CompiledWorkflowToolDefinition | undefined; let webSearchProvider: WebSearchProvider | undefined; - for (const entry of compiledToolEntries) { + for (const { entry, source } of compiledToolEntries) { + const sourceComposition = findSourceComposition(sources, source.sourceId); + assertExtensionToolPolicy(entry.kind, source.logicalPath, sourceComposition?.winner.layer); if (entry.kind === "tool") { tools.push(entry.definition); } else if (entry.kind === "dynamic-tool") { @@ -210,24 +231,34 @@ async function compileAgentResources( } else if (entry.kind === "web-search-tool") { webSearchProvider = entry.provider; } else { - disabledFrameworkTools.push(entry.name); + const disabled = validateDisableTarget(source.logicalPath, sourceComposition); + if (disabled.owner.kind === "framework") disabledFrameworkTools.push(entry.name); } } const compiledChannelResults = await Promise.all( - manifest.channels.map((channelSource) => - compileChannelDefinition( + manifest.channels.map(async (channelSource) => ({ + entries: await compileChannelDefinition( manifest.agentRoot, channelSource, loadOptions(channelSource.sourceId), ), - ), + source: channelSource, + })), ); // compileChannelDefinition returns one entry for a disabled-channel // sentinel or an array of entries (one per route) for an authored // CompiledChannel. Flatten so the manifest holds a single channel list. - const compiledChannels = compiledChannelResults.flat(); + const compiledChannels = compiledChannelResults.flatMap(({ entries, source }) => { + const flattened = Array.isArray(entries) ? entries : [entries]; + if (flattened[0]?.kind !== "disabled") return flattened; + const disabled = validateDisableTarget( + source.logicalPath, + findSourceComposition(sources, source.sourceId), + ); + return disabled.owner.kind === "framework" ? flattened : []; + }); const compiledSkillEntries = await Promise.all( manifest.skills.map((skillSource) => @@ -281,56 +312,6 @@ async function compileAgentResources( ), ); - // Sorted by namespace so first-registration-wins dedup is deterministic when - // two extensions contribute the same composed name. - const toolNames = new Set(tools.map((tool) => tool.name)); - const dynamicToolSlugs = new Set(dynamicTools.map((tool) => tool.slug)); - const connectionNames = new Set(connections.map((connection) => connection.connectionName)); - const skillNames = new Set(skills.map((skill) => skill.name)); - const extensionInstructions: CompiledInstructionsDefinition[] = []; - for (const mount of [...manifest.resolvedExtensions].sort((left, right) => - left.namespace.localeCompare(right.namespace), - )) { - const contributions = await compileExtensionContributions({ - mount, - context, - consumerAgentRoot: manifest.agentRoot, - externalDependencies, - }); - compiledChannels.push(...contributions.channels); - for (const tool of contributions.tools) { - if (!toolNames.has(tool.name)) { - toolNames.add(tool.name); - tools.push(tool); - } - } - for (const tool of contributions.dynamicTools) { - if (!dynamicToolSlugs.has(tool.slug)) { - dynamicToolSlugs.add(tool.slug); - dynamicTools.push(tool); - } - } - for (const connection of contributions.connections) { - if (!connectionNames.has(connection.connectionName)) { - connectionNames.add(connection.connectionName); - connections.push(connection); - } - } - for (const skill of contributions.skills) { - if (!skillNames.has(skill.name)) { - skillNames.add(skill.name); - skills.push(skill); - } - } - schedules.push(...contributions.schedules); - hooks.push(...contributions.hooks); - dynamicSkills.push(...contributions.dynamicSkills); - dynamicInstructions.push(...contributions.dynamicInstructions); - extensionInstructions.push(...contributions.instructions); - } - - const instructions = [...staticInstructions, ...extensionInstructions]; - const resources = createCompiledAgentResources({ agentRoot: manifest.agentRoot, appRoot: manifest.appRoot, @@ -359,7 +340,7 @@ async function compileAgentResources( schedules, dynamicInstructions, skills, - instructions, + instructions: staticInstructions, tools, }); return { @@ -373,14 +354,18 @@ function createNodeBindings( context: ManifestCompileContext, externalDependencies?: readonly string[], ): CompiledAgentResources["bindings"] { - return { - ...createFilesystemModuleBindings({ - agentRoot: manifest.agentRoot, - externalDependencies, - manifest, - }), - ...context.bindingsByAgentRoot.get(manifest.agentRoot), - }; + const bindings = createFilesystemModuleBindings({ + agentRoot: manifest.agentRoot, + externalDependencies, + manifest, + }); + const composedBindings = context.bindingsByAgentRoot.get(manifest.agentRoot) ?? {}; + for (const sourceId of Object.keys(bindings)) { + if (composedBindings[sourceId] !== undefined) { + bindings[sourceId] = composedBindings[sourceId]; + } + } + return bindings; } function compileExtensionMounts(manifest: AgentSourceManifest): CompiledExtensionMount[] { @@ -413,3 +398,89 @@ function mergeExternalDependencies( return [...dependencies]; } + +function getComposedSources( + manifest: AgentSourceManifest, + context: ManifestCompileContext, + nodeId: string, +): ComposedAgentSources { + const composition = context.compositionsByNodeId.get(nodeId); + if (composition === undefined) { + throw new Error(`Agent node "${nodeId}" has no composed source graph.`); + } + return { + bindings: context.bindingsByAgentRoot.get(manifest.agentRoot) ?? {}, + composition, + manifest, + }; +} + +function findSourceComposition(sources: ComposedAgentSources, sourceId: string) { + return sources.composition.entries.find((entry) => entry.winner.sourceId === sourceId); +} + +function validateDisableTarget( + logicalPath: string, + composition: ReturnType, +) { + if (composition === undefined) { + throw new Error(`Cannot validate the disable sentinel at "${logicalPath}".`); + } + const winner = composition.winner; + if (winner.layer === "extension-package") { + throw new Error( + `The extension source "${logicalPath}" exports a disable sentinel, but extension packages cannot disable lower sources.`, + ); + } + const target = composition.candidates.at(-2); + if (target === undefined) { + throw new Error( + `The source "${logicalPath}" exports a disable sentinel, but no lower-precedence source occupies that slot.`, + ); + } + if (winner.layer === "extension-override" && target.layer !== "extension-package") { + throw new Error( + `The extension override "${logicalPath}" exports a disable sentinel, but its extension package contributes no source at that slot.`, + ); + } + return target; +} + +function assertExtensionToolPolicy( + kind: "disabled" | "dynamic-tool" | "tool" | "web-search-tool" | "workflow-tool", + logicalPath: string, + layer: AgentSourceLayer | undefined, +): void { + if (layer !== "extension-package" && layer !== "extension-override") return; + const role = layer === "extension-package" ? "extension package" : "extension override"; + if (kind === "workflow-tool") { + throw new Error( + `The ${role} source "${logicalPath}" enables Workflow, but Workflow is owned by the consuming agent.`, + ); + } + if (kind === "web-search-tool") { + throw new Error( + `The ${role} source "${logicalPath}" configures web search, but its provider is owned by the consuming agent.`, + ); + } +} + +function bindOriginConfigModule( + bindings: Readonly>, + manifest: AgentSourceManifest, + origin: AgentSourceOrigin | undefined, +): Readonly> { + if (origin === undefined || manifest.configModule === undefined) return bindings; + const source = manifest.configModule; + return { + ...bindings, + [source.sourceId]: { + backing: { + ...origin.backing, + sourcePath: resolve(manifest.agentRoot, source.logicalPath), + }, + logicalPath: source.logicalPath, + owner: origin.owner, + }, + }; +} diff --git a/packages/eve/src/compiler/normalize-subagent.ts b/packages/eve/src/compiler/normalize-subagent.ts index ab84b39d01..3eaf2bc34b 100644 --- a/packages/eve/src/compiler/normalize-subagent.ts +++ b/packages/eve/src/compiler/normalize-subagent.ts @@ -1,10 +1,6 @@ -import { join, relative } from "node:path"; +import { join } from "node:path"; -import { - type AgentSourceManifest, - createPathDerivedSourceId, - type LocalSubagentSourceRef, -} from "#discover/manifest.js"; +import { type AgentSourceManifest, type LocalSubagentSourceRef } from "#discover/manifest.js"; import { type CompiledAgentNodeManifest, type CompiledAgentResources, @@ -13,7 +9,10 @@ import { type CompiledSubagentNode, createCompiledSubagentNodeId, } from "#compiler/manifest.js"; -import { composeAgentSubagentSources } from "#compiler/normalize-extension.js"; +import { + getSubagentSourceOrigin, + type AgentSourceOrigin, +} from "#compiler/compose-agent-sources.js"; import type { CompiledDynamicSubagentDefinition } from "#compiler/remote-agent-node.js"; import { loadModuleBackedDefinition, @@ -29,6 +28,7 @@ import { import { EVE_SESSION_ROUTE_PATH } from "#protocol/routes.js"; import { serializeOutputSchema, type ToolSchemaSource } from "#shared/tool-schema.js"; import type { JsonObject } from "#shared/json.js"; +import type { ModuleSourceRef } from "#shared/source-ref.js"; import { isDynamicSentinel, type DynamicToolEventName } from "#shared/dynamic-tool-definition.js"; import { createFilesystemModuleBindings } from "#compiler/module-binding.js"; @@ -51,6 +51,7 @@ export type CompileAgentNodeManifestFn = ( readonly externalDependencies?: readonly string[]; readonly allowRootOnlyConfig?: boolean; readonly nodeId?: string; + readonly sourceOrigin?: AgentSourceOrigin; readonly sourcesComposed?: boolean; }, ) => Promise; @@ -61,6 +62,7 @@ export type CompileAgentResourcesFn = ( options?: { readonly externalDependencies?: readonly string[]; readonly nodeId?: string; + readonly sourceOrigin?: AgentSourceOrigin; readonly sourcesComposed?: boolean; }, ) => Promise; @@ -155,16 +157,24 @@ async function compileSubagentDefinition(input: { throw new Error(`Subagent "${input.source.logicalPath}" is missing an agent config module.`); } - const configModuleSource = createSubagentConfigModuleSourceRef( - input.source, - configModule, - input.parentAgentRoot, - ); + const configModuleSource = createSubagentConfigModuleSourceRef(input.source, configModule); + const sourceOrigin = getSubagentSourceOrigin(input.source); const definition = await loadModuleBackedDefinition({ agentRoot: input.source.manifest.agentRoot, - binding: input.context.bindingsByAgentRoot.get(input.source.manifest.agentRoot)?.[ - configModule.sourceId - ], + binding: + input.context.bindingsByAgentRoot.get(input.source.manifest.agentRoot)?.[ + configModule.sourceId + ] ?? + (sourceOrigin === undefined + ? undefined + : { + backing: { + ...sourceOrigin.backing, + sourcePath: join(input.source.manifest.agentRoot, configModule.logicalPath), + }, + logicalPath: configModule.logicalPath, + owner: sourceOrigin.owner, + }), displayPath: configModuleSource.logicalPath, externalDependencies: input.externalDependencies, kind: "subagent config", @@ -219,6 +229,7 @@ async function compileSubagent(input: { readonly node: CompiledSubagentNode; }> { const nodeId = createCompiledSubagentNodeId(input.parentNodeId, input.source.sourceId); + const sourceOrigin = getSubagentSourceOrigin(input.source); const subagentName = input.source.subagentId; const sourceManifest = { ...input.source.manifest, @@ -244,6 +255,7 @@ async function compileSubagent(input: { allowRootOnlyConfig: false, externalDependencies: inheritedExternalDependencies, nodeId, + sourceOrigin, }); const description = agent.config.description; if (!description) { @@ -261,7 +273,7 @@ async function compileSubagent(input: { agent.config.build?.externalDependencies ?? inheritedExternalDependencies, parentAgentRoot: input.source.manifest.agentRoot, parentNodeId: nodeId, - subagents: composeAgentSubagentSources(input.source.manifest), + subagents: input.context.manifestsByNodeId.get(nodeId)?.subagents ?? sourceManifest.subagents, }); const compiledAgent = { ...agent, remoteAgents: [...descendants.remoteAgents] }; return { @@ -270,14 +282,11 @@ async function compileSubagent(input: { ...nodeBase, agent: { ...compiledAgent, - bindings: { - ...createFilesystemModuleBindings({ - agentRoot: compiledAgent.agentRoot, - externalDependencies: compiledAgent.config.build?.externalDependencies, - manifest: compiledAgent, - }), - ...compiledAgent.bindings, - }, + bindings: createSubagentNodeBindings({ + agent: compiledAgent, + context: input.context, + externalDependencies: compiledAgent.config.build?.externalDependencies, + }), }, description, }, @@ -287,6 +296,7 @@ async function compileSubagent(input: { const resources = await input.compileAgentResources(sourceManifest, input.context, { externalDependencies: inheritedExternalDependencies, nodeId, + sourceOrigin, }); const descendants = await compileSubagentGraph({ appRoot: input.appRoot, @@ -296,7 +306,7 @@ async function compileSubagent(input: { externalDependencies: inheritedExternalDependencies, parentAgentRoot: input.source.manifest.agentRoot, parentNodeId: nodeId, - subagents: composeAgentSubagentSources(input.source.manifest), + subagents: input.context.manifestsByNodeId.get(nodeId)?.subagents ?? sourceManifest.subagents, }); const compiledResources = { ...resources, remoteAgents: [...descendants.remoteAgents] }; return { @@ -305,15 +315,12 @@ async function compileSubagent(input: { ...nodeBase, agent: { ...compiledResources, - bindings: { - ...createFilesystemModuleBindings({ - additionalRefs: [input.configResolver], - agentRoot: compiledResources.agentRoot, - externalDependencies: inheritedExternalDependencies, - manifest: compiledResources, - }), - ...compiledResources.bindings, - }, + bindings: createSubagentNodeBindings({ + additionalRefs: [input.configResolver], + agent: compiledResources, + context: input.context, + externalDependencies: inheritedExternalDependencies, + }), }, configResolver: input.configResolver, }, @@ -322,6 +329,27 @@ async function compileSubagent(input: { const compileLocalSubagent = compileSubagent; +function createSubagentNodeBindings(input: { + readonly additionalRefs?: readonly ModuleSourceRef[]; + readonly agent: CompiledAgentNodeManifest | CompiledAgentResources; + readonly context: ManifestCompileContext; + readonly externalDependencies?: readonly string[]; +}): CompiledAgentResources["bindings"] { + const bindings = createFilesystemModuleBindings({ + additionalRefs: input.additionalRefs, + agentRoot: input.agent.agentRoot, + externalDependencies: input.externalDependencies, + manifest: input.agent, + }); + const composedBindings = input.context.bindingsByAgentRoot.get(input.agent.agentRoot) ?? {}; + for (const sourceId of Object.keys(bindings)) { + if (composedBindings[sourceId] !== undefined) { + bindings[sourceId] = composedBindings[sourceId]; + } + } + return { ...bindings, ...input.agent.bindings }; +} + function compileRemoteAgent(input: { readonly parentAgentRoot: string; readonly source: LocalSubagentSourceRef; @@ -335,11 +363,7 @@ function compileRemoteAgent(input: { assertRemoteAgentDefinitionHasNoLocalPackageEntries(input.source); - const moduleSource = createSubagentConfigModuleSourceRef( - input.source, - configModule, - input.parentAgentRoot, - ); + const moduleSource = createSubagentConfigModuleSourceRef(input.source, configModule); const definition = normalizeRemoteAgentDefinition( input.value, `Expected the remote agent config export "${configModule.exportName ?? "default"}" from "${moduleSource.logicalPath}" to match the public eve shape.`, @@ -424,21 +448,13 @@ function mergeExternalDependencies( function createSubagentConfigModuleSourceRef( source: LocalSubagentSourceRef, configModule: NonNullable, - parentAgentRoot: string, ): { readonly exportName?: string; readonly logicalPath: string; readonly sourceId: string; readonly sourceKind: "module"; } { - const logicalPath = relative( - parentAgentRoot, - join(source.manifest.agentRoot, configModule.logicalPath), - ).replaceAll("\\", "/"); - const sourceId = - configModule.sourceId.startsWith("ext:") || configModule.sourceId.startsWith("ext-override:") - ? configModule.sourceId - : createPathDerivedSourceId(logicalPath); + const logicalPath = source.logicalPath; const moduleSource: { exportName?: string; logicalPath: string; @@ -446,7 +462,7 @@ function createSubagentConfigModuleSourceRef( sourceKind: "module"; } = { logicalPath, - sourceId, + sourceId: source.sourceId, sourceKind: "module", }; diff --git a/packages/eve/src/discover/agent.integration.test.ts b/packages/eve/src/discover/agent.integration.test.ts index b333a34881..3cbc82dee1 100644 --- a/packages/eve/src/discover/agent.integration.test.ts +++ b/packages/eve/src/discover/agent.integration.test.ts @@ -14,7 +14,6 @@ import { DISCOVER_EXTENSION_MOUNT_AMBIGUOUS, DISCOVER_EXTENSION_MOUNT_MISSING_DECLARATION, DISCOVER_EXTENSION_NESTED_MOUNT_UNSUPPORTED, - DISCOVER_EXTENSION_OVERRIDE_OUTSIDE_MOUNT, } from "#discover/extensions.js"; import { DISCOVER_DEPRECATED_SYSTEM_SLOT, @@ -976,7 +975,7 @@ describe("discoverAgent (memory)", () => { expect(result.manifest.resolvedExtensions).toEqual([]); }); - it("rejects agent-root contributions that override a mounted extension's namespace", async () => { + it("allows agent-root contributions to replace a mounted extension's final slot", async () => { const project = buildMemoryAgentProject({ appFiles: { "node_modules/@acme/crm/package.json": JSON.stringify({ @@ -988,8 +987,6 @@ describe("discoverAgent (memory)", () => { }, agentFiles: { "extensions/crm.ts": 'export { default } from "@acme/crm";\n', - // A root tool using the mounted `crm__` prefix would shadow the - // extension from outside its mount directory. "tools/crm__search.ts": "export default {};\n", "subagents/crm__reviewer.ts": "export default {};\n", "instructions.md": "You are a precise assistant.", @@ -1002,11 +999,13 @@ describe("discoverAgent (memory)", () => { source: project.source, }); - const collisions = result.diagnostics.filter( - (diagnostic) => diagnostic.code === DISCOVER_EXTENSION_OVERRIDE_OUTSIDE_MOUNT, + expect(result.diagnostics).toEqual([]); + expect(result.manifest.tools.map((source) => source.logicalPath)).toContain( + "tools/crm__search.ts", + ); + expect(result.manifest.subagents.map((source) => source.logicalPath)).toContain( + "subagents/crm__reviewer.ts", ); - expect(collisions).toHaveLength(2); - expect(collisions[0]?.message).toContain("extensions/crm/"); }); it("rejects a mounted extension that requires an unsupported capability version", async () => { @@ -1176,11 +1175,7 @@ describe("discoverAgent (memory)", () => { source: project.source, }); - expect( - result.diagnostics.some( - (diagnostic) => diagnostic.code === DISCOVER_EXTENSION_OVERRIDE_OUTSIDE_MOUNT, - ), - ).toBe(false); + expect(result.diagnostics).toEqual([]); }); it("rejects an extension distribution without generated compatibility metadata", async () => { diff --git a/packages/eve/src/discover/discover-agent.ts b/packages/eve/src/discover/discover-agent.ts index e401f2c7be..0ccff14fa8 100644 --- a/packages/eve/src/discover/discover-agent.ts +++ b/packages/eve/src/discover/discover-agent.ts @@ -8,16 +8,11 @@ import { DISCOVER_EXTENSION_MOUNT_AMBIGUOUS, DISCOVER_EXTENSION_MOUNT_MISSING_DECLARATION, DISCOVER_EXTENSION_NESTED_MOUNT_UNSUPPORTED, - DISCOVER_EXTENSION_OVERRIDE_OUTSIDE_MOUNT, DISCOVER_EXTENSION_SANDBOX_UNSUPPORTED, locateExtensionMount, mountNamespace, } from "#discover/extensions.js"; -import { - classifyAgentRootEntry, - normalizeLogicalPath, - SUPPORTED_AUTHORED_MODULE_FILE_EXTENSIONS, -} from "#discover/filesystem.js"; +import { classifyAgentRootEntry, normalizeLogicalPath } from "#discover/filesystem.js"; import { createChannelNameDiagnostic, createExtensionNameDiagnostic, @@ -240,23 +235,6 @@ export async function discoverAgent(input: DiscoverAgentInput): Promise__` composed-name prefix would - // shadow that extension from outside its mount directory, so reject it. - diagnostics.push( - ...detectRootNamespaceCollisions({ - agentRoot, - namespaces: mountCollection.mounts.map((descriptor) => descriptor.namespace), - sources: [ - ...toolsResult.sources, - ...connectionsResult.connections, - ...skillsResult.skills, - ...schedulesResult.schedules, - ...subagentsResult.subagents, - ], - }), - ); - let resolvedExtensions: readonly ResolvedExtensionMount[] = []; if (role !== "agent") { // Extensions cannot mount other extensions yet. Fail loudly instead of @@ -526,54 +504,6 @@ async function collectExtensionMounts(input: { return { diagnostics, mounts }; } -/** - * Flags agent-root contributions whose composed name uses a mounted extension's - * `__` prefix. That prefix is reserved for the extension and its co-located - * overrides, so a root-level `__…` file would override the extension from - * outside its mount directory — rejected here. - */ -export function detectRootNamespaceCollisions(input: { - readonly agentRoot: string; - readonly namespaces: readonly string[]; - readonly sources: ReadonlyArray<{ readonly logicalPath: string }>; -}): DiscoverDiagnostic[] { - if (input.namespaces.length === 0) { - return []; - } - - const diagnostics: DiscoverDiagnostic[] = []; - for (const source of input.sources) { - const name = rootContributionName(source.logicalPath); - const namespace = input.namespaces.find((candidate) => name.startsWith(`${candidate}__`)); - if (namespace !== undefined) { - diagnostics.push( - createDiscoverErrorDiagnostic({ - code: DISCOVER_EXTENSION_OVERRIDE_OUTSIDE_MOUNT, - message: `"${source.logicalPath}" uses the "${namespace}__" prefix reserved for the mounted extension "${namespace}". Override an extension's contributions inside its mount directory ("extensions/${namespace}/…"), not at the agent root.`, - sourcePath: join(input.agentRoot, source.logicalPath), - }), - ); - } - } - return diagnostics; -} - -/** - * Derives a contribution's composed name from its slot-relative logical path: - * the first path segment below the slot directory, minus any module extension - * (`tools/crm__x.ts` → `crm__x`; `skills/crm__x/SKILL.md` → `crm__x`). - */ -function rootContributionName(logicalPath: string): string { - const afterSlot = logicalPath.slice(logicalPath.indexOf("/") + 1); - const firstSegment = afterSlot.split("/")[0] ?? afterSlot; - for (const extension of SUPPORTED_AUTHORED_MODULE_FILE_EXTENSIONS) { - if (firstSegment.toLowerCase().endsWith(extension)) { - return firstSegment.slice(0, firstSegment.length - extension.length); - } - } - return firstSegment; -} - /** * Reads the `name` field from the app root's package.json through `source` * and strips the npm scope prefix when present (e.g. `"@org/my-agent"` → diff --git a/packages/eve/src/discover/discover-subagent.ts b/packages/eve/src/discover/discover-subagent.ts index c6def21d0b..4a1e2fb983 100644 --- a/packages/eve/src/discover/discover-subagent.ts +++ b/packages/eve/src/discover/discover-subagent.ts @@ -2,7 +2,6 @@ import { join, relative, resolve } from "node:path"; import { discoverConnectionSources } from "#discover/connections.js"; import { createDiscoverErrorDiagnostic, type DiscoverDiagnostic } from "#discover/diagnostics.js"; import { - detectRootNamespaceCollisions, discoverExtensionMountDeclarations, resolveExtensionMounts, } from "#discover/discover-agent.js"; @@ -290,19 +289,6 @@ async function discoverLocalSubagentPackage(input: { source: input.source, }); diagnostics.push(...extensionsResult.diagnostics); - diagnostics.push( - ...detectRootNamespaceCollisions({ - agentRoot: input.subagentRoot, - namespaces: extensionsResult.mounts.map((mount) => mount.namespace), - sources: [ - ...toolsResult.sources, - ...connectionsResult.connections, - ...skillsResult.skills, - ...subagentsResult.subagents, - ], - }), - ); - const resolvedExtensions = await resolveExtensionMounts({ agentRoot: input.subagentRoot, appRoot: input.appRoot, diff --git a/packages/eve/src/discover/extensions.ts b/packages/eve/src/discover/extensions.ts index 1abce481cc..6f69f4af24 100644 --- a/packages/eve/src/discover/extensions.ts +++ b/packages/eve/src/discover/extensions.ts @@ -38,14 +38,6 @@ export const DISCOVER_EXTENSION_MOUNT_MISSING_DECLARATION = export const DISCOVER_EXTENSION_NESTED_MOUNT_UNSUPPORTED = "discover/extension-nested-mount-unsupported"; -/** - * Emitted when a consumer's agent-root contribution (e.g. `agent/tools/crm__x.ts`) - * uses a mounted extension's `__` prefix. That prefix is reserved for the - * extension and its co-located overrides, not the agent root. - */ -export const DISCOVER_EXTENSION_OVERRIDE_OUTSIDE_MOUNT = - "discover/extension-override-outside-mount"; - /** * Emitted when a resolved package is not a valid eve extension. */ From 5a3b89df00c9aff82f7ffe57737bc3028ea7a7cb Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Sat, 22 Aug 2026 11:46:48 -0400 Subject: [PATCH 08/12] refactor(eve): compile native kernel capabilities Replace the runtime framework-tool catalog with a closed compiler-prepared kernel capability plan and primitive-specific materializers. Signed-off-by: Andrew Barba --- .../src/cli/dev/tui/tool-presentation.test.ts | 11 +- packages/eve/src/compiler/manifest.ts | 13 +- packages/eve/src/compiler/module-map.test.ts | 2 +- .../compiler/normalize-agent-config.test.ts | 2 +- .../src/compiler/normalize-agent-config.ts | 1 - .../eve/src/compiler/normalize-helpers.ts | 2 +- .../src/compiler/normalize-manifest.test.ts | 63 +++++++ .../eve/src/compiler/normalize-manifest.ts | 62 ++++-- .../eve/src/compiler/normalize-subagent.ts | 13 +- .../prepare-kernel-capabilities.test.ts | 70 +++++++ .../compiler/prepare-kernel-capabilities.ts | 37 ++++ .../eve/src/context/build-dynamic-tools.ts | 17 +- .../context/dynamic-tool-lifecycle.test.ts | 18 ++ .../src/execution/create-session-step.test.ts | 4 +- .../eve/src/execution/create-session-step.ts | 11 +- packages/eve/src/execution/node-step.test.ts | 26 ++- packages/eve/src/execution/node-step.ts | 84 +++++---- .../eve/src/execution/sandbox/glob-tool.ts | 2 +- .../eve/src/execution/sandbox/grep-tool.ts | 2 +- .../src/execution/sandbox/read-file-tool.ts | 2 +- .../src/execution/sandbox/require-sandbox.ts | 54 ------ .../execution/sandbox/resolve-file-path.ts | 19 ++ .../src/execution/sandbox/write-file-tool.ts | 2 +- .../src/execution/tasks/parent/dispatch.ts | 11 +- .../eve/src/execution/workflow-steps.test.ts | 2 +- packages/eve/src/execution/workflow-steps.ts | 11 +- packages/eve/src/harness/advertised-tools.ts | 5 +- packages/eve/src/harness/provider-tools.ts | 4 +- .../tool-input-validation.integration.test.ts | 6 +- packages/eve/src/harness/tool-loop.ts | 17 +- packages/eve/src/harness/tools.ts | 10 +- packages/eve/src/harness/types.ts | 2 + ...-agent-info-response-from-manifest.test.ts | 5 +- ...build-agent-info-response-from-manifest.ts | 37 ++-- .../build-agent-info-response.test.ts | 91 +-------- .../agent-info/build-agent-info-response.ts | 114 ++++++------ .../nitro/routes/runtime-artifacts.test.ts | 2 +- packages/eve/src/kernel/capabilities.ts | 110 +++++++++++ .../eve/src/runtime/agent/bootstrap.test.ts | 8 +- packages/eve/src/runtime/agent/bootstrap.ts | 16 +- .../eve/src/runtime/framework-tools/agent.ts | 39 ---- .../framework-tools/ask-question.test.ts | 4 +- .../runtime/framework-tools/ask-question.ts | 23 +-- .../eve/src/runtime/framework-tools/bash.ts | 36 +--- .../eve/src/runtime/framework-tools/glob.ts | 38 +--- .../eve/src/runtime/framework-tools/grep.ts | 41 +--- .../src/runtime/framework-tools/index.test.ts | 79 -------- .../eve/src/runtime/framework-tools/index.ts | 82 -------- .../src/runtime/framework-tools/read-file.ts | 43 +---- .../eve/src/runtime/framework-tools/skill.ts | 16 -- .../runtime/framework-tools/subagent/local.ts | 2 +- .../framework-tools/subagent/remote.ts | 2 +- .../framework-tools/subagent/task-receipt.ts | 7 + .../runtime/framework-tools/task-cancel.ts | 55 ++++++ .../runtime/framework-tools/task-update.ts | 22 +++ .../eve/src/runtime/framework-tools/tasks.ts | 154 --------------- .../eve/src/runtime/framework-tools/todo.ts | 32 ---- .../src/runtime/framework-tools/web-fetch.ts | 29 --- .../src/runtime/framework-tools/web-search.ts | 30 ++- .../src/runtime/framework-tools/write-file.ts | 43 +---- .../eve/src/runtime/resolve-agent-graph.ts | 58 +----- packages/eve/src/runtime/resolve-agent.ts | 2 +- packages/eve/src/runtime/types.ts | 10 +- packages/eve/test/runtime-agent-graph.test.ts | 176 +++--------------- .../app-runtime-dependencies.scenario.test.ts | 4 +- .../bundle-module-evaluation.scenario.test.ts | 30 --- .../scenarios/compile-agent.scenario.test.ts | 16 +- 67 files changed, 763 insertions(+), 1278 deletions(-) create mode 100644 packages/eve/src/compiler/prepare-kernel-capabilities.test.ts create mode 100644 packages/eve/src/compiler/prepare-kernel-capabilities.ts delete mode 100644 packages/eve/src/execution/sandbox/require-sandbox.ts create mode 100644 packages/eve/src/execution/sandbox/resolve-file-path.ts create mode 100644 packages/eve/src/kernel/capabilities.ts delete mode 100644 packages/eve/src/runtime/framework-tools/index.test.ts delete mode 100644 packages/eve/src/runtime/framework-tools/index.ts create mode 100644 packages/eve/src/runtime/framework-tools/subagent/task-receipt.ts create mode 100644 packages/eve/src/runtime/framework-tools/task-cancel.ts create mode 100644 packages/eve/src/runtime/framework-tools/task-update.ts delete mode 100644 packages/eve/src/runtime/framework-tools/tasks.ts diff --git a/packages/eve/src/cli/dev/tui/tool-presentation.test.ts b/packages/eve/src/cli/dev/tui/tool-presentation.test.ts index 201449e671..64fd302d1b 100644 --- a/packages/eve/src/cli/dev/tui/tool-presentation.test.ts +++ b/packages/eve/src/cli/dev/tui/tool-presentation.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getAllFrameworkToolNames } from "#runtime/framework-tools/index.js"; - import { presentPreparingTool, presentTool } from "./tool-presentation.js"; describe("presentPreparingTool", () => { @@ -169,7 +167,7 @@ describe("presentTool", () => { expect(presentTool("final_output", { anything: true }).title).toBe("Return final output"); }); - it("covers every framework builtin with semantic copy", () => { + it("covers every native and default tool with semantic copy", () => { const representativeInputs: Record = { agent: { message: "audit the auth flow" }, ask_question: { prompt: "Which environment?" }, @@ -187,12 +185,7 @@ describe("presentTool", () => { write_file: { filePath: "/workspace/a.ts", content: "x" }, }; - for (const name of getAllFrameworkToolNames()) { - const input = representativeInputs[name]; - expect( - input, - `framework tool "${name}" has no representative input — add semantic copy for it in tool-presentation.ts and cover it here`, - ).toBeDefined(); + for (const [name, input] of Object.entries(representativeInputs)) { expect(presentTool(name, input).title, name).not.toBe(name); } }); diff --git a/packages/eve/src/compiler/manifest.ts b/packages/eve/src/compiler/manifest.ts index 384e11e51f..9ad7fe7426 100644 --- a/packages/eve/src/compiler/manifest.ts +++ b/packages/eve/src/compiler/manifest.ts @@ -29,6 +29,7 @@ import type { } from "#shared/agent-definition.js"; import type { InternalToolDefinition } from "#shared/tool-definition.js"; import type { WebSearchProvider } from "#shared/web-search.js"; +import { KERNEL_CAPABILITY_NAMES, type KernelCapabilityName } from "#kernel/capabilities.js"; import { compiledModuleBindingSchema, createFilesystemModuleBindings, @@ -48,7 +49,7 @@ export const ROOT_COMPILED_AGENT_NODE_ID = "__root__"; /** * Current compiled manifest schema version. */ -export const COMPILED_AGENT_MANIFEST_VERSION = 42; +export const COMPILED_AGENT_MANIFEST_VERSION = 43; /** * Compiled channel entry preserved in the compiled manifest. @@ -698,7 +699,7 @@ const compiledAgentResourceFields = { channels: z.array(compiledChannelEntrySchema), connections: z.array(compiledConnectionDefinitionSchema), diagnosticsSummary: discoverDiagnosticsSummarySchema, - disabledFrameworkTools: z.array(z.string()).readonly(), + kernelCapabilities: z.array(z.enum(KERNEL_CAPABILITY_NAMES)).readonly(), workflowTool: compiledWorkflowToolDefinitionSchema.optional(), webSearchProvider: z.enum(["exa", "parallel"]).optional(), dynamicInstructions: z.array(compiledDynamicInstructionsDefinitionSchema).default([]), @@ -809,7 +810,7 @@ export const compiledAgentManifestSchema = z config: compiledAgentConfigSchema, connections: z.array(compiledConnectionDefinitionSchema), diagnosticsSummary: discoverDiagnosticsSummarySchema, - disabledFrameworkTools: z.array(z.string()).readonly(), + kernelCapabilities: z.array(z.enum(KERNEL_CAPABILITY_NAMES)).readonly(), workflowTool: compiledWorkflowToolDefinitionSchema.optional(), webSearchProvider: z.enum(["exa", "parallel"]).optional(), dynamicInstructions: z.array(compiledDynamicInstructionsDefinitionSchema).default([]), @@ -838,7 +839,7 @@ export interface CreateCompiledAgentResourcesInput { readonly channels?: readonly CompiledChannelEntry[]; readonly connections?: readonly CompiledConnectionDefinition[]; readonly diagnosticsSummary?: DiscoverDiagnosticsSummary; - readonly disabledFrameworkTools?: readonly string[]; + readonly kernelCapabilities?: readonly KernelCapabilityName[]; readonly workflowTool?: CompiledWorkflowToolDefinition; readonly webSearchProvider?: WebSearchProvider; readonly dynamicInstructions?: readonly CompiledDynamicInstructionsDefinition[]; @@ -870,7 +871,7 @@ export function createCompiledAgentResources( errors: 0, warnings: 0, }, - disabledFrameworkTools: [...(input.disabledFrameworkTools ?? [])], + kernelCapabilities: [...(input.kernelCapabilities ?? [])], workflowTool: input.workflowTool === undefined ? undefined @@ -1038,7 +1039,7 @@ export function createCompiledAgentManifest(input: { readonly config: CompiledAgentDefinition; readonly connections?: readonly CompiledConnectionDefinition[]; readonly diagnosticsSummary?: DiscoverDiagnosticsSummary; - readonly disabledFrameworkTools?: readonly string[]; + readonly kernelCapabilities?: readonly KernelCapabilityName[]; readonly workflowTool?: CompiledWorkflowToolDefinition; readonly webSearchProvider?: WebSearchProvider; readonly dynamicSkills?: readonly CompiledDynamicSkillDefinition[]; diff --git a/packages/eve/src/compiler/module-map.test.ts b/packages/eve/src/compiler/module-map.test.ts index 7076fc405b..1001ca71f8 100644 --- a/packages/eve/src/compiler/module-map.test.ts +++ b/packages/eve/src/compiler/module-map.test.ts @@ -38,7 +38,7 @@ function createManifestWithTool(agentRoot: string): CompiledAgentManifest { warnings: 0, }, extensionMounts: [], - disabledFrameworkTools: [], + kernelCapabilities: [], dynamicInstructions: [], dynamicSkills: [], dynamicTools: [], diff --git a/packages/eve/src/compiler/normalize-agent-config.test.ts b/packages/eve/src/compiler/normalize-agent-config.test.ts index 47aa44a69f..5533a90e7a 100644 --- a/packages/eve/src/compiler/normalize-agent-config.test.ts +++ b/packages/eve/src/compiler/normalize-agent-config.test.ts @@ -111,7 +111,7 @@ function createContext( modelCatalog: ManifestCompileContext["modelCatalog"], ): ManifestCompileContext { return { - bindingsByAgentRoot: new Map(), + bindingsByNodeId: new Map(), compositionsByNodeId: new Map(), manifestsByNodeId: new Map(), modelCatalog, diff --git a/packages/eve/src/compiler/normalize-agent-config.ts b/packages/eve/src/compiler/normalize-agent-config.ts index 76c0e3c7c0..3b34bbb7d5 100644 --- a/packages/eve/src/compiler/normalize-agent-config.ts +++ b/packages/eve/src/compiler/normalize-agent-config.ts @@ -47,7 +47,6 @@ export async function compileAgentConfig( ? { model: DEFAULT_AGENT_MODEL_ID } : await loadModuleBackedDefinition({ agentRoot: manifest.agentRoot, - binding: context.bindingsByAgentRoot.get(manifest.agentRoot)?.[configModule.sourceId], displayPath: configModulePath!, kind: "agent config", moduleLoader: context.moduleLoader, diff --git a/packages/eve/src/compiler/normalize-helpers.ts b/packages/eve/src/compiler/normalize-helpers.ts index 7c53b850ff..714fffe2a3 100644 --- a/packages/eve/src/compiler/normalize-helpers.ts +++ b/packages/eve/src/compiler/normalize-helpers.ts @@ -27,7 +27,7 @@ const SANDBOX_PARENT_DEFINITION_MARKER = Symbol.for("eve.sandbox-parent-definiti * reuses the cache across all of its child compilations. */ export interface ManifestCompileContext { - readonly bindingsByAgentRoot: Map>>; + readonly bindingsByNodeId: Map>>; readonly compositionsByNodeId: Map; readonly manifestsByNodeId: Map; readonly modelCatalog: CompiledRuntimeModelCatalogLoader; diff --git a/packages/eve/src/compiler/normalize-manifest.test.ts b/packages/eve/src/compiler/normalize-manifest.test.ts index 9b1cd0ea72..3b058199e6 100644 --- a/packages/eve/src/compiler/normalize-manifest.test.ts +++ b/packages/eve/src/compiler/normalize-manifest.test.ts @@ -295,6 +295,7 @@ describe("compileAgentManifest", () => { const compiled = await compileAgentManifest(manifest); expect(compiled.workflowTool).toEqual({ maxSubagents: 6 }); + expect(compiled.kernelCapabilities).toContain("Workflow"); }); it("compiles web search provider configuration", async () => { @@ -357,6 +358,28 @@ describe("compileAgentManifest", () => { expect(compiled.bindings["eve.framework-defaults:tools/web_search.ts"]).toBeUndefined(); }); + it("rejects authored tools in the reserved final_output kernel slot", async () => { + mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); + mocks.applicationDefinition.mockResolvedValue( + defineTool({ + description: "Return structured data", + execute: () => ({ ok: true }), + inputSchema: z.object({}), + }), + ); + + await expect( + compileAgentManifest( + createAgentSourceManifest({ + agentId: "root", + agentRoot: "/app/agent", + appRoot: "/app", + tools: [createModuleSourceRef({ logicalPath: "tools/final_output.ts" })], + }), + ), + ).rejects.toThrow("reserved final_output kernel slot"); + }); + it("compiles framework defaults through ordinary module bindings", async () => { mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); @@ -377,6 +400,7 @@ describe("compileAgentManifest", () => { "write_file", ]); expect(compiled.webSearchProvider).toBe("exa"); + expect(compiled.kernelCapabilities).toEqual(["agent", "ask_question", "web_search"]); expect(compiled.channels).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -572,6 +596,45 @@ describe("compileAgentManifest", () => { expect(mocks.compileAgentConfig).toHaveBeenCalledTimes(1); }); + it("keeps composed bindings isolated when a dynamic subagent shares the root directory", async () => { + const dynamic = defineDynamic({ + events: { + "session.started": () => + defineAgent({ + description: "Resolve a remote worker.", + model: "openai/gpt-5.5", + }), + }, + }); + const root = createAgentSourceManifest({ + agentId: "root", + agentRoot: "/app/agent", + appRoot: "/app", + subagents: [ + createLocalSubagentSourceRef({ + entryPath: "/app/agent/subagents/dynamic.ts", + logicalPath: "subagents/dynamic.ts", + manifest: createAgentSourceManifest({ + agentId: "dynamic", + agentRoot: "/app/agent", + appRoot: "/app", + configModule: createModuleSourceRef({ logicalPath: "subagents/dynamic.ts" }), + }), + rootPath: "/app/agent", + subagentId: "dynamic", + }), + ], + }); + mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); + mocks.applicationDefinition.mockResolvedValue(dynamic); + + const compiled = await compileAgentManifest(root); + + expect( + compiled.bindings["eve.framework-root:channels/eve/v1/callback/post.ts"]?.backing.kind, + ).toBe("programmatic"); + }); + it("applies dynamic subagent build configuration before resolving events", async () => { const dynamic = defineDynamic({ build: { externalDependencies: ["just-bash"] }, diff --git a/packages/eve/src/compiler/normalize-manifest.ts b/packages/eve/src/compiler/normalize-manifest.ts index 1794c44c85..ca0458c40a 100644 --- a/packages/eve/src/compiler/normalize-manifest.ts +++ b/packages/eve/src/compiler/normalize-manifest.ts @@ -47,6 +47,12 @@ import { } from "#compiler/compose-agent-sources.js"; import { frameworkAgentSourceRegistry } from "#framework-sources/registry.js"; import { createAgentModuleNamespaceLoader } from "#compiler/module-namespace-loader.js"; +import { prepareKernelCapabilities } from "#compiler/prepare-kernel-capabilities.js"; +import { + getKernelCapabilityAtPath, + getReplaceableKernelCapabilityAtPath, + type KernelCapabilityName, +} from "#kernel/capabilities.js"; /** * Compiles one discovery manifest into the normalized manifest loaded by the runtime. @@ -55,7 +61,7 @@ export async function compileAgentManifest( manifest: AgentSourceManifest, ): Promise { const context: ManifestCompileContext = { - bindingsByAgentRoot: new Map(), + bindingsByNodeId: new Map(), compositionsByNodeId: new Map(), manifestsByNodeId: new Map(), modelCatalog: createCompiledRuntimeModelCatalogLoader(manifest.appRoot), @@ -97,6 +103,7 @@ export async function compileAgentManifest( bindings: createNodeBindings( compiledManifest, context, + ROOT_COMPILED_AGENT_NODE_ID, compiledManifest.config.build?.externalDependencies, ), }; @@ -157,18 +164,19 @@ async function compileAgentNodeManifest( }); manifest = sources.manifest; const bindings = bindOriginConfigModule(sources.bindings, manifest, options.sourceOrigin); - context.bindingsByAgentRoot.set(manifest.agentRoot, bindings); + context.bindingsByNodeId.set(nodeId, bindings); context.compositionsByNodeId.set(nodeId, sources.composition); context.manifestsByNodeId.set(nodeId, manifest); const resources = await compileAgentResources(manifest, context, { externalDependencies, nodeId, sourcesComposed: true, + tasksEnabled: config.experimental?.tasks === true, }); const compiledNode = createCompiledAgentNodeManifest({ ...resources, config }); return { ...compiledNode, - bindings: createNodeBindings(compiledNode, context, config.build?.externalDependencies), + bindings: createNodeBindings(compiledNode, context, nodeId, config.build?.externalDependencies), }; } @@ -180,6 +188,7 @@ async function compileAgentResources( readonly nodeId?: string; readonly sourceOrigin?: AgentSourceOrigin; readonly sourcesComposed?: boolean; + readonly tasksEnabled?: boolean; } = {}, ): Promise { const nodeId = options.nodeId ?? manifest.agentId; @@ -194,7 +203,7 @@ async function compileAgentResources( registry: frameworkAgentSourceRegistry, }); manifest = sources.manifest; - context.bindingsByAgentRoot.set(manifest.agentRoot, sources.bindings); + context.bindingsByNodeId.set(nodeId, sources.bindings); context.compositionsByNodeId.set(nodeId, sources.composition); context.manifestsByNodeId.set(nodeId, manifest); const externalDependencies = [...(options.externalDependencies ?? [])]; @@ -215,15 +224,28 @@ async function compileAgentResources( ); const tools: CompiledToolDefinition[] = []; const dynamicTools: CompiledDynamicToolDefinition[] = []; - const disabledFrameworkTools: string[] = []; + const disabledKernelCapabilities = new Set(); + let frameworkLoadSkill = false; let workflowTool: CompiledWorkflowToolDefinition | undefined; let webSearchProvider: WebSearchProvider | undefined; for (const { entry, source } of compiledToolEntries) { const sourceComposition = findSourceComposition(sources, source.sourceId); assertExtensionToolPolicy(entry.kind, source.logicalPath, sourceComposition?.winner.layer); + const kernelCapability = getKernelCapabilityAtPath(source.logicalPath); + if (kernelCapability === "final_output") { + throw new Error( + `The source "${source.logicalPath}" occupies the reserved final_output kernel slot. Structured output owns this tool name when an agent requests an output schema.`, + ); + } if (entry.kind === "tool") { tools.push(entry.definition); + if ( + entry.definition.name === "load_skill" && + sourceComposition?.winner.owner.kind === "framework" + ) { + frameworkLoadSkill = true; + } } else if (entry.kind === "dynamic-tool") { dynamicTools.push(entry.definition); } else if (entry.kind === "workflow-tool") { @@ -232,7 +254,7 @@ async function compileAgentResources( webSearchProvider = entry.provider; } else { const disabled = validateDisableTarget(source.logicalPath, sourceComposition); - if (disabled.owner.kind === "framework") disabledFrameworkTools.push(entry.name); + if (disabled.kind === "kernel") disabledKernelCapabilities.add(disabled.name); } } @@ -257,7 +279,9 @@ async function compileAgentResources( source.logicalPath, findSourceComposition(sources, source.sourceId), ); - return disabled.owner.kind === "framework" ? flattened : []; + return disabled.kind === "source" && disabled.target.owner.kind === "framework" + ? flattened + : []; }); const compiledSkillEntries = await Promise.all( @@ -319,7 +343,16 @@ async function compileAgentResources( extensionMounts: compileExtensionMounts(manifest), connections, diagnosticsSummary: manifest.diagnosticsSummary, - disabledFrameworkTools, + kernelCapabilities: prepareKernelCapabilities({ + disabled: disabledKernelCapabilities, + frameworkLoadSkill, + hasSkills: skills.length > 0 || dynamicSkills.length > 0, + isRoot: nodeId === ROOT_COMPILED_AGENT_NODE_ID, + tasksEnabled: options.tasksEnabled === true, + toolNames: new Set(tools.map((tool) => tool.name)), + webSearch: webSearchProvider !== undefined, + workflow: workflowTool !== undefined, + }), workflowTool, webSearchProvider, dynamicSkills, @@ -345,13 +378,14 @@ async function compileAgentResources( }); return { ...resources, - bindings: createNodeBindings(resources, context, externalDependencies), + bindings: createNodeBindings(resources, context, nodeId, externalDependencies), }; } function createNodeBindings( manifest: CompiledAgentNodeManifest | CompiledAgentResources, context: ManifestCompileContext, + nodeId: string, externalDependencies?: readonly string[], ): CompiledAgentResources["bindings"] { const bindings = createFilesystemModuleBindings({ @@ -359,7 +393,7 @@ function createNodeBindings( externalDependencies, manifest, }); - const composedBindings = context.bindingsByAgentRoot.get(manifest.agentRoot) ?? {}; + const composedBindings = context.bindingsByNodeId.get(nodeId) ?? {}; for (const sourceId of Object.keys(bindings)) { if (composedBindings[sourceId] !== undefined) { bindings[sourceId] = composedBindings[sourceId]; @@ -409,7 +443,7 @@ function getComposedSources( throw new Error(`Agent node "${nodeId}" has no composed source graph.`); } return { - bindings: context.bindingsByAgentRoot.get(manifest.agentRoot) ?? {}, + bindings: context.bindingsByNodeId.get(nodeId) ?? {}, composition, manifest, }; @@ -434,6 +468,10 @@ function validateDisableTarget( } const target = composition.candidates.at(-2); if (target === undefined) { + const kernelCapability = getReplaceableKernelCapabilityAtPath(logicalPath); + if (winner.layer === "application" && kernelCapability !== undefined) { + return { kind: "kernel" as const, name: kernelCapability }; + } throw new Error( `The source "${logicalPath}" exports a disable sentinel, but no lower-precedence source occupies that slot.`, ); @@ -443,7 +481,7 @@ function validateDisableTarget( `The extension override "${logicalPath}" exports a disable sentinel, but its extension package contributes no source at that slot.`, ); } - return target; + return { kind: "source" as const, target }; } function assertExtensionToolPolicy( diff --git a/packages/eve/src/compiler/normalize-subagent.ts b/packages/eve/src/compiler/normalize-subagent.ts index 3eaf2bc34b..8fa40b8135 100644 --- a/packages/eve/src/compiler/normalize-subagent.ts +++ b/packages/eve/src/compiler/normalize-subagent.ts @@ -53,6 +53,7 @@ export type CompileAgentNodeManifestFn = ( readonly nodeId?: string; readonly sourceOrigin?: AgentSourceOrigin; readonly sourcesComposed?: boolean; + readonly tasksEnabled?: boolean; }, ) => Promise; @@ -162,10 +163,7 @@ async function compileSubagentDefinition(input: { const definition = await loadModuleBackedDefinition({ agentRoot: input.source.manifest.agentRoot, binding: - input.context.bindingsByAgentRoot.get(input.source.manifest.agentRoot)?.[ - configModule.sourceId - ] ?? - (sourceOrigin === undefined + sourceOrigin === undefined ? undefined : { backing: { @@ -174,7 +172,7 @@ async function compileSubagentDefinition(input: { }, logicalPath: configModule.logicalPath, owner: sourceOrigin.owner, - }), + }, displayPath: configModuleSource.logicalPath, externalDependencies: input.externalDependencies, kind: "subagent config", @@ -286,6 +284,7 @@ async function compileSubagent(input: { agent: compiledAgent, context: input.context, externalDependencies: compiledAgent.config.build?.externalDependencies, + nodeId, }), }, description, @@ -320,6 +319,7 @@ async function compileSubagent(input: { agent: compiledResources, context: input.context, externalDependencies: inheritedExternalDependencies, + nodeId, }), }, configResolver: input.configResolver, @@ -334,6 +334,7 @@ function createSubagentNodeBindings(input: { readonly agent: CompiledAgentNodeManifest | CompiledAgentResources; readonly context: ManifestCompileContext; readonly externalDependencies?: readonly string[]; + readonly nodeId: string; }): CompiledAgentResources["bindings"] { const bindings = createFilesystemModuleBindings({ additionalRefs: input.additionalRefs, @@ -341,7 +342,7 @@ function createSubagentNodeBindings(input: { externalDependencies: input.externalDependencies, manifest: input.agent, }); - const composedBindings = input.context.bindingsByAgentRoot.get(input.agent.agentRoot) ?? {}; + const composedBindings = input.context.bindingsByNodeId.get(input.nodeId) ?? {}; for (const sourceId of Object.keys(bindings)) { if (composedBindings[sourceId] !== undefined) { bindings[sourceId] = composedBindings[sourceId]; diff --git a/packages/eve/src/compiler/prepare-kernel-capabilities.test.ts b/packages/eve/src/compiler/prepare-kernel-capabilities.test.ts new file mode 100644 index 0000000000..afda129468 --- /dev/null +++ b/packages/eve/src/compiler/prepare-kernel-capabilities.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { prepareKernelCapabilities } from "#compiler/prepare-kernel-capabilities.js"; + +const base = { + disabled: new Set(), + frameworkLoadSkill: false, + hasSkills: false, + isRoot: true, + tasksEnabled: false, + toolNames: new Set(), + webSearch: false, + workflow: false, +} as const; + +describe("prepareKernelCapabilities", () => { + it("prepares only unconditional native work for a plain root", () => { + expect(prepareKernelCapabilities(base)).toEqual(["agent", "ask_question"]); + }); + + it("prepares configured capabilities in stable inventory order", () => { + expect( + prepareKernelCapabilities({ + ...base, + frameworkLoadSkill: true, + hasSkills: true, + tasksEnabled: true, + webSearch: true, + workflow: true, + }), + ).toEqual([ + "agent", + "task_cancel", + "task_update", + "ask_question", + "load_skill", + "web_search", + "Workflow", + ]); + }); + + it("lets compiled tools replace native capabilities", () => { + expect( + prepareKernelCapabilities({ + ...base, + tasksEnabled: true, + toolNames: new Set(["agent", "task_cancel", "ask_question"]), + }), + ).toEqual(["task_update"]); + }); + + it("omits disabled native capabilities without retaining runtime disable state", () => { + expect( + prepareKernelCapabilities({ + ...base, + disabled: new Set(["agent", "ask_question"]), + }), + ).toEqual([]); + }); + + it("does not prepare root-only capabilities for subagent nodes", () => { + expect( + prepareKernelCapabilities({ + ...base, + isRoot: false, + tasksEnabled: true, + }), + ).toEqual(["ask_question"]); + }); +}); diff --git a/packages/eve/src/compiler/prepare-kernel-capabilities.ts b/packages/eve/src/compiler/prepare-kernel-capabilities.ts new file mode 100644 index 0000000000..7f4fd86869 --- /dev/null +++ b/packages/eve/src/compiler/prepare-kernel-capabilities.ts @@ -0,0 +1,37 @@ +import { KERNEL_CAPABILITY_NAMES, type KernelCapabilityName } from "#kernel/capabilities.js"; + +/** Compiler-owned inputs that determine the native work a node still needs. */ +export interface PrepareKernelCapabilitiesInput { + readonly disabled: ReadonlySet; + readonly frameworkLoadSkill: boolean; + readonly hasSkills: boolean; + readonly isRoot: boolean; + readonly tasksEnabled: boolean; + readonly toolNames: ReadonlySet; + readonly webSearch: boolean; + readonly workflow: boolean; +} + +/** + * Produces the closed native plan consumed by runtime preparation. Capability + * order follows the inventory so compiled artifacts stay deterministic. + */ +export function prepareKernelCapabilities( + input: PrepareKernelCapabilitiesInput, +): readonly KernelCapabilityName[] { + const prepared = new Set(); + const available = (name: KernelCapabilityName): boolean => + !input.disabled.has(name) && !input.toolNames.has(name); + + if (input.isRoot && available("agent")) prepared.add("agent"); + if (input.isRoot && input.tasksEnabled) { + if (available("task_cancel")) prepared.add("task_cancel"); + if (available("task_update")) prepared.add("task_update"); + } + if (available("ask_question")) prepared.add("ask_question"); + if (input.frameworkLoadSkill && input.hasSkills) prepared.add("load_skill"); + if (input.webSearch) prepared.add("web_search"); + if (input.workflow) prepared.add("Workflow"); + + return KERNEL_CAPABILITY_NAMES.filter((name) => prepared.has(name)); +} diff --git a/packages/eve/src/context/build-dynamic-tools.ts b/packages/eve/src/context/build-dynamic-tools.ts index ff1f6a9569..ded8c8043a 100644 --- a/packages/eve/src/context/build-dynamic-tools.ts +++ b/packages/eve/src/context/build-dynamic-tools.ts @@ -133,9 +133,12 @@ export function replayDynamicTools( export function buildResponseAuthorizationTools(input: { readonly authoredTools: HarnessToolMap; readonly context?: ContextReader; + readonly reservedToolNames?: ReadonlySet; }): HarnessToolMap { const tools = new Map(); - for (const tool of input.context === undefined ? [] : buildDynamicTools(input.context)) { + for (const tool of input.context === undefined + ? [] + : buildDynamicTools(input.context, input.reservedToolNames)) { if (!tools.has(tool.name)) tools.set(tool.name, tool); } for (const [name, tool] of input.authoredTools) { @@ -144,9 +147,17 @@ export function buildResponseAuthorizationTools(input: { return tools; } -export function buildDynamicTools(ctx: ContextReader): readonly HarnessToolDefinition[] { +export function buildDynamicTools( + ctx: ContextReader, + reservedToolNames: ReadonlySet = new Set(), +): readonly HarnessToolDefinition[] { const step = replayDynamicTools(ctx.get(StepDynamicToolMetadataKey) ?? []); const turn = replayDynamicTools(ctx.get(TurnDynamicToolMetadataKey) ?? []); const session = replayDynamicTools(ctx.get(SessionDynamicToolMetadataKey) ?? []); - return [...step, ...turn, ...session]; + const tools = [...step, ...turn, ...session]; + const collision = tools.find((tool) => reservedToolNames.has(tool.name)); + if (collision !== undefined) { + throw new Error(`Dynamic tool "${collision.name}" collides with a native kernel capability.`); + } + return tools; } diff --git a/packages/eve/src/context/dynamic-tool-lifecycle.test.ts b/packages/eve/src/context/dynamic-tool-lifecycle.test.ts index c8cbac0a9d..07ade0b1c1 100644 --- a/packages/eve/src/context/dynamic-tool-lifecycle.test.ts +++ b/packages/eve/src/context/dynamic-tool-lifecycle.test.ts @@ -1278,6 +1278,24 @@ describe("framework dynamic tools (no bundler transform)", () => { getDynamicCallbackRegistry().delete("guarded"); }); + it("rejects dynamic tools that collide with native kernel capabilities", () => { + const ctx = createCtx(); + ctx.set(StepDynamicToolMetadataKey, [ + { + callbacks: { execute: { closure: {} } }, + description: "dynamic override", + entryKey: "step:final_output", + inputSchema: { type: "object" }, + name: "final_output", + resolverSlug: "step", + }, + ]); + + expect(() => buildDynamicTools(ctx, new Set(["final_output"]))).toThrow( + 'Dynamic tool "final_output" collides with a native kernel capability.', + ); + }); + it("rejects an untransformed tool atomically without resolver hydration", async () => { const ctx = createCtx(); const execute = vi.fn(async () => ({ ok: true })); diff --git a/packages/eve/src/execution/create-session-step.test.ts b/packages/eve/src/execution/create-session-step.test.ts index 19643f55a7..1c5e0c2984 100644 --- a/packages/eve/src/execution/create-session-step.test.ts +++ b/packages/eve/src/execution/create-session-step.test.ts @@ -22,7 +22,7 @@ describe("createSessionStep", () => { vi.mocked(getCompiledRuntimeAgentBundle).mockResolvedValue({ resolvedAgent: { config: { experimental: { tasks: true } }, - disabledFrameworkTools: [], + kernelCapabilities: ["task_update"], }, turnAgent: TestTurnAgent, } as never); @@ -43,7 +43,7 @@ describe("createSessionStep", () => { vi.mocked(getCompiledRuntimeAgentBundle).mockResolvedValue({ resolvedAgent: { config: {}, - disabledFrameworkTools: [], + kernelCapabilities: [], }, turnAgent: TestTurnAgent, } as never); diff --git a/packages/eve/src/execution/create-session-step.ts b/packages/eve/src/execution/create-session-step.ts index 375c429ddd..85c57dc94a 100644 --- a/packages/eve/src/execution/create-session-step.ts +++ b/packages/eve/src/execution/create-session-step.ts @@ -14,7 +14,7 @@ import type { JsonObject } from "#shared/json.js"; import { resolveEffectiveAgentRuntimeFromConfig } from "#execution/effective-agent-config.js"; import type { DynamicSubagentAgentConfig } from "#runtime/subagents/dynamic-agent-config.js"; import { TASK_UPDATE_SESSION_INSTRUCTION } from "#execution/tasks/child/instructions.js"; -import { isTaskToolAvailable, TASK_UPDATE_TOOL_NAME } from "#runtime/framework-tools/tasks.js"; +import { hasKernelCapability } from "#kernel/capabilities.js"; /** * Result returned by {@link createSessionStep}. @@ -56,14 +56,7 @@ export async function createSessionStep(input: { ); const taskUpdatesEnabled = input.taskOwned === true && - isTaskToolAvailable({ - disabledFrameworkTools: bundle.resolvedAgent.disabledFrameworkTools ?? [], - hasAuthoredTool: effectiveAgent.turnAgent.tools.some( - (tool) => tool.name === TASK_UPDATE_TOOL_NAME, - ), - tasksEnabled: bundle.resolvedAgent.config?.experimental?.tasks === true, - toolName: TASK_UPDATE_TOOL_NAME, - }); + hasKernelCapability(bundle.resolvedAgent.kernelCapabilities, "task_update"); // Both token axes resolve tighter-wins against the cap inherited from the // delegating parent: a child may narrow what its parent granted, never widen diff --git a/packages/eve/src/execution/node-step.test.ts b/packages/eve/src/execution/node-step.test.ts index 5f85e4f1b9..695a99a001 100644 --- a/packages/eve/src/execution/node-step.test.ts +++ b/packages/eve/src/execution/node-step.test.ts @@ -200,7 +200,7 @@ function createTestNode( return { agent: { ...agent, - disabledFrameworkTools: [], + kernelCapabilities: ["agent", "ask_question"], }, channels: [], hookRegistry: createEmptyHookRegistry(), @@ -251,6 +251,10 @@ describe("createNodeHarnessTools", () => { const toolRegistry = await createRuntimeToolRegistry({ tools: [definition] }); const tools = createNodeHarnessTools({ node: createTestNode(createTestTurnAgent({ tools: toolRegistry.preparedTools }), { + agent: { + ...({} as ResolvedRuntimeAgentNode["agent"]), + kernelCapabilities: ["agent", "ask_question", "load_skill"], + }, toolRegistry, }), }); @@ -297,20 +301,26 @@ describe("createNodeHarnessTools", () => { it("does not give declared subagent nodes the built-in agent tool", () => { const tools = createNodeHarnessTools({ - node: createTestNode(undefined, { nodeId: "subagents/researcher" }), + node: createTestNode(undefined, { + agent: { + ...({} as ResolvedRuntimeAgentNode["agent"]), + kernelCapabilities: ["ask_question"], + }, + nodeId: "subagents/researcher", + }), }); expect(tools.has("agent")).toBe(false); }); - it("does not give the root node the built-in agent tool when it is disabled", () => { + it("does not give the root node the built-in agent tool when the plan omits it", () => { const node = createTestNode(); const tools = createNodeHarnessTools({ node: { ...node, agent: { ...node.agent, - disabledFrameworkTools: ["agent"], + kernelCapabilities: ["ask_question"], }, }, }); @@ -334,6 +344,7 @@ describe("createNodeHarnessTools", () => { agent: { ...node.agent, config: { experimental: { tasks: true }, model: { id: "test-model" }, name: "test" }, + kernelCapabilities: ["agent", "ask_question", "task_cancel", "task_update"], }, }, }); @@ -377,6 +388,9 @@ describe("createNodeHarnessTools", () => { model: { id: "test-model" }, name: "test", }, + kernelCapabilities: tasks + ? (["agent", "ask_question", "task_cancel", "task_update"] as const) + : (["agent", "ask_question"] as const), }, }; }; @@ -404,7 +418,7 @@ describe("createNodeHarnessTools", () => { ).toBe(2); }); - it("respects disableTool for individual task tools", () => { + it("materializes only task tools present in the compiled plan", () => { const node = createTestNode(); const tools = createNodeHarnessTools({ node: { @@ -412,7 +426,7 @@ describe("createNodeHarnessTools", () => { agent: { ...node.agent, config: { experimental: { tasks: true }, model: { id: "test-model" }, name: "test" }, - disabledFrameworkTools: ["task_cancel"], + kernelCapabilities: ["agent", "ask_question", "task_update"], }, }, }); diff --git a/packages/eve/src/execution/node-step.ts b/packages/eve/src/execution/node-step.ts index 992aa5b9ce..73a5ebd650 100644 --- a/packages/eve/src/execution/node-step.ts +++ b/packages/eve/src/execution/node-step.ts @@ -7,6 +7,7 @@ import { createHarnessDelegationToolDefinition, } from "#execution/delegation-tool.js"; import type { HarnessToolDefinition } from "#harness/execute-tool.js"; +import { hasKernelCapability, RESERVED_KERNEL_CAPABILITY_NAMES } from "#kernel/capabilities.js"; import { LOAD_SKILL_TOOL_NAME } from "#runtime/skills/fragment-context.js"; import { createToolLoopHarness } from "#harness/tool-loop.js"; import type { HandleEventFn, HarnessToolMap, StepFn } from "#harness/types.js"; @@ -21,15 +22,11 @@ import { type RuntimeModelResolutionScope, } from "#runtime/agent/resolve-model.js"; import type { RuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js"; -import { - AGENT_TOOL_DESCRIPTION, - AGENT_TOOL_NAME, - isImplicitAgentToolAvailable, -} from "#runtime/framework-tools/agent.js"; -import { - createTaskToolHarnessDefinitions, - isTaskToolAvailable, -} from "#runtime/framework-tools/tasks.js"; +import { AGENT_TOOL_DESCRIPTION, AGENT_TOOL_NAME } from "#runtime/framework-tools/agent.js"; +import { createAskQuestionHarnessDefinition } from "#runtime/framework-tools/ask-question.js"; +import { createTaskCancelHarnessDefinition } from "#runtime/framework-tools/task-cancel.js"; +import { createTaskUpdateHarnessDefinition } from "#runtime/framework-tools/task-update.js"; +import { createWebSearchHarnessDefinition } from "#runtime/framework-tools/web-search.js"; import type { ResolvedRuntimeAgentNode } from "#runtime/graph.js"; import type { HistoryViewProjector, PreparedHistoryView } from "#shared/history-view.js"; @@ -105,15 +102,21 @@ export function createExecutionNodeStep(input: CreateExecutionNodeStepInput): St input.node.turnAgent.dynamicModel, ); const tools = createNodeHarnessTools({ node: input.node }); + const reservedToolNames = new Set([ + ...input.node.agent.kernelCapabilities, + ...RESERVED_KERNEL_CAPABILITY_NAMES, + ]); const instrumentation = getInstrumentationRuntime(); const step = createToolLoopHarness({ abortSignal: input.abortSignal, capabilities: input.capabilities, clearOnly: input.clearOnly, compactOnly: input.compactOnly, - workflow: input.node.agent.workflowTool !== undefined, + workflow: hasKernelCapability(input.node.agent.kernelCapabilities, "Workflow"), workflowMaxSubagents: input.workflowMaxSubagents, - webSearchProvider: input.node.agent.webSearchProvider, + webSearchProvider: hasKernelCapability(input.node.agent.kernelCapabilities, "web_search") + ? input.node.agent.webSearchProvider + : undefined, handleEvent: input.handleEvent, historyProjector: input.historyProjector, historyView: input.historyView, @@ -125,6 +128,7 @@ export function createExecutionNodeStep(input: CreateExecutionNodeStepInput): St input.node.agent.config?.experimental?.subagentPersistentSessions === true, dispatchDynamicModelEvent: dispatchModelEvent, resolveModel, + reservedToolNames, runtimeIdentity: buildRuntimeIdentity(input.node), tools, }); @@ -211,17 +215,17 @@ export function createNodeHarnessTools(input: { }); if (definition !== null) { + if ( + definition.frameworkAction === "load-skill" && + !hasKernelCapability(input.node.agent.kernelCapabilities, "load_skill") + ) { + continue; + } tools.set(tool.name, definition); } } - if ( - isImplicitAgentToolAvailable({ - disabledFrameworkTools: input.node.agent.disabledFrameworkTools, - hasAuthoredAgentTool: tools.has(AGENT_TOOL_NAME), - nodeId: input.node.nodeId, - }) - ) { + if (hasKernelCapability(input.node.agent.kernelCapabilities, "agent")) { const implicitAgent = { description: AGENT_TOOL_DESCRIPTION, inputSchema: @@ -241,17 +245,24 @@ export function createNodeHarnessTools(input: { ); } - for (const definition of createTaskToolHarnessDefinitions()) { - if ( - isTaskToolAvailable({ - disabledFrameworkTools: input.node.agent.disabledFrameworkTools, - hasAuthoredTool: tools.has(definition.name), - tasksEnabled, - toolName: definition.name, - }) - ) { - tools.set(definition.name, definition); - } + if (hasKernelCapability(input.node.agent.kernelCapabilities, "task_cancel")) { + const definition = createTaskCancelHarnessDefinition(); + tools.set(definition.name, definition); + } + + if (hasKernelCapability(input.node.agent.kernelCapabilities, "task_update")) { + const definition = createTaskUpdateHarnessDefinition(); + tools.set(definition.name, definition); + } + + if (hasKernelCapability(input.node.agent.kernelCapabilities, "ask_question")) { + const definition = createAskQuestionHarnessDefinition(); + tools.set(definition.name, definition); + } + + if (hasKernelCapability(input.node.agent.kernelCapabilities, "web_search")) { + const definition = createWebSearchHarnessDefinition(); + tools.set(definition.name, definition); } return tools; @@ -280,8 +291,7 @@ function resolveHarnessToolDefinition(input: { } const def = registeredTool.definition; - const isNativeFrameworkTool = def.sourceOwner === undefined && def.sourceId.startsWith("eve:"); - const isFrameworkOwned = def.sourceOwner?.kind === "framework" || isNativeFrameworkTool; + const isFrameworkOwned = def.sourceOwner?.kind === "framework"; const rawExecute = def.execute; return { @@ -289,7 +299,6 @@ function resolveHarnessToolDefinition(input: { description: def.description, execution: def.execution, execute: resolveAuthoredExecute({ - isNativeFrameworkTool, rawExecute, scope: def.name, }), @@ -306,26 +315,19 @@ function resolveHarnessToolDefinition(input: { /** * Selects the harness-facing `execute` for one authored tool. * - * - Native framework tools run their `execute` verbatim — they - * manage their own context and never receive an authored - * {@link ToolContext}. - * - Authored tools are wrapped by {@link createToolExecuteWithAuth}, + * Tool implementations are wrapped by {@link createToolExecuteWithAuth}, * which builds a token-aware context. Providers passed to * `ctx.getToken(provider)` use tool-qualified auth scopes. * - Tools without `execute` (provider-managed) stay `undefined`. */ function resolveAuthoredExecute(input: { - readonly isNativeFrameworkTool: boolean; readonly rawExecute: ResolvedToolDefinition["execute"]; readonly scope: string; }): HarnessToolDefinition["execute"] { - const { isNativeFrameworkTool, rawExecute, scope } = input; + const { rawExecute, scope } = input; if (rawExecute === undefined) { return undefined; } - if (isNativeFrameworkTool) { - return rawExecute; - } const authored = rawExecute as ( toolInput: unknown, ctx: unknown, diff --git a/packages/eve/src/execution/sandbox/glob-tool.ts b/packages/eve/src/execution/sandbox/glob-tool.ts index 08b84ebe53..eedd192cac 100644 --- a/packages/eve/src/execution/sandbox/glob-tool.ts +++ b/packages/eve/src/execution/sandbox/glob-tool.ts @@ -1,5 +1,5 @@ import { normalizeModelPath } from "#runtime/framework-tools/file-state.js"; -import { resolveAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js"; +import { resolveAbsoluteFilePath } from "#execution/sandbox/resolve-file-path.js"; import type { SandboxSession } from "#shared/sandbox-session.js"; import { ripgrepIsAvailable } from "#execution/sandbox/ripgrep-probe.js"; import { shellQuote } from "#execution/sandbox/shell-quote.js"; diff --git a/packages/eve/src/execution/sandbox/grep-tool.ts b/packages/eve/src/execution/sandbox/grep-tool.ts index 3fdb4413d9..044dd52d3b 100644 --- a/packages/eve/src/execution/sandbox/grep-tool.ts +++ b/packages/eve/src/execution/sandbox/grep-tool.ts @@ -1,5 +1,5 @@ import { normalizeModelPath } from "#runtime/framework-tools/file-state.js"; -import { resolveAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js"; +import { resolveAbsoluteFilePath } from "#execution/sandbox/resolve-file-path.js"; import type { SandboxSession } from "#shared/sandbox-session.js"; import { ripgrepIsAvailable } from "#execution/sandbox/ripgrep-probe.js"; import { shellQuote } from "#execution/sandbox/shell-quote.js"; diff --git a/packages/eve/src/execution/sandbox/read-file-tool.ts b/packages/eve/src/execution/sandbox/read-file-tool.ts index 34868f2883..c85212c31c 100644 --- a/packages/eve/src/execution/sandbox/read-file-tool.ts +++ b/packages/eve/src/execution/sandbox/read-file-tool.ts @@ -5,7 +5,7 @@ import { normalizeModelPath, setReadFileStamp, } from "#runtime/framework-tools/file-state.js"; -import { resolveAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js"; +import { resolveAbsoluteFilePath } from "#execution/sandbox/resolve-file-path.js"; import type { SandboxSession } from "#shared/sandbox-session.js"; import { capLineLength, MAX_OUTPUT_BYTES } from "#execution/sandbox/truncate-output.js"; diff --git a/packages/eve/src/execution/sandbox/require-sandbox.ts b/packages/eve/src/execution/sandbox/require-sandbox.ts deleted file mode 100644 index 10698fa470..0000000000 --- a/packages/eve/src/execution/sandbox/require-sandbox.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { loadContext } from "#context/container.js"; -import { SandboxKey } from "#context/keys.js"; -import type { SandboxSession } from "#public/definitions/sandbox.js"; -import { bindSandboxAbortSignal } from "#execution/sandbox/abort-bound-session.js"; -import { resolveSandboxModelPath } from "#shared/skill-paths.js"; - -/** - * Resolves the active sandbox session from the runtime context. - * - * Shared preamble for every sandbox-backed tool executor (`bash`, - * `read_file`, `write_file`, `glob`, `grep`). Centralizes the context - * lookup, null checks, and error messages so each executor does not - * duplicate them. - * - * Binds the returned session to `abortSignal` when provided. - */ -export async function requireSandboxSession(abortSignal?: AbortSignal): Promise { - const sandboxAccess = loadContext().get(SandboxKey); - - if (sandboxAccess === undefined) { - throw new Error( - "This tool requires sandbox access on the runtime context. " + - "Ensure the step is running inside a managed runtime context with sandbox support.", - ); - } - - const sandbox = await sandboxAccess.get(); - - if (sandbox === null) { - throw new Error("The sandbox is not available in the current runtime context."); - } - - return abortSignal === undefined ? sandbox : bindSandboxAbortSignal(sandbox, abortSignal); -} - -/** - * Resolves a model-supplied `$HOME` prefix and validates that the resulting - * sandbox file path is absolute. - */ -export async function resolveAbsoluteFilePath( - sandbox: SandboxSession, - filePath: string, -): Promise { - const resolvedPath = await resolveSandboxModelPath({ path: filePath, sandbox }); - - if (!resolvedPath.startsWith("/")) { - throw new Error( - `filePath must be an absolute path. Received: "${filePath}". ` + - "Use an absolute path such as /workspace/foo.ts or a path beginning with $HOME/.", - ); - } - - return resolvedPath; -} diff --git a/packages/eve/src/execution/sandbox/resolve-file-path.ts b/packages/eve/src/execution/sandbox/resolve-file-path.ts new file mode 100644 index 0000000000..a04c286447 --- /dev/null +++ b/packages/eve/src/execution/sandbox/resolve-file-path.ts @@ -0,0 +1,19 @@ +import type { SandboxSession } from "#public/definitions/sandbox.js"; +import { resolveSandboxModelPath } from "#shared/skill-paths.js"; + +/** Resolves `$HOME` and requires an absolute model-supplied sandbox path. */ +export async function resolveAbsoluteFilePath( + sandbox: SandboxSession, + filePath: string, +): Promise { + const resolvedPath = await resolveSandboxModelPath({ path: filePath, sandbox }); + + if (!resolvedPath.startsWith("/")) { + throw new Error( + `filePath must be an absolute path. Received: "${filePath}". ` + + "Use an absolute path such as /workspace/foo.ts or a path beginning with $HOME/.", + ); + } + + return resolvedPath; +} diff --git a/packages/eve/src/execution/sandbox/write-file-tool.ts b/packages/eve/src/execution/sandbox/write-file-tool.ts index e3727cdbc2..6b92a7257f 100644 --- a/packages/eve/src/execution/sandbox/write-file-tool.ts +++ b/packages/eve/src/execution/sandbox/write-file-tool.ts @@ -7,7 +7,7 @@ import { ReadFileStateKey, setReadFileStamp, } from "#runtime/framework-tools/file-state.js"; -import { resolveAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js"; +import { resolveAbsoluteFilePath } from "#execution/sandbox/resolve-file-path.js"; import type { SandboxSession } from "#shared/sandbox-session.js"; // --------------------------------------------------------------------------- diff --git a/packages/eve/src/execution/tasks/parent/dispatch.ts b/packages/eve/src/execution/tasks/parent/dispatch.ts index 132131e8da..d6df55d418 100644 --- a/packages/eve/src/execution/tasks/parent/dispatch.ts +++ b/packages/eve/src/execution/tasks/parent/dispatch.ts @@ -23,11 +23,8 @@ import type { RuntimeToolCallActionRequest, } from "#runtime/actions/types.js"; import type { CompiledBundle } from "#runtime/sessions/runtime-context-keys.js"; -import { - TASK_CANCEL_TOOL_NAME, - TASK_CONTROL_TOOL_NAMES, - TASK_UPDATE_TOOL_NAME, -} from "#runtime/framework-tools/tasks.js"; +import { TASK_CANCEL_TOOL_NAME } from "#runtime/framework-tools/task-cancel.js"; +import { TASK_UPDATE_TOOL_NAME } from "#runtime/framework-tools/task-update.js"; import type { SessionTaskIndexEntry } from "#tasks/session-index.js"; import { isTerminalTaskStatus, @@ -41,6 +38,10 @@ const log = createLogger("execution.tasks.dispatch"); const CANCEL_COMMIT_POLL_ATTEMPTS = 10; const CANCEL_COMMIT_POLL_DELAY_MS = 250; +const TASK_CONTROL_TOOL_NAMES: ReadonlySet = new Set([ + TASK_CANCEL_TOOL_NAME, + TASK_UPDATE_TOOL_NAME, +]); /** True for task-control calls dispatched outside the model loop. */ export function isTaskControlAction( diff --git a/packages/eve/src/execution/workflow-steps.test.ts b/packages/eve/src/execution/workflow-steps.test.ts index 52db413b9d..e237dd18e6 100644 --- a/packages/eve/src/execution/workflow-steps.test.ts +++ b/packages/eve/src/execution/workflow-steps.test.ts @@ -1677,7 +1677,7 @@ describe("turnStep", () => { }, moduleMap: { nodes: {} }, hookRegistry: createEmptyHookRegistry(), - resolvedAgent: { config: { experimental: { tasks: true } } }, + resolvedAgent: { config: { experimental: { tasks: true } }, kernelCapabilities: [] }, subagentRegistry: {}, toolRegistry: {}, turnAgent: TestTurnAgent, diff --git a/packages/eve/src/execution/workflow-steps.ts b/packages/eve/src/execution/workflow-steps.ts index 91a766ef4c..83fb7ffc0b 100644 --- a/packages/eve/src/execution/workflow-steps.ts +++ b/packages/eve/src/execution/workflow-steps.ts @@ -103,8 +103,8 @@ import { hydrateDurableSession, refreshSessionFromTurnAgent } from "#execution/s import { createExecutionHistoryView } from "#execution/history-view.js"; import { resolveRuntimeCompiledArtifactsVersionedCacheKey } from "#runtime/cache-key.js"; import { createWorkflowRuntime } from "#execution/workflow-runtime.js"; -import { isTaskToolAvailable, TASK_UPDATE_TOOL_NAME } from "#runtime/framework-tools/tasks.js"; import { stageAttachmentsToSandbox } from "#harness/attachment-staging.js"; +import { hasKernelCapability } from "#kernel/capabilities.js"; const TASK_DONE_WITH_PENDING_INPUT_ERROR_MESSAGE = "Task mode cannot complete while input requests remain pending."; @@ -132,14 +132,7 @@ export async function turnStep(rawInput: TurnStepInput): Promise tool.name === TASK_UPDATE_TOOL_NAME, - ), - tasksEnabled, - toolName: TASK_UPDATE_TOOL_NAME, - }); + hasKernelCapability(bundle.resolvedAgent.kernelCapabilities, "task_update"); // Populate the callback base URL so getHookUrl() works during tool // execution, preferring eve's active local origin over metadata fallback. diff --git a/packages/eve/src/harness/advertised-tools.ts b/packages/eve/src/harness/advertised-tools.ts index 630d9ea0f7..4c7012c539 100644 --- a/packages/eve/src/harness/advertised-tools.ts +++ b/packages/eve/src/harness/advertised-tools.ts @@ -2,7 +2,8 @@ import type { ToolSet } from "ai"; import type { HarnessToolDefinition } from "#harness/execute-tool.js"; import { resolveSubagentDepth } from "#harness/subagent-depth.js"; import { AGENT_TOOL_NAME } from "#runtime/framework-tools/agent.js"; -import { TASK_TOOL_NAMES, TASK_UPDATE_TOOL_NAME } from "#runtime/framework-tools/tasks.js"; +import { TASK_CANCEL_TOOL_NAME } from "#runtime/framework-tools/task-cancel.js"; +import { TASK_UPDATE_TOOL_NAME } from "#runtime/framework-tools/task-update.js"; import { ROOT_RUNTIME_AGENT_NODE_ID } from "#runtime/graph.js"; import { ensureWorkflowContinuationSecurity, @@ -196,7 +197,7 @@ function isRootOnlyFrameworkTool(definition: HarnessToolDefinition): boolean { return true; } - return definition.name !== TASK_UPDATE_TOOL_NAME && TASK_TOOL_NAMES.has(definition.name); + return definition.name === TASK_CANCEL_TOOL_NAME; } function isToolDefinitionList( diff --git a/packages/eve/src/harness/provider-tools.ts b/packages/eve/src/harness/provider-tools.ts index 036c68d681..a4cefe0b03 100644 --- a/packages/eve/src/harness/provider-tools.ts +++ b/packages/eve/src/harness/provider-tools.ts @@ -7,7 +7,7 @@ import { WEB_SEARCH_GOOGLE_OUTPUT_SCHEMA, WEB_SEARCH_OPENAI_OUTPUT_SCHEMA, WEB_SEARCH_PARALLEL_OUTPUT_SCHEMA, - WEB_SEARCH_TOOL_DEFINITION, + WEB_SEARCH_TOOL_NAME, } from "#runtime/framework-tools/web-search.js"; import type { JsonObject } from "#shared/json.js"; import type { WebSearchProvider } from "#shared/web-search.js"; @@ -34,7 +34,7 @@ const UPSTREAM_TOOL_TYPE_TO_FRAMEWORK_NAME: Readonly> = { // Anthropic's stable web search tool. The Bedrock and Vertex // Anthropic backends reject this type because they only host the // older Claude Messages surface. - web_search_20250305: WEB_SEARCH_TOOL_DEFINITION.name, + web_search_20250305: WEB_SEARCH_TOOL_NAME, }; /** diff --git a/packages/eve/src/harness/tool-input-validation.integration.test.ts b/packages/eve/src/harness/tool-input-validation.integration.test.ts index b6ec9d5ae1..98f6ea2593 100644 --- a/packages/eve/src/harness/tool-input-validation.integration.test.ts +++ b/packages/eve/src/harness/tool-input-validation.integration.test.ts @@ -7,7 +7,7 @@ import { createToolLoopHarness } from "#harness/tool-loop.js"; import type { HarnessSession, ToolLoopHarnessConfig } from "#harness/types.js"; import { ASK_QUESTION_INPUT_SCHEMA, - ASK_QUESTION_TOOL_DEFINITION, + ASK_QUESTION_TOOL_DESCRIPTION, } from "#runtime/framework-tools/ask-question.js"; import { serializeInputSchema } from "#shared/tool-schema.js"; @@ -90,7 +90,7 @@ describe("framework tool input validation (real AI SDK)", () => { [ "ask_question", { - description: ASK_QUESTION_TOOL_DEFINITION.description, + description: ASK_QUESTION_TOOL_DESCRIPTION, inputSchema: ASK_QUESTION_INPUT_SCHEMA, name: "ask_question", }, @@ -108,7 +108,7 @@ describe("framework tool input validation (real AI SDK)", () => { system: "You are a test assistant.", tools: [ { - description: ASK_QUESTION_TOOL_DEFINITION.description, + description: ASK_QUESTION_TOOL_DESCRIPTION, inputSchema: serializeInputSchema(ASK_QUESTION_INPUT_SCHEMA), name: "ask_question", }, diff --git a/packages/eve/src/harness/tool-loop.ts b/packages/eve/src/harness/tool-loop.ts index 787d689792..cadc366d30 100644 --- a/packages/eve/src/harness/tool-loop.ts +++ b/packages/eve/src/harness/tool-loop.ts @@ -157,7 +157,7 @@ import { attemptIdempotencyKey } from "#harness/instrumentation/lifecycle.js"; import { resolveParentLineage } from "#harness/parent-lineage.js"; import { prepareTurnTraceContext } from "#harness/prepare-trace-context.js"; import { ChannelKey } from "#runtime/sessions/runtime-context-keys.js"; -import { TASK_UPDATE_TOOL_NAME } from "#runtime/framework-tools/tasks.js"; +import { TASK_UPDATE_TOOL_NAME } from "#runtime/framework-tools/task-update.js"; import { readTaskIdFromInboxToken } from "#tasks/task-inbox-token.js"; import { consumeDeferredStepInput, @@ -565,6 +565,7 @@ function resolveStepOtelContext( function buildHarnessToolsWithDynamicSubagents( tools: HarnessToolMap, ctx: Parameters[0] | undefined, + reservedToolNames: ReadonlySet | undefined, ): HarnessToolMap { const effectiveTools = new Map(tools); if (ctx === undefined) { @@ -572,7 +573,7 @@ function buildHarnessToolsWithDynamicSubagents( } for (const dynamicSubagent of buildDynamicSubagentTools(ctx)) { - if (effectiveTools.has(dynamicSubagent.name)) { + if (effectiveTools.has(dynamicSubagent.name) || reservedToolNames?.has(dynamicSubagent.name)) { throw new Error( `Dynamic subagent "${dynamicSubagent.name}" collides with another runtime-visible tool name.`, ); @@ -875,6 +876,7 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { tools: buildResponseAuthorizationTools({ authoredTools: config.tools, context: approvalContext, + reservedToolNames: config.reservedToolNames, }), }); session = coordinated.session; @@ -1442,7 +1444,11 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { const callMessages = opts.trailingUserNote ? [...modelMessages, { role: "user" as const, content: opts.trailingUserNote }] : modelMessages; - const harnessTools = buildHarnessToolsWithDynamicSubagents(config.tools, ctx); + const harnessTools = buildHarnessToolsWithDynamicSubagents( + config.tools, + ctx, + config.reservedToolNames, + ); const backgroundBatch = createBackgroundToolCallBatch(); const advertisedHarnessTools = getAdvertisedTools({ delegatedCaller: taskUpdatesEnabled, @@ -1465,7 +1471,7 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { const dynamicTools = getAdvertisedTools({ delegatedCaller: taskUpdatesEnabled, session, - tools: buildDynamicTools(ctx), + tools: buildDynamicTools(ctx, config.reservedToolNames), }); const dynamicToolSet = buildToolSetFromDefinitions({ approvedTools, @@ -1732,7 +1738,7 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { emissionState, runStep, session, - tools: buildHarnessToolsWithDynamicSubagents(config.tools, ctx), + tools: buildHarnessToolsWithDynamicSubagents(config.tools, ctx, config.reservedToolNames), }); if (pendingWorkflowInterrupt !== null) { return pendingWorkflowInterrupt; @@ -2720,6 +2726,7 @@ async function handleStepResult(input: { const responseAuthorizationTools = buildResponseAuthorizationTools({ authoredTools: config.tools, context: contextStorage.getStore(), + reservedToolNames: config.reservedToolNames, }); let parkedSession = appendPendingInputBatch({ event: { diff --git a/packages/eve/src/harness/tools.ts b/packages/eve/src/harness/tools.ts index 5b371cfda8..71d4081c21 100644 --- a/packages/eve/src/harness/tools.ts +++ b/packages/eve/src/harness/tools.ts @@ -4,7 +4,7 @@ import type { SessionCapabilities } from "#channel/types.js"; import type { RuntimeModelReference } from "#runtime/agent/bootstrap.js"; import type { WebSearchProvider } from "#shared/web-search.js"; import { ASK_QUESTION_TOOL_NAME } from "#runtime/framework-tools/ask-question.js"; -import { WEB_SEARCH_TOOL_DEFINITION } from "#runtime/framework-tools/web-search.js"; +import { WEB_SEARCH_TOOL_NAME } from "#runtime/framework-tools/web-search.js"; import { isObject } from "#shared/guards.js"; import type { HarnessToolDefinition } from "#harness/execute-tool.js"; import { resolveApprovalPolicy, type ApprovalStatus } from "#public/definitions/approval.js"; @@ -300,14 +300,14 @@ export async function buildToolSetWithProviderTools(input: { // Inject the real provider tool for web_search when the definition has // no local execute (i.e. the framework definition uses the provider sentinel). - if (!disabled?.has(WEB_SEARCH_TOOL_DEFINITION.name)) { - const webSearchTool = input.tools.get(WEB_SEARCH_TOOL_DEFINITION.name); + if (!disabled?.has(WEB_SEARCH_TOOL_NAME)) { + const webSearchTool = input.tools.get(WEB_SEARCH_TOOL_NAME); if (webSearchTool !== undefined && webSearchTool.execute === undefined) { const backend = resolveWebSearchBackend(input.modelReference, input.webSearchProvider); if (backend === null) { - delete tools[WEB_SEARCH_TOOL_DEFINITION.name]; + delete tools[WEB_SEARCH_TOOL_NAME]; } else { - tools[WEB_SEARCH_TOOL_DEFINITION.name] = await resolveWebSearchProviderTool(backend); + tools[WEB_SEARCH_TOOL_NAME] = await resolveWebSearchProviderTool(backend); } } } diff --git a/packages/eve/src/harness/types.ts b/packages/eve/src/harness/types.ts index 437ae624a5..12e1fe3a82 100644 --- a/packages/eve/src/harness/types.ts +++ b/packages/eve/src/harness/types.ts @@ -344,6 +344,8 @@ export interface ToolLoopHarnessConfig { readonly messages: readonly ModelMessage[]; }) => Promise; readonly resolveModel: (reference: RuntimeModelReference) => Promise; + /** Names owned by the native kernel that runtime-resolved tools cannot replace. */ + readonly reservedToolNames?: ReadonlySet; /** * Runtime identity metadata attached to the `session.started` event. * diff --git a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.test.ts b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.test.ts index 73e9af5a98..8153f8e7f2 100644 --- a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.test.ts +++ b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.test.ts @@ -5,7 +5,7 @@ import { createCompiledAgentManifest } from "#compiler/manifest.js"; import { buildAgentInfoResponseFromManifest } from "#internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.js"; describe("buildAgentInfoResponseFromManifest", () => { - it("reports opt-in framework tools as unavailable", () => { + it("does not invent tools absent from compiled artifacts", () => { const manifest = createCompiledAgentManifest({ agentRoot: "/app/agent", appRoot: "/app", @@ -28,8 +28,7 @@ describe("buildAgentInfoResponseFromManifest", () => { expect(result.tools.available.map((tool) => tool.name)).not.toContain("glob"); expect(result.tools.available.map((tool) => tool.name)).not.toContain("grep"); - expect(result.tools.framework.find((tool) => tool.name === "glob")?.status).toBe("opt-in"); - expect(result.tools.framework.find((tool) => tool.name === "grep")?.status).toBe("opt-in"); + expect(result.tools.framework).toEqual([]); expect(AgentInfoResultSchema.safeParse(result).success).toBe(true); }); diff --git a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.ts b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.ts index d03007818f..6488d6237b 100644 --- a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.ts +++ b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response-from-manifest.ts @@ -1,19 +1,17 @@ -import { getAllFrameworkToolNames } from "#runtime/framework-tools/index.js"; import { getAllFrameworkChannelNames, getFrameworkChannelDefinitions, } from "#runtime/framework-channels/index.js"; import type { AgentInfoManifestData } from "#internal/nitro/routes/agent-info/load-agent-info-data.js"; import type { ResolvedChannelDefinition } from "#runtime/types.js"; -import { LOAD_SKILL_TOOL_NAME } from "#runtime/skills/fragment-context.js"; import { WORKFLOW_TOOL_NAME } from "#shared/workflow-sandbox.js"; import type { AgentInfoFrameworkChannelEntry, AgentInfoResponse, } from "#internal/nitro/routes/agent-info/build-agent-info-response.js"; import { - buildFrameworkToolInfo, - getRootDelegationToolNames, + buildActiveFrameworkToolInfo, + getReservedKernelCapabilityNames, renderChannel, renderDynamicResolver, renderSchedule, @@ -48,12 +46,12 @@ export function buildAgentInfoResponseFromManifest( const disabledFrameworkChannels = manifest.channels .filter((channel) => channel.kind === "disabled") .map((channel) => channel.name); - const authoredToolNames = new Set(manifest.tools.map((tool) => tool.name)); - const disabledFrameworkTools = new Set(manifest.disabledFrameworkTools); - const allFrameworkToolNames = getAllFrameworkToolNames(); const allFrameworkChannelNames = getAllFrameworkChannelNames(); const frameworkChannelDefinitions = getFrameworkChannelDefinitions(); - const authoredTools = manifest.tools.map((tool) => ({ + const renderCompiledTool = ( + tool: (typeof manifest.tools)[number], + origin: "authored" | "framework", + ) => ({ ...toSource(tool), description: tool.description, hasAuth: false, @@ -63,21 +61,26 @@ export function buildAgentInfoResponseFromManifest( hasOutputSchema: tool.outputSchema !== undefined && tool.outputSchema !== null, inputSchema: tool.inputSchema, name: tool.name, - origin: "authored" as const, + origin, outputSchema: tool.outputSchema ?? null, - replacesFrameworkTool: allFrameworkToolNames.has(tool.name), + replacesFrameworkTool: false, requiresApproval: false, - })); + }); + const authoredTools = manifest.tools + .filter((tool) => manifest.bindings[tool.sourceId]?.owner.kind !== "framework") + .map((tool) => renderCompiledTool(tool, "authored")); + const ordinaryFrameworkTools = manifest.tools + .filter((tool) => manifest.bindings[tool.sourceId]?.owner.kind === "framework") + .map((tool) => renderCompiledTool(tool, "framework")); const authoredChannelNames = new Set(authoredChannels.map((channel) => channel.name)); const disabledFrameworkChannelNames = new Set(disabledFrameworkChannels); const activeFrameworkChannels = frameworkChannelDefinitions.filter( (channel) => !authoredChannelNames.has(channel.name) && !disabledFrameworkChannelNames.has(channel.name), ); - const frameworkToolInfo = buildFrameworkToolInfo({ - authoredToolNames, - delegationToolNames: getRootDelegationToolNames(manifest), - disabledFrameworkToolNames: disabledFrameworkTools, + const frameworkToolInfo = buildActiveFrameworkToolInfo({ + kernelCapabilities: manifest.kernelCapabilities, + ordinary: ordinaryFrameworkTools, }); const renderedAuthoredChannels = authoredChannels.map((channel) => ({ ...toSource(channel), @@ -217,7 +220,7 @@ export function buildAgentInfoResponseFromManifest( tools: { available: [...frameworkToolInfo.available, ...authoredTools], authored: authoredTools, - disabledFramework: [...manifest.disabledFrameworkTools], + disabledFramework: [], dynamic: manifest.dynamicTools.map((resolver) => renderDynamicResolver(resolver, { origin: @@ -227,7 +230,7 @@ export function buildAgentInfoResponseFromManifest( }), ), framework: frameworkToolInfo.framework, - reserved: [WORKFLOW_TOOL_NAME, LOAD_SKILL_TOOL_NAME], + reserved: getReservedKernelCapabilityNames(), }, version: 2, workflow: { diff --git a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.test.ts b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.test.ts index 31c7603d5a..15c28678c4 100644 --- a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.test.ts +++ b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.test.ts @@ -1,98 +1,9 @@ import { describe, expect, it } from "vitest"; import { createCompiledAgentManifest } from "#compiler/manifest.js"; -import { - buildAgentInfoResponse, - buildFrameworkToolInfo, -} from "#internal/nitro/routes/agent-info/build-agent-info-response.js"; +import { buildAgentInfoResponse } from "#internal/nitro/routes/agent-info/build-agent-info-response.js"; import { resolveAgent } from "#runtime/resolve-agent.js"; -describe("buildFrameworkToolInfo", () => { - it("reports opt-in tools as inactive and unavailable by default", () => { - const info = buildFrameworkToolInfo({ - authoredToolNames: new Set(), - delegationToolNames: new Set(), - disabledFrameworkToolNames: new Set(), - }); - - expect(info.available.map((tool) => tool.name)).not.toContain("glob"); - expect(info.available.map((tool) => tool.name)).not.toContain("grep"); - expect(info.framework.find((tool) => tool.name === "glob")).toMatchObject({ - status: "opt-in", - }); - expect(info.framework.find((tool) => tool.name === "grep")).toMatchObject({ - status: "opt-in", - }); - }); - - it("reports an authored opt-in tool as replacing the framework definition", () => { - const info = buildFrameworkToolInfo({ - authoredToolNames: new Set(["grep"]), - delegationToolNames: new Set(), - disabledFrameworkToolNames: new Set(), - }); - - expect(info.framework.find((tool) => tool.name === "grep")).toMatchObject({ - replacedByAuthoredTool: true, - status: "replaced", - }); - }); - - it("reports the built-in agent action as active and available by default", () => { - const info = buildFrameworkToolInfo({ - authoredToolNames: new Set(), - delegationToolNames: new Set(), - disabledFrameworkToolNames: new Set(), - }); - - expect(info.available.map((tool) => tool.name)).toContain("agent"); - expect(info.framework.find((tool) => tool.name === "agent")).toMatchObject({ - status: "active", - }); - }); - - it("reports the built-in agent action as disabled and unavailable", () => { - const info = buildFrameworkToolInfo({ - authoredToolNames: new Set(), - delegationToolNames: new Set(), - disabledFrameworkToolNames: new Set(["agent"]), - }); - - expect(info.available.map((tool) => tool.name)).not.toContain("agent"); - expect(info.framework.find((tool) => tool.name === "agent")).toMatchObject({ - disabledByAuthor: true, - status: "disabled", - }); - }); - - it("reports a declared agent delegation tool as replacing the recursive action", () => { - const info = buildFrameworkToolInfo({ - authoredToolNames: new Set(), - delegationToolNames: new Set(["agent"]), - disabledFrameworkToolNames: new Set(), - }); - - expect(info.available.map((tool) => tool.name)).not.toContain("agent"); - expect(info.framework.find((tool) => tool.name === "agent")).toMatchObject({ - replacedByAuthoredTool: false, - status: "replaced", - }); - }); - - it("reports an authored agent tool as replacing the recursive action", () => { - const info = buildFrameworkToolInfo({ - authoredToolNames: new Set(["agent"]), - delegationToolNames: new Set(), - disabledFrameworkToolNames: new Set(), - }); - - expect(info.framework.find((tool) => tool.name === "agent")).toMatchObject({ - replacedByAuthoredTool: true, - status: "replaced", - }); - }); -}); - describe("buildAgentInfoResponse", () => { it("preserves direct-provider routing from the compiled manifest", async () => { const manifest = createCompiledAgentManifest({ 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 c705ba99d1..934957a548 100644 --- a/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.ts +++ b/packages/eve/src/internal/nitro/routes/agent-info/build-agent-info-response.ts @@ -1,9 +1,9 @@ import { ROOT_COMPILED_AGENT_NODE_ID } from "#compiler/manifest.js"; import { - getAllFrameworkToolDefinitions, - getAllFrameworkToolNames, - getOptInFrameworkToolNames, -} from "#runtime/framework-tools/index.js"; + KERNEL_CAPABILITIES, + RESERVED_KERNEL_CAPABILITY_NAMES, + type KernelCapabilityName, +} from "#kernel/capabilities.js"; import { getAllFrameworkChannelNames, getFrameworkChannelDefinitions, @@ -22,7 +22,6 @@ import type { ResolvedToolDefinition, } from "#runtime/types.js"; import { serializeInputSchema, serializeOutputSchema } from "#shared/tool-schema.js"; -import { LOAD_SKILL_TOOL_NAME } from "#runtime/skills/fragment-context.js"; import { WORKFLOW_TOOL_NAME } from "#shared/workflow-sandbox.js"; import type { AgentReasoningDefinition, ModelRouting } from "#shared/agent-definition.js"; import type { ModelEndpointStatus } from "#shared/model-endpoint-status.js"; @@ -238,7 +237,7 @@ export function buildAgentInfoResponse( if (config === undefined) { throw new Error("Cannot inspect unresolved dynamic subagent resources as a root agent."); } - const tools = buildToolInfo(agent, getRootDelegationToolNames(data.manifest), data.manifest); + const tools = buildToolInfo(agent, data.manifest); return { agent: { @@ -380,30 +379,22 @@ function buildChannelInfo(agent: ResolvedAgent): AgentInfoChannels { }; } -function buildToolInfo( - agent: ResolvedAgent, - delegationToolNames: ReadonlySet, - manifest: CompiledAgentManifest, -): AgentInfoTools { - const authoredToolNames = new Set(agent.tools.map((tool) => tool.name)); - const disabledFrameworkTools = new Set(agent.disabledFrameworkTools); - const allFrameworkToolNames = getAllFrameworkToolNames(); - const authored = agent.tools.map((tool) => - renderTool(tool, { - origin: "authored", - replacesFrameworkTool: allFrameworkToolNames.has(tool.name), - }), - ); - const frameworkInfo = buildFrameworkToolInfo({ - authoredToolNames, - delegationToolNames, - disabledFrameworkToolNames: disabledFrameworkTools, +function buildToolInfo(agent: ResolvedAgent, manifest: CompiledAgentManifest): AgentInfoTools { + const authored = agent.tools + .filter((tool) => tool.sourceOwner?.kind !== "framework") + .map((tool) => renderTool(tool, { origin: "authored", replacesFrameworkTool: false })); + const ordinaryFramework = agent.tools + .filter((tool) => tool.sourceOwner?.kind === "framework") + .map((tool) => renderTool(tool, { origin: "framework", replacesFrameworkTool: false })); + const frameworkInfo = buildActiveFrameworkToolInfo({ + kernelCapabilities: agent.kernelCapabilities, + ordinary: ordinaryFramework, }); return { available: [...frameworkInfo.available, ...authored], authored, - disabledFramework: [...agent.disabledFrameworkTools], + disabledFramework: [], dynamic: agent.dynamicToolResolvers.map((resolver) => renderDynamicResolver(resolver, { origin: @@ -413,48 +404,47 @@ function buildToolInfo( }), ), framework: frameworkInfo.framework, - reserved: [WORKFLOW_TOOL_NAME, LOAD_SKILL_TOOL_NAME], + reserved: getReservedKernelCapabilityNames(), }; } -export function buildFrameworkToolInfo(input: { - readonly authoredToolNames: ReadonlySet; - readonly delegationToolNames: ReadonlySet; - readonly disabledFrameworkToolNames: ReadonlySet; +export function buildActiveFrameworkToolInfo(input: { + readonly kernelCapabilities: readonly KernelCapabilityName[]; + readonly ordinary: readonly AgentInfoToolEntry[]; }): Pick { - const occupiedToolNames = new Set([...input.authoredToolNames, ...input.delegationToolNames]); - const available: AgentInfoToolEntry[] = []; - const framework: AgentInfoFrameworkToolEntry[] = []; - const optInFrameworkToolNames = getOptInFrameworkToolNames(); - - for (const definition of getAllFrameworkToolDefinitions()) { - const disabledByAuthor = input.disabledFrameworkToolNames.has(definition.name); - const replacedByAuthoredTool = input.authoredToolNames.has(definition.name); - const status: AgentInfoFrameworkToolEntry["status"] = disabledByAuthor - ? "disabled" - : occupiedToolNames.has(definition.name) - ? "replaced" - : optInFrameworkToolNames.has(definition.name) - ? "opt-in" - : "active"; - const rendered = renderTool(definition, { - origin: "framework", - replacesFrameworkTool: false, - }); - - if (status === "active") { - available.push(rendered); - } - - framework.push({ - ...rendered, - disabledByAuthor, - replacedByAuthoredTool, - status, - }); - } + const active = [...input.ordinary, ...input.kernelCapabilities.map(renderKernelCapability)]; + return { + available: active, + framework: active.map((tool) => ({ + ...tool, + disabledByAuthor: false, + replacedByAuthoredTool: false, + status: "active" as const, + })), + }; +} + +function renderKernelCapability(name: KernelCapabilityName): AgentInfoToolEntry { + const definition = KERNEL_CAPABILITIES[name]; + return { + description: "", + hasAuth: false, + hasExecute: definition.materialization === "runtime-action", + hasModelOutputProjection: false, + hasOutputSchema: false, + inputSchema: null, + logicalPath: definition.canonicalPath, + name, + origin: "framework", + outputSchema: null, + replacesFrameworkTool: false, + requiresApproval: false, + sourceKind: "kernel", + }; +} - return { available, framework }; +export function getReservedKernelCapabilityNames(): readonly string[] { + return RESERVED_KERNEL_CAPABILITY_NAMES; } export function getRootDelegationToolNames(manifest: CompiledAgentManifest): ReadonlySet { diff --git a/packages/eve/src/internal/nitro/routes/runtime-artifacts.test.ts b/packages/eve/src/internal/nitro/routes/runtime-artifacts.test.ts index ba594dea4f..97bd179762 100644 --- a/packages/eve/src/internal/nitro/routes/runtime-artifacts.test.ts +++ b/packages/eve/src/internal/nitro/routes/runtime-artifacts.test.ts @@ -27,7 +27,7 @@ function installEmptyBundledArtifacts(): void { errors: 0, warnings: 0, }, - disabledFrameworkTools: [], + kernelCapabilities: [], kind: "eve-agent-compiled-manifest", sandbox: null, schedules: [], diff --git a/packages/eve/src/kernel/capabilities.ts b/packages/eve/src/kernel/capabilities.ts new file mode 100644 index 0000000000..d01a5964c0 --- /dev/null +++ b/packages/eve/src/kernel/capabilities.ts @@ -0,0 +1,110 @@ +/** + * The complete set of capabilities implemented by eve's native execution kernel. + * Everything else enters the runtime through compiled agent sources. + */ +export const KERNEL_CAPABILITY_NAMES = [ + "agent", + "task_cancel", + "task_update", + "ask_question", + "load_skill", + "web_search", + "Workflow", + "final_output", +] as const; + +export type KernelCapabilityName = (typeof KERNEL_CAPABILITY_NAMES)[number]; + +export interface KernelCapabilityDefinition { + readonly audience: "all-sessions" | "root-node" | "task-child" | "turn-output"; + readonly canonicalPath: `tools/${string}.ts`; + readonly materialization: "harness" | "provider" | "runtime-action" | "tool-loop"; + readonly replacement: "authored-source" | "reserved"; +} + +/** Metadata only: implementations remain isolated in their primitive-specific modules. */ +export const KERNEL_CAPABILITIES: Readonly< + Record +> = { + agent: { + audience: "root-node", + canonicalPath: "tools/agent.ts", + materialization: "runtime-action", + replacement: "authored-source", + }, + task_cancel: { + audience: "root-node", + canonicalPath: "tools/task_cancel.ts", + materialization: "runtime-action", + replacement: "authored-source", + }, + task_update: { + audience: "task-child", + canonicalPath: "tools/task_update.ts", + materialization: "runtime-action", + replacement: "authored-source", + }, + ask_question: { + audience: "all-sessions", + canonicalPath: "tools/ask_question.ts", + materialization: "harness", + replacement: "authored-source", + }, + load_skill: { + audience: "all-sessions", + canonicalPath: "tools/load_skill.ts", + materialization: "harness", + replacement: "authored-source", + }, + web_search: { + audience: "all-sessions", + canonicalPath: "tools/web_search.ts", + materialization: "provider", + replacement: "authored-source", + }, + Workflow: { + audience: "all-sessions", + canonicalPath: "tools/workflow.ts", + materialization: "tool-loop", + replacement: "authored-source", + }, + final_output: { + audience: "turn-output", + canonicalPath: "tools/final_output.ts", + materialization: "tool-loop", + replacement: "reserved", + }, +}; + +const KERNEL_CAPABILITY_NAMES_SET: ReadonlySet = new Set(KERNEL_CAPABILITY_NAMES); +export const RESERVED_KERNEL_CAPABILITY_NAMES: readonly KernelCapabilityName[] = + KERNEL_CAPABILITY_NAMES.filter((name) => KERNEL_CAPABILITIES[name].replacement === "reserved"); +const KERNEL_CAPABILITIES_BY_PATH: ReadonlyMap = new Map( + KERNEL_CAPABILITY_NAMES.map((name) => [KERNEL_CAPABILITIES[name].canonicalPath, name] as const), +); +const REPLACEABLE_KERNEL_CAPABILITIES_BY_PATH: ReadonlyMap = new Map( + KERNEL_CAPABILITY_NAMES.filter( + (name) => KERNEL_CAPABILITIES[name].replacement === "authored-source", + ).map((name) => [KERNEL_CAPABILITIES[name].canonicalPath, name] as const), +); + +export function isKernelCapabilityName(value: string): value is KernelCapabilityName { + return KERNEL_CAPABILITY_NAMES_SET.has(value); +} + +export function getReplaceableKernelCapabilityAtPath( + logicalPath: string, +): KernelCapabilityName | undefined { + return REPLACEABLE_KERNEL_CAPABILITIES_BY_PATH.get(logicalPath); +} + +export function getKernelCapabilityAtPath(logicalPath: string): KernelCapabilityName | undefined { + return KERNEL_CAPABILITIES_BY_PATH.get(logicalPath); +} + +export function hasKernelCapability( + capabilities: readonly KernelCapabilityName[], + name: KernelCapabilityName, +): boolean { + return capabilities.includes(name); +} diff --git a/packages/eve/src/runtime/agent/bootstrap.test.ts b/packages/eve/src/runtime/agent/bootstrap.test.ts index 3c8feecdd5..eea30ac4c2 100644 --- a/packages/eve/src/runtime/agent/bootstrap.test.ts +++ b/packages/eve/src/runtime/agent/bootstrap.test.ts @@ -9,7 +9,7 @@ function createResolvedAgentForTest(overrides: Partial = {}): Res const agent: Partial = { config: { name: "test-agent" } as ResolvedAgent["config"], connections: [], - disabledFrameworkTools: [], + kernelCapabilities: ["agent", "ask_question"], instructions: [], skills: [], ...overrides, @@ -107,6 +107,7 @@ describe("createResolvedRuntimeTurnAgent agent-messaging gating", () => { experimental: { subagentPersistentSessions: true }, name: "test-agent", } as ResolvedAgent["config"], + kernelCapabilities: [], }), nodeId: ROOT_RUNTIME_AGENT_NODE_ID, tools: [ @@ -124,14 +125,14 @@ describe("createResolvedRuntimeTurnAgent agent-messaging gating", () => { expect(turnAgent.instructions).not.toContainEqual(expect.stringContaining("Pass `agentId`")); }); - it("omits the messaging instruction when the root disables the framework agent tool", () => { + it("omits the messaging instruction when the compiled plan omits agent", () => { const turnAgent = createResolvedRuntimeTurnAgent({ agent: createResolvedAgentForTest({ config: { experimental: { subagentPersistentSessions: true }, name: "test-agent", } as ResolvedAgent["config"], - disabledFrameworkTools: [AGENT_TOOL_NAME], + kernelCapabilities: [], } as Partial), nodeId: ROOT_RUNTIME_AGENT_NODE_ID, tools: [], @@ -147,6 +148,7 @@ describe("createResolvedRuntimeTurnAgent agent-messaging gating", () => { experimental: { subagentPersistentSessions: true }, name: "test-agent", } as ResolvedAgent["config"], + kernelCapabilities: [], }), nodeId: "subagents/researcher", tools: [], diff --git a/packages/eve/src/runtime/agent/bootstrap.ts b/packages/eve/src/runtime/agent/bootstrap.ts index de456584af..e1c2205958 100644 --- a/packages/eve/src/runtime/agent/bootstrap.ts +++ b/packages/eve/src/runtime/agent/bootstrap.ts @@ -1,6 +1,6 @@ import type { ModelMessage } from "ai"; -import { AGENT_TOOL_NAME, isImplicitAgentToolAvailable } from "#runtime/framework-tools/agent.js"; +import { hasKernelCapability } from "#kernel/capabilities.js"; import { composeRuntimeBasePrompt } from "#runtime/prompt/compose.js"; import type { PreparedRuntimeTool } from "#runtime/sessions/turn.js"; import type { ResolvedAgent, ResolvedAgentDefinition } from "#runtime/types.js"; @@ -94,16 +94,8 @@ export function createResolvedRuntimeTurnAgent(input: { const subagentDeclaredTool = input.tools.some( (tool) => tool.kind === "subagent" || tool.kind === "remote", ); - // The framework `agent` tool is injected after graph resolution, so - // declared tools alone under-count. isImplicitAgentToolAvailable is the - // same predicate node-step uses for the injection itself — including the - // authored-tool shadowing leg, so instructions never advertise a tool an - // authored "agent" tool has replaced. - const subagentImplicitRootTool = isImplicitAgentToolAvailable({ - disabledFrameworkTools: agent.disabledFrameworkTools, - hasAuthoredAgentTool: input.tools.some((tool) => tool.name === AGENT_TOOL_NAME), - nodeId: input.nodeId, - }); + const subagentImplicitRootTool = hasKernelCapability(agent.kernelCapabilities, "agent"); + const kernelToolsAvailable = agent.kernelCapabilities.length > 0; const base: RuntimeTurnAgentBase = { availableSkills: agent.skills.map((skill) => ({ description: skill.description, @@ -121,7 +113,7 @@ export function createResolvedRuntimeTurnAgent(input: { config?.experimental?.subagentPersistentSessions === true, subagentsAvailable: subagentDeclaredTool || subagentImplicitRootTool, tasksEnabled: config?.experimental?.tasks === true, - toolsAvailable: input.tools.length > 0 || subagentImplicitRootTool, + toolsAvailable: input.tools.length > 0 || kernelToolsAvailable, workspaceSpec: agent.workspaceSpec, }), compactionModel: config?.compaction?.model, diff --git a/packages/eve/src/runtime/framework-tools/agent.ts b/packages/eve/src/runtime/framework-tools/agent.ts index 8681309e9a..14b20a974c 100644 --- a/packages/eve/src/runtime/framework-tools/agent.ts +++ b/packages/eve/src/runtime/framework-tools/agent.ts @@ -1,7 +1,3 @@ -import { ROOT_RUNTIME_AGENT_NODE_ID } from "#runtime/graph.js"; -import { SUBAGENT_TOOL_INPUT_SCHEMA } from "#runtime/subagents/registry.js"; -import type { ResolvedToolDefinition } from "#runtime/types.js"; - /** * Stable model-visible name for the root-only agent delegation tool. */ @@ -16,38 +12,3 @@ export const AGENT_TOOL_DESCRIPTION = [ "Issue multiple `agent` calls in one response to run a small fixed set in parallel.", "Each child has fresh history and state but shares your tools and sandbox, so include essential context in `message` and give parallel writers non-overlapping scopes.", ].join(" "); - -/** - * Whether one node receives the implicit built-in `agent` tool. - * - * Single source of truth for the injection predicate: node-step uses it to - * decide whether to add the tool, and prompt bootstrap uses it to decide - * whether agent-messaging instructions may reference the tool. The - * `hasAuthoredAgentTool` leg matters because an authored tool named "agent" - * shadows the framework tool — instructions must not advertise a tool the - * model cannot call. - */ -export function isImplicitAgentToolAvailable(input: { - readonly disabledFrameworkTools: readonly string[]; - readonly hasAuthoredAgentTool: boolean; - /** Undefined when the caller prepares a turn without a graph node (never root). */ - readonly nodeId: string | undefined; -}): boolean { - return ( - input.nodeId === ROOT_RUNTIME_AGENT_NODE_ID && - !input.disabledFrameworkTools.includes(AGENT_TOOL_NAME) && - !input.hasAuthoredAgentTool - ); -} - -/** - * Shared metadata for the root-only agent delegation tool. - */ -export const AGENT_TOOL_DEFINITION: ResolvedToolDefinition = { - description: AGENT_TOOL_DESCRIPTION, - inputSchema: SUBAGENT_TOOL_INPUT_SCHEMA, - logicalPath: "eve:framework/agent", - name: AGENT_TOOL_NAME, - sourceId: "eve:agent-tool", - sourceKind: "module", -}; diff --git a/packages/eve/src/runtime/framework-tools/ask-question.test.ts b/packages/eve/src/runtime/framework-tools/ask-question.test.ts index 571f9afd0f..32e9f097ad 100644 --- a/packages/eve/src/runtime/framework-tools/ask-question.test.ts +++ b/packages/eve/src/runtime/framework-tools/ask-question.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { ASK_QUESTION_INPUT_SCHEMA, - ASK_QUESTION_TOOL_DEFINITION, + createAskQuestionHarnessDefinition, } from "#runtime/framework-tools/ask-question.js"; import { serializeInputSchema } from "#shared/tool-schema.js"; @@ -52,6 +52,6 @@ describe("ASK_QUESTION_INPUT_SCHEMA", () => { required: ["prompt"], type: "object", }); - expect(ASK_QUESTION_TOOL_DEFINITION.inputSchema).toBe(ASK_QUESTION_INPUT_SCHEMA); + expect(createAskQuestionHarnessDefinition().inputSchema).toBe(ASK_QUESTION_INPUT_SCHEMA); }); }); diff --git a/packages/eve/src/runtime/framework-tools/ask-question.ts b/packages/eve/src/runtime/framework-tools/ask-question.ts index 0b3377dad5..0fcce13dff 100644 --- a/packages/eve/src/runtime/framework-tools/ask-question.ts +++ b/packages/eve/src/runtime/framework-tools/ask-question.ts @@ -1,7 +1,7 @@ import { z } from "#compiled/zod/index.js"; +import type { HarnessToolDefinition } from "#harness/execute-tool.js"; import { inputRequestSchema } from "#runtime/input/types.js"; -import type { ResolvedToolDefinition } from "#runtime/types.js"; /** * Stable model-visible name for the framework question tool. @@ -22,19 +22,20 @@ export const ASK_QUESTION_OUTPUT_SCHEMA = z }) .strict(); +export const ASK_QUESTION_TOOL_DESCRIPTION = + "Ask the user a question and wait for their response before continuing. Use this when you need clarification or a choice from the user."; + /** * Root-only framework tool that lets the agent request structured user input. * * This is a client-side tool (as indicated by it not having an `execute` method). It requires user input * and therefore cannot be autonomously executed by the runtime. */ -export const ASK_QUESTION_TOOL_DEFINITION: ResolvedToolDefinition = { - description: - "Ask the user a question and wait for their response before continuing. Use this when you need clarification or a choice from the user.", - inputSchema: ASK_QUESTION_INPUT_SCHEMA, - logicalPath: "eve:framework/ask-question", - name: ASK_QUESTION_TOOL_NAME, - outputSchema: ASK_QUESTION_OUTPUT_SCHEMA, - sourceId: "eve:ask-question-tool", - sourceKind: "module", -}; +export function createAskQuestionHarnessDefinition(): HarnessToolDefinition { + return { + description: ASK_QUESTION_TOOL_DESCRIPTION, + inputSchema: ASK_QUESTION_INPUT_SCHEMA, + name: ASK_QUESTION_TOOL_NAME, + outputSchema: ASK_QUESTION_OUTPUT_SCHEMA, + }; +} diff --git a/packages/eve/src/runtime/framework-tools/bash.ts b/packages/eve/src/runtime/framework-tools/bash.ts index e3d775e005..bd2dad8d7e 100644 --- a/packages/eve/src/runtime/framework-tools/bash.ts +++ b/packages/eve/src/runtime/framework-tools/bash.ts @@ -1,17 +1,11 @@ import { z } from "#compiled/zod/index.js"; -import { type BashInput, executeBashOnSandbox } from "#execution/sandbox/bash-tool.js"; -import { requireSandboxSession } from "#execution/sandbox/require-sandbox.js"; -import type { ResolvedToolDefinition } from "#runtime/types.js"; -import type { ToolExecuteOptions } from "#shared/tool-definition.js"; - /** * Shared input schema used by the framework `bash` tool and any author tool * constructed via {@link defineBashTool}. * - * Exported so the public `defineBashTool` factory and the framework - * `BASH_TOOL_DEFINITION` use the exact same schema object — keeping model - * input contracts in sync without duplication. + * Exported so the public `defineBashTool` factory and defaults share one + * model input contract. */ export const BASH_INPUT_SCHEMA = z.strictObject({ command: z.string().describe("The shell command to execute."), @@ -27,29 +21,3 @@ export const BASH_OUTPUT_SCHEMA = z.strictObject({ stdout: z.string(), truncated: z.boolean(), }); - -/** - * Framework-owned executors stay statically imported so hosted server bundles - * can trace and rewrite them into deployable output chunks. - * - * These modules are only used by the Nitro-hosted runtime path. Their deeper - * sandbox dependencies remain lazily loaded inside the execution layer, so the - * top-level import here does not force those backends to initialize eagerly. - */ -async function executeBash(input: unknown, options?: ToolExecuteOptions): Promise { - return executeBashOnSandbox( - await requireSandboxSession(options?.abortSignal), - input as BashInput, - ); -} - -export const BASH_TOOL_DEFINITION: ResolvedToolDefinition = { - description: "Execute a shell command in the shared workspace environment.", - execute: executeBash, - inputSchema: BASH_INPUT_SCHEMA, - logicalPath: "eve:framework/bash", - name: "bash", - outputSchema: BASH_OUTPUT_SCHEMA, - sourceId: "eve:bash-tool", - sourceKind: "module", -}; diff --git a/packages/eve/src/runtime/framework-tools/glob.ts b/packages/eve/src/runtime/framework-tools/glob.ts index ae2b26be8a..3e248bf67a 100644 --- a/packages/eve/src/runtime/framework-tools/glob.ts +++ b/packages/eve/src/runtime/framework-tools/glob.ts @@ -1,17 +1,11 @@ import { z } from "#compiled/zod/index.js"; -import { executeGlobOnSandbox, type GlobInput } from "#execution/sandbox/glob-tool.js"; -import { requireSandboxSession } from "#execution/sandbox/require-sandbox.js"; -import type { ResolvedToolDefinition } from "#runtime/types.js"; -import type { ToolExecuteOptions } from "#shared/tool-definition.js"; - /** * Shared input schema used by the framework `glob` tool and any author tool * constructed via {@link defineGlobTool}. * - * Exported so the public `defineGlobTool` factory and the framework - * `GLOB_TOOL_DEFINITION` use the exact same schema object — keeping model - * input contracts in sync without duplication. + * Exported so the public `defineGlobTool` factory and defaults share one model + * input contract. */ export const GLOB_INPUT_SCHEMA = z.strictObject({ limit: z @@ -43,31 +37,3 @@ export const GLOB_OUTPUT_SCHEMA = z.strictObject({ path: z.string(), truncated: z.boolean(), }); - -/** - * Framework-owned executor that delegates to the default sandbox. - */ -async function executeGlob(input: unknown, options?: ToolExecuteOptions): Promise { - return executeGlobOnSandbox( - await requireSandboxSession(options?.abortSignal), - input as GlobInput, - ); -} - -export const GLOB_TOOL_DEFINITION: ResolvedToolDefinition = { - description: [ - "Fast file pattern matching tool that works with any codebase size.", - "", - "Usage:", - '- Supports glob patterns like "**/*.js" or "src/**/*.ts".', - "- Returns matching file paths.", - "- Call this tool in parallel when you know there are multiple patterns to search for.", - ].join("\n"), - execute: executeGlob, - inputSchema: GLOB_INPUT_SCHEMA, - logicalPath: "eve:framework/glob", - name: "glob", - outputSchema: GLOB_OUTPUT_SCHEMA, - sourceId: "eve:glob-tool", - sourceKind: "module", -}; diff --git a/packages/eve/src/runtime/framework-tools/grep.ts b/packages/eve/src/runtime/framework-tools/grep.ts index 65767bd509..3b528c9852 100644 --- a/packages/eve/src/runtime/framework-tools/grep.ts +++ b/packages/eve/src/runtime/framework-tools/grep.ts @@ -1,17 +1,11 @@ import { z } from "#compiled/zod/index.js"; -import { executeGrepOnSandbox, type GrepInput } from "#execution/sandbox/grep-tool.js"; -import { requireSandboxSession } from "#execution/sandbox/require-sandbox.js"; -import type { ResolvedToolDefinition } from "#runtime/types.js"; -import type { ToolExecuteOptions } from "#shared/tool-definition.js"; - /** * Shared input schema used by the framework `grep` tool and any author tool * constructed via {@link defineGrepTool}. * - * Exported so the public `defineGrepTool` factory and the framework - * `GREP_TOOL_DEFINITION` use the exact same schema object — keeping model - * input contracts in sync without duplication. + * Exported so the public `defineGrepTool` factory and defaults share one model + * input contract. */ export const GREP_INPUT_SCHEMA = z.strictObject({ context: z @@ -64,34 +58,3 @@ export const GREP_OUTPUT_SCHEMA = z.strictObject({ path: z.string(), truncated: z.boolean(), }); - -/** - * Framework-owned executor that delegates to the default sandbox. - */ -async function executeGrep(input: unknown, options?: ToolExecuteOptions): Promise { - return executeGrepOnSandbox( - await requireSandboxSession(options?.abortSignal), - input as GrepInput, - ); -} - -export const GREP_TOOL_DEFINITION: ResolvedToolDefinition = { - description: [ - "Fast content search tool that works with any codebase size.", - "", - "Usage:", - "- Searches file contents using regular expressions.", - '- Supports full regex syntax (e.g. "log.*Error", "function\\s+\\w+").', - '- Filter files by pattern with the glob parameter (e.g. "*.js", "*.{ts,tsx}").', - "- Returns matching lines with file paths and line numbers.", - "- Call this tool in parallel when you have multiple independent searches.", - "- Any line longer than 2000 characters is truncated.", - ].join("\n"), - execute: executeGrep, - inputSchema: GREP_INPUT_SCHEMA, - logicalPath: "eve:framework/grep", - name: "grep", - outputSchema: GREP_OUTPUT_SCHEMA, - sourceId: "eve:grep-tool", - sourceKind: "module", -}; diff --git a/packages/eve/src/runtime/framework-tools/index.test.ts b/packages/eve/src/runtime/framework-tools/index.test.ts deleted file mode 100644 index d115c81741..0000000000 --- a/packages/eve/src/runtime/framework-tools/index.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - getAllFrameworkToolDefinitions, - getAllFrameworkToolNames, - getFrameworkToolDefinitions, - getOptInFrameworkToolNames, -} from "#runtime/framework-tools/index.js"; -import { isToolSchema } from "#shared/tool-schema.js"; - -describe("framework-tools/index", () => { - it("returns every known framework tool name regardless of config", () => { - const names = getAllFrameworkToolNames(); - expect(names.has("bash")).toBe(true); - expect(names.has("read_file")).toBe(true); - expect(names.has("write_file")).toBe(true); - expect(names.has("glob")).toBe(true); - expect(names.has("grep")).toBe(true); - expect(names.has("web_fetch")).toBe(true); - expect(names.has("web_search")).toBe(true); - expect(names.has("todo")).toBe(true); - expect(names.has("load_skill")).toBe(true); - expect(names.has("ask_question")).toBe(true); - expect(names.has("agent")).toBe(true); - expect(names.has("task_update")).toBe(true); - expect(names.has("task_sleep")).toBe(false); - expect(names.has("task_send")).toBe(false); - expect(names.has("connection_search")).toBe(true); - }); - - it("contains every framework tool exactly once", () => { - const tools = getAllFrameworkToolDefinitions(); - const names = tools.map((tool) => tool.name); - - expect(new Set(names).size).toBe(names.length); - for (const tool of tools) { - expect(tool.name).toBeTypeOf("string"); - expect(tool.name.length).toBeGreaterThan(0); - } - - expect(names).toContain("agent"); - const defaultNames = getFrameworkToolDefinitions().map((tool) => tool.name); - expect(defaultNames).not.toContain("agent"); - expect(defaultNames).not.toContain("glob"); - expect(defaultNames).not.toContain("grep"); - }); - - it("identifies framework tools that require explicit authoring", () => { - expect([...getOptInFrameworkToolNames()].sort()).toEqual(["glob", "grep"]); - }); - - it("does not direct default tools to opt-in tools", () => { - const readFile = getFrameworkToolDefinitions().find((tool) => tool.name === "read_file"); - - expect(readFile?.description).not.toMatch(/\b(?:glob|grep)\b/u); - }); - - it("uses one validated runtime schema for every framework-defined input", () => { - for (const tool of getAllFrameworkToolDefinitions()) { - if (tool.inputSchema !== null) { - expect(isToolSchema(tool.inputSchema), `${tool.name} has a validated input schema`).toBe( - true, - ); - } - } - }); - - it("declares an output schema for every statically shaped registered tool", () => { - const tools = getFrameworkToolDefinitions(); - for (const tool of tools) { - if (tool.name === "web_search") { - expect(tool.outputSchema).toBeUndefined(); - continue; - } - - expect(tool.outputSchema, `${tool.name} has outputSchema`).toBeDefined(); - } - }); -}); diff --git a/packages/eve/src/runtime/framework-tools/index.ts b/packages/eve/src/runtime/framework-tools/index.ts deleted file mode 100644 index d3930aa578..0000000000 --- a/packages/eve/src/runtime/framework-tools/index.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { AGENT_TOOL_DEFINITION } from "#runtime/framework-tools/agent.js"; -import { ASK_QUESTION_TOOL_DEFINITION } from "#runtime/framework-tools/ask-question.js"; -import { BASH_TOOL_DEFINITION } from "#runtime/framework-tools/bash.js"; -import { GLOB_TOOL_DEFINITION } from "#runtime/framework-tools/glob.js"; -import { GREP_TOOL_DEFINITION } from "#runtime/framework-tools/grep.js"; -import { READ_FILE_TOOL_DEFINITION } from "#runtime/framework-tools/read-file.js"; -import { SKILL_TOOL_DEFINITION } from "#runtime/framework-tools/skill.js"; -import { TASK_TOOL_DEFINITIONS } from "#runtime/framework-tools/tasks.js"; -import { TODO_TOOL_DEFINITION } from "#runtime/framework-tools/todo.js"; -import { WEB_FETCH_TOOL_DEFINITION } from "#runtime/framework-tools/web-fetch.js"; -import { WEB_SEARCH_TOOL_DEFINITION } from "#runtime/framework-tools/web-search.js"; -import { WRITE_FILE_TOOL_DEFINITION } from "#runtime/framework-tools/write-file.js"; - -export { ConnectionRegistryKey } from "#context/providers/connection-key.js"; -export type { ReadFileStamp, ReadFileState } from "#runtime/framework-tools/file-state.js"; -export { ReadFileStateKey } from "#runtime/framework-tools/file-state.js"; -export type { TodoItem, TodoState } from "#runtime/framework-tools/todo.js"; -export { TodoStateKey } from "#runtime/framework-tools/todo.js"; - -import type { ResolvedToolDefinition } from "#runtime/types.js"; - -const REGISTERED_FRAMEWORK_TOOLS: readonly ResolvedToolDefinition[] = [ - ASK_QUESTION_TOOL_DEFINITION, - BASH_TOOL_DEFINITION, - READ_FILE_TOOL_DEFINITION, - WRITE_FILE_TOOL_DEFINITION, - TODO_TOOL_DEFINITION, - WEB_FETCH_TOOL_DEFINITION, - WEB_SEARCH_TOOL_DEFINITION, - SKILL_TOOL_DEFINITION, -]; - -const OPT_IN_FRAMEWORK_TOOLS: readonly ResolvedToolDefinition[] = [ - GLOB_TOOL_DEFINITION, - GREP_TOOL_DEFINITION, -]; - -const ALL_FRAMEWORK_TOOLS: readonly ResolvedToolDefinition[] = [ - ...REGISTERED_FRAMEWORK_TOOLS, - ...OPT_IN_FRAMEWORK_TOOLS, - AGENT_TOOL_DEFINITION, - ...TASK_TOOL_DEFINITIONS, -]; - -/** - * Returns framework-owned tool definitions registered in the tool registry - * alongside authored tools during graph resolution. - * - * Source-composed dynamic tools are not represented in this legacy catalog. - */ -export function getFrameworkToolDefinitions(): readonly ResolvedToolDefinition[] { - return REGISTERED_FRAMEWORK_TOOLS; -} - -/** - * Returns every static framework-owned tool definition, including tools such - * as `agent` that the runtime does not register in the tool registry. - */ -export function getAllFrameworkToolDefinitions(): readonly ResolvedToolDefinition[] { - return ALL_FRAMEWORK_TOOLS; -} - -/** Returns framework tools that authors must explicitly add to an agent. */ -export function getOptInFrameworkToolNames(): ReadonlySet { - return new Set(OPT_IN_FRAMEWORK_TOOLS.map((definition) => definition.name)); -} - -/** - * Returns the names of every framework-provided tool the framework knows - * about, regardless of whether the current agent gates any of them on - * runtime configuration. - * - * Used by the graph resolver to validate `disableTool(name)` arguments — - * disabling a name that does not match any known framework tool is treated - * as an authoring error rather than silently dropping the request. - */ -export function getAllFrameworkToolNames(): ReadonlySet { - return new Set([ - ...ALL_FRAMEWORK_TOOLS.map((definition) => definition.name), - "connection_search", - ]); -} diff --git a/packages/eve/src/runtime/framework-tools/read-file.ts b/packages/eve/src/runtime/framework-tools/read-file.ts index c6fbd1fa2b..b055069118 100644 --- a/packages/eve/src/runtime/framework-tools/read-file.ts +++ b/packages/eve/src/runtime/framework-tools/read-file.ts @@ -1,17 +1,11 @@ import { z } from "#compiled/zod/index.js"; -import { executeReadFileOnSandbox, type ReadFileInput } from "#execution/sandbox/read-file-tool.js"; -import { requireSandboxSession } from "#execution/sandbox/require-sandbox.js"; -import type { ResolvedToolDefinition } from "#runtime/types.js"; -import type { ToolExecuteOptions } from "#shared/tool-definition.js"; - /** * Shared input schema used by the framework `read_file` tool and any author * tool constructed via {@link defineReadFileTool}. * - * Exported so the public `defineReadFileTool` factory and the framework - * `READ_FILE_TOOL_DEFINITION` use the exact same schema object — keeping - * model input contracts in sync without duplication. + * Exported so the public `defineReadFileTool` factory and defaults share one + * model input contract. */ export const READ_FILE_INPUT_SCHEMA = z.strictObject({ filePath: z @@ -42,36 +36,3 @@ export const READ_FILE_OUTPUT_SCHEMA = z.strictObject({ totalLines: z.number().int().min(0), truncated: z.boolean(), }); - -/** - * Framework-owned executor that delegates to the default sandbox. - */ -async function executeReadFile(input: unknown, options?: ToolExecuteOptions): Promise { - return executeReadFileOnSandbox( - await requireSandboxSession(options?.abortSignal), - input as ReadFileInput, - ); -} - -export const READ_FILE_TOOL_DEFINITION: ResolvedToolDefinition = { - description: [ - "Read a file from the local filesystem. If the path does not exist, an error is returned.", - "", - "Usage:", - "- The filePath parameter should be an absolute path or begin with $HOME/.", - "- By default, this tool returns up to 2000 lines from the start of the file.", - "- The offset parameter is the line number to start from (1-indexed).", - "- To read later sections, call this tool again with a larger offset.", - '- Contents are returned with each line prefixed by its line number as `: `. For example, if a file has contents "foo\\n", you will receive "1: foo\\n".', - "- Any line longer than 2000 characters is truncated.", - "- Call this tool in parallel when you know there are multiple files you want to read.", - "- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.", - ].join("\n"), - execute: executeReadFile, - inputSchema: READ_FILE_INPUT_SCHEMA, - logicalPath: "eve:framework/read-file", - name: "read_file", - outputSchema: READ_FILE_OUTPUT_SCHEMA, - sourceId: "eve:read-file-tool", - sourceKind: "module", -}; diff --git a/packages/eve/src/runtime/framework-tools/skill.ts b/packages/eve/src/runtime/framework-tools/skill.ts index 2148a3540b..369b43c431 100644 --- a/packages/eve/src/runtime/framework-tools/skill.ts +++ b/packages/eve/src/runtime/framework-tools/skill.ts @@ -6,7 +6,6 @@ import { ConnectionRegistryKey } from "#context/providers/connection-key.js"; import { AuthoredSkillsKey } from "#context/providers/skill-key.js"; import type { ToolDefinition } from "#public/definitions/tool.js"; import { loadSkillFromSandbox } from "#runtime/skills/sandbox-access.js"; -import type { ResolvedToolDefinition } from "#runtime/types.js"; /** * Typed input accepted by {@link executeLoadSkillTool}. @@ -91,18 +90,3 @@ export const loadSkillToolDefinition: ToolDefinition = { inputSchema: SKILL_INPUT_SCHEMA, outputSchema: SKILL_OUTPUT_SCHEMA, }; - -/** - * Transitional runtime-catalog projection. Source-composed manifests replace - * this entry by canonical path; legacy in-memory graph fixtures still use it. - */ -export const SKILL_TOOL_DEFINITION: ResolvedToolDefinition = { - description: loadSkillToolDefinition.description, - execute: (input) => executeLoadSkillTool(input as LoadSkillInput), - inputSchema: SKILL_INPUT_SCHEMA, - logicalPath: "eve:framework/load-skill", - name: "load_skill", - outputSchema: SKILL_OUTPUT_SCHEMA, - sourceId: "eve:load-skill-tool", - sourceKind: "module", -}; diff --git a/packages/eve/src/runtime/framework-tools/subagent/local.ts b/packages/eve/src/runtime/framework-tools/subagent/local.ts index 607bb1613d..485a488158 100644 --- a/packages/eve/src/runtime/framework-tools/subagent/local.ts +++ b/packages/eve/src/runtime/framework-tools/subagent/local.ts @@ -26,7 +26,7 @@ import type { RuntimeRemoteAgentCallActionRequest, RuntimeSubagentCallActionRequest, } from "#runtime/actions/types.js"; -import { SUBAGENT_TASK_RECEIPT_OUTPUT_SCHEMA } from "#runtime/framework-tools/tasks.js"; +import { SUBAGENT_TASK_RECEIPT_OUTPUT_SCHEMA } from "#runtime/framework-tools/subagent/task-receipt.js"; import { PERSISTENT_SUBAGENT_TOOL_INPUT_SCHEMA } from "#runtime/subagents/registry.js"; import { parseJsonObject } from "#shared/json.js"; import { createSubagentExecutorBinding } from "#tasks/types.js"; diff --git a/packages/eve/src/runtime/framework-tools/subagent/remote.ts b/packages/eve/src/runtime/framework-tools/subagent/remote.ts index dff8f72698..163dfb6574 100644 --- a/packages/eve/src/runtime/framework-tools/subagent/remote.ts +++ b/packages/eve/src/runtime/framework-tools/subagent/remote.ts @@ -1,5 +1,5 @@ import { defineTool } from "#public/definitions/tool.js"; -import { SUBAGENT_TASK_RECEIPT_OUTPUT_SCHEMA } from "#runtime/framework-tools/tasks.js"; +import { SUBAGENT_TASK_RECEIPT_OUTPUT_SCHEMA } from "#runtime/framework-tools/subagent/task-receipt.js"; import { PERSISTENT_SUBAGENT_TOOL_INPUT_SCHEMA } from "#runtime/subagents/registry.js"; import { executeSubagentTool } from "#runtime/framework-tools/subagent/local.js"; diff --git a/packages/eve/src/runtime/framework-tools/subagent/task-receipt.ts b/packages/eve/src/runtime/framework-tools/subagent/task-receipt.ts new file mode 100644 index 0000000000..c00ea4a38d --- /dev/null +++ b/packages/eve/src/runtime/framework-tools/subagent/task-receipt.ts @@ -0,0 +1,7 @@ +import { z } from "#compiled/zod/index.js"; + +export const SUBAGENT_TASK_RECEIPT_OUTPUT_SCHEMA = z.strictObject({ + agentId: z.string(), + status: z.literal("working"), + taskId: z.string(), +}); diff --git a/packages/eve/src/runtime/framework-tools/task-cancel.ts b/packages/eve/src/runtime/framework-tools/task-cancel.ts new file mode 100644 index 0000000000..8e09b708e9 --- /dev/null +++ b/packages/eve/src/runtime/framework-tools/task-cancel.ts @@ -0,0 +1,55 @@ +import { z } from "#compiled/zod/index.js"; + +import type { HarnessToolDefinition } from "#harness/execute-tool.js"; + +export const TASK_CANCEL_TOOL_NAME = "task_cancel"; + +const TASK_IDS_SCHEMA = z + .array(z.string().min(1)) + .min(1) + .describe("Task ids from earlier subagent task receipts."); + +export const TASK_CANCEL_INPUT_SCHEMA = z.strictObject({ taskIds: TASK_IDS_SCHEMA }); + +const TASK_VIEW_SCHEMA = z.object({ + inputRequests: z.array(z.unknown()).optional(), + lastOutput: z + .object({ + data: z.unknown(), + type: z.enum(["result", "error"]), + }) + .optional(), + metadata: z.union([ + z.object({ + agentId: z.string(), + kind: z.literal("subagent"), + mode: z.enum(["local", "remote"]), + name: z.string(), + }), + z.object({ + data: z.record(z.string(), z.unknown()).optional(), + kind: z.string(), + name: z.string(), + }), + ]), + status: z.enum(["working", "input_required", "completed", "failed", "cancelled"]), + taskId: z.string(), +}); + +export const TASK_VIEWS_OUTPUT_SCHEMA = z.object({ + tasks: z.array(TASK_VIEW_SCHEMA), +}); + +export const TASK_CANCEL_DESCRIPTION = + "Request cooperative cancellation of one or more background tasks. " + + "Cancellation is final: a task that finishes after you cancel it stays cancelled. Cancelling an already-finished task changes nothing."; + +export function createTaskCancelHarnessDefinition(): HarnessToolDefinition { + return { + description: TASK_CANCEL_DESCRIPTION, + inputSchema: TASK_CANCEL_INPUT_SCHEMA, + name: TASK_CANCEL_TOOL_NAME, + outputSchema: TASK_VIEWS_OUTPUT_SCHEMA, + runtimeAction: { kind: "task-control" }, + }; +} diff --git a/packages/eve/src/runtime/framework-tools/task-update.ts b/packages/eve/src/runtime/framework-tools/task-update.ts new file mode 100644 index 0000000000..1b3a61ece6 --- /dev/null +++ b/packages/eve/src/runtime/framework-tools/task-update.ts @@ -0,0 +1,22 @@ +import { z } from "#compiled/zod/index.js"; + +import type { HarnessToolDefinition } from "#harness/execute-tool.js"; + +export const TASK_UPDATE_TOOL_NAME = "task_update"; + +export const TASK_UPDATE_INPUT_SCHEMA = z.strictObject({ + message: z.string().min(1).describe("Brief description of what this task is currently doing."), +}); + +export const TASK_UPDATE_DESCRIPTION = + "Briefly tell the parent agent what this background task is currently doing. " + + "Report activity, not preliminary findings or results."; + +export function createTaskUpdateHarnessDefinition(): HarnessToolDefinition { + return { + description: TASK_UPDATE_DESCRIPTION, + inputSchema: TASK_UPDATE_INPUT_SCHEMA, + name: TASK_UPDATE_TOOL_NAME, + runtimeAction: { kind: "task-control" }, + }; +} diff --git a/packages/eve/src/runtime/framework-tools/tasks.ts b/packages/eve/src/runtime/framework-tools/tasks.ts deleted file mode 100644 index d06e741daf..0000000000 --- a/packages/eve/src/runtime/framework-tools/tasks.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { z } from "#compiled/zod/index.js"; - -import type { HarnessToolDefinition } from "#harness/execute-tool.js"; -import type { ResolvedToolDefinition } from "#runtime/types.js"; - -/** - * Framework task tools for `experimental.tasks`. - * - * With the flag on, subagent calls return a task receipt instead of - * blocking the parent turn; these tools coordinate that delegated work. - * `task_cancel` and `task_update` are execute-less runtime actions — - * they need durable session state and world access, so the runtime-action - * dispatch step executes them. - */ - -export const TASK_CANCEL_TOOL_NAME = "task_cancel"; -export const TASK_UPDATE_TOOL_NAME = "task_update"; - -/** Every model-visible task tool name, for gating and dispatch matching. */ -export const TASK_TOOL_NAMES: ReadonlySet = new Set([ - TASK_CANCEL_TOOL_NAME, - TASK_UPDATE_TOOL_NAME, -]); - -/** Task-control tools executed by the runtime-action dispatch step. */ -export const TASK_CONTROL_TOOL_NAMES: ReadonlySet = new Set([ - TASK_CANCEL_TOOL_NAME, - TASK_UPDATE_TOOL_NAME, -]); - -const TASK_IDS_SCHEMA = z - .array(z.string().min(1)) - .min(1) - .describe("Task ids from earlier subagent task receipts."); - -export const TASK_CANCEL_INPUT_SCHEMA = z.strictObject({ taskIds: TASK_IDS_SCHEMA }); - -export const TASK_UPDATE_INPUT_SCHEMA = z.strictObject({ - message: z.string().min(1).describe("Brief description of what this task is currently doing."), -}); - -const TASK_VIEW_SCHEMA = z.object({ - inputRequests: z.array(z.unknown()).optional(), - lastOutput: z - .object({ - data: z.unknown(), - type: z.enum(["result", "error"]), - }) - .optional(), - metadata: z.union([ - z.object({ - agentId: z.string(), - kind: z.literal("subagent"), - mode: z.enum(["local", "remote"]), - name: z.string(), - }), - z.object({ - data: z.record(z.string(), z.unknown()).optional(), - kind: z.string(), - name: z.string(), - }), - ]), - status: z.enum(["working", "input_required", "completed", "failed", "cancelled"]), - taskId: z.string(), -}); - -export const TASK_VIEWS_OUTPUT_SCHEMA = z.object({ - tasks: z.array(TASK_VIEW_SCHEMA), -}); - -export const SUBAGENT_TASK_RECEIPT_OUTPUT_SCHEMA = z.strictObject({ - agentId: z.string(), - status: z.literal("working"), - taskId: z.string(), -}); - -const TASK_CANCEL_DESCRIPTION = - "Request cooperative cancellation of one or more background tasks. " + - "Cancellation is final: a task that finishes after you cancel it stays cancelled. Cancelling an already-finished task changes nothing."; - -const TASK_UPDATE_DESCRIPTION = - "Briefly tell the parent agent what this background task is currently doing. " + - "Report activity, not preliminary findings or results."; - -/** - * Builds the harness definitions injected when the root agent enables - * `experimental.tasks`. Follows the implicit `agent` tool pattern: - * inline definitions, no registry entry, session-shape hiding in - * advertised-tools, and re-validation at dispatch. - */ -export function createTaskToolHarnessDefinitions(): readonly HarnessToolDefinition[] { - return [ - { - description: TASK_CANCEL_DESCRIPTION, - inputSchema: TASK_CANCEL_INPUT_SCHEMA, - name: TASK_CANCEL_TOOL_NAME, - outputSchema: TASK_VIEWS_OUTPUT_SCHEMA, - runtimeAction: { kind: "task-control" }, - }, - { - description: TASK_UPDATE_DESCRIPTION, - inputSchema: TASK_UPDATE_INPUT_SCHEMA, - name: TASK_UPDATE_TOOL_NAME, - runtimeAction: { kind: "task-control" }, - }, - ]; -} - -/** - * Whether one node's sessions receive the task tools. - * - * Mirrors `isImplicitAgentToolAvailable`: the compile step already - * rejects `experimental.tasks` on subagents, authored tools with the - * same name shadow the framework tool, and `disableTool(name)` removes - * individual tools. Root-node self-delegated children share this node's - * config, so advertised-tools uses caller/session shape to expose only - * `task_update` to delegated task children. - */ -export function isTaskToolAvailable(input: { - readonly disabledFrameworkTools: readonly string[]; - readonly hasAuthoredTool: boolean; - readonly tasksEnabled: boolean; - readonly toolName: string; -}): boolean { - return ( - input.tasksEnabled && - !input.disabledFrameworkTools.includes(input.toolName) && - !input.hasAuthoredTool - ); -} - -function createResolvedTaskToolStub(input: { - readonly description: string; - readonly name: string; -}): ResolvedToolDefinition { - return { - description: input.description, - inputSchema: null, - logicalPath: `eve:framework/${input.name}`, - name: input.name, - sourceId: `eve:${input.name}-tool`, - sourceKind: "module", - }; -} - -/** - * Registry-shaped metadata for the task tools. Not registered in the - * tool registry (the harness injects the real definitions per node); - * these entries exist so `disableTool(name)` validates the names. - */ -export const TASK_TOOL_DEFINITIONS: readonly ResolvedToolDefinition[] = [ - createResolvedTaskToolStub({ description: TASK_CANCEL_DESCRIPTION, name: TASK_CANCEL_TOOL_NAME }), - createResolvedTaskToolStub({ description: TASK_UPDATE_DESCRIPTION, name: TASK_UPDATE_TOOL_NAME }), -]; diff --git a/packages/eve/src/runtime/framework-tools/todo.ts b/packages/eve/src/runtime/framework-tools/todo.ts index bae7bda863..69efa58cd8 100644 --- a/packages/eve/src/runtime/framework-tools/todo.ts +++ b/packages/eve/src/runtime/framework-tools/todo.ts @@ -4,7 +4,6 @@ import { z } from "#compiled/zod/index.js"; import { loadContext } from "#context/container.js"; import { ContextKey } from "#context/key.js"; import { TODO_COMPACTION_PRESERVATION_LABEL } from "#harness/compaction-prompt.js"; -import type { ResolvedToolDefinition } from "#runtime/types.js"; // --------------------------------------------------------------------------- // Durable context key @@ -135,34 +134,3 @@ export const TODO_OUTPUT_SCHEMA = z.strictObject({ }), todos: z.array(TODO_ITEM_SCHEMA), }); - -export const TODO_TOOL_DEFINITION: ResolvedToolDefinition = { - description: [ - "Use this tool to create and manage a structured task list for the current session.", - "This helps you track progress, organize complex tasks, and demonstrate thoroughness.", - "", - "When to use:", - "- Complex multistep tasks requiring 3 or more distinct steps", - "- When the user provides multiple tasks or a numbered list", - "- After receiving new instructions, to capture requirements", - "- After completing a task, to mark it complete and add follow-ups", - "", - "When NOT to use:", - "- Single, straightforward tasks that need no tracking", - "- Purely conversational or informational requests", - "", - "Usage:", - "- Call with `todos` to replace the entire list (full replacement write)", - "- Call without `todos` to read the current list", - "- Both return the full current list with status counts", - "- Mark tasks in_progress when you start, completed when done", - "- Only have ONE task in_progress at a time", - ].join("\n"), - execute: async (input) => executeTodoTool((input ?? {}) as TodoToolInput), - inputSchema: TODO_INPUT_SCHEMA, - logicalPath: "eve:framework/todo", - name: "todo", - outputSchema: TODO_OUTPUT_SCHEMA, - sourceId: "eve:todo-tool", - sourceKind: "module", -}; diff --git a/packages/eve/src/runtime/framework-tools/web-fetch.ts b/packages/eve/src/runtime/framework-tools/web-fetch.ts index 37fb1fa084..d51dccd236 100644 --- a/packages/eve/src/runtime/framework-tools/web-fetch.ts +++ b/packages/eve/src/runtime/framework-tools/web-fetch.ts @@ -1,13 +1,5 @@ import { z } from "#compiled/zod/index.js"; -import { executeWebFetchTool, type WebFetchInput } from "#execution/web-fetch/tool.js"; -import type { ResolvedToolDefinition } from "#runtime/types.js"; -import type { ToolExecuteOptions } from "#shared/tool-definition.js"; - -async function executeWebFetch(input: unknown, options?: ToolExecuteOptions): Promise { - return executeWebFetchTool(input as WebFetchInput, { abortSignal: options?.abortSignal }); -} - export const WEB_FETCH_INPUT_SCHEMA = z.strictObject({ format: z .enum(["markdown", "text", "html"]) @@ -25,24 +17,3 @@ export const WEB_FETCH_OUTPUT_SCHEMA = z.strictObject({ truncated: z.boolean(), url: z.string(), }); - -export const WEB_FETCH_TOOL_DEFINITION: ResolvedToolDefinition = { - description: [ - "Fetch a webpage and return its content in the requested format. Use this to retrieve and analyze content from URLs.", - "", - "Usage notes:", - "- The URL must be a fully-formed valid URL starting with https://", - "- HTML responses are automatically converted to markdown or plain text based on the requested format", - '- Format options: "markdown" (default), "text", or "html"', - "- Default timeout is 30 seconds (max 120 seconds)", - "- Maximum response size is 5 MB; content is further capped at the shared tool-output budget (50 KB / 2000 lines)", - "- This tool is read-only and does not modify any files", - ].join("\n"), - execute: executeWebFetch, - inputSchema: WEB_FETCH_INPUT_SCHEMA, - logicalPath: "eve:framework/web-fetch", - name: "web_fetch", - outputSchema: WEB_FETCH_OUTPUT_SCHEMA, - sourceId: "eve:web-fetch-tool", - sourceKind: "module", -}; diff --git a/packages/eve/src/runtime/framework-tools/web-search.ts b/packages/eve/src/runtime/framework-tools/web-search.ts index 738dec014d..d7099503dc 100644 --- a/packages/eve/src/runtime/framework-tools/web-search.ts +++ b/packages/eve/src/runtime/framework-tools/web-search.ts @@ -1,5 +1,6 @@ -import type { ResolvedToolDefinition } from "#runtime/types.js"; import type { JsonObject } from "#shared/json.js"; +import type { HarnessToolDefinition } from "#harness/execute-tool.js"; +import { UNSPECIFIED_INPUT_SCHEMA } from "#shared/tool-schema.js"; /** * Output schema for OpenAI's provider-managed `webSearch` tool. @@ -305,20 +306,15 @@ export const WEB_SEARCH_PARALLEL_OUTPUT_SCHEMA: JsonObject = { }, ], }; +export const WEB_SEARCH_TOOL_NAME = "web_search"; -/** - * Framework-provided web search tool definition. - * - * Omits `execute` — the execution layer skips executor creation for tools - * without it, and the harness injects the real provider-managed tool at - * step time. - */ -export const WEB_SEARCH_TOOL_DEFINITION: ResolvedToolDefinition = { - description: - "Search the web for real-time information. Use this to find up-to-date information about current events, recent developments, or topics that may have changed since the knowledge cutoff.", - inputSchema: null, - logicalPath: "eve:framework/web-search", - name: "web_search", - sourceId: "eve:web-search-tool", - sourceKind: "module", -}; +export const WEB_SEARCH_TOOL_DESCRIPTION = + "Search the web for real-time information. Use this to find up-to-date information about current events, recent developments, or topics that may have changed since the knowledge cutoff."; + +export function createWebSearchHarnessDefinition(): HarnessToolDefinition { + return { + description: WEB_SEARCH_TOOL_DESCRIPTION, + inputSchema: UNSPECIFIED_INPUT_SCHEMA, + name: WEB_SEARCH_TOOL_NAME, + }; +} diff --git a/packages/eve/src/runtime/framework-tools/write-file.ts b/packages/eve/src/runtime/framework-tools/write-file.ts index 313a18888b..2122e2b3fc 100644 --- a/packages/eve/src/runtime/framework-tools/write-file.ts +++ b/packages/eve/src/runtime/framework-tools/write-file.ts @@ -1,20 +1,11 @@ import { z } from "#compiled/zod/index.js"; -import { - executeWriteFileOnSandbox, - type WriteFileInput, -} from "#execution/sandbox/write-file-tool.js"; -import { requireSandboxSession } from "#execution/sandbox/require-sandbox.js"; -import type { ResolvedToolDefinition } from "#runtime/types.js"; -import type { ToolExecuteOptions } from "#shared/tool-definition.js"; - /** * Shared input schema used by the framework `write_file` tool and any author * tool constructed via {@link defineWriteFileTool}. * - * Exported so the public `defineWriteFileTool` factory and the framework - * `WRITE_FILE_TOOL_DEFINITION` use the exact same schema object — keeping - * model input contracts in sync without duplication. + * Exported so the public `defineWriteFileTool` factory and defaults share one + * model input contract. */ export const WRITE_FILE_INPUT_SCHEMA = z.strictObject({ content: z.string().describe("Complete replacement file contents."), @@ -31,33 +22,3 @@ export const WRITE_FILE_OUTPUT_SCHEMA = z.strictObject({ existed: z.boolean(), path: z.string(), }); - -/** - * Framework-owned executor that delegates to the default sandbox. - */ -async function executeWriteFile(input: unknown, options?: ToolExecuteOptions): Promise { - return executeWriteFileOnSandbox( - await requireSandboxSession(options?.abortSignal), - input as WriteFileInput, - ); -} - -export const WRITE_FILE_TOOL_DEFINITION: ResolvedToolDefinition = { - description: [ - "Writes a file to the local filesystem.", - "", - "Usage:", - "- This tool will overwrite the existing file if there is one at the provided path.", - "- If this is an existing file, you MUST use the read_file tool first to read the file's contents. This tool will fail if you did not read the file first.", - "- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.", - "- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.", - "- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.", - ].join("\n"), - execute: executeWriteFile, - inputSchema: WRITE_FILE_INPUT_SCHEMA, - logicalPath: "eve:framework/write-file", - name: "write_file", - outputSchema: WRITE_FILE_OUTPUT_SCHEMA, - sourceId: "eve:write-file-tool", - sourceKind: "module", -}; diff --git a/packages/eve/src/runtime/resolve-agent-graph.ts b/packages/eve/src/runtime/resolve-agent-graph.ts index 933a1c54a2..23be8cbc2f 100644 --- a/packages/eve/src/runtime/resolve-agent-graph.ts +++ b/packages/eve/src/runtime/resolve-agent-graph.ts @@ -14,21 +14,16 @@ import { getAllFrameworkChannelNames, getFrameworkChannelDefinitions, } from "#runtime/framework-channels/index.js"; -import { - getAllFrameworkToolNames, - getFrameworkToolDefinitions, -} from "#runtime/framework-tools/index.js"; import { type ResolvedAgentGraphBundle, ROOT_RUNTIME_AGENT_NODE_ID } from "#runtime/graph.js"; import { createRuntimeHookRegistry } from "#runtime/hooks/registry.js"; import { resolveAgent } from "#runtime/resolve-agent.js"; import { resolveDynamicSubagentDefinition } from "#runtime/resolve-dynamic-subagent.js"; import { loadResolvedModuleExport } from "#runtime/resolve-helpers.js"; import { createRuntimeSandboxRegistry } from "#runtime/sandbox/registry.js"; -import { LOAD_SKILL_TOOL_NAME } from "#runtime/skills/fragment-context.js"; import { createRuntimeSubagentRegistry } from "#runtime/subagents/registry.js"; import { createRuntimeToolRegistry } from "#runtime/tools/registry.js"; -import { WORKFLOW_TOOL_NAME } from "#shared/workflow-sandbox.js"; import { createWorkspacePromptSection } from "#runtime/workspace/spec.js"; +import { RESERVED_KERNEL_CAPABILITY_NAMES } from "#kernel/capabilities.js"; import type { ResolvedChannelDefinition, ResolvedDynamicSubagentDefinition, @@ -142,49 +137,9 @@ async function resolveRuntimeAgentNode( moduleMap: input.moduleMap, nodeId: input.nodeId, }); - const frameworkTools = getFrameworkToolDefinitions(); - const frameworkToolNames = new Set(frameworkTools.map((t) => t.name)); - const allFrameworkToolNames = getAllFrameworkToolNames(); - - // Authored tools whose filename slug matches a framework default replace - // it. Authored disable sentinels (whose target is also taken from the - // file's slug) remove a framework default. Both interactions happen here, - // before the registry is built, so the duplicate-name guard inside - // `createRuntimeToolRegistry` keeps doing its job for authored-vs-authored - // collisions. - const authoredToolNames = new Set(agent.tools.map((tool) => tool.name)); - - for (const disabledName of agent.disabledFrameworkTools) { - if (!allFrameworkToolNames.has(disabledName)) { - throw new ResolveRuntimeAgentGraphError( - `agent/tools/${disabledName}.ts exports disableTool() but "${disabledName}" is not a framework tool. ` + - `Rename the file to one of: ${[...allFrameworkToolNames].sort().join(", ")}.`, - { - nodeId, - sourceId: input.sourceId, - }, - ); - } - } - - const disabledFrameworkTools = new Set(agent.disabledFrameworkTools); - const activeFrameworkTools = frameworkTools.filter( - (tool) => !authoredToolNames.has(tool.name) && !disabledFrameworkTools.has(tool.name), - ); - const toolRegistry = await createRuntimeToolRegistry( - { - tools: [...activeFrameworkTools, ...agent.tools], - }, - { - reservedToolNames: [ - WORKFLOW_TOOL_NAME, - ...(frameworkToolNames.has(LOAD_SKILL_TOOL_NAME) || - authoredToolNames.has(LOAD_SKILL_TOOL_NAME) - ? [] - : [LOAD_SKILL_TOOL_NAME]), - ], - }, + { tools: agent.tools }, + { reservedToolNames: RESERVED_KERNEL_CAPABILITY_NAMES }, ); // Authored channels override framework defaults by matching logical name; // disable sentinels remove framework defaults with the same name. @@ -223,8 +178,11 @@ async function resolveRuntimeAgentNode( agent.config?.experimental?.tasks === true || agent.config?.experimental?.subagentPersistentSessions === true, reservedToolNames: [ - LOAD_SKILL_TOOL_NAME, - ...toolRegistry.preparedTools.map((tool) => tool.name), + ...new Set([ + ...agent.kernelCapabilities, + ...RESERVED_KERNEL_CAPABILITY_NAMES, + ...toolRegistry.preparedTools.map((tool) => tool.name), + ]), ], subagents: await resolveRuntimeSubagents({ childNodeIdsByParentNodeId: input.childNodeIdsByParentNodeId, diff --git a/packages/eve/src/runtime/resolve-agent.ts b/packages/eve/src/runtime/resolve-agent.ts index e731681b70..e4b31e52a4 100644 --- a/packages/eve/src/runtime/resolve-agent.ts +++ b/packages/eve/src/runtime/resolve-agent.ts @@ -111,7 +111,7 @@ export async function resolveAgent(input: ResolveAgentInput): Promise { "subagents/researcher::subagents/reviewer", ]); expect(graph.root.turnAgent.tools).toMatchObject([ - { - description: - "Ask the user a question and wait for their response before continuing. Use this when you need clarification or a choice from the user.", - kind: "authored-tool", - name: "ask_question", - }, - { - description: "Execute a shell command in the shared workspace environment.", - kind: "authored-tool", - name: "bash", - }, - { - kind: "authored-tool", - name: "read_file", - }, - { - kind: "authored-tool", - name: "write_file", - }, - { - kind: "authored-tool", - name: "todo", - }, - { - description: [ - "Fetch a webpage and return its content in the requested format. Use this to retrieve and analyze content from URLs.", - "", - "Usage notes:", - "- The URL must be a fully-formed valid URL starting with https://", - "- HTML responses are automatically converted to markdown or plain text based on the requested format", - '- Format options: "markdown" (default), "text", or "html"', - "- Default timeout is 30 seconds (max 120 seconds)", - "- Maximum response size is 5 MB; content is further capped at the shared tool-output budget (50 KB / 2000 lines)", - "- This tool is read-only and does not modify any files", - ].join("\n"), - kind: "authored-tool", - name: "web_fetch", - }, - { - description: - "Search the web for real-time information. Use this to find up-to-date information about current events, recent developments, or topics that may have changed since the knowledge cutoff.", - kind: "authored-tool", - name: "web_search", - }, - { - kind: "authored-tool", - name: "load_skill", - }, { description: "Get the weather.", inputSchema: null, @@ -452,54 +404,6 @@ describe("resolveRuntimeAgentGraph", () => { }, ]); expect(researcherNode?.turnAgent.tools).toMatchObject([ - { - description: - "Ask the user a question and wait for their response before continuing. Use this when you need clarification or a choice from the user.", - kind: "authored-tool", - name: "ask_question", - }, - { - description: "Execute a shell command in the shared workspace environment.", - kind: "authored-tool", - name: "bash", - }, - { - kind: "authored-tool", - name: "read_file", - }, - { - kind: "authored-tool", - name: "write_file", - }, - { - kind: "authored-tool", - name: "todo", - }, - { - description: [ - "Fetch a webpage and return its content in the requested format. Use this to retrieve and analyze content from URLs.", - "", - "Usage notes:", - "- The URL must be a fully-formed valid URL starting with https://", - "- HTML responses are automatically converted to markdown or plain text based on the requested format", - '- Format options: "markdown" (default), "text", or "html"', - "- Default timeout is 30 seconds (max 120 seconds)", - "- Maximum response size is 5 MB; content is further capped at the shared tool-output budget (50 KB / 2000 lines)", - "- This tool is read-only and does not modify any files", - ].join("\n"), - kind: "authored-tool", - name: "web_fetch", - }, - { - description: - "Search the web for real-time information. Use this to find up-to-date information about current events, recent developments, or topics that may have changed since the knowledge cutoff.", - kind: "authored-tool", - name: "web_search", - }, - { - kind: "authored-tool", - name: "load_skill", - }, { description: "Search the web.", inputSchema: null, @@ -769,19 +673,10 @@ describe("resolveRuntimeAgentGraph", () => { logicalPath: "tools/bash.mjs", name: "bash", }); - expect(tools.map((tool) => tool.name)).toEqual([ - "ask_question", - "read_file", - "write_file", - "todo", - "web_fetch", - "web_search", - "load_skill", - "bash", - ]); + expect(tools.map((tool) => tool.name)).toEqual(["bash"]); }); - it("removes a framework tool when listed in disabledFrameworkTools", async () => { + it("does not synthesize ordinary tools omitted from compiled artifacts", async () => { const manifest = createCompiledAgentManifest({ agentRoot: "/app/agent", appRoot: "/app", @@ -792,7 +687,6 @@ describe("resolveRuntimeAgentGraph", () => { }, name: "weather-agent", }, - disabledFrameworkTools: ["web_fetch"], }); const graph = await resolveRuntimeAgentGraph({ @@ -806,18 +700,10 @@ describe("resolveRuntimeAgentGraph", () => { }, }); - expect(graph.root.turnAgent.tools.map((tool) => tool.name)).toEqual([ - "ask_question", - "bash", - "read_file", - "write_file", - "todo", - "web_search", - "load_skill", - ]); + expect(graph.root.turnAgent.tools).toEqual([]); }); - it("accepts the runtime-owned agent tool in disabledFrameworkTools", async () => { + it("uses the compiled kernel plan as the only native tool authority", async () => { const manifest = createCompiledAgentManifest({ agentRoot: "/app/agent", appRoot: "/app", @@ -828,7 +714,7 @@ describe("resolveRuntimeAgentGraph", () => { }, name: "weather-agent", }, - disabledFrameworkTools: ["agent"], + kernelCapabilities: [], }); const graph = await resolveRuntimeAgentGraph({ @@ -842,7 +728,7 @@ describe("resolveRuntimeAgentGraph", () => { }, }); - expect(graph.root.agent.disabledFrameworkTools).toContain("agent"); + expect(graph.root.agent.kernelCapabilities).toEqual([]); expect(createNodeHarnessTools({ node: graph.root }).has("agent")).toBe(false); }); @@ -857,7 +743,6 @@ describe("resolveRuntimeAgentGraph", () => { }, name: "weather-agent", }, - disabledFrameworkTools: ["web_fetch"], tools: [ { description: "Sandboxed shell.", @@ -890,22 +775,14 @@ describe("resolveRuntimeAgentGraph", () => { const graph = await resolveRuntimeAgentGraph({ manifest, moduleMap }); const tools = graph.root.turnAgent.tools; - expect(tools.map((tool) => tool.name)).toEqual([ - "ask_question", - "read_file", - "write_file", - "todo", - "web_search", - "load_skill", - "bash", - ]); + expect(tools.map((tool) => tool.name)).toEqual(["bash"]); expect(tools.find((tool) => tool.name === "bash")).toMatchObject({ description: "Sandboxed shell.", logicalPath: "tools/bash.mjs", }); }); - it("includes web_search as a default framework tool", async () => { + it("materializes web_search from the compiled kernel plan", async () => { const manifest = createCompiledAgentManifest({ agentRoot: "/app/agent", appRoot: "/app", @@ -916,6 +793,8 @@ describe("resolveRuntimeAgentGraph", () => { }, name: "weather-agent", }, + kernelCapabilities: ["web_search"], + webSearchProvider: "exa", }); const graph = await resolveRuntimeAgentGraph({ @@ -929,10 +808,10 @@ describe("resolveRuntimeAgentGraph", () => { }, }); - expect(graph.root.turnAgent.tools.map((t) => t.name)).toContain("web_search"); + expect(createNodeHarnessTools({ node: graph.root }).has("web_search")).toBe(true); }); - it("removes web_search when listed in disabledFrameworkTools", async () => { + it("omits web_search when the compiled kernel plan omits it", async () => { const manifest = createCompiledAgentManifest({ agentRoot: "/app/agent", appRoot: "/app", @@ -943,7 +822,7 @@ describe("resolveRuntimeAgentGraph", () => { }, name: "weather-agent", }, - disabledFrameworkTools: ["web_search"], + kernelCapabilities: [], }); const graph = await resolveRuntimeAgentGraph({ @@ -957,7 +836,7 @@ describe("resolveRuntimeAgentGraph", () => { }, }); - expect(graph.root.turnAgent.tools.map((t) => t.name)).not.toContain("web_search"); + expect(createNodeHarnessTools({ node: graph.root }).has("web_search")).toBe(false); }); it("replaces the framework web_search when an authored tool overrides it", async () => { @@ -1009,7 +888,7 @@ describe("resolveRuntimeAgentGraph", () => { }); }); - it("throws when disabledFrameworkTools references an unknown framework tool", async () => { + it("accepts a compiled manifest with no ordinary or native tools", async () => { const manifest = createCompiledAgentManifest({ agentRoot: "/app/agent", appRoot: "/app", @@ -1020,22 +899,21 @@ describe("resolveRuntimeAgentGraph", () => { }, name: "weather-agent", }, - disabledFrameworkTools: ["nonexistent_tool"], + kernelCapabilities: [], }); - await expect( - resolveRuntimeAgentGraph({ - manifest, - moduleMap: { - nodes: { - [ROOT_COMPILED_AGENT_NODE_ID]: { - modules: {}, - }, + const graph = await resolveRuntimeAgentGraph({ + manifest, + moduleMap: { + nodes: { + [ROOT_COMPILED_AGENT_NODE_ID]: { + modules: {}, }, }, - }), - ).rejects.toThrow( - /agent\/tools\/nonexistent_tool\.ts exports disableTool\(\) but "nonexistent_tool" is not a framework tool/, - ); + }, + }); + + expect(graph.root.turnAgent.tools).toEqual([]); + expect(createNodeHarnessTools({ node: graph.root }).size).toBe(0); }); }); diff --git a/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts b/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts index 8c8a032d2a..fcda45d655 100644 --- a/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts +++ b/packages/eve/test/scenarios/app-runtime-dependencies.scenario.test.ts @@ -801,9 +801,7 @@ describe("app runtime dependency tracing", () => { ); expect(serverModuleSource).not.toContain('import("esbuild")'); expect(serverModuleSource).not.toContain('import("rolldown")'); - expect(serverModuleSource).toContain( - "This tool requires sandbox access on the runtime context.", - ); + expect(serverModuleSource).toContain("read_file only supports text files."); expect(serverModuleSource).toContain("The dynamic skill"); expect(serverModuleSource).toContain("URL must start with https://"); }, 30_000); diff --git a/packages/eve/test/scenarios/bundle-module-evaluation.scenario.test.ts b/packages/eve/test/scenarios/bundle-module-evaluation.scenario.test.ts index 1eeba0abac..42f05351a4 100644 --- a/packages/eve/test/scenarios/bundle-module-evaluation.scenario.test.ts +++ b/packages/eve/test/scenarios/bundle-module-evaluation.scenario.test.ts @@ -93,34 +93,4 @@ describe("eve dist single-chunk module evaluation", () => { const loaded = await import(pathToFileURL(outfile).href); expect(loaded.__steps_registered).toBe(true); }, 180_000); - - it("every framework tool definition is defined in the concatenated chunk (no silent `undefined` slots)", async () => { - // Defense in depth: catches any future cycle that lands a - // `*_TOOL_DEFINITION` after the registry in the bundle. - const scratch = await createScratchDirectory("eve-framework-tools-eval-"); - const outDir = join(scratch, "out"); - await mkdir(outDir, { recursive: true }); - - const entryFile = join(scratch, "entry.mjs"); - const eveEntry = resolvePackageSourceFilePath("src/runtime/framework-tools/index.ts"); - await writeFile( - entryFile, - `import * as ft from ${JSON.stringify(eveEntry)};\nexport default ft;\n`, - ); - - const outfile = await bundleEveDistAsSingleChunk({ - cwd: scratch, - entry: entryFile, - outDir, - }); - - const loaded = await import(pathToFileURL(outfile).href); - const tools = loaded.default.getAllFrameworkToolDefinitions(); - expect(tools.length).toBeGreaterThan(0); - for (const tool of tools) { - expect(tool, "framework tool entry must be defined").toBeDefined(); - expect(typeof tool.name).toBe("string"); - expect(tool.name.length).toBeGreaterThan(0); - } - }, 180_000); }); diff --git a/packages/eve/test/scenarios/compile-agent.scenario.test.ts b/packages/eve/test/scenarios/compile-agent.scenario.test.ts index 20a387ed65..e41a9d2069 100644 --- a/packages/eve/test/scenarios/compile-agent.scenario.test.ts +++ b/packages/eve/test/scenarios/compile-agent.scenario.test.ts @@ -691,20 +691,20 @@ describe("compileAgent", () => { startPath: app.appRoot, }); - // The disable sentinel reaches the compiled manifest as a name in the - // dedicated array, not as a tool entry. - expect([...result.manifest.disabledFrameworkTools].sort()).toEqual([ - "agent", - "web_fetch", - "web_search", - ]); + expect(result.manifest.kernelCapabilities).toEqual(["ask_question"]); // Both the wrapped bash and the replacement todo land in `tools` as // ordinary CompiledToolDefinitions. The web_fetch override is intentionally // absent — the disable sentinel is partitioned out before this point. const toolsByName = new Map(result.manifest.tools.map((tool) => [tool.name, tool])); - expect([...toolsByName.keys()].sort()).toEqual(["bash", "todo"]); + expect([...toolsByName.keys()].sort()).toEqual([ + "bash", + "load_skill", + "read_file", + "todo", + "write_file", + ]); expect(toolsByName.get("bash")).toMatchObject({ description: "Run a vetted shell command in the project sandbox.", From 736b51cc725dd63d674b921159e76c39fe2d79ab Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Sat, 22 Aug 2026 11:46:50 -0400 Subject: [PATCH 09/12] fix(eve): preserve dynamic extension namespaces Carry the mount namespace through canonical composition so map-producing dynamic tools and skills retain their qualified runtime names. Signed-off-by: Andrew Barba --- .../src/compiler/agent-module-candidate.ts | 1 + .../eve/src/compiler/compose-agent-sources.ts | 6 +++ .../src/compiler/normalize-manifest.test.ts | 53 +++++++++++++++++++ .../eve/src/compiler/normalize-manifest.ts | 27 +++++++--- 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/packages/eve/src/compiler/agent-module-candidate.ts b/packages/eve/src/compiler/agent-module-candidate.ts index 604fcf39b4..90fc816b31 100644 --- a/packages/eve/src/compiler/agent-module-candidate.ts +++ b/packages/eve/src/compiler/agent-module-candidate.ts @@ -8,6 +8,7 @@ export type AgentSourceLayer = export interface AgentModuleCandidate { readonly backing: CompiledModuleBacking; + readonly extensionNamespace?: string; readonly layer: AgentSourceLayer; readonly logicalPath: string; readonly nodeId: string; diff --git a/packages/eve/src/compiler/compose-agent-sources.ts b/packages/eve/src/compiler/compose-agent-sources.ts index 2c912a0f3d..ad2f0071cb 100644 --- a/packages/eve/src/compiler/compose-agent-sources.ts +++ b/packages/eve/src/compiler/compose-agent-sources.ts @@ -44,6 +44,7 @@ type ComposableSlot = export interface AgentSourceOrigin { readonly backing: Omit, "sourcePath">; + readonly extensionNamespace?: string; readonly layer: Exclude; readonly owner: AgentSourceOwner; readonly sourceIdPrefix?: string; @@ -184,6 +185,7 @@ function createExtensionCandidates( extensionScope, kind: "filesystem", }, + extensionNamespace: mount.namespace, layer: "extension-package", owner: { kind: "extension", @@ -206,6 +208,7 @@ function createExtensionCandidates( externalDependencies: [...externalDependencies], kind: "filesystem", }, + extensionNamespace: mount.namespace, layer: "extension-override", owner: { kind: "application" }, sourceIdPrefix: `ext-override:${mount.namespace}`, @@ -245,6 +248,9 @@ function createManifestCandidates(input: { return { candidate: { backing: { ...input.origin.backing, sourcePath }, + ...(input.origin.extensionNamespace === undefined + ? {} + : { extensionNamespace: input.origin.extensionNamespace }), layer: input.origin.layer, logicalPath, nodeId: input.nodeId, diff --git a/packages/eve/src/compiler/normalize-manifest.test.ts b/packages/eve/src/compiler/normalize-manifest.test.ts index 3b058199e6..7be059a5c9 100644 --- a/packages/eve/src/compiler/normalize-manifest.test.ts +++ b/packages/eve/src/compiler/normalize-manifest.test.ts @@ -205,6 +205,59 @@ describe("compileAgentManifest", () => { }); }); + it("preserves the mount namespace on extension dynamic tool and skill resolvers", async () => { + const extensionManifest = createAgentSourceManifest({ + agentId: "toolkit-extension", + agentRoot: "/packages/toolkit/dist/extension", + appRoot: "/packages/toolkit", + tools: [createModuleSourceRef({ logicalPath: "tools/forecast.ts" })], + }); + const overrides = createAgentSourceManifest({ + agentId: "toolkit-overrides", + agentRoot: "/app/agent/extensions/toolkit", + appRoot: "/app", + skills: [createModuleSourceRef({ logicalPath: "skills/playbooks.ts" })], + }); + const manifest = createAgentSourceManifest({ + agentId: "root", + agentRoot: "/app/agent", + appRoot: "/app", + resolvedExtensions: [ + { + namespace: "toolkit", + specifier: "toolkit-extension", + packageName: "toolkit-extension", + packageRoot: "/packages/toolkit", + sourceRoot: "/packages/toolkit/dist/extension", + manifest: extensionManifest, + overrides, + externalDependencies: [], + }, + ], + }); + mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" })); + mocks.applicationDefinition.mockResolvedValue( + defineDynamic({ events: { "session.started": () => null } }), + ); + + const compiled = await compileAgentManifest(manifest); + + expect(compiled.dynamicTools).toContainEqual( + expect.objectContaining({ + extensionNamespace: "toolkit", + logicalPath: "tools/toolkit__forecast.ts", + slug: "toolkit__forecast", + }), + ); + expect(compiled.dynamicSkills).toContainEqual( + expect.objectContaining({ + extensionNamespace: "toolkit", + logicalPath: "skills/toolkit__playbooks.ts", + slug: "toolkit__playbooks", + }), + ); + }); + it("rejects background-task configuration on subagents", async () => { const subagentManifest = createAgentSourceManifest({ agentId: "research", diff --git a/packages/eve/src/compiler/normalize-manifest.ts b/packages/eve/src/compiler/normalize-manifest.ts index ca0458c40a..9407452473 100644 --- a/packages/eve/src/compiler/normalize-manifest.ts +++ b/packages/eve/src/compiler/normalize-manifest.ts @@ -247,7 +247,7 @@ async function compileAgentResources( frameworkLoadSkill = true; } } else if (entry.kind === "dynamic-tool") { - dynamicTools.push(entry.definition); + dynamicTools.push(withExtensionNamespace(entry.definition, sourceComposition)); } else if (entry.kind === "workflow-tool") { workflowTool = { maxSubagents: entry.maxSubagents }; } else if (entry.kind === "web-search-tool") { @@ -285,18 +285,25 @@ async function compileAgentResources( }); const compiledSkillEntries = await Promise.all( - manifest.skills.map((skillSource) => - compileSkillSource(manifest.agentRoot, skillSource, loadOptions(skillSource.sourceId)), - ), + manifest.skills.map(async (skillSource) => ({ + entry: await compileSkillSource( + manifest.agentRoot, + skillSource, + loadOptions(skillSource.sourceId), + ), + source: skillSource, + })), ); const skills: CompiledSkillDefinition[] = []; const dynamicSkills: CompiledDynamicSkillDefinition[] = []; - for (const entry of compiledSkillEntries) { + for (const { entry, source } of compiledSkillEntries) { if (entry.kind === "skill") { skills.push(entry.definition); } else { - dynamicSkills.push(entry.definition); + dynamicSkills.push( + withExtensionNamespace(entry.definition, findSourceComposition(sources, source.sourceId)), + ); } } @@ -453,6 +460,14 @@ function findSourceComposition(sources: ComposedAgentSources, sourceId: string) return sources.composition.entries.find((entry) => entry.winner.sourceId === sourceId); } +function withExtensionNamespace( + definition: T, + composition: ReturnType, +): T { + const extensionNamespace = composition?.winner.extensionNamespace; + return extensionNamespace === undefined ? definition : { ...definition, extensionNamespace }; +} + function validateDisableTarget( logicalPath: string, composition: ReturnType, From 9867d729cba169cfd67308533c3a8de1c37e999b Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Sat, 22 Aug 2026 11:46:53 -0400 Subject: [PATCH 10/12] fix(eve): materialize programmatic source generations Signed-off-by: Andrew Barba --- .../authored-module-loader.scenario.test.ts | 4 +- .../src/internal/authored-module-loader.ts | 62 ++++++++++++++++++- .../dev-generation-artifacts.scenario.test.ts | 12 +++- .../runtime-loaders.scenario.test.ts | 40 +++++------- 4 files changed, 89 insertions(+), 29 deletions(-) diff --git a/packages/eve/src/internal/authored-module-loader.scenario.test.ts b/packages/eve/src/internal/authored-module-loader.scenario.test.ts index 1a26d80d08..a8d761075d 100644 --- a/packages/eve/src/internal/authored-module-loader.scenario.test.ts +++ b/packages/eve/src/internal/authored-module-loader.scenario.test.ts @@ -1057,7 +1057,7 @@ describe("loadAuthoredModuleNamespace", () => { const manifest = await compileAgentManifest(discovered.manifest); expect(manifest.config.build?.externalDependencies).toEqual(["external-only"]); - expect(manifest.tools).toHaveLength(1); + expect(manifest.tools.some((tool) => tool.name === "read_external")).toBe(true); } finally { await rm(workspaceRoot, { force: true, recursive: true }); } @@ -1163,7 +1163,7 @@ describe("loadAuthoredModuleNamespace", () => { expect(manifest.config.build?.externalDependencies).toEqual(["external-only"]); expect(subagent?.agent.config.build?.externalDependencies).toEqual(["external-only"]); - expect(subagent?.agent.tools).toHaveLength(1); + expect(subagent?.agent.tools.some((tool) => tool.name === "read_external")).toBe(true); } finally { await rm(workspaceRoot, { force: true, recursive: true }); } diff --git a/packages/eve/src/internal/authored-module-loader.ts b/packages/eve/src/internal/authored-module-loader.ts index c442cb49f1..554e67343d 100644 --- a/packages/eve/src/internal/authored-module-loader.ts +++ b/packages/eve/src/internal/authored-module-loader.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import type { CompiledAgentManifest } from "#compiler/manifest.js"; import { createCompiledModuleMapSource } from "#compiler/module-map.js"; @@ -31,6 +31,11 @@ import { } from "#internal/bundler/nitro-rolldown.js"; import { createNodeEsmCompatBannerPlugin } from "#internal/node-esm-compat-banner.js"; import { createDynamicCapabilityTransformPlugin } from "#internal/workflow-bundle/dynamic-capability-transform-plugin.js"; +import { + resolvePackageCompiledFilePath, + resolvePackageRoot, + resolvePackageSourceFilePath, +} from "#internal/application/package.js"; const AUTHORED_BUNDLED_MODULE_EXTENSION = /\.[cm]?[jt]sx?$/; const AUTHORED_MODULE_BUNDLE_DIRECTORY_PATH = join( @@ -285,6 +290,7 @@ export async function bundleAuthoredModuleMapForGeneration(input: { id: input.moduleMapPath, source: moduleMapSource, }), + createEvePackageImportResolverPlugin(), createDynamicCapabilityTransformPlugin(), createAuthoredDirectiveGuardPlugin(), extensionScopePlugin, @@ -321,6 +327,60 @@ export async function bundleAuthoredModuleMapForGeneration(input: { } } +/** + * Resolves eve's private package imports to the files present in the executing + * installation. Generation bundling opts into the `eve-source` condition for + * linked workspace packages, but published eve packages intentionally omit + * `src/`; resolving these edges explicitly keeps package-owned programmatic + * sources bundleable in both layouts. + */ +function createEvePackageImportResolverPlugin(): Record { + const packageRoot = realpathSync.native(resolvePackageRoot()); + + return { + name: "eve-package-imports", + resolveId(source: string, importer: string | undefined) { + if (importer === undefined || !source.startsWith("#")) return undefined; + + const importerPath = resolve(importer); + if (!isPathInsideOrEqual(realpathExistingAncestor(importerPath), packageRoot)) { + return undefined; + } + + if (source.startsWith("#compiled/")) { + return { + id: resolvePackageCompiledFilePath(`src/compiled/${source.slice("#compiled/".length)}`), + }; + } + + const match = source.match(/^#(.+)\.js$/); + if (match === null) return undefined; + + return { + id: resolvePackageSourceFilePath(`src/${match[1]}.ts`), + }; + }, + }; +} + +function realpathExistingAncestor(path: string): string { + let candidate = path; + while (!existsSync(candidate)) { + const parent = dirname(candidate); + if (parent === candidate) return path; + candidate = parent; + } + return realpathSync.native(candidate); +} + +function isPathInsideOrEqual(path: string, root: string): boolean { + const relativePath = relative(root, path); + return ( + relativePath === "" || + (relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath)) + ); +} + function createVirtualGenerationModuleMapPlugin(input: { readonly id: string; readonly source: string; diff --git a/packages/eve/src/internal/nitro/dev-generation-artifacts.scenario.test.ts b/packages/eve/src/internal/nitro/dev-generation-artifacts.scenario.test.ts index 352b3c4565..a9c8668d87 100644 --- a/packages/eve/src/internal/nitro/dev-generation-artifacts.scenario.test.ts +++ b/packages/eve/src/internal/nitro/dev-generation-artifacts.scenario.test.ts @@ -75,7 +75,9 @@ describe("development generation artifacts", () => { ), }); const subagent = compileResult.manifest.subagents[0]; - const subagentToolSourceId = subagent?.agent.tools[0]?.sourceId; + const subagentToolSourceId = subagent?.agent.tools.find( + (tool) => tool.name === "read_shared", + )?.sourceId; expect(subagentToolSourceId).toBeDefined(); const subagentTool = moduleMap.nodes[subagent!.nodeId]?.modules[subagentToolSourceId!] as { default: { execute(): string }; @@ -385,7 +387,9 @@ describe("development generation artifacts", () => { snapshot.runtimeAppRoot, ), }); - const toolSourceId = compileResult.manifest.tools[0]?.sourceId; + const toolSourceId = compileResult.manifest.tools.find( + (tool) => tool.name === "read_dynamic", + )?.sourceId; expect(toolSourceId).toBeDefined(); const tool = moduleMap.nodes[ROOT_COMPILED_AGENT_NODE_ID]?.modules[toolSourceId!] as { default: { execute(): Promise }; @@ -454,7 +458,9 @@ describe("development generation artifacts", () => { snapshot.runtimeAppRoot, ), }); - const toolSourceId = compileResult.manifest.tools[0]?.sourceId; + const toolSourceId = compileResult.manifest.tools.find( + (tool) => tool.name === "read_value", + )?.sourceId; expect(toolSourceId).toBeDefined(); const tool = moduleMap.nodes[ROOT_COMPILED_AGENT_NODE_ID]?.modules[toolSourceId!] as { default: { execute(): string }; diff --git a/packages/eve/test/scenarios/runtime-loaders.scenario.test.ts b/packages/eve/test/scenarios/runtime-loaders.scenario.test.ts index 363e54abe0..ba5c8c6453 100644 --- a/packages/eve/test/scenarios/runtime-loaders.scenario.test.ts +++ b/packages/eve/test/scenarios/runtime-loaders.scenario.test.ts @@ -345,8 +345,8 @@ describe("runtime compiled artifact loaders", () => { compiledArtifactsSource, }), ]); - const [compiledChannel] = manifest.channels; - const [resolvedChannel] = resolvedAgent.channels; + const compiledChannel = manifest.channels.find((channel) => channel.name === "slack"); + const resolvedChannel = resolvedAgent.channels.find((channel) => channel.name === "slack"); expect(manifest.config).toEqual({ compaction: {}, @@ -412,14 +412,12 @@ describe("runtime compiled artifact loaders", () => { expect(resolvedChannel.method).toBe("POST"); expect(resolvedChannel.urlPath).toBe("/slack"); expect(typeof resolvedChannel.fetch).toBe("function"); - expect(resolvedAgent.channels).toHaveLength(1); + expect(resolvedAgent.channels.some((channel) => channel.name === "eve")).toBe(true); // Authored instructions modules execute once at build time. They never appear in // the runtime module map. - expect(Object.keys(moduleMap.nodes[ROOT_COMPILED_AGENT_NODE_ID]?.modules ?? {})).toEqual([ - "agent.mjs", - "channels/slack.mjs", - "tools/get_weather.mjs", - ]); + expect(Object.keys(moduleMap.nodes[ROOT_COMPILED_AGENT_NODE_ID]?.modules ?? {})).toEqual( + expect.arrayContaining(["agent.mjs", "channels/slack.mjs", "tools/get_weather.mjs"]), + ); expect( ( moduleMap.nodes[ROOT_COMPILED_AGENT_NODE_ID]!.modules["tools/get_weather.mjs"] as { @@ -428,10 +426,9 @@ describe("runtime compiled artifact loaders", () => { ).default.description, ).toBe("Get the weather."); await expect( - resolvedAgent.tools[0]?.execute?.( - { city: "Brooklyn" }, - { messages: [], toolCallId: "call_1" }, - ), + resolvedAgent.tools + .find((tool) => tool.name === "get_weather") + ?.execute?.({ city: "Brooklyn" }, { messages: [], toolCallId: "call_1" }), ).resolves.toEqual({ city: "Brooklyn", source: "lib", @@ -531,11 +528,9 @@ describe("runtime compiled artifact loaders", () => { ROOT_COMPILED_AGENT_NODE_ID, "subagents/researcher", ]); - expect(Object.keys(moduleMap.nodes["subagents/researcher"]?.modules ?? {})).toEqual([ - "agent.mjs", - "sandbox/sandbox.mjs", - "tools/search.mjs", - ]); + expect(Object.keys(moduleMap.nodes["subagents/researcher"]?.modules ?? {})).toEqual( + expect.arrayContaining(["agent.mjs", "sandbox/sandbox.mjs", "tools/search.mjs"]), + ); expect(researcherNode?.agent.instructions).toEqual([ { content: "Investigate research tasks thoroughly.", @@ -560,10 +555,9 @@ describe("runtime compiled artifact loaders", () => { ), ).toBe(false); await expect( - researcherNode?.agent.tools[0]?.execute?.( - { query: "climate" }, - { messages: [], toolCallId: "call_1" }, - ), + researcherNode?.agent.tools + .find((tool) => tool.name === "search") + ?.execute?.({ query: "climate" }, { messages: [], toolCallId: "call_1" }), ).resolves.toEqual({ query: "climate", source: "subagent-lib", @@ -618,7 +612,7 @@ describe("runtime compiled artifact loaders", () => { const firstResolved = await loadResolvedCompiledAgent({ compiledArtifactsSource, }); - const firstTool = firstResolved.tools[0]; + const firstTool = firstResolved.tools.find((tool) => tool.name === "get_weather"); if (firstTool === undefined) { throw new Error("Expected one compiled tool before the source update."); @@ -646,7 +640,7 @@ describe("runtime compiled artifact loaders", () => { const secondResolved = await loadResolvedCompiledAgent({ compiledArtifactsSource, }); - const secondTool = secondResolved.tools[0]; + const secondTool = secondResolved.tools.find((tool) => tool.name === "get_weather"); if (secondTool === undefined) { throw new Error("Expected one compiled tool after the source update."); From 39e332f47a13e16119ae3dd522a8c689d8976902 Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Sat, 22 Aug 2026 11:46:54 -0400 Subject: [PATCH 11/12] fix(eve): keep framework source migration serial Signed-off-by: Andrew Barba --- .../eve/src/compiler/compile-from-memory.ts | 11 +++ packages/eve/src/kernel/capabilities.test.ts | 18 ++++ packages/eve/src/kernel/capabilities.ts | 14 ++- .../agent-info-route.scenario.test.ts | 5 +- .../scenarios/compile-agent.scenario.test.ts | 92 +++++++++---------- 5 files changed, 83 insertions(+), 57 deletions(-) create mode 100644 packages/eve/src/kernel/capabilities.test.ts diff --git a/packages/eve/src/compiler/compile-from-memory.ts b/packages/eve/src/compiler/compile-from-memory.ts index da9ccfaf92..ebbd1389fd 100644 --- a/packages/eve/src/compiler/compile-from-memory.ts +++ b/packages/eve/src/compiler/compile-from-memory.ts @@ -9,6 +9,7 @@ import { ROOT_COMPILED_AGENT_NODE_ID, } from "#compiler/manifest.js"; import type { CompiledModuleMap } from "#compiler/module-map.js"; +import { prepareKernelCapabilities } from "#compiler/prepare-kernel-capabilities.js"; /** * Declarative description of an in-memory authored agent used by the test @@ -131,6 +132,16 @@ export function compileFromMemory(input: CompileFromMemoryInput): CompileFromMem agentRoot, appRoot, config, + kernelCapabilities: prepareKernelCapabilities({ + disabled: new Set(), + frameworkLoadSkill: true, + hasSkills: skills.length > 0, + isRoot: true, + tasksEnabled: true, + toolNames: new Set(tools.map((tool) => tool.name)), + webSearch: false, + workflow: false, + }), skills, tools, }); diff --git a/packages/eve/src/kernel/capabilities.test.ts b/packages/eve/src/kernel/capabilities.test.ts new file mode 100644 index 0000000000..e3059bbede --- /dev/null +++ b/packages/eve/src/kernel/capabilities.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { + getKernelCapabilityAtPath, + getReplaceableKernelCapabilityAtPath, +} from "#kernel/capabilities.js"; + +describe("kernel capability paths", () => { + it("uses canonical module identity across authored extensions", () => { + expect(getKernelCapabilityAtPath("tools/agent.mjs")).toBe("agent"); + expect(getReplaceableKernelCapabilityAtPath("tools/web_search.cts")).toBe("web_search"); + }); + + it("keeps reserved capabilities non-replaceable", () => { + expect(getKernelCapabilityAtPath("tools/final_output.js")).toBe("final_output"); + expect(getReplaceableKernelCapabilityAtPath("tools/final_output.js")).toBeUndefined(); + }); +}); diff --git a/packages/eve/src/kernel/capabilities.ts b/packages/eve/src/kernel/capabilities.ts index d01a5964c0..2354f4504d 100644 --- a/packages/eve/src/kernel/capabilities.ts +++ b/packages/eve/src/kernel/capabilities.ts @@ -1,3 +1,5 @@ +import { stripLogicalPathExtension } from "#discover/filesystem.js"; + /** * The complete set of capabilities implemented by eve's native execution kernel. * Everything else enters the runtime through compiled agent sources. @@ -80,12 +82,16 @@ const KERNEL_CAPABILITY_NAMES_SET: ReadonlySet = new Set(KERNEL_CAPABILI export const RESERVED_KERNEL_CAPABILITY_NAMES: readonly KernelCapabilityName[] = KERNEL_CAPABILITY_NAMES.filter((name) => KERNEL_CAPABILITIES[name].replacement === "reserved"); const KERNEL_CAPABILITIES_BY_PATH: ReadonlyMap = new Map( - KERNEL_CAPABILITY_NAMES.map((name) => [KERNEL_CAPABILITIES[name].canonicalPath, name] as const), + KERNEL_CAPABILITY_NAMES.map( + (name) => [stripLogicalPathExtension(KERNEL_CAPABILITIES[name].canonicalPath), name] as const, + ), ); const REPLACEABLE_KERNEL_CAPABILITIES_BY_PATH: ReadonlyMap = new Map( KERNEL_CAPABILITY_NAMES.filter( (name) => KERNEL_CAPABILITIES[name].replacement === "authored-source", - ).map((name) => [KERNEL_CAPABILITIES[name].canonicalPath, name] as const), + ).map( + (name) => [stripLogicalPathExtension(KERNEL_CAPABILITIES[name].canonicalPath), name] as const, + ), ); export function isKernelCapabilityName(value: string): value is KernelCapabilityName { @@ -95,11 +101,11 @@ export function isKernelCapabilityName(value: string): value is KernelCapability export function getReplaceableKernelCapabilityAtPath( logicalPath: string, ): KernelCapabilityName | undefined { - return REPLACEABLE_KERNEL_CAPABILITIES_BY_PATH.get(logicalPath); + return REPLACEABLE_KERNEL_CAPABILITIES_BY_PATH.get(stripLogicalPathExtension(logicalPath)); } export function getKernelCapabilityAtPath(logicalPath: string): KernelCapabilityName | undefined { - return KERNEL_CAPABILITIES_BY_PATH.get(logicalPath); + return KERNEL_CAPABILITIES_BY_PATH.get(stripLogicalPathExtension(logicalPath)); } export function hasKernelCapability( diff --git a/packages/eve/test/scenarios/agent-info-route.scenario.test.ts b/packages/eve/test/scenarios/agent-info-route.scenario.test.ts index 746ad7c0d7..752370da33 100644 --- a/packages/eve/test/scenarios/agent-info-route.scenario.test.ts +++ b/packages/eve/test/scenarios/agent-info-route.scenario.test.ts @@ -160,10 +160,7 @@ describe("eve agent info route", () => { ).json()) as AgentInfoResponse; expect(disabledPayload.tools.available.map((tool) => tool.name)).not.toContain("agent"); - expect(disabledPayload.tools.framework.find((tool) => tool.name === "agent")).toMatchObject({ - disabledByAuthor: true, - status: "disabled", - }); + expect(disabledPayload.tools.framework.find((tool) => tool.name === "agent")).toBeUndefined(); }); it("returns 401 for a deployment request without a Vercel OIDC bearer token", async () => { diff --git a/packages/eve/test/scenarios/compile-agent.scenario.test.ts b/packages/eve/test/scenarios/compile-agent.scenario.test.ts index e41a9d2069..603b13e67e 100644 --- a/packages/eve/test/scenarios/compile-agent.scenario.test.ts +++ b/packages/eve/test/scenarios/compile-agent.scenario.test.ts @@ -200,8 +200,8 @@ describe("compiler artifacts", () => { errors: 0, warnings: 1, }, - channels: [ - { + channels: expect.arrayContaining([ + expect.objectContaining({ kind: "channel", logicalPath: "channels/support.mjs", method: "POST", @@ -209,8 +209,8 @@ describe("compiler artifacts", () => { sourceId: "channels/support.mjs", sourceKind: "module", urlPath: "/support", - }, - { + }), + expect.objectContaining({ kind: "channel", logicalPath: "channels/support.mjs", method: "GET", @@ -218,8 +218,8 @@ describe("compiler artifacts", () => { sourceId: "channels/support.mjs", sourceKind: "module", urlPath: "/support/events", - }, - ], + }), + ]), kind: "eve-agent-compiled-manifest", instructions: [ { @@ -413,22 +413,16 @@ describe("compiler artifacts", () => { // compiled manifest as markdown. They never appear in the module map. expect(normalizedModuleMapText).not.toContain("instructions.mjs"); expect(normalizedModuleMapText).toContain('import * as module_0 from "../../agent/agent.mjs";'); - expect(normalizedModuleMapText).toContain( - 'import * as module_1 from "../../agent/tools/get_weather.mjs";', - ); - expect(normalizedModuleMapText).toContain( - 'import * as module_2 from "../../agent/subagents/reviewer/agent.mjs";', - ); - expect(normalizedModuleMapText).toContain( - 'import * as module_3 from "../../agent/subagents/reviewer/tools/review.mjs";', - ); + expect(normalizedModuleMapText).toContain("../../agent/tools/get_weather.mjs"); + expect(normalizedModuleMapText).toContain("../../agent/subagents/reviewer/agent.mjs"); + expect(normalizedModuleMapText).toContain("../../agent/subagents/reviewer/tools/review.mjs"); expect(normalizedModuleMapText).toContain('"nodes": Object.freeze({'); expect(normalizedModuleMapText).toContain(`"${ROOT_COMPILED_AGENT_NODE_ID}": Object.freeze({`); expect(normalizedModuleMapText).toContain('"agent.mjs": module_0'); - expect(normalizedModuleMapText).toContain('"tools/get_weather.mjs": module_1'); + expect(normalizedModuleMapText).toMatch(/"tools\/get_weather\.mjs": module_\d+/); expect(normalizedModuleMapText).toContain('"subagents/reviewer": Object.freeze({'); - expect(normalizedModuleMapText).toContain('"agent.mjs": module_2'); - expect(normalizedModuleMapText).toContain('"tools/review.mjs": module_3'); + expect(normalizedModuleMapText).toMatch(/"agent\.mjs": module_\d+/); + expect(normalizedModuleMapText).toMatch(/"tools\/review\.mjs": module_\d+/); }); it("records versioned artifact hashes in compile metadata", () => { @@ -633,17 +627,17 @@ describe("compileAgent", () => { sourceKind: "module", }, ]); - expect(result.manifest.tools).toEqual([ - { - description: - "Get weather details using lib extension imports through mixed extension loading across cjs/js/mts/mjs modules.", - inputSchema: null, - logicalPath: "tools/get_weather.mts", - name: "get_weather", - sourceId: "tools/get_weather.mts", - sourceKind: "module", - }, - ]); + expect( + result.manifest.tools.find((tool) => tool.sourceId === "tools/get_weather.mts"), + ).toMatchObject({ + description: + "Get weather details using lib extension imports through mixed extension loading across cjs/js/mts/mjs modules.", + inputSchema: null, + logicalPath: "tools/get_weather.mts", + name: "get_weather", + sourceId: "tools/get_weather.mts", + sourceKind: "module", + }); expect(result.manifest.sandbox).toEqual({ description: undefined, exportName: undefined, @@ -654,11 +648,11 @@ describe("compileAgent", () => { sourceKind: "module", }); expect(normalizeArtifactValue(moduleMapText, app.appRoot)).toContain('"agent.cjs": module_0'); - expect(normalizeArtifactValue(moduleMapText, app.appRoot)).toContain( - '"sandbox/sandbox.cjs": module_1', + expect(normalizeArtifactValue(moduleMapText, app.appRoot)).toMatch( + /"sandbox\/sandbox\.cjs": module_\d+/, ); - expect(normalizeArtifactValue(moduleMapText, app.appRoot)).toContain( - '"tools/get_weather.mts": module_2', + expect(normalizeArtifactValue(moduleMapText, app.appRoot)).toMatch( + /"tools\/get_weather\.mts": module_\d+/, ); }); @@ -672,16 +666,16 @@ describe("compileAgent", () => { startPath: app.appRoot, }); - expect(result.manifest.tools).toEqual([ - { - description: "Return alias path markers from @/ and @/lib/ imports.", - inputSchema: null, - logicalPath: "tools/check_alias_paths.ts", - name: "check_alias_paths", - sourceId: "tools/check_alias_paths.ts", - sourceKind: "module", - }, - ]); + expect( + result.manifest.tools.find((tool) => tool.sourceId === "tools/check_alias_paths.ts"), + ).toMatchObject({ + description: "Return alias path markers from @/ and @/lib/ imports.", + inputSchema: null, + logicalPath: "tools/check_alias_paths.ts", + name: "check_alias_paths", + sourceId: "tools/check_alias_paths.ts", + sourceKind: "module", + }); }); it("compiles a fixture that wraps, disables, and replaces framework tools", async () => { @@ -855,7 +849,9 @@ describe("compileAgent", () => { startPath: appRoot, }); - expect(result.manifest.tools).toEqual([ + expect( + result.manifest.tools.filter((tool) => tool.sourceId.startsWith("tools/")), + ).toMatchObject([ { description: "Refund a charge.", inputSchema: null, @@ -1290,14 +1286,12 @@ describe("compileAgent", () => { sourceId: "subagents/researcher", }); expect(normalizedModuleMapText).toContain('import * as module_0 from "../../agent/agent.mjs";'); + expect(normalizedModuleMapText).toContain("../../agent/subagents/researcher/agent.mjs"); expect(normalizedModuleMapText).toContain( - 'import * as module_1 from "../../agent/subagents/researcher/agent.mjs";', - ); - expect(normalizedModuleMapText).toContain( - 'import * as module_2 from "../../agent/subagents/researcher/sandbox/sandbox.mjs";', + "../../agent/subagents/researcher/sandbox/sandbox.mjs", ); expect(normalizedModuleMapText).toContain('"subagents/researcher": Object.freeze({'); - expect(normalizedModuleMapText).toContain('"sandbox/sandbox.mjs": module_2'); + expect(normalizedModuleMapText).toMatch(/"sandbox\/sandbox\.mjs": module_\d+/); }); it("fails fast on discovery errors after writing inspectable artifacts", async () => { From 0cf961ce49cd15c58d0785288479fb892d27e249 Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Sat, 22 Aug 2026 11:46:57 -0400 Subject: [PATCH 12/12] fix(eve): preserve dev connections during rebuilds Signed-off-by: Andrew Barba --- .../nitro/host/drained-nitro-dev-server.scenario.test.ts | 9 +++++++++ .../src/internal/nitro/host/drained-nitro-dev-server.ts | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/packages/eve/src/internal/nitro/host/drained-nitro-dev-server.scenario.test.ts b/packages/eve/src/internal/nitro/host/drained-nitro-dev-server.scenario.test.ts index 74d411998f..3f113f5eea 100644 --- a/packages/eve/src/internal/nitro/host/drained-nitro-dev-server.scenario.test.ts +++ b/packages/eve/src/internal/nitro/host/drained-nitro-dev-server.scenario.test.ts @@ -108,6 +108,15 @@ async function withinDeadline(operation: Promise, message: string): Promis } describe("drained Nitro dev server", () => { + it("keeps idle connections alive during slower structural rebuilds", async () => { + const server = new DrainedNitroDevServer(LOGGER); + const listener = await listen(server); + + expect(listener.node.server.keepAliveTimeout).toBe(30_000); + + await server.close(); + }); + it("keeps the previous worker serving when a candidate fails readiness", async () => { const { createRunner, runners } = createRunnerFactory( async (_request, runnerIndex) => new Response(`runner-${String(runnerIndex)}`), diff --git a/packages/eve/src/internal/nitro/host/drained-nitro-dev-server.ts b/packages/eve/src/internal/nitro/host/drained-nitro-dev-server.ts index 806d3479d4..6099f68202 100644 --- a/packages/eve/src/internal/nitro/host/drained-nitro-dev-server.ts +++ b/packages/eve/src/internal/nitro/host/drained-nitro-dev-server.ts @@ -16,6 +16,7 @@ import { stampDevelopmentClientAddress } from "#internal/nitro/dev-client-addres import { toErrorMessage } from "#shared/errors.js"; const RUNNER_READY_TIMEOUT_MS = 60_000; +const DEV_SERVER_KEEP_ALIVE_TIMEOUT_MS = 30_000; export interface DrainedDevServerListener { close(): Promise; @@ -163,6 +164,9 @@ export class DrainedNitroDevServer { const server = createServer((request, response) => { void this.#handleRequest(request, response); }); + // Structural rebuilds can outlast Node's five-second default, but swapping + // workers should remain transparent to idle keep-alive connections. + server.keepAliveTimeout = DEV_SERVER_KEEP_ALIVE_TIMEOUT_MS; server.on("connection", (socket) => { sockets.add(socket); socket.once("close", () => sockets.delete(socket));