From 21671b70682071cc3470a42037538a8b18c176f8 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Tue, 11 Aug 2026 01:48:20 +0000 Subject: [PATCH 1/6] feat(eve): deploy declarative agent collections Signed-off-by: owenkephart --- .changeset/declarative-agent-collections.md | 5 + docs/guides/deployment/vercel.mdx | 22 +++++ packages/eve/src/cli/commands/build.ts | 20 ++++ .../cli/commands/deploy.integration.test.ts | 24 +++++ packages/eve/src/cli/commands/deploy.ts | 12 ++- .../src/cli/commands/link.integration.test.ts | 24 +++++ packages/eve/src/cli/commands/link.ts | 11 ++- packages/eve/src/internal/agent-workspace.ts | 9 ++ .../host/build-application.scenario.test.ts | 1 + .../internal/nitro/host/build-application.ts | 24 +++-- .../vercel/agent-collection-deployment.ts | 18 ++++ ...build-agent-collection.integration.test.ts | 97 +++++++++++++++++++ .../internal/vercel/build-agent-collection.ts | 74 ++++++++++++++ .../vercel/eve-service-contribution.test.ts | 28 ++---- .../vercel-service-config-operations.ts | 24 ++--- ...hostless-agent-collection.scenario.test.ts | 72 ++++++++++++++ 16 files changed, 422 insertions(+), 43 deletions(-) create mode 100644 .changeset/declarative-agent-collections.md create mode 100644 packages/eve/src/internal/vercel/agent-collection-deployment.ts create mode 100644 packages/eve/src/internal/vercel/build-agent-collection.integration.test.ts create mode 100644 packages/eve/src/internal/vercel/build-agent-collection.ts create mode 100644 packages/eve/test/scenarios/hostless-agent-collection.scenario.test.ts diff --git a/.changeset/declarative-agent-collections.md b/.changeset/declarative-agent-collections.md new file mode 100644 index 0000000000..b9c643146c --- /dev/null +++ b/.changeset/declarative-agent-collections.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Deploy strict `agents//agent/` collections as declaratively inferred peer Vercel services, with no generated `vercel.json` step. Collection children can share the root package or use workspace-member packages with their own dependencies and build scripts. diff --git a/docs/guides/deployment/vercel.mdx b/docs/guides/deployment/vercel.mdx index ad001baf4d..ece2d5a445 100644 --- a/docs/guides/deployment/vercel.mdx +++ b/docs/guides/deployment/vercel.mdx @@ -49,6 +49,28 @@ See [Sandbox](../../sandbox) for resource limits, network policy, and lifecycle ## Deploy the agent +A project containing only several independently addressed root agents can use eve's hostless collection layout: + +```json title="package.json" +{ + "eve": { "collection": true } +} +``` + +```text +my-project/ +├── package.json +└── agents/ + ├── support/ + │ └── agent/ + └── research/ + └── agent/ +``` + +The `eve.collection` declaration marks the project as a collection. eve then validates and discovers only direct `agents//agent/` children. Their directory names become their public identities, exposed at `/eve/agents//eve/v1/*`. Run `eve dev`, `eve info`, and `eve eval` from an individual child directory; run project-level build, link, and deploy commands from the collection root. A child `package.json` is optional, must belong to the root package-manager workspace, and may provide its own `build` script and dependencies. + +With no authored `vercel.json#services`, `eve build` derives the complete Vercel Services graph on every build. If `vercel.json` declares `services`, that authored graph is authoritative instead; use `vercel build` to build and validate the complete project. This supports heterogeneous projects with frontends, private APIs, bindings, and other non-eve services without generated configuration files. + Deploy the linked project to production: ```bash diff --git a/packages/eve/src/cli/commands/build.ts b/packages/eve/src/cli/commands/build.ts index ead5dff857..12f6896fcb 100644 --- a/packages/eve/src/cli/commands/build.ts +++ b/packages/eve/src/cli/commands/build.ts @@ -5,6 +5,7 @@ import { applicationCommand, type CliApplicationContext } from "#cli/application import { resolveInternalVercelServiceOutput } from "#cli/vercel-service-output.js"; import { createCliTheme, renderCliTaggedLine } from "#cli/ui/output.js"; import type { ApplicationBuildOptions } from "#internal/nitro/host/types.js"; +import { resolveEveProjectContext } from "#internal/project-context.js"; import { EVE_PUBLIC_ROUTE_PREFIX_ENV, normalizePublicRoutePrefix, @@ -42,6 +43,25 @@ export function registerBuildCommand(input: { await loadDevelopmentEnvironmentFiles(input.applicationContext.root); + const projectContext = await resolveEveProjectContext(input.appRoot); + if (projectContext.kind === "collection") { + if (options.profile !== undefined || options.skipSandboxPrewarm === true) { + throw new Error( + "Collection builds do not support --profile or --skip-sandbox-prewarm. Run those options from an individual agent directory.", + ); + } + const { buildAgentCollection } = await import("#internal/vercel/build-agent-collection.js"); + const outputDir = await buildAgentCollection(projectContext.collection); + input.logger.log( + renderCliTaggedLine(theme, { + message: `built output at ${outputDir}`, + tag: "build", + tone: "success", + }), + ); + return; + } + const buildHost = input.buildHost ?? (await import("#internal/nitro/host.js")).buildApplication; const profileOutputPath = diff --git a/packages/eve/src/cli/commands/deploy.integration.test.ts b/packages/eve/src/cli/commands/deploy.integration.test.ts index 3856f684d2..de289bdc17 100644 --- a/packages/eve/src/cli/commands/deploy.integration.test.ts +++ b/packages/eve/src/cli/commands/deploy.integration.test.ts @@ -24,6 +24,17 @@ class TestLogger implements DeployCliLogger { } } +async function createCollectionProject(): Promise { + const projectRoot = await mkdtemp(join(tmpdir(), "eve-deploy-collection-")); + await mkdir(join(projectRoot, "agents/support/agent"), { recursive: true }); + await writeFile( + join(projectRoot, "package.json"), + JSON.stringify({ eve: { collection: true }, private: true }), + "utf8", + ); + return projectRoot; +} + async function createAgentProject(): Promise { const projectRoot = await mkdtemp(join(tmpdir(), "eve-deploy-command-")); await mkdir(join(projectRoot, "agent"), { recursive: true }); @@ -78,6 +89,19 @@ describe("runDeployCommand", () => { expect(process.exitCode).toBe(1); }); + test("refuses to deploy one member of a collection", async () => { + const projectRoot = await createCollectionProject(); + const logger = new TestLogger(); + + await runDeployCommand(logger, join(projectRoot, "agents/support"), { + isEveProject, + hasInteractiveTerminal: () => true, + }); + + expect(logger.errors[0]).toContain("collection root"); + expect(process.exitCode).toBe(1); + }); + test("points an unlinked non-interactive run at eve link", async () => { const projectRoot = await createAgentProject(); const logger = new TestLogger(); diff --git a/packages/eve/src/cli/commands/deploy.ts b/packages/eve/src/cli/commands/deploy.ts index 756287386c..51ea5c6298 100644 --- a/packages/eve/src/cli/commands/deploy.ts +++ b/packages/eve/src/cli/commands/deploy.ts @@ -1,4 +1,5 @@ import { isEveProject } from "#setup/scaffold/index.js"; +import { resolveEveProjectContext } from "#internal/project-context.js"; import { runDeployFlow, type DeployFlowDeps } from "#setup/flows/deploy.js"; import { createPrompter, type Prompter } from "#setup/prompter.js"; @@ -35,12 +36,19 @@ export async function runDeployCommand( appRoot: string, dependencies: DeployCommandDependencies = defaultDependencies, ): Promise { - if (!(await dependencies.isEveProject(appRoot))) { + const projectContext = await resolveEveProjectContext(appRoot); + if (projectContext.kind === "collection-member") { + logger.error( + `This agent belongs to the collection at ${projectContext.collection.root}. Run \`eve deploy\` from the collection root to deploy every peer agent together.`, + ); + process.exitCode = 1; + return; + } + if (!(await dependencies.isEveProject(appRoot)) && projectContext.kind === "standalone") { logger.error(NOT_AN_AGENT_MESSAGE); process.exitCode = 1; return; } - const prompter = dependencies.createPrompter?.() ?? createPrompter(); prompter.intro("Deploy your eve agent to Vercel"); try { diff --git a/packages/eve/src/cli/commands/link.integration.test.ts b/packages/eve/src/cli/commands/link.integration.test.ts index c22e62d041..f2c05c6800 100644 --- a/packages/eve/src/cli/commands/link.integration.test.ts +++ b/packages/eve/src/cli/commands/link.integration.test.ts @@ -26,6 +26,17 @@ class TestLogger implements LinkCliLogger { } } +async function createCollectionProject(): Promise { + const projectRoot = await mkdtemp(join(tmpdir(), "eve-link-collection-")); + await mkdir(join(projectRoot, "agents/support/agent"), { recursive: true }); + await writeFile( + join(projectRoot, "package.json"), + JSON.stringify({ eve: { collection: true }, private: true }), + "utf8", + ); + return projectRoot; +} + async function createAgentProject(): Promise { const projectRoot = await mkdtemp(join(tmpdir(), "eve-link-command-")); await mkdir(join(projectRoot, "agent"), { recursive: true }); @@ -123,6 +134,19 @@ describe("runLinkCommand", () => { expect(process.exitCode).toBe(1); }); + test("refuses to link one member of a collection", async () => { + const projectRoot = await createCollectionProject(); + const logger = new TestLogger(); + + await runLinkCommand(logger, join(projectRoot, "agents/support"), { + isEveProject, + hasInteractiveTerminal: () => true, + }); + + expect(logger.errors[0]).toContain("collection root"); + expect(process.exitCode).toBe(1); + }); + test("refuses without an interactive terminal", async () => { const projectRoot = await createAgentProject(); const logger = new TestLogger(); diff --git a/packages/eve/src/cli/commands/link.ts b/packages/eve/src/cli/commands/link.ts index 9be9b0b6d5..b23f0f8a4f 100644 --- a/packages/eve/src/cli/commands/link.ts +++ b/packages/eve/src/cli/commands/link.ts @@ -1,4 +1,5 @@ import { isEveProject } from "#setup/scaffold/index.js"; +import { resolveEveProjectContext } from "#internal/project-context.js"; import { runLinkFlow, type LinkFlowDeps } from "#setup/flows/link.js"; import { createPrompter, type Prompter } from "#setup/prompter.js"; @@ -36,7 +37,15 @@ export async function runLinkCommand( appRoot: string, dependencies: LinkCommandDependencies = defaultDependencies, ): Promise { - if (!(await dependencies.isEveProject(appRoot))) { + const projectContext = await resolveEveProjectContext(appRoot); + if (projectContext.kind === "collection-member") { + logger.error( + `This agent belongs to the collection at ${projectContext.collection.root}. Run \`eve link\` from the collection root.`, + ); + process.exitCode = 1; + return; + } + if (!(await dependencies.isEveProject(appRoot)) && projectContext.kind === "standalone") { logger.error(NOT_AN_AGENT_MESSAGE); process.exitCode = 1; return; diff --git a/packages/eve/src/internal/agent-workspace.ts b/packages/eve/src/internal/agent-workspace.ts index d5be182198..8ab27d4411 100644 --- a/packages/eve/src/internal/agent-workspace.ts +++ b/packages/eve/src/internal/agent-workspace.ts @@ -79,3 +79,12 @@ export async function resolveAgentWorkspace( return { members, root: workspaceRoot }; } + +/** Load a collection that the caller has already identified as collection-shaped. */ +export async function loadAgentCollection(root: string): Promise { + const collection = await resolveAgentCollection(root); + if (collection === undefined) { + throw new Error("An eve agent collection requires an agents/ directory."); + } + return collection; +} diff --git a/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts b/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts index 4445f30eaa..519bdbd9db 100644 --- a/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts +++ b/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts @@ -442,6 +442,7 @@ describe("buildApplication", () => { { buildDir: expect.stringContaining(join(appRoot, ".eve", "builds")), outputDir: expect.stringContaining(join(appRoot, ".eve", "builds")), + publicRoutePrefix: "/_eve_internal/eve", }, ); const nestedFunctionStats = await lstat( diff --git a/packages/eve/src/internal/nitro/host/build-application.ts b/packages/eve/src/internal/nitro/host/build-application.ts index 2c51e87180..afdeca0cdb 100644 --- a/packages/eve/src/internal/nitro/host/build-application.ts +++ b/packages/eve/src/internal/nitro/host/build-application.ts @@ -40,7 +40,7 @@ import type { ApplicationBuildOptions } from "#internal/nitro/host/types.js"; import { findClosestVercelOutputDirectory } from "#shared/vercel-output-directory.js"; import { toErrorMessage } from "#shared/errors.js"; import { resolveDiscoveryProject } from "#discover/project.js"; -import { resolveCoDeployedEveServicePrefix } from "#internal/vercel/vercel-service-config-operations.js"; +import { resolveEveServicePrefixByRoot } from "#internal/vercel/vercel-service-config-operations.js"; import { parseVercelServicesConfig } from "#internal/vercel/vercel-services-config.js"; import { createDiskRuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js"; import { isObject } from "#shared/guards.js"; @@ -100,7 +100,7 @@ async function writeOptionalApplicationBuildProfile(input: { } } -async function resolveCoDeployedEveServicePrefixForVercelFunctionOutput( +async function resolveEveServicePrefixForVercelFunctionOutput( appRoot: string, agentRoot: string, ): Promise { @@ -114,7 +114,7 @@ async function resolveCoDeployedEveServicePrefixForVercelFunctionOutput( JSON.parse(await readFile(configPath, "utf8")) as unknown, configPath, ); - const servicePrefix = resolveCoDeployedEveServicePrefix({ + const servicePrefix = resolveEveServicePrefixByRoot({ appRoots, configRoot: await resolveVercelOutputConfigRoot(outputDirectory), config, @@ -146,7 +146,7 @@ async function resolveCoDeployedEveServicePrefixForVercelFunctionOutput( ? currentDir : await resolveVercelOutputConfigRoot(dirname(configPath)); - const servicePrefix = resolveCoDeployedEveServicePrefix({ + const servicePrefix = resolveEveServicePrefixByRoot({ appRoots, configRoot, config, @@ -288,17 +288,27 @@ async function buildApplicationInWorkspace( const servicePrefix = isVercelBuild ? await measureBuildPhase(profiler, "vercel.service-prefix.resolve", () => - resolveCoDeployedEveServicePrefixForVercelFunctionOutput( + resolveEveServicePrefixForVercelFunctionOutput( preparedHost.appRoot, preparedHost.compileResult.project.agentRoot, ), ) : undefined; + if ( + options.publicRoutePrefix !== undefined && + servicePrefix !== undefined && + options.publicRoutePrefix !== servicePrefix + ) { + throw new Error( + `EVE_PUBLIC_ROUTE_PREFIX ${JSON.stringify(options.publicRoutePrefix)} conflicts with the configured Vercel service prefix ${JSON.stringify(servicePrefix)}.`, + ); + } + const publicRoutePrefix = options.publicRoutePrefix ?? servicePrefix; const nitro = await measureBuildPhase(profiler, "nitro.create", () => createProductionApplicationNitro(preparedHost, { buildDir: workspace.nitro.buildDir, outputDir: workspace.publication.output.stagedDir, - publicRoutePrefix: options.publicRoutePrefix, + publicRoutePrefix, }), ); @@ -341,7 +351,7 @@ async function buildApplicationInWorkspace( if (vercelServiceOutput !== undefined) { await measureBuildPhase(profiler, "vercel.service-crons.normalize", () => normalizeVercelServiceCrons({ - publicRoutePrefix: options.publicRoutePrefix, + publicRoutePrefix, serviceOutputDirectory: workspace.publication.output.stagedDir, }), ); diff --git a/packages/eve/src/internal/vercel/agent-collection-deployment.ts b/packages/eve/src/internal/vercel/agent-collection-deployment.ts new file mode 100644 index 0000000000..a335b4b7b5 --- /dev/null +++ b/packages/eve/src/internal/vercel/agent-collection-deployment.ts @@ -0,0 +1,18 @@ +import { join } from "node:path"; + +import type { AgentCollection } from "#internal/agent-collection.js"; +import { readVercelJsonFile } from "#internal/vercel/vercel-services-config.js"; + +export type AgentCollectionDeploymentMode = "authored" | "inferred"; + +/** Resolve and validate the Vercel deployment policy for an agent collection. */ +export async function resolveAgentCollectionDeploymentMode( + collection: AgentCollection, +): Promise { + const config = await readVercelJsonFile(join(collection.root, "vercel.json")); + return config.services !== undefined || + config.experimentalServices !== undefined || + config.experimentalServicesV2 !== undefined + ? "authored" + : "inferred"; +} diff --git a/packages/eve/src/internal/vercel/build-agent-collection.integration.test.ts b/packages/eve/src/internal/vercel/build-agent-collection.integration.test.ts new file mode 100644 index 0000000000..4ee676729b --- /dev/null +++ b/packages/eve/src/internal/vercel/build-agent-collection.integration.test.ts @@ -0,0 +1,97 @@ +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +vi.mock("#shared/resolve-eve-binary.js", () => ({ + resolveEveBinaryPath: (root: string) => join(root, "node_modules", "eve", "bin", "eve.js"), +})); + +import { loadAgentCollection } from "#internal/agent-collection.js"; +import { buildAgentCollection } from "#internal/vercel/build-agent-collection.js"; + +async function createCollection(): Promise { + const root = await mkdtemp(join(tmpdir(), "eve-collection-build-")); + await writeFile( + join(root, "package.json"), + JSON.stringify({ eve: { collection: true }, packageManager: "pnpm@10.0.0", private: true }), + ); + await Promise.all([ + mkdir(join(root, "agents", "support", "agent"), { recursive: true }), + mkdir(join(root, "agents", "research", "agent"), { recursive: true }), + ]); + return root; +} + +describe("buildAgentCollection", () => { + it("emits peer services and canonical public routes", async () => { + const root = await createCollection(); + const collection = await loadAgentCollection(root); + expect(collection).toBeDefined(); + + const output = await buildAgentCollection(collection); + const config = JSON.parse(await readFile(join(output, "config.json"), "utf8")); + + expect(config.routes).toEqual([ + { + destination: { service: "eve-research", type: "service" }, + src: "^/eve/agents/research/eve/v1/(.*)$", + }, + { + destination: { service: "eve-support", type: "service" }, + src: "^/eve/agents/support/eve/v1/(.*)$", + }, + ]); + expect(config.services["eve-support"]).toEqual({ + buildCommand: + "cd '../../../agents/support' && export EVE_INTERNAL_BUILD_OUTPUT_DIRECTORY='../../.eve/vercel-services/eve-support/.vercel/output' && export EVE_INTERNAL_HOST_BUILD_OUTPUT_DIRECTORY='../../.vercel/output' && export EVE_PUBLIC_ROUTE_PREFIX='/eve/agents/support' && node 'node_modules/eve/bin/eve.js' build", + framework: "eve", + root: ".eve/vercel-services/eve-support", + routePrefix: "/eve/agents/support", + routes: [ + { + src: "^/eve/agents/support/eve/v1/(.*)$", + transforms: [{ args: "/eve/v1/$1", op: "set", type: "request.path" }], + }, + ], + }); + }); + + it("keeps digit-bearing public names while encoding the generated service name", async () => { + const root = await createCollection(); + await mkdir(join(root, "agents", "support2", "agent"), { recursive: true }); + const collection = await loadAgentCollection(root); + const output = await buildAgentCollection(collection); + const config = JSON.parse(await readFile(join(output, "config.json"), "utf8")); + const supportServiceName = Object.keys(config.services).find((name) => + name.startsWith("eve-support-"), + ); + + expect(supportServiceName).toMatch(/^eve-support-[a-z]+$/); + expect(config.routes).toContainEqual({ + destination: { service: supportServiceName, type: "service" }, + src: "^/eve/agents/support2/eve/v1/(.*)$", + }); + }); + + it("uses a child package build script", async () => { + const root = await createCollection(); + await writeFile(join(root, "pnpm-workspace.yaml"), 'packages:\n - "agents/*"\n'); + await writeFile( + join(root, "agents", "support", "package.json"), + JSON.stringify({ private: true, scripts: { build: "generate && eve build" } }), + ); + const collection = await loadAgentCollection(root); + const output = await buildAgentCollection(collection); + const config = JSON.parse(await readFile(join(output, "config.json"), "utf8")); + expect(config.services["eve-support"].buildCommand).toContain("pnpm run build"); + }); + + it("refuses to assemble an authored graph", async () => { + const root = await createCollection(); + await writeFile(join(root, "vercel.json"), JSON.stringify({ services: {} })); + const collection = await loadAgentCollection(root); + await expect(buildAgentCollection(collection)).rejects.toThrow(/Run `vercel build`/); + }); +}); diff --git a/packages/eve/src/internal/vercel/build-agent-collection.ts b/packages/eve/src/internal/vercel/build-agent-collection.ts new file mode 100644 index 0000000000..29c0207d22 --- /dev/null +++ b/packages/eve/src/internal/vercel/build-agent-collection.ts @@ -0,0 +1,74 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import type { AgentCollection, AgentCollectionMember } from "#internal/agent-collection.js"; +import { assembleEveVercelServices } from "#internal/vercel/assemble-eve-services.js"; +import { quoteVercelShellArgument, toVercelRelativePath } from "#internal/vercel/build-command.js"; +import { resolveAgentCollectionDeploymentMode } from "#internal/vercel/agent-collection-deployment.js"; +import { resolveEveBinaryPath } from "#shared/resolve-eve-binary.js"; +import { detectPackageManager, type PackageManagerKind } from "#setup/package-manager.js"; +import { parseJsonObject } from "#shared/json.js"; + +const VERCEL_BUILD_OUTPUT_VERSION = 3; + +async function hasBuildScript(member: AgentCollectionMember): Promise { + if (member.packageJsonPath === undefined) return false; + const packageJson = parseJsonObject(JSON.parse(await readFile(member.packageJsonPath, "utf8"))); + if (packageJson.scripts === undefined) return false; + const scripts = parseJsonObject(packageJson.scripts); + return typeof scripts.build === "string"; +} + +async function resolveMemberBuildCommand( + member: AgentCollectionMember, + packageManager: PackageManagerKind, +): Promise { + if (await hasBuildScript(member)) return `${packageManager} run build`; + + return `node ${quoteVercelShellArgument( + toVercelRelativePath(member.appRoot, resolveEveBinaryPath(member.appRoot)), + )} build`; +} + +/** Emit the inferred Vercel Services project for a strict hostless collection. */ +export async function buildAgentCollection(collection: AgentCollection): Promise { + if ((await resolveAgentCollectionDeploymentMode(collection)) === "authored") { + throw new Error( + "This project defines its Vercel service graph in vercel.json. Run `vercel build` to build the complete project, or run `eve build` from an individual agent directory.", + ); + } + + const packageManager = await detectPackageManager(collection.root); + const agents = await Promise.all( + collection.members.map(async (member) => ({ + agent: { + appRoot: member.appRoot, + buildCommand: await resolveMemberBuildCommand(member, packageManager.kind), + name: member.name, + publicRoutePrefix: `/eve/agents/${member.name}`, + }, + target: { + hostOutputDirectory: join(collection.root, ".vercel", "output"), + projectRoot: collection.root, + }, + })), + ); + const assembled = assembleEveVercelServices({ agents }); + + const outputDirectory = join(collection.root, ".vercel", "output"); + await rm(outputDirectory, { force: true, recursive: true }); + await mkdir(outputDirectory, { recursive: true }); + await writeFile( + join(outputDirectory, "config.json"), + `${JSON.stringify( + { + version: VERCEL_BUILD_OUTPUT_VERSION, + routes: assembled.routes, + services: assembled.services, + }, + null, + 2, + )}\n`, + ); + return outputDirectory; +} diff --git a/packages/eve/src/internal/vercel/eve-service-contribution.test.ts b/packages/eve/src/internal/vercel/eve-service-contribution.test.ts index 920fe6ab7a..c8355cd8d6 100644 --- a/packages/eve/src/internal/vercel/eve-service-contribution.test.ts +++ b/packages/eve/src/internal/vercel/eve-service-contribution.test.ts @@ -1,44 +1,28 @@ import { describe, expect, it } from "vitest"; import { createEveServiceName } from "#internal/vercel/eve-service-contribution.js"; -import { resolveCoDeployedEveServicePrefix } from "#internal/vercel/vercel-service-config-operations.js"; +import { resolveEveServicePrefixByRoot } from "#internal/vercel/vercel-service-config-operations.js"; import { isValidVercelServiceName } from "#internal/vercel/vercel-service-name.js"; -describe("resolveCoDeployedEveServicePrefix", () => { - it("resolves the matching eve service only when it is co-deployed with a host", () => { +describe("resolveEveServicePrefixByRoot", () => { + it("resolves stable and legacy services by application root", () => { expect( - resolveCoDeployedEveServicePrefix({ + resolveEveServicePrefixByRoot({ appRoots: ["/project/agents/support"], config: { services: { eve: { framework: "eve", root: "agents/support", routePrefix: "/support" }, - web: { framework: "nextjs", root: "." }, }, }, configRoot: "/project", }), ).toBe("/support"); expect( - resolveCoDeployedEveServicePrefix({ - appRoots: ["/project/agents/support"], - config: { - services: { - eve: { framework: "eve", root: "agents/support", routePrefix: "/support" }, - }, - }, - configRoot: "/project", - }), - ).toBeUndefined(); - }); - - it("supports legacy service collections", () => { - expect( - resolveCoDeployedEveServicePrefix({ + resolveEveServicePrefixByRoot({ appRoots: ["/project/agent"], config: { experimentalServices: { eve: { entrypoint: "agent", framework: "eve", mount: { path: "/agent" } }, - web: { framework: "nextjs" }, }, }, configRoot: "/project", @@ -48,7 +32,7 @@ describe("resolveCoDeployedEveServicePrefix", () => { it("ignores malformed legacy service collections", () => { expect( - resolveCoDeployedEveServicePrefix({ + resolveEveServicePrefixByRoot({ appRoots: ["/project/agent"], config: { experimentalServices: null, diff --git a/packages/eve/src/internal/vercel/vercel-service-config-operations.ts b/packages/eve/src/internal/vercel/vercel-service-config-operations.ts index ddf5d01fb7..88505bf334 100644 --- a/packages/eve/src/internal/vercel/vercel-service-config-operations.ts +++ b/packages/eve/src/internal/vercel/vercel-service-config-operations.ts @@ -62,21 +62,23 @@ function resolveServiceRoot(configRoot: string, service: VercelServiceConfig): s return typeof root === "string" && root.trim().length > 0 ? resolve(configRoot, root) : undefined; } -/** Resolve the mounted prefix for an eve service co-deployed with a host service. */ -export function resolveCoDeployedEveServicePrefix(input: { +function resolveServiceCollection( + config: VercelServicesConfig, +): readonly VercelServiceConfig[] | undefined { + return ( + parseLegacyServiceCollection(config.experimentalServices) ?? + parseLegacyServiceCollection(config.experimentalServicesV2) ?? + createServiceConfigList(config.services) + ); +} + +/** Resolve the mounted prefix for the eve service matching an application root. */ +export function resolveEveServicePrefixByRoot(input: { readonly appRoots: readonly string[]; readonly config: VercelServicesConfig; readonly configRoot: string; }): string | undefined { - const services = - parseLegacyServiceCollection(input.config.experimentalServices) ?? - parseLegacyServiceCollection(input.config.experimentalServicesV2) ?? - createServiceConfigList(input.config.services); - if (services === undefined || !services.some((service) => service.framework !== "eve")) { - return undefined; - } - - for (const service of services) { + for (const service of resolveServiceCollection(input.config) ?? []) { const prefix = resolveServicePrefix(service); if ( service.framework === "eve" && diff --git a/packages/eve/test/scenarios/hostless-agent-collection.scenario.test.ts b/packages/eve/test/scenarios/hostless-agent-collection.scenario.test.ts new file mode 100644 index 0000000000..ef15a8fdbf --- /dev/null +++ b/packages/eve/test/scenarios/hostless-agent-collection.scenario.test.ts @@ -0,0 +1,72 @@ +import { access, readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { useScenarioApp } from "../../src/internal/testing/scenario-app.js"; +import { runPnpmCommand } from "../../src/internal/testing/run-pnpm-command.js"; + +const require = createRequire(import.meta.url); +const vercelManifest = require("vercel/package.json") as { version: string }; +const scenarioApp = useScenarioApp(); + +describe("hostless agent collection", () => { + it("assembles every direct child as a peer Vercel service", async () => { + const app = await scenarioApp({ + files: { + "agents/research/agent/agent.mjs": `import { defineAgent } from "eve";\nexport default defineAgent({ model: "openai/gpt-5.4" });\n`, + "agents/research/agent/instructions.md": "You are the research agent.\n", + "agents/support/agent/agent.mjs": `import { defineAgent } from "eve";\nexport default defineAgent({ model: "openai/gpt-5.4" });\n`, + "agents/support/agent/instructions.md": "You are the support agent.\n", + ".vercel/project.json": `${JSON.stringify( + { + orgId: "team_eve_scenario", + projectId: "prj_eve_collection_scenario", + projectName: "hostless-agent-collection", + settings: { + buildCommand: "pnpm exec eve build", + framework: null, + outputDirectory: null, + rootDirectory: null, + }, + }, + null, + 2, + )}\n`, + "pnpm-workspace.yaml": "minimumReleaseAge: 0\n", + }, + dependencies: { vercel: vercelManifest.version }, + installDependencies: true, + name: "hostless-agent-collection", + }); + const packageJsonPath = join(app.appRoot, "package.json"); + const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); + packageJson.eve = { collection: true }; + packageJson.scripts = { build: "eve build" }; + await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + + await runPnpmCommand({ args: ["exec", "vercel", "build", "--yes"], cwd: app.appRoot }); + + const outputRoot = join(app.appRoot, ".vercel", "output"); + const config = JSON.parse(await readFile(join(outputRoot, "config.json"), "utf8")); + expect(config.routes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + destination: { service: "eve-support", type: "service" }, + src: "^/eve/agents/support/eve/v1/(.*)$", + }), + expect.objectContaining({ + destination: { service: "eve-research", type: "service" }, + src: "^/eve/agents/research/eve/v1/(.*)$", + }), + ]), + ); + await expect( + access(join(outputRoot, "services", "eve-support", "functions", "__server.func")), + ).resolves.toBeUndefined(); + await expect( + access(join(outputRoot, "services", "eve-research", "functions", "__server.func")), + ).resolves.toBeUndefined(); + }, 240_000); +}); From b8dbbdb35356e3291ff80dcbdb54d09697d024db Mon Sep 17 00:00:00 2001 From: owenkephart Date: Wed, 12 Aug 2026 21:36:27 +0000 Subject: [PATCH 2/6] refactor(eve): standardize collection builds Signed-off-by: owenkephart --- docs/guides/deployment/vercel.mdx | 2 +- ...build-agent-collection.integration.test.ts | 13 ----- .../internal/vercel/build-agent-collection.ts | 50 ++++++------------- 3 files changed, 17 insertions(+), 48 deletions(-) diff --git a/docs/guides/deployment/vercel.mdx b/docs/guides/deployment/vercel.mdx index ece2d5a445..8f9ddf4022 100644 --- a/docs/guides/deployment/vercel.mdx +++ b/docs/guides/deployment/vercel.mdx @@ -67,7 +67,7 @@ my-project/ └── agent/ ``` -The `eve.collection` declaration marks the project as a collection. eve then validates and discovers only direct `agents//agent/` children. Their directory names become their public identities, exposed at `/eve/agents//eve/v1/*`. Run `eve dev`, `eve info`, and `eve eval` from an individual child directory; run project-level build, link, and deploy commands from the collection root. A child `package.json` is optional, must belong to the root package-manager workspace, and may provide its own `build` script and dependencies. +The `eve.collection` declaration marks the project as a collection. eve then validates and discovers only direct `agents//agent/` children. Their directory names become their public identities, exposed at `/eve/agents//eve/v1/*`. Run `eve dev`, `eve info`, and `eve eval` from an individual child directory; run project-level build, link, and deploy commands from the collection root. The collection root is the project package; child agent directories do not define packages or build scripts. With no authored `vercel.json#services`, `eve build` derives the complete Vercel Services graph on every build. If `vercel.json` declares `services`, that authored graph is authoritative instead; use `vercel build` to build and validate the complete project. This supports heterogeneous projects with frontends, private APIs, bindings, and other non-eve services without generated configuration files. diff --git a/packages/eve/src/internal/vercel/build-agent-collection.integration.test.ts b/packages/eve/src/internal/vercel/build-agent-collection.integration.test.ts index 4ee676729b..8efb5609e1 100644 --- a/packages/eve/src/internal/vercel/build-agent-collection.integration.test.ts +++ b/packages/eve/src/internal/vercel/build-agent-collection.integration.test.ts @@ -75,19 +75,6 @@ describe("buildAgentCollection", () => { }); }); - it("uses a child package build script", async () => { - const root = await createCollection(); - await writeFile(join(root, "pnpm-workspace.yaml"), 'packages:\n - "agents/*"\n'); - await writeFile( - join(root, "agents", "support", "package.json"), - JSON.stringify({ private: true, scripts: { build: "generate && eve build" } }), - ); - const collection = await loadAgentCollection(root); - const output = await buildAgentCollection(collection); - const config = JSON.parse(await readFile(join(output, "config.json"), "utf8")); - expect(config.services["eve-support"].buildCommand).toContain("pnpm run build"); - }); - it("refuses to assemble an authored graph", async () => { const root = await createCollection(); await writeFile(join(root, "vercel.json"), JSON.stringify({ services: {} })); diff --git a/packages/eve/src/internal/vercel/build-agent-collection.ts b/packages/eve/src/internal/vercel/build-agent-collection.ts index 29c0207d22..1bef5da0cc 100644 --- a/packages/eve/src/internal/vercel/build-agent-collection.ts +++ b/packages/eve/src/internal/vercel/build-agent-collection.ts @@ -1,32 +1,17 @@ -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import type { AgentCollection, AgentCollectionMember } from "#internal/agent-collection.js"; +import type { AgentCollection } from "#internal/agent-collection.js"; import { assembleEveVercelServices } from "#internal/vercel/assemble-eve-services.js"; import { quoteVercelShellArgument, toVercelRelativePath } from "#internal/vercel/build-command.js"; import { resolveAgentCollectionDeploymentMode } from "#internal/vercel/agent-collection-deployment.js"; import { resolveEveBinaryPath } from "#shared/resolve-eve-binary.js"; -import { detectPackageManager, type PackageManagerKind } from "#setup/package-manager.js"; -import { parseJsonObject } from "#shared/json.js"; const VERCEL_BUILD_OUTPUT_VERSION = 3; -async function hasBuildScript(member: AgentCollectionMember): Promise { - if (member.packageJsonPath === undefined) return false; - const packageJson = parseJsonObject(JSON.parse(await readFile(member.packageJsonPath, "utf8"))); - if (packageJson.scripts === undefined) return false; - const scripts = parseJsonObject(packageJson.scripts); - return typeof scripts.build === "string"; -} - -async function resolveMemberBuildCommand( - member: AgentCollectionMember, - packageManager: PackageManagerKind, -): Promise { - if (await hasBuildScript(member)) return `${packageManager} run build`; - +function resolveMemberBuildCommand(appRoot: string): string { return `node ${quoteVercelShellArgument( - toVercelRelativePath(member.appRoot, resolveEveBinaryPath(member.appRoot)), + toVercelRelativePath(appRoot, resolveEveBinaryPath(appRoot)), )} build`; } @@ -38,21 +23,18 @@ export async function buildAgentCollection(collection: AgentCollection): Promise ); } - const packageManager = await detectPackageManager(collection.root); - const agents = await Promise.all( - collection.members.map(async (member) => ({ - agent: { - appRoot: member.appRoot, - buildCommand: await resolveMemberBuildCommand(member, packageManager.kind), - name: member.name, - publicRoutePrefix: `/eve/agents/${member.name}`, - }, - target: { - hostOutputDirectory: join(collection.root, ".vercel", "output"), - projectRoot: collection.root, - }, - })), - ); + const agents = collection.members.map((member) => ({ + agent: { + appRoot: member.appRoot, + buildCommand: resolveMemberBuildCommand(member.appRoot), + name: member.name, + publicRoutePrefix: `/eve/agents/${member.name}`, + }, + target: { + hostOutputDirectory: join(collection.root, ".vercel", "output"), + projectRoot: collection.root, + }, + })); const assembled = assembleEveVercelServices({ agents }); const outputDirectory = join(collection.root, ".vercel", "output"); From 97125efe46e4771d254a2e5fc3c3123a51b7032d Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 13 Aug 2026 15:53:51 +0000 Subject: [PATCH 3/6] refactor(eve): rename agent collection deployment to workspace Signed-off-by: owenkephart --- .changeset/declarative-agent-collections.md | 2 +- docs/guides/deployment/vercel.mdx | 6 ++-- packages/eve/src/cli/commands/build.ts | 8 ++--- .../cli/commands/deploy.integration.test.ts | 12 +++---- packages/eve/src/cli/commands/deploy.ts | 4 +-- .../src/cli/commands/link.integration.test.ts | 12 +++---- packages/eve/src/cli/commands/link.ts | 4 +-- packages/eve/src/internal/agent-workspace.ts | 12 +++---- .../vercel/agent-collection-deployment.ts | 18 ----------- .../vercel/agent-workspace-deployment.ts | 18 +++++++++++ ...build-agent-workspace.integration.test.ts} | 32 +++++++++---------- ...collection.ts => build-agent-workspace.ts} | 18 +++++------ ...hostless-agent-workspace.scenario.test.ts} | 8 ++--- 13 files changed, 77 insertions(+), 77 deletions(-) delete mode 100644 packages/eve/src/internal/vercel/agent-collection-deployment.ts create mode 100644 packages/eve/src/internal/vercel/agent-workspace-deployment.ts rename packages/eve/src/internal/vercel/{build-agent-collection.integration.test.ts => build-agent-workspace.integration.test.ts} (72%) rename packages/eve/src/internal/vercel/{build-agent-collection.ts => build-agent-workspace.ts} (71%) rename packages/eve/test/scenarios/{hostless-agent-collection.scenario.test.ts => hostless-agent-workspace.scenario.test.ts} (93%) diff --git a/.changeset/declarative-agent-collections.md b/.changeset/declarative-agent-collections.md index b9c643146c..f6c075860a 100644 --- a/.changeset/declarative-agent-collections.md +++ b/.changeset/declarative-agent-collections.md @@ -2,4 +2,4 @@ "eve": patch --- -Deploy strict `agents//agent/` collections as declaratively inferred peer Vercel services, with no generated `vercel.json` step. Collection children can share the root package or use workspace-member packages with their own dependencies and build scripts. +Deploy strict, single-package `agents//agent/` workspaces as declaratively inferred peer Vercel services, with no generated `vercel.json` step. diff --git a/docs/guides/deployment/vercel.mdx b/docs/guides/deployment/vercel.mdx index 8f9ddf4022..fc55d0f496 100644 --- a/docs/guides/deployment/vercel.mdx +++ b/docs/guides/deployment/vercel.mdx @@ -49,11 +49,11 @@ See [Sandbox](../../sandbox) for resource limits, network policy, and lifecycle ## Deploy the agent -A project containing only several independently addressed root agents can use eve's hostless collection layout: +A project containing only several independently addressed root agents can use eve's hostless workspace layout: ```json title="package.json" { - "eve": { "collection": true } + "eve": { "agents": ["agents/*"] } } ``` @@ -67,7 +67,7 @@ my-project/ └── agent/ ``` -The `eve.collection` declaration marks the project as a collection. eve then validates and discovers only direct `agents//agent/` children. Their directory names become their public identities, exposed at `/eve/agents//eve/v1/*`. Run `eve dev`, `eve info`, and `eve eval` from an individual child directory; run project-level build, link, and deploy commands from the collection root. The collection root is the project package; child agent directories do not define packages or build scripts. +The `eve.agents` declaration marks the project as a workspace. eve then validates and discovers only direct `agents//agent/` children. Their directory names become their public identities, exposed at `/eve/agents//eve/v1/*`. Run `eve dev`, `eve info`, and `eve eval` from an individual child directory; run project-level build, link, and deploy commands from the workspace root. The workspace root is the project package; child agent directories do not define packages or build scripts. With no authored `vercel.json#services`, `eve build` derives the complete Vercel Services graph on every build. If `vercel.json` declares `services`, that authored graph is authoritative instead; use `vercel build` to build and validate the complete project. This supports heterogeneous projects with frontends, private APIs, bindings, and other non-eve services without generated configuration files. diff --git a/packages/eve/src/cli/commands/build.ts b/packages/eve/src/cli/commands/build.ts index 12f6896fcb..3f88661f23 100644 --- a/packages/eve/src/cli/commands/build.ts +++ b/packages/eve/src/cli/commands/build.ts @@ -44,14 +44,14 @@ export function registerBuildCommand(input: { await loadDevelopmentEnvironmentFiles(input.applicationContext.root); const projectContext = await resolveEveProjectContext(input.appRoot); - if (projectContext.kind === "collection") { + if (projectContext.kind === "workspace") { if (options.profile !== undefined || options.skipSandboxPrewarm === true) { throw new Error( - "Collection builds do not support --profile or --skip-sandbox-prewarm. Run those options from an individual agent directory.", + "Workspace builds do not support --profile or --skip-sandbox-prewarm. Run those options from an individual agent directory.", ); } - const { buildAgentCollection } = await import("#internal/vercel/build-agent-collection.js"); - const outputDir = await buildAgentCollection(projectContext.collection); + const { buildAgentWorkspace } = await import("#internal/vercel/build-agent-workspace.js"); + const outputDir = await buildAgentWorkspace(projectContext.workspace); input.logger.log( renderCliTaggedLine(theme, { message: `built output at ${outputDir}`, diff --git a/packages/eve/src/cli/commands/deploy.integration.test.ts b/packages/eve/src/cli/commands/deploy.integration.test.ts index de289bdc17..e76c38068e 100644 --- a/packages/eve/src/cli/commands/deploy.integration.test.ts +++ b/packages/eve/src/cli/commands/deploy.integration.test.ts @@ -24,12 +24,12 @@ class TestLogger implements DeployCliLogger { } } -async function createCollectionProject(): Promise { - const projectRoot = await mkdtemp(join(tmpdir(), "eve-deploy-collection-")); +async function createWorkspaceProject(): Promise { + const projectRoot = await mkdtemp(join(tmpdir(), "eve-deploy-workspace-")); await mkdir(join(projectRoot, "agents/support/agent"), { recursive: true }); await writeFile( join(projectRoot, "package.json"), - JSON.stringify({ eve: { collection: true }, private: true }), + JSON.stringify({ eve: { agents: ["agents/*"] }, private: true }), "utf8", ); return projectRoot; @@ -89,8 +89,8 @@ describe("runDeployCommand", () => { expect(process.exitCode).toBe(1); }); - test("refuses to deploy one member of a collection", async () => { - const projectRoot = await createCollectionProject(); + test("refuses to deploy one member of a workspace", async () => { + const projectRoot = await createWorkspaceProject(); const logger = new TestLogger(); await runDeployCommand(logger, join(projectRoot, "agents/support"), { @@ -98,7 +98,7 @@ describe("runDeployCommand", () => { hasInteractiveTerminal: () => true, }); - expect(logger.errors[0]).toContain("collection root"); + expect(logger.errors[0]).toContain("workspace root"); expect(process.exitCode).toBe(1); }); diff --git a/packages/eve/src/cli/commands/deploy.ts b/packages/eve/src/cli/commands/deploy.ts index 51ea5c6298..35b1dfefc3 100644 --- a/packages/eve/src/cli/commands/deploy.ts +++ b/packages/eve/src/cli/commands/deploy.ts @@ -37,9 +37,9 @@ export async function runDeployCommand( dependencies: DeployCommandDependencies = defaultDependencies, ): Promise { const projectContext = await resolveEveProjectContext(appRoot); - if (projectContext.kind === "collection-member") { + if (projectContext.kind === "workspace-member") { logger.error( - `This agent belongs to the collection at ${projectContext.collection.root}. Run \`eve deploy\` from the collection root to deploy every peer agent together.`, + `This agent belongs to the workspace at ${projectContext.workspace.root}. Run \`eve deploy\` from the workspace root to deploy every peer agent together.`, ); process.exitCode = 1; return; diff --git a/packages/eve/src/cli/commands/link.integration.test.ts b/packages/eve/src/cli/commands/link.integration.test.ts index f2c05c6800..b93a7fade5 100644 --- a/packages/eve/src/cli/commands/link.integration.test.ts +++ b/packages/eve/src/cli/commands/link.integration.test.ts @@ -26,12 +26,12 @@ class TestLogger implements LinkCliLogger { } } -async function createCollectionProject(): Promise { - const projectRoot = await mkdtemp(join(tmpdir(), "eve-link-collection-")); +async function createWorkspaceProject(): Promise { + const projectRoot = await mkdtemp(join(tmpdir(), "eve-link-workspace-")); await mkdir(join(projectRoot, "agents/support/agent"), { recursive: true }); await writeFile( join(projectRoot, "package.json"), - JSON.stringify({ eve: { collection: true }, private: true }), + JSON.stringify({ eve: { agents: ["agents/*"] }, private: true }), "utf8", ); return projectRoot; @@ -134,8 +134,8 @@ describe("runLinkCommand", () => { expect(process.exitCode).toBe(1); }); - test("refuses to link one member of a collection", async () => { - const projectRoot = await createCollectionProject(); + test("refuses to link one member of a workspace", async () => { + const projectRoot = await createWorkspaceProject(); const logger = new TestLogger(); await runLinkCommand(logger, join(projectRoot, "agents/support"), { @@ -143,7 +143,7 @@ describe("runLinkCommand", () => { hasInteractiveTerminal: () => true, }); - expect(logger.errors[0]).toContain("collection root"); + expect(logger.errors[0]).toContain("workspace root"); expect(process.exitCode).toBe(1); }); diff --git a/packages/eve/src/cli/commands/link.ts b/packages/eve/src/cli/commands/link.ts index b23f0f8a4f..d6f4266cdb 100644 --- a/packages/eve/src/cli/commands/link.ts +++ b/packages/eve/src/cli/commands/link.ts @@ -38,9 +38,9 @@ export async function runLinkCommand( dependencies: LinkCommandDependencies = defaultDependencies, ): Promise { const projectContext = await resolveEveProjectContext(appRoot); - if (projectContext.kind === "collection-member") { + if (projectContext.kind === "workspace-member") { logger.error( - `This agent belongs to the collection at ${projectContext.collection.root}. Run \`eve link\` from the collection root.`, + `This agent belongs to the workspace at ${projectContext.workspace.root}. Run \`eve link\` from the workspace root.`, ); process.exitCode = 1; return; diff --git a/packages/eve/src/internal/agent-workspace.ts b/packages/eve/src/internal/agent-workspace.ts index 8ab27d4411..506ecf8ac5 100644 --- a/packages/eve/src/internal/agent-workspace.ts +++ b/packages/eve/src/internal/agent-workspace.ts @@ -80,11 +80,11 @@ export async function resolveAgentWorkspace( return { members, root: workspaceRoot }; } -/** Load a collection that the caller has already identified as collection-shaped. */ -export async function loadAgentCollection(root: string): Promise { - const collection = await resolveAgentCollection(root); - if (collection === undefined) { - throw new Error("An eve agent collection requires an agents/ directory."); +/** Load a workspace that the caller has already identified as workspace-shaped. */ +export async function loadAgentWorkspace(root: string): Promise { + const workspace = await resolveAgentWorkspace(root); + if (workspace === undefined) { + throw new Error("An eve agent workspace requires an agents/ directory."); } - return collection; + return workspace; } diff --git a/packages/eve/src/internal/vercel/agent-collection-deployment.ts b/packages/eve/src/internal/vercel/agent-collection-deployment.ts deleted file mode 100644 index a335b4b7b5..0000000000 --- a/packages/eve/src/internal/vercel/agent-collection-deployment.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { join } from "node:path"; - -import type { AgentCollection } from "#internal/agent-collection.js"; -import { readVercelJsonFile } from "#internal/vercel/vercel-services-config.js"; - -export type AgentCollectionDeploymentMode = "authored" | "inferred"; - -/** Resolve and validate the Vercel deployment policy for an agent collection. */ -export async function resolveAgentCollectionDeploymentMode( - collection: AgentCollection, -): Promise { - const config = await readVercelJsonFile(join(collection.root, "vercel.json")); - return config.services !== undefined || - config.experimentalServices !== undefined || - config.experimentalServicesV2 !== undefined - ? "authored" - : "inferred"; -} diff --git a/packages/eve/src/internal/vercel/agent-workspace-deployment.ts b/packages/eve/src/internal/vercel/agent-workspace-deployment.ts new file mode 100644 index 0000000000..34b07742cd --- /dev/null +++ b/packages/eve/src/internal/vercel/agent-workspace-deployment.ts @@ -0,0 +1,18 @@ +import { join } from "node:path"; + +import type { AgentWorkspace } from "#internal/agent-workspace.js"; +import { readVercelJsonFile } from "#internal/vercel/vercel-services-config.js"; + +export type AgentWorkspaceDeploymentMode = "authored" | "inferred"; + +/** Resolve and validate the Vercel deployment policy for an agent workspace. */ +export async function resolveAgentWorkspaceDeploymentMode( + workspace: AgentWorkspace, +): Promise { + const config = await readVercelJsonFile(join(workspace.root, "vercel.json")); + return config.services !== undefined || + config.experimentalServices !== undefined || + config.experimentalServicesV2 !== undefined + ? "authored" + : "inferred"; +} diff --git a/packages/eve/src/internal/vercel/build-agent-collection.integration.test.ts b/packages/eve/src/internal/vercel/build-agent-workspace.integration.test.ts similarity index 72% rename from packages/eve/src/internal/vercel/build-agent-collection.integration.test.ts rename to packages/eve/src/internal/vercel/build-agent-workspace.integration.test.ts index 8efb5609e1..387b4e4dca 100644 --- a/packages/eve/src/internal/vercel/build-agent-collection.integration.test.ts +++ b/packages/eve/src/internal/vercel/build-agent-workspace.integration.test.ts @@ -8,14 +8,14 @@ vi.mock("#shared/resolve-eve-binary.js", () => ({ resolveEveBinaryPath: (root: string) => join(root, "node_modules", "eve", "bin", "eve.js"), })); -import { loadAgentCollection } from "#internal/agent-collection.js"; -import { buildAgentCollection } from "#internal/vercel/build-agent-collection.js"; +import { loadAgentWorkspace } from "#internal/agent-workspace.js"; +import { buildAgentWorkspace } from "#internal/vercel/build-agent-workspace.js"; -async function createCollection(): Promise { - const root = await mkdtemp(join(tmpdir(), "eve-collection-build-")); +async function createWorkspace(): Promise { + const root = await mkdtemp(join(tmpdir(), "eve-workspace-build-")); await writeFile( join(root, "package.json"), - JSON.stringify({ eve: { collection: true }, packageManager: "pnpm@10.0.0", private: true }), + JSON.stringify({ eve: { agents: ["agents/*"] }, packageManager: "pnpm@10.0.0", private: true }), ); await Promise.all([ mkdir(join(root, "agents", "support", "agent"), { recursive: true }), @@ -24,13 +24,13 @@ async function createCollection(): Promise { return root; } -describe("buildAgentCollection", () => { +describe("buildAgentWorkspace", () => { it("emits peer services and canonical public routes", async () => { - const root = await createCollection(); - const collection = await loadAgentCollection(root); - expect(collection).toBeDefined(); + const root = await createWorkspace(); + const workspace = await loadAgentWorkspace(root); + expect(workspace).toBeDefined(); - const output = await buildAgentCollection(collection); + const output = await buildAgentWorkspace(workspace); const config = JSON.parse(await readFile(join(output, "config.json"), "utf8")); expect(config.routes).toEqual([ @@ -59,10 +59,10 @@ describe("buildAgentCollection", () => { }); it("keeps digit-bearing public names while encoding the generated service name", async () => { - const root = await createCollection(); + const root = await createWorkspace(); await mkdir(join(root, "agents", "support2", "agent"), { recursive: true }); - const collection = await loadAgentCollection(root); - const output = await buildAgentCollection(collection); + const workspace = await loadAgentWorkspace(root); + const output = await buildAgentWorkspace(workspace); const config = JSON.parse(await readFile(join(output, "config.json"), "utf8")); const supportServiceName = Object.keys(config.services).find((name) => name.startsWith("eve-support-"), @@ -76,9 +76,9 @@ describe("buildAgentCollection", () => { }); it("refuses to assemble an authored graph", async () => { - const root = await createCollection(); + const root = await createWorkspace(); await writeFile(join(root, "vercel.json"), JSON.stringify({ services: {} })); - const collection = await loadAgentCollection(root); - await expect(buildAgentCollection(collection)).rejects.toThrow(/Run `vercel build`/); + const workspace = await loadAgentWorkspace(root); + await expect(buildAgentWorkspace(workspace)).rejects.toThrow(/Run `vercel build`/); }); }); diff --git a/packages/eve/src/internal/vercel/build-agent-collection.ts b/packages/eve/src/internal/vercel/build-agent-workspace.ts similarity index 71% rename from packages/eve/src/internal/vercel/build-agent-collection.ts rename to packages/eve/src/internal/vercel/build-agent-workspace.ts index 1bef5da0cc..88e5d5fae1 100644 --- a/packages/eve/src/internal/vercel/build-agent-collection.ts +++ b/packages/eve/src/internal/vercel/build-agent-workspace.ts @@ -1,10 +1,10 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import type { AgentCollection } from "#internal/agent-collection.js"; +import type { AgentWorkspace } from "#internal/agent-workspace.js"; import { assembleEveVercelServices } from "#internal/vercel/assemble-eve-services.js"; import { quoteVercelShellArgument, toVercelRelativePath } from "#internal/vercel/build-command.js"; -import { resolveAgentCollectionDeploymentMode } from "#internal/vercel/agent-collection-deployment.js"; +import { resolveAgentWorkspaceDeploymentMode } from "#internal/vercel/agent-workspace-deployment.js"; import { resolveEveBinaryPath } from "#shared/resolve-eve-binary.js"; const VERCEL_BUILD_OUTPUT_VERSION = 3; @@ -15,15 +15,15 @@ function resolveMemberBuildCommand(appRoot: string): string { )} build`; } -/** Emit the inferred Vercel Services project for a strict hostless collection. */ -export async function buildAgentCollection(collection: AgentCollection): Promise { - if ((await resolveAgentCollectionDeploymentMode(collection)) === "authored") { +/** Emit the inferred Vercel Services project for a strict hostless workspace. */ +export async function buildAgentWorkspace(workspace: AgentWorkspace): Promise { + if ((await resolveAgentWorkspaceDeploymentMode(workspace)) === "authored") { throw new Error( "This project defines its Vercel service graph in vercel.json. Run `vercel build` to build the complete project, or run `eve build` from an individual agent directory.", ); } - const agents = collection.members.map((member) => ({ + const agents = workspace.members.map((member) => ({ agent: { appRoot: member.appRoot, buildCommand: resolveMemberBuildCommand(member.appRoot), @@ -31,13 +31,13 @@ export async function buildAgentCollection(collection: AgentCollection): Promise publicRoutePrefix: `/eve/agents/${member.name}`, }, target: { - hostOutputDirectory: join(collection.root, ".vercel", "output"), - projectRoot: collection.root, + hostOutputDirectory: join(workspace.root, ".vercel", "output"), + projectRoot: workspace.root, }, })); const assembled = assembleEveVercelServices({ agents }); - const outputDirectory = join(collection.root, ".vercel", "output"); + const outputDirectory = join(workspace.root, ".vercel", "output"); await rm(outputDirectory, { force: true, recursive: true }); await mkdir(outputDirectory, { recursive: true }); await writeFile( diff --git a/packages/eve/test/scenarios/hostless-agent-collection.scenario.test.ts b/packages/eve/test/scenarios/hostless-agent-workspace.scenario.test.ts similarity index 93% rename from packages/eve/test/scenarios/hostless-agent-collection.scenario.test.ts rename to packages/eve/test/scenarios/hostless-agent-workspace.scenario.test.ts index ef15a8fdbf..1cb0a64c9f 100644 --- a/packages/eve/test/scenarios/hostless-agent-collection.scenario.test.ts +++ b/packages/eve/test/scenarios/hostless-agent-workspace.scenario.test.ts @@ -11,7 +11,7 @@ const require = createRequire(import.meta.url); const vercelManifest = require("vercel/package.json") as { version: string }; const scenarioApp = useScenarioApp(); -describe("hostless agent collection", () => { +describe("hostless agent workspace", () => { it("assembles every direct child as a peer Vercel service", async () => { const app = await scenarioApp({ files: { @@ -23,7 +23,7 @@ describe("hostless agent collection", () => { { orgId: "team_eve_scenario", projectId: "prj_eve_collection_scenario", - projectName: "hostless-agent-collection", + projectName: "hostless-agent-workspace", settings: { buildCommand: "pnpm exec eve build", framework: null, @@ -38,11 +38,11 @@ describe("hostless agent collection", () => { }, dependencies: { vercel: vercelManifest.version }, installDependencies: true, - name: "hostless-agent-collection", + name: "hostless-agent-workspace", }); const packageJsonPath = join(app.appRoot, "package.json"); const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); - packageJson.eve = { collection: true }; + packageJson.eve = { agents: ["agents/*"] }; packageJson.scripts = { build: "eve build" }; await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); From 58fd40a5db539650263e4ec5356f44e005a39ffb Mon Sep 17 00:00:00 2001 From: owenkephart Date: Thu, 13 Aug 2026 17:04:07 +0000 Subject: [PATCH 4/6] fix(eve): materialize agent workspace service roots Signed-off-by: owenkephart --- .../vercel/build-agent-workspace.integration.test.ts | 9 ++++++++- .../eve/src/internal/vercel/build-agent-workspace.ts | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/eve/src/internal/vercel/build-agent-workspace.integration.test.ts b/packages/eve/src/internal/vercel/build-agent-workspace.integration.test.ts index 387b4e4dca..45e82f4ee6 100644 --- a/packages/eve/src/internal/vercel/build-agent-workspace.integration.test.ts +++ b/packages/eve/src/internal/vercel/build-agent-workspace.integration.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { lstat, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -43,6 +43,13 @@ describe("buildAgentWorkspace", () => { src: "^/eve/agents/support/eve/v1/(.*)$", }, ]); + const serviceRoot = await lstat(join(root, ".eve", "vercel-services", "eve-support")); + const serviceOutput = await lstat( + join(root, ".eve", "vercel-services", "eve-support", ".vercel", "output"), + ); + expect(serviceRoot.isDirectory()).toBe(true); + expect(serviceOutput.isSymbolicLink()).toBe(true); + expect(config.services["eve-support"]).toEqual({ buildCommand: "cd '../../../agents/support' && export EVE_INTERNAL_BUILD_OUTPUT_DIRECTORY='../../.eve/vercel-services/eve-support/.vercel/output' && export EVE_INTERNAL_HOST_BUILD_OUTPUT_DIRECTORY='../../.vercel/output' && export EVE_PUBLIC_ROUTE_PREFIX='/eve/agents/support' && node 'node_modules/eve/bin/eve.js' build", diff --git a/packages/eve/src/internal/vercel/build-agent-workspace.ts b/packages/eve/src/internal/vercel/build-agent-workspace.ts index 88e5d5fae1..566a3d3ac4 100644 --- a/packages/eve/src/internal/vercel/build-agent-workspace.ts +++ b/packages/eve/src/internal/vercel/build-agent-workspace.ts @@ -1,4 +1,4 @@ -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { AgentWorkspace } from "#internal/agent-workspace.js"; @@ -40,6 +40,12 @@ export async function buildAgentWorkspace(workspace: AgentWorkspace): Promise { + await mkdir(join(rootDirectory, ".vercel"), { recursive: true }); + await symlink(outputDirectory, join(rootDirectory, ".vercel", "output"), "junction"); + }), + ); await writeFile( join(outputDirectory, "config.json"), `${JSON.stringify( From 0f1cf83f0efba3b7a90cee2fc5df2e563a34e9e5 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Fri, 14 Aug 2026 17:56:43 +0000 Subject: [PATCH 5/6] fix(eve): resolve workspace builds from CLI roots Signed-off-by: owenkephart --- packages/eve/src/cli/commands/build.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eve/src/cli/commands/build.ts b/packages/eve/src/cli/commands/build.ts index 3f88661f23..020eb49d01 100644 --- a/packages/eve/src/cli/commands/build.ts +++ b/packages/eve/src/cli/commands/build.ts @@ -43,7 +43,7 @@ export function registerBuildCommand(input: { await loadDevelopmentEnvironmentFiles(input.applicationContext.root); - const projectContext = await resolveEveProjectContext(input.appRoot); + const projectContext = await resolveEveProjectContext(input.applicationContext.root); if (projectContext.kind === "workspace") { if (options.profile !== undefined || options.skipSandboxPrewarm === true) { throw new Error( From 86b4108f798f9a236bc15d6a33a6db365b0b28a1 Mon Sep 17 00:00:00 2001 From: owenkephart Date: Fri, 14 Aug 2026 21:24:56 +0000 Subject: [PATCH 6/6] fix(eve): preserve agent workspace build output Signed-off-by: owenkephart --- .../build-agent-workspace.integration.test.ts | 11 ++-- .../internal/vercel/build-agent-workspace.ts | 56 ++++++++++++------- 2 files changed, 40 insertions(+), 27 deletions(-) diff --git a/packages/eve/src/internal/vercel/build-agent-workspace.integration.test.ts b/packages/eve/src/internal/vercel/build-agent-workspace.integration.test.ts index 45e82f4ee6..f052e6e090 100644 --- a/packages/eve/src/internal/vercel/build-agent-workspace.integration.test.ts +++ b/packages/eve/src/internal/vercel/build-agent-workspace.integration.test.ts @@ -1,4 +1,4 @@ -import { lstat, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { access, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -43,12 +43,9 @@ describe("buildAgentWorkspace", () => { src: "^/eve/agents/support/eve/v1/(.*)$", }, ]); - const serviceRoot = await lstat(join(root, ".eve", "vercel-services", "eve-support")); - const serviceOutput = await lstat( - join(root, ".eve", "vercel-services", "eve-support", ".vercel", "output"), - ); - expect(serviceRoot.isDirectory()).toBe(true); - expect(serviceOutput.isSymbolicLink()).toBe(true); + await expect( + access(join(root, ".eve", "vercel-services", "eve-support")), + ).resolves.toBeUndefined(); expect(config.services["eve-support"]).toEqual({ buildCommand: diff --git a/packages/eve/src/internal/vercel/build-agent-workspace.ts b/packages/eve/src/internal/vercel/build-agent-workspace.ts index 566a3d3ac4..de22b1f4c9 100644 --- a/packages/eve/src/internal/vercel/build-agent-workspace.ts +++ b/packages/eve/src/internal/vercel/build-agent-workspace.ts @@ -1,17 +1,33 @@ -import { mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import type { AgentWorkspace } from "#internal/agent-workspace.js"; +import type { AgentWorkspace, AgentWorkspaceMember } from "#internal/agent-workspace.js"; import { assembleEveVercelServices } from "#internal/vercel/assemble-eve-services.js"; import { quoteVercelShellArgument, toVercelRelativePath } from "#internal/vercel/build-command.js"; import { resolveAgentWorkspaceDeploymentMode } from "#internal/vercel/agent-workspace-deployment.js"; import { resolveEveBinaryPath } from "#shared/resolve-eve-binary.js"; +import { detectPackageManager, type PackageManagerKind } from "#setup/package-manager.js"; +import { parseJsonObject } from "#shared/json.js"; const VERCEL_BUILD_OUTPUT_VERSION = 3; -function resolveMemberBuildCommand(appRoot: string): string { +async function hasBuildScript(member: AgentWorkspaceMember): Promise { + if (member.packageJsonPath === undefined) return false; + + const packageJson = parseJsonObject(JSON.parse(await readFile(member.packageJsonPath, "utf8"))); + if (packageJson.scripts === undefined) return false; + const scripts = parseJsonObject(packageJson.scripts); + return typeof scripts.build === "string"; +} + +async function resolveMemberBuildCommand( + member: AgentWorkspaceMember, + packageManager: PackageManagerKind, +): Promise { + if (await hasBuildScript(member)) return `${packageManager} run build`; + return `node ${quoteVercelShellArgument( - toVercelRelativePath(appRoot, resolveEveBinaryPath(appRoot)), + toVercelRelativePath(member.appRoot, resolveEveBinaryPath(member.appRoot)), )} build`; } @@ -23,28 +39,28 @@ export async function buildAgentWorkspace(workspace: AgentWorkspace): Promise ({ - agent: { - appRoot: member.appRoot, - buildCommand: resolveMemberBuildCommand(member.appRoot), - name: member.name, - publicRoutePrefix: `/eve/agents/${member.name}`, - }, - target: { - hostOutputDirectory: join(workspace.root, ".vercel", "output"), - projectRoot: workspace.root, - }, - })); + const packageManager = await detectPackageManager(workspace.root); + const agents = await Promise.all( + workspace.members.map(async (member) => ({ + agent: { + appRoot: member.appRoot, + buildCommand: await resolveMemberBuildCommand(member, packageManager.kind), + name: member.name, + publicRoutePrefix: `/eve/agents/${member.name}`, + }, + target: { + hostOutputDirectory: join(workspace.root, ".vercel", "output"), + projectRoot: workspace.root, + }, + })), + ); const assembled = assembleEveVercelServices({ agents }); const outputDirectory = join(workspace.root, ".vercel", "output"); await rm(outputDirectory, { force: true, recursive: true }); await mkdir(outputDirectory, { recursive: true }); await Promise.all( - assembled.rootDirectories.map(async (rootDirectory) => { - await mkdir(join(rootDirectory, ".vercel"), { recursive: true }); - await symlink(outputDirectory, join(rootDirectory, ".vercel", "output"), "junction"); - }), + assembled.rootDirectories.map((rootDirectory) => mkdir(rootDirectory, { recursive: true })), ); await writeFile( join(outputDirectory, "config.json"),