diff --git a/.changeset/calm-source-composition.md b/.changeset/calm-source-composition.md new file mode 100644 index 0000000000..a3e06aad00 --- /dev/null +++ b/.changeset/calm-source-composition.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Add immutable programmatic agent sources, explicit source registries, and deterministic slot composition. Programmatic modules now share the ordinary definition materialization path and generated module maps can bind them through static registry imports without virtual files. diff --git a/packages/eve/src/compiler/agent-module-candidate.ts b/packages/eve/src/compiler/agent-module-candidate.ts new file mode 100644 index 0000000000..604fcf39b4 --- /dev/null +++ b/packages/eve/src/compiler/agent-module-candidate.ts @@ -0,0 +1,16 @@ +import type { AgentSourceOwner, CompiledModuleBacking } from "#compiler/module-binding.js"; + +export type AgentSourceLayer = + | "framework-default" + | "extension-package" + | "extension-override" + | "application"; + +export interface AgentModuleCandidate { + readonly backing: CompiledModuleBacking; + readonly layer: AgentSourceLayer; + readonly logicalPath: string; + readonly nodeId: string; + readonly owner: AgentSourceOwner; + readonly sourceId: string; +} diff --git a/packages/eve/src/compiler/agent-source-registry.ts b/packages/eve/src/compiler/agent-source-registry.ts new file mode 100644 index 0000000000..25dc370a2a --- /dev/null +++ b/packages/eve/src/compiler/agent-source-registry.ts @@ -0,0 +1,68 @@ +import type { CompiledModuleBacking } from "#compiler/module-binding.js"; +import type { + ProgrammaticAgentModule, + ProgrammaticAgentSource, + ProgrammaticModuleNamespace, +} from "#compiler/programmatic-agent-source.js"; + +export interface AgentSourceRegistration { + readonly applyTo: "root" | "all-local-nodes"; + readonly source: ProgrammaticAgentSource; +} + +export interface AgentSourceRegistry { + readonly registrations: readonly AgentSourceRegistration[]; + getModule( + backing: Extract, + ): ProgrammaticAgentModule; +} + +export function createAgentSourceRegistry( + registrations: readonly AgentSourceRegistration[], +): AgentSourceRegistry { + const sources = new Map>(); + const frozenRegistrations = registrations.map((registration) => { + if (sources.has(registration.source.id)) { + throw new Error( + `Programmatic agent source id "${registration.source.id}" is registered twice.`, + ); + } + if (registration.applyTo === "all-local-nodes") { + const recursiveModule = registration.source.modules.find((module) => + /^(?:agent\.[^/]+|channels\/|extensions\/|schedules\/|subagents\/)/.test( + module.logicalPath, + ), + ); + if (recursiveModule !== undefined) { + throw new Error( + `Programmatic source "${registration.source.id}" cannot apply "${recursiveModule.logicalPath}" to all local nodes because that slot can expand the graph or host surface.`, + ); + } + } + const modules = new Map( + registration.source.modules.map((module) => [module.logicalPath, module] as const), + ); + sources.set(registration.source.id, modules); + return Object.freeze({ applyTo: registration.applyTo, source: registration.source }); + }); + + return Object.freeze({ + registrations: Object.freeze(frozenRegistrations), + getModule(backing: Extract) { + const module = sources.get(backing.registryId)?.get(backing.moduleId); + if (module === undefined) { + throw new Error( + `Programmatic module binding "${backing.registryId}:${backing.moduleId}" is not registered.`, + ); + } + return module; + }, + }); +} + +export function getProgrammaticModuleNamespace( + registry: AgentSourceRegistry, + backing: Extract, +): ProgrammaticModuleNamespace { + return registry.getModule(backing).namespace; +} diff --git a/packages/eve/src/compiler/compose-agent-module-candidates.test.ts b/packages/eve/src/compiler/compose-agent-module-candidates.test.ts new file mode 100644 index 0000000000..d151e73000 --- /dev/null +++ b/packages/eve/src/compiler/compose-agent-module-candidates.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import type { AgentModuleCandidate } from "#compiler/agent-module-candidate.js"; +import { + canonicalModuleSlot, + composeAgentModuleCandidates, +} from "#compiler/compose-agent-module-candidates.js"; + +function candidate( + layer: AgentModuleCandidate["layer"], + logicalPath: string, +): AgentModuleCandidate { + return { + backing: { + externalDependencies: [], + kind: "filesystem", + sourcePath: `/physical/${layer}/${logicalPath}`, + }, + layer, + logicalPath, + nodeId: "__root__", + owner: { kind: "application" }, + sourceId: `${layer}:${logicalPath}`, + }; +} + +describe("composeAgentModuleCandidates", () => { + it("selects one winner by the global layer order", () => { + const candidates = [ + candidate("application", "tools/search.js"), + candidate("framework-default", "tools/search.ts"), + candidate("extension-override", "tools/search.mts"), + candidate("extension-package", "tools/search.cjs"), + ]; + const result = composeAgentModuleCandidates(candidates); + + expect(result.winners).toEqual([candidates[0]]); + expect(result.entries[0]?.candidates.map((entry) => entry.layer)).toEqual([ + "framework-default", + "extension-package", + "extension-override", + "application", + ]); + }); + + it("rejects same-layer aliases before loading either candidate", () => { + expect(() => + composeAgentModuleCandidates([ + candidate("application", "connections/linear.ts"), + candidate("application", "connections/linear/connection.js"), + ]), + ).toThrow("duplicate application candidates"); + }); +}); + +describe("canonicalModuleSlot", () => { + it.each([ + ["tools/read.ts", "tools/read"], + ["connections/linear/connection.mjs", "connections/linear"], + ["sandbox/sandbox.ts", "sandbox"], + ])("maps %s to %s", (logicalPath, expected) => { + expect(canonicalModuleSlot(logicalPath)).toBe(expected); + }); +}); diff --git a/packages/eve/src/compiler/compose-agent-module-candidates.ts b/packages/eve/src/compiler/compose-agent-module-candidates.ts new file mode 100644 index 0000000000..2232e13c00 --- /dev/null +++ b/packages/eve/src/compiler/compose-agent-module-candidates.ts @@ -0,0 +1,64 @@ +import { stripLogicalPathExtension } from "#discover/filesystem.js"; +import type { AgentModuleCandidate, AgentSourceLayer } from "#compiler/agent-module-candidate.js"; + +const PRECEDENCE: Readonly> = { + "framework-default": 0, + "extension-package": 1, + "extension-override": 2, + application: 3, +}; + +export interface AgentModuleCompositionEntry { + readonly candidates: readonly AgentModuleCandidate[]; + readonly slot: string; + readonly winner: AgentModuleCandidate; +} + +export interface AgentModuleComposition { + readonly entries: readonly AgentModuleCompositionEntry[]; + readonly winners: readonly AgentModuleCandidate[]; +} + +export function composeAgentModuleCandidates( + candidates: readonly AgentModuleCandidate[], +): AgentModuleComposition { + const candidatesBySlot = new Map(); + + for (const candidate of candidates) { + const slot = canonicalModuleSlot(candidate.logicalPath); + const entries = candidatesBySlot.get(slot) ?? []; + const sameLayer = entries.find((entry) => entry.layer === candidate.layer); + if (sameLayer !== undefined) { + throw new Error( + `Agent node "${candidate.nodeId}" has duplicate ${candidate.layer} candidates for "${slot}": "${sameLayer.logicalPath}" and "${candidate.logicalPath}".`, + ); + } + entries.push(candidate); + candidatesBySlot.set(slot, entries); + } + + const entries = [...candidatesBySlot] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([slot, slotCandidates]) => { + const ordered = [...slotCandidates].sort( + (left, right) => PRECEDENCE[left.layer] - PRECEDENCE[right.layer], + ); + return Object.freeze({ + candidates: Object.freeze(ordered), + slot, + winner: ordered.at(-1)!, + }); + }); + + return Object.freeze({ + entries: Object.freeze(entries), + winners: Object.freeze(entries.map((entry) => entry.winner)), + }); +} + +export function canonicalModuleSlot(logicalPath: string): string { + const withoutExtension = stripLogicalPathExtension(logicalPath); + const connectionFolder = withoutExtension.match(/^connections\/([^/]+)\/connection$/); + if (connectionFolder !== null) return `connections/${connectionFolder[1]}`; + return withoutExtension === "sandbox/sandbox" ? "sandbox" : withoutExtension; +} diff --git a/packages/eve/src/compiler/module-map.test.ts b/packages/eve/src/compiler/module-map.test.ts index db04a2a6ad..42e9999c50 100644 --- a/packages/eve/src/compiler/module-map.test.ts +++ b/packages/eve/src/compiler/module-map.test.ts @@ -73,6 +73,41 @@ function createManifestWithTool(agentRoot: string): CompiledAgentManifest { } describe("createCompiledModuleMapSource", () => { + it("emits a static registry lookup for programmatic modules", () => { + const manifest = createManifestWithTool("/consumer/agent"); + const source = createCompiledModuleMapSource({ + manifest: { + ...manifest, + bindings: { + "tools/echo.ts": { + backing: { + kind: "programmatic", + moduleId: "tools/echo.ts", + registryId: "eve.defaults", + }, + logicalPath: "tools/echo.ts", + owner: { feature: "defaults", kind: "framework" }, + }, + }, + }, + moduleMapPath: "/consumer/.eve/compile/module-map.mjs", + programmaticRegistryImports: { + "eve.defaults": { + exportName: "defaultAgentSourceRegistry", + importSpecifier: "eve/internal/default-agent-source-registry", + }, + }, + }); + + expect(source).toContain( + 'import { defaultAgentSourceRegistry as module_0 } from "eve/internal/default-agent-source-registry";', + ); + expect(source).toContain( + 'module_0.getModule({"kind":"programmatic","moduleId":"tools/echo.ts","registryId":"eve.defaults"}).namespace', + ); + expect(source).not.toContain("/consumer/agent/tools/echo.ts"); + }); + 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 9e9e4beeb5..c3c5c23c2d 100644 --- a/packages/eve/src/compiler/module-map.ts +++ b/packages/eve/src/compiler/module-map.ts @@ -47,11 +47,15 @@ export interface CreateCompiledModuleMapSourceInput { importSpecifierStyle?: "absolute" | "relative"; manifest: CompiledAgentManifest; moduleMapPath: string; + programmaticRegistryImports?: Readonly< + Record + >; } interface CollectedModuleImport { readonly bindingName: string; - readonly importSpecifier: string; + readonly importStatement: string; + readonly moduleExpression: string; readonly sourceId: string; } @@ -77,6 +81,7 @@ export function createCompiledModuleMapSource(input: CreateCompiledModuleMapSour return `module_${nextBindingIndex++}`; }, nodeId: ROOT_COMPILED_AGENT_NODE_ID, + programmaticRegistryImports: input.programmaticRegistryImports, }), ...[...input.manifest.subagents] .sort((left, right) => left.nodeId.localeCompare(right.nodeId)) @@ -90,15 +95,13 @@ export function createCompiledModuleMapSource(input: CreateCompiledModuleMapSour return `module_${nextBindingIndex++}`; }, nodeId: subagent.nodeId, + programmaticRegistryImports: input.programmaticRegistryImports, }), ), ]; const allModules = collectedScopes.flatMap((scope) => scope.modules); - const staticImports = allModules.map( - (moduleImport) => - `import * as ${moduleImport.bindingName} from ${JSON.stringify(moduleImport.importSpecifier)};`, - ); + const staticImports = allModules.map((moduleImport) => moduleImport.importStatement); return [ "// Generated by eve. Do not edit by hand.", @@ -119,6 +122,7 @@ function collectModuleNodeScope(input: { readonly moduleMapDirectory: string; readonly nextBindingName: () => string; readonly nodeId: string; + readonly programmaticRegistryImports?: CreateCompiledModuleMapSourceInput["programmaticRegistryImports"]; }): CollectedModuleNodeScope { assertTotalModuleBindings({ additionalRefs: input.additionalModuleRef === undefined ? [] : [input.additionalModuleRef], @@ -133,36 +137,58 @@ function collectModuleNodeScope(input: { ...(input.additionalModuleRef === undefined ? [] : [input.additionalModuleRef]), ] .sort((left, right) => left.sourceId.localeCompare(right.sourceId)) - .map((moduleSourceRef) => ({ - bindingName: input.nextBindingName(), - importSpecifier: createImportSpecifier({ - fromDirectory: input.moduleMapDirectory, + .map((moduleSourceRef) => + collectModuleImport({ + binding: input.manifest.bindings[moduleSourceRef.sourceId]!, + bindingName: input.nextBindingName(), importSpecifierStyle: input.importSpecifierStyle, - targetPath: readFilesystemSourcePath( - input.manifest.bindings[moduleSourceRef.sourceId]!, - input.nodeId, - moduleSourceRef.sourceId, - ), + moduleMapDirectory: input.moduleMapDirectory, + nodeId: input.nodeId, + programmaticRegistryImports: input.programmaticRegistryImports, + sourceId: moduleSourceRef.sourceId, }), - sourceId: moduleSourceRef.sourceId, - })), + ), nodeId: input.nodeId, }; } export { collectModuleRefsForManifest } from "#compiler/module-references.js"; -function readFilesystemSourcePath( - binding: CompiledAgentResources["bindings"][string], - nodeId: string, - sourceId: string, -): string { - if (binding.backing.kind !== "filesystem") { +function collectModuleImport(input: { + readonly binding: CompiledAgentResources["bindings"][string]; + readonly bindingName: string; + readonly importSpecifierStyle: "absolute" | "relative"; + readonly moduleMapDirectory: string; + readonly nodeId: string; + readonly programmaticRegistryImports?: CreateCompiledModuleMapSourceInput["programmaticRegistryImports"]; + readonly sourceId: string; +}): CollectedModuleImport { + if (input.binding.backing.kind === "filesystem") { + const importSpecifier = createImportSpecifier({ + fromDirectory: input.moduleMapDirectory, + importSpecifierStyle: input.importSpecifierStyle, + targetPath: input.binding.backing.sourcePath, + }); + return { + bindingName: input.bindingName, + importStatement: `import * as ${input.bindingName} from ${JSON.stringify(importSpecifier)};`, + moduleExpression: input.bindingName, + sourceId: input.sourceId, + }; + } + + const registryImport = input.programmaticRegistryImports?.[input.binding.backing.registryId]; + if (registryImport === undefined) { throw new Error( - `Cannot generate a static filesystem import for programmatic binding "${sourceId}" on compiled node "${nodeId}".`, + `Cannot generate programmatic binding "${input.sourceId}" on compiled node "${input.nodeId}" because registry "${input.binding.backing.registryId}" has no static import.`, ); } - return binding.backing.sourcePath; + return { + bindingName: input.bindingName, + importStatement: `import { ${registryImport.exportName} as ${input.bindingName} } from ${JSON.stringify(registryImport.importSpecifier)};`, + moduleExpression: `${input.bindingName}.getModule(${JSON.stringify(input.binding.backing)}).namespace`, + sourceId: input.sourceId, + }; } function createImportSpecifier(input: { @@ -198,7 +224,7 @@ function renderModuleMap(scopes: readonly CollectedModuleNodeScope[]): string { value: renderFrozenObject( scope.modules.map((moduleImport) => ({ key: moduleImport.sourceId, - value: moduleImport.bindingName, + value: moduleImport.moduleExpression, })), ), }, diff --git a/packages/eve/src/compiler/module-namespace-loader.test.ts b/packages/eve/src/compiler/module-namespace-loader.test.ts new file mode 100644 index 0000000000..bf6a61bc19 --- /dev/null +++ b/packages/eve/src/compiler/module-namespace-loader.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { createAgentSourceRegistry } from "#compiler/agent-source-registry.js"; +import { createAgentModuleNamespaceLoader } from "#compiler/module-namespace-loader.js"; +import { defineProgrammaticAgentSource } from "#compiler/programmatic-agent-source.js"; +import { loadModuleBackedDefinition } from "#compiler/normalize-helpers.js"; + +describe("createAgentModuleNamespaceLoader", () => { + it("loads the exact immutable namespace from an explicit registry", async () => { + const definition = { execute: () => "ok" }; + const registry = createAgentSourceRegistry([ + { + applyTo: "root", + source: defineProgrammaticAgentSource({ + id: "eve.defaults", + modules: [{ logicalPath: "tools/read.ts", namespace: { default: definition } }], + }), + }, + ]); + const loader = createAgentModuleNamespaceLoader({ registry }); + + await expect( + loader.load({ kind: "programmatic", moduleId: "tools/read.ts", registryId: "eve.defaults" }), + ).resolves.toEqual({ default: definition }); + }); + + it("never probes disk when a programmatic binding is absent", async () => { + const loader = createAgentModuleNamespaceLoader({ registry: createAgentSourceRegistry([]) }); + + await expect( + loader.load({ kind: "programmatic", moduleId: "tools/missing.ts", registryId: "missing" }), + ).rejects.toThrow('Programmatic module binding "missing:tools/missing.ts" is not registered'); + }); + + it("feeds a programmatic export through ordinary definition materialization", async () => { + const definition = () => ({ description: "Reads a file." }); + const registry = createAgentSourceRegistry([ + { + applyTo: "root", + source: defineProgrammaticAgentSource({ + id: "eve.materialization", + modules: [{ logicalPath: "tools/read.ts", namespace: { default: definition } }], + }), + }, + ]); + + await expect( + loadModuleBackedDefinition({ + agentRoot: "/virtual/agent", + binding: { + backing: { + kind: "programmatic", + moduleId: "tools/read.ts", + registryId: "eve.materialization", + }, + logicalPath: "tools/read.ts", + owner: { feature: "test", kind: "framework" }, + }, + kind: "tool", + moduleLoader: createAgentModuleNamespaceLoader({ registry }), + source: { + logicalPath: "tools/read.ts", + sourceId: "eve.materialization:tools/read.ts", + sourceKind: "module", + }, + }), + ).resolves.toEqual({ description: "Reads a file." }); + }); +}); diff --git a/packages/eve/src/compiler/module-namespace-loader.ts b/packages/eve/src/compiler/module-namespace-loader.ts new file mode 100644 index 0000000000..c141a5da3d --- /dev/null +++ b/packages/eve/src/compiler/module-namespace-loader.ts @@ -0,0 +1,32 @@ +import type { AgentSourceRegistry } from "#compiler/agent-source-registry.js"; +import { getProgrammaticModuleNamespace } from "#compiler/agent-source-registry.js"; +import type { CompiledModuleBacking } from "#compiler/module-binding.js"; +import { loadAuthoredModuleNamespace } from "#internal/authored-module-loader.js"; + +export interface AgentModuleNamespaceLoader { + load(backing: CompiledModuleBacking): Promise>; +} + +export function createAgentModuleNamespaceLoader( + input: { + readonly registry?: AgentSourceRegistry; + } = {}, +): AgentModuleNamespaceLoader { + return { + async load(backing) { + if (backing.kind === "filesystem") { + return await loadAuthoredModuleNamespace(backing.sourcePath, { + externalDependencies: backing.externalDependencies, + extensionScopeNamespace: backing.extensionScope?.namespace, + }); + } + + if (input.registry === undefined) { + throw new Error( + `Programmatic module binding "${backing.registryId}:${backing.moduleId}" requires its agent source registry.`, + ); + } + return { ...getProgrammaticModuleNamespace(input.registry, backing) }; + }, + }; +} diff --git a/packages/eve/src/compiler/normalize-helpers.ts b/packages/eve/src/compiler/normalize-helpers.ts index 315cc49317..e92a1969a0 100644 --- a/packages/eve/src/compiler/normalize-helpers.ts +++ b/packages/eve/src/compiler/normalize-helpers.ts @@ -1,16 +1,18 @@ -import { join } from "node:path"; +import { resolve } from "node:path"; import { getAuthoredModuleExport, materializeAuthoredModuleExport, } from "#internal/authored-module.js"; -import { - type AuthoredModuleLoadOptions, - loadAuthoredModuleNamespace, -} from "#internal/authored-module-loader.js"; +import type { AuthoredModuleLoadOptions } from "#internal/authored-module-loader.js"; 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 { + createAgentModuleNamespaceLoader, + type AgentModuleNamespaceLoader, +} from "#compiler/module-namespace-loader.js"; const SANDBOX_PARENT_DEFINITION_MARKER = Symbol.for("eve.sandbox-parent-definition"); @@ -27,7 +29,9 @@ export interface ManifestCompileContext { } export interface ModuleBackedDefinitionLoadOptions { + readonly binding?: CompiledModuleBinding; readonly externalDependencies?: AuthoredModuleLoadOptions["externalDependencies"]; + readonly moduleLoader?: AgentModuleNamespaceLoader; } /** @@ -42,13 +46,25 @@ export interface ModuleBackedDefinitionLoadOptions { export async function loadModuleBackedDefinition(input: { readonly agentRoot: string; readonly displayPath?: string; + readonly binding?: CompiledModuleBinding; readonly externalDependencies?: ModuleBackedDefinitionLoadOptions["externalDependencies"]; readonly kind: string; + readonly moduleLoader?: AgentModuleNamespaceLoader; readonly source: ModuleSourceRef; }): Promise { - const moduleNamespace = await loadAuthoredModuleNamespace( - join(input.agentRoot, input.source.logicalPath), - { externalDependencies: input.externalDependencies }, + const binding = + input.binding ?? + ({ + backing: { + externalDependencies: [...(input.externalDependencies ?? [])], + kind: "filesystem", + sourcePath: resolve(input.agentRoot, input.source.logicalPath), + }, + logicalPath: input.source.logicalPath, + owner: { kind: "application" }, + } satisfies CompiledModuleBinding); + const moduleNamespace = await (input.moduleLoader ?? createAgentModuleNamespaceLoader()).load( + binding.backing, ); const exportValue = getAuthoredModuleExport(moduleNamespace, input.source); diff --git a/packages/eve/src/compiler/programmatic-agent-source.test.ts b/packages/eve/src/compiler/programmatic-agent-source.test.ts new file mode 100644 index 0000000000..c62e4f8710 --- /dev/null +++ b/packages/eve/src/compiler/programmatic-agent-source.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import { defineProgrammaticAgentSource } from "#compiler/programmatic-agent-source.js"; +import { createAgentSourceRegistry } from "#compiler/agent-source-registry.js"; +import { createProgrammaticModuleCandidates } from "#compiler/programmatic-module-candidates.js"; + +describe("defineProgrammaticAgentSource", () => { + it("freezes containers without cloning definition values", () => { + const marker = Symbol("definition"); + const definition = { execute: () => "ok", marker }; + const source = defineProgrammaticAgentSource({ + id: "eve.defaults", + modules: [{ logicalPath: "tools/read_file.ts", namespace: { default: definition } }], + }); + + expect(Object.isFrozen(source)).toBe(true); + expect(Object.isFrozen(source.modules)).toBe(true); + expect(Object.isFrozen(source.modules[0]?.namespace)).toBe(true); + expect(source.modules[0]?.namespace.default).toBe(definition); + }); + + it.each(["/tools/read.ts", "../tools/read.ts", "tools//read.ts", "tools/read.txt"])( + "rejects invalid module path %s", + (logicalPath) => { + expect(() => + defineProgrammaticAgentSource({ + id: "eve.invalid", + modules: [{ logicalPath, namespace: {} }], + }), + ).toThrow(); + }, + ); + + it("rejects duplicate paths", () => { + expect(() => + defineProgrammaticAgentSource({ + id: "eve.duplicate", + modules: [ + { logicalPath: "sandbox.ts", namespace: {} }, + { logicalPath: "sandbox.ts", namespace: {} }, + ], + }), + ).toThrow('declares "sandbox.ts" more than once'); + }); +}); + +describe("programmatic source registration", () => { + it("creates deterministic framework candidates for eligible nodes", () => { + const source = defineProgrammaticAgentSource({ + id: "eve.defaults", + modules: [{ logicalPath: "tools/read_file.ts", namespace: {} }], + }); + const registry = createAgentSourceRegistry([{ applyTo: "all-local-nodes", source }]); + + expect( + createProgrammaticModuleCandidates({ isRoot: false, nodeId: "subagents/research", registry }), + ).toEqual([ + { + backing: { + kind: "programmatic", + moduleId: "tools/read_file.ts", + registryId: "eve.defaults", + }, + layer: "framework-default", + logicalPath: "tools/read_file.ts", + nodeId: "subagents/research", + owner: { feature: "eve.defaults", kind: "framework" }, + sourceId: "eve.defaults:tools/read_file.ts", + }, + ]); + }); + + it("rejects graph-expanding all-node modules", () => { + const source = defineProgrammaticAgentSource({ + id: "eve.invalid", + modules: [{ logicalPath: "schedules/daily.ts", namespace: {} }], + }); + expect(() => createAgentSourceRegistry([{ applyTo: "all-local-nodes", source }])).toThrow( + "can expand the graph or host surface", + ); + }); +}); diff --git a/packages/eve/src/compiler/programmatic-agent-source.ts b/packages/eve/src/compiler/programmatic-agent-source.ts new file mode 100644 index 0000000000..e42ee06a96 --- /dev/null +++ b/packages/eve/src/compiler/programmatic-agent-source.ts @@ -0,0 +1,96 @@ +import { posix } from "node:path"; + +import { getSupportedModuleBaseName, normalizeLogicalPath } from "#discover/filesystem.js"; + +export type ProgrammaticModuleNamespace = Readonly>; + +export interface ProgrammaticAgentModule { + readonly exportName?: string; + readonly logicalPath: string; + readonly namespace: ProgrammaticModuleNamespace; +} + +export interface ProgrammaticAgentSource { + readonly id: string; + readonly modules: readonly ProgrammaticAgentModule[]; +} + +export function defineProgrammaticAgentSource( + input: ProgrammaticAgentSource, +): ProgrammaticAgentSource { + if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(input.id)) { + throw new Error( + `Programmatic agent source id "${input.id}" must start with a letter and contain only letters, digits, dots, underscores, or dashes.`, + ); + } + + const modules = input.modules.map((module) => { + const logicalPath = assertProgrammaticModuleLogicalPath(module.logicalPath); + const namespace = Object.freeze({ ...module.namespace }); + const compiledModule: { + exportName?: string; + logicalPath: string; + namespace: ProgrammaticModuleNamespace; + } = { + logicalPath, + namespace, + }; + if (module.exportName !== undefined) compiledModule.exportName = module.exportName; + return Object.freeze(compiledModule); + }); + const paths = new Set(); + for (const module of modules) { + if (paths.has(module.logicalPath)) { + throw new Error( + `Programmatic agent source "${input.id}" declares "${module.logicalPath}" more than once.`, + ); + } + paths.add(module.logicalPath); + } + + return Object.freeze({ id: input.id, modules: Object.freeze(modules) }); +} + +export function assertProgrammaticModuleLogicalPath(input: string): string { + if ( + input.length === 0 || + input.includes("\\") || + input.startsWith("/") || + input.split("/").some((segment) => segment === "" || segment === "." || segment === "..") + ) { + throw new Error( + `Programmatic module path "${input}" must be a normalized relative POSIX path.`, + ); + } + + const logicalPath = normalizeLogicalPath(input); + if (posix.normalize(logicalPath) !== logicalPath) { + throw new Error( + `Programmatic module path "${input}" must be a normalized relative POSIX path.`, + ); + } + const segments = logicalPath.split("/"); + const moduleName = getSupportedModuleBaseName(segments.at(-1)!); + if (moduleName === null || !isModuleBackedSlot(segments, moduleName)) { + throw new Error(`Programmatic module path "${input}" does not select an eve module slot.`); + } + + return logicalPath; +} + +function isModuleBackedSlot(segments: readonly string[], moduleName: string): boolean { + if (segments.length === 1) { + return moduleName === "agent" || moduleName === "instructions" || moduleName === "sandbox"; + } + + const [root] = segments; + if (root === "channels" || root === "hooks") return segments.length >= 2; + if (root === "connections") { + return segments.length === 2 || (segments.length === 3 && moduleName === "connection"); + } + if (root === "sandbox") return segments.length === 2 && moduleName === "sandbox"; + return ( + (root === "instructions" || root === "schedules" || root === "skills" || root === "tools") && + segments.length === 2 + ); +} diff --git a/packages/eve/src/compiler/programmatic-module-candidates.ts b/packages/eve/src/compiler/programmatic-module-candidates.ts new file mode 100644 index 0000000000..9cfa7e5b01 --- /dev/null +++ b/packages/eve/src/compiler/programmatic-module-candidates.ts @@ -0,0 +1,28 @@ +import type { AgentModuleCandidate } from "#compiler/agent-module-candidate.js"; +import type { AgentSourceRegistry } from "#compiler/agent-source-registry.js"; + +export function createProgrammaticModuleCandidates(input: { + readonly isRoot: boolean; + readonly nodeId: string; + readonly registry: AgentSourceRegistry; +}): AgentModuleCandidate[] { + return input.registry.registrations.flatMap((registration) => { + if (registration.applyTo === "root" && !input.isRoot) return []; + + return registration.source.modules.map((module) => ({ + backing: { + kind: "programmatic" as const, + moduleId: module.logicalPath, + registryId: registration.source.id, + }, + layer: "framework-default" as const, + logicalPath: module.logicalPath, + nodeId: input.nodeId, + owner: { + feature: registration.source.id, + kind: "framework" as const, + }, + sourceId: `${registration.source.id}:${module.logicalPath}`, + })); + }); +}