Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/declarative-agent-collections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Deploy strict `agents/<name>/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.
16 changes: 16 additions & 0 deletions docs/guides/deployment/vercel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@ 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:

```text
my-project/
├── package.json
└── agents/
├── support/
│ └── agent/
└── research/
└── agent/
```

Only direct `agents/<name>/agent/` children are discovered. Their directory names become their public identities, exposed at `/eve/agents/<name>/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
Expand Down
2 changes: 1 addition & 1 deletion packages/eve/src/cli/acp/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function registerAcpCommand(options: RegisterAcpCommandOptions): void {
)
.action(async (positionalUrl: string | undefined, commandOptions: AcpCliOptions) => {
const target = resolveDevelopmentUrlTarget(commandOptions, positionalUrl);
loadDevelopmentEnvironmentFiles(options.appRoot);
await loadDevelopmentEnvironmentFiles(options.appRoot);
const lifecycle = installShutdownSignal({ exitAfterMs: FORCED_EXIT_BACKSTOP_MS });

if (target !== undefined) {
Expand Down
22 changes: 21 additions & 1 deletion packages/eve/src/cli/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Command } from "#compiled/commander/index.js";
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,
Expand Down Expand Up @@ -40,7 +41,26 @@ export function registerBuildCommand(input: {
.action(async (options: BuildCliOptions) => {
const { loadDevelopmentEnvironmentFiles } = await import("#cli/dev/environment.js");

loadDevelopmentEnvironmentFiles(input.appRoot);
await loadDevelopmentEnvironmentFiles(input.appRoot);

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;
Expand Down
20 changes: 20 additions & 0 deletions packages/eve/src/cli/commands/deploy.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ class TestLogger implements DeployCliLogger {
}
}

async function createCollectionProject(): Promise<string> {
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({ private: true }), "utf8");
return projectRoot;
}

async function createAgentProject(): Promise<string> {
const projectRoot = await mkdtemp(join(tmpdir(), "eve-deploy-command-"));
await mkdir(join(projectRoot, "agent"), { recursive: true });
Expand Down Expand Up @@ -78,6 +85,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();
Expand Down
12 changes: 10 additions & 2 deletions packages/eve/src/cli/commands/deploy.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -35,12 +36,19 @@ export async function runDeployCommand(
appRoot: string,
dependencies: DeployCommandDependencies = defaultDependencies,
): Promise<void> {
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 {
Expand Down
20 changes: 20 additions & 0 deletions packages/eve/src/cli/commands/link.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ class TestLogger implements LinkCliLogger {
}
}

async function createCollectionProject(): Promise<string> {
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({ private: true }), "utf8");
return projectRoot;
}

async function createAgentProject(): Promise<string> {
const projectRoot = await mkdtemp(join(tmpdir(), "eve-link-command-"));
await mkdir(join(projectRoot, "agent"), { recursive: true });
Expand Down Expand Up @@ -123,6 +130,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();
Expand Down
11 changes: 10 additions & 1 deletion packages/eve/src/cli/commands/link.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -36,7 +37,15 @@ export async function runLinkCommand(
appRoot: string,
dependencies: LinkCommandDependencies = defaultDependencies,
): Promise<void> {
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;
Expand Down
70 changes: 45 additions & 25 deletions packages/eve/src/cli/dev/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { parseEnv } from "node:util";

import { resolveEveProjectContext } from "#internal/project-context.js";
import { isObject } from "#shared/guards.js";

/**
Expand All @@ -21,6 +22,7 @@ function isMissingEnvironmentFileError(error: unknown): error is NodeJS.ErrnoExc
}

interface DevelopmentEnvironmentLoader {
readonly environmentRoots: readonly string[];
reload(): void;
stageReload(): DevelopmentEnvironmentReload;
}
Expand All @@ -37,9 +39,9 @@ const developmentEnvironmentLoaders = new Map<string, DevelopmentEnvironmentLoad
* application root, ordered from highest to lowest precedence.
*/
export function getDevelopmentEnvironmentFilePaths(appRoot: string): string[] {
const resolvedAppRoot = resolve(appRoot);

return DEVELOPMENT_ENV_FILE_NAMES.map((fileName) => join(resolvedAppRoot, fileName));
return [...getDevelopmentEnvironmentLoader(appRoot).environmentRoots]
.reverse()
.flatMap((root) => DEVELOPMENT_ENV_FILE_NAMES.map((fileName) => join(root, fileName)));
}

/**
Expand All @@ -50,8 +52,14 @@ export function getDevelopmentEnvironmentFilePaths(appRoot: string): string[] {
* precedence. Variables supplied by env files are refreshed on subsequent
* reloads so dev-mode file watching can pick up changed values.
*/
export function loadDevelopmentEnvironmentFiles(appRoot: string): void {
getDevelopmentEnvironmentLoader(appRoot).reload();
export async function loadDevelopmentEnvironmentFiles(appRoot: string): Promise<void> {
const resolvedAppRoot = resolve(appRoot);
const context = await resolveEveProjectContext(resolvedAppRoot);
const environmentRoots =
context.kind === "collection-member"
? [context.collection.root, resolvedAppRoot]
: [resolvedAppRoot];
getDevelopmentEnvironmentLoader(resolvedAppRoot, environmentRoots).reload();
}

export function stageDevelopmentEnvironmentFiles(appRoot: string): DevelopmentEnvironmentReload {
Expand All @@ -62,7 +70,9 @@ export function readDevelopmentEnvironmentHostValues(
appRoot: string,
): Readonly<Record<string, string | null>> {
const values: Record<string, string | null> = {};
const fileValues = readDevelopmentEnvironmentValues(resolve(appRoot));
const fileValues = readDevelopmentEnvironmentValues(
getDevelopmentEnvironmentLoader(appRoot).environmentRoots,
);

for (const key of [...fileValues.keys()].sort((left, right) => left.localeCompare(right))) {
values[key] = process.env[key] ?? null;
Expand All @@ -71,26 +81,37 @@ export function readDevelopmentEnvironmentHostValues(
return values;
}

function getDevelopmentEnvironmentLoader(appRoot: string): DevelopmentEnvironmentLoader {
function getDevelopmentEnvironmentLoader(
appRoot: string,
environmentRoots?: readonly string[],
): DevelopmentEnvironmentLoader {
const resolvedAppRoot = resolve(appRoot);
const existingLoader = developmentEnvironmentLoaders.get(resolvedAppRoot);

if (existingLoader !== undefined) {
if (existingLoader !== undefined && environmentRoots === undefined) return existingLoader;

const resolvedEnvironmentRoots = environmentRoots ?? [resolvedAppRoot];
if (
existingLoader !== undefined &&
existingLoader.environmentRoots.length === resolvedEnvironmentRoots.length &&
existingLoader.environmentRoots.every((root, index) => root === resolvedEnvironmentRoots[index])
) {
return existingLoader;
}

const loader = createDevelopmentEnvironmentLoader(resolvedAppRoot);
const loader = createDevelopmentEnvironmentLoader(resolvedEnvironmentRoots);
developmentEnvironmentLoaders.set(resolvedAppRoot, loader);
return loader;
}

function createDevelopmentEnvironmentLoader(appRoot: string): DevelopmentEnvironmentLoader {
function createDevelopmentEnvironmentLoader(
environmentRoots: readonly string[],
): DevelopmentEnvironmentLoader {
const protectedKeys = new Set(Object.keys(process.env));
const managedValues = new Map<string, string>();

const stageReload = (): DevelopmentEnvironmentReload => {
const previousManagedValues = new Map(managedValues);
const nextValues = readDevelopmentEnvironmentValues(appRoot);
const nextValues = readDevelopmentEnvironmentValues(environmentRoots);
const affectedKeys = new Set([...managedValues.keys(), ...nextValues.keys()]);
const previousEnvironment = new Map(
[...affectedKeys].map((key) => [key, process.env[key]] as const),
Expand Down Expand Up @@ -128,6 +149,7 @@ function createDevelopmentEnvironmentLoader(appRoot: string): DevelopmentEnviron
};

return {
environmentRoots,
reload() {
stageReload().commit();
},
Expand Down Expand Up @@ -162,23 +184,21 @@ function applyDevelopmentEnvironmentValues(input: {
}
}

function readDevelopmentEnvironmentValues(appRoot: string): Map<string, string> {
function readDevelopmentEnvironmentValues(
environmentRoots: readonly string[],
): Map<string, string> {
const values = new Map<string, string>();

for (const fileName of [...DEVELOPMENT_ENV_FILE_NAMES].reverse()) {
try {
const parsedValues = parseEnv(readFileSync(join(appRoot, fileName), "utf8"));
for (const environmentRoot of environmentRoots) {
for (const fileName of [...DEVELOPMENT_ENV_FILE_NAMES].reverse()) {
try {
const parsedValues = parseEnv(readFileSync(join(environmentRoot, fileName), "utf8"));

for (const [key, value] of Object.entries(parsedValues)) {
if (value === undefined) {
continue;
for (const [key, value] of Object.entries(parsedValues)) {
if (value !== undefined) values.set(key, value);
}

values.set(key, value);
}
} catch (error) {
if (!isMissingEnvironmentFileError(error)) {
throw error;
} catch (error) {
if (!isMissingEnvironmentFileError(error)) throw error;
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/eve/src/cli/dev/local-server-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,10 @@ export function createDevelopmentServer(
active.unref();
};

const start = (): Promise<DevelopmentServerHandle> => {
const start = async (): Promise<DevelopmentServerHandle> => {
if (child !== undefined) throw new Error("DevelopmentServer.start() was already called.");
const shellEnvironment = { ...process.env };
loadDevelopmentEnvironmentFiles(appRoot);
await loadDevelopmentEnvironmentFiles(appRoot);
process.env[EVE_DEV_ENV_FLAG] ??= "1";
const spawned = fork(
childPath,
Expand Down
2 changes: 1 addition & 1 deletion packages/eve/src/cli/dev/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1534,7 +1534,7 @@ export class EveTUIRunner {
const appRoot = this.#appRoot;
if (appRoot === undefined) return;

loadDevelopmentEnvironmentFiles(appRoot);
await loadDevelopmentEnvironmentFiles(appRoot);
const refreshedInfo = this.#replaceAgentInfo(await this.#readAgentInfo());
void this.#refreshSetupAttention(refreshedInfo);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/eve/src/cli/invoke/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function registerRuntimeInvokeCommand(input: {
...input,
deps: {
loadEnvironment: async (root) =>
(await import("#cli/dev/environment.js")).loadDevelopmentEnvironmentFiles(root),
await (await import("#cli/dev/environment.js")).loadDevelopmentEnvironmentFiles(root),
runInvoke: async (invokeInput) =>
await (input.runtime.runInvoke ?? (await import("./invoke.js")).runInvoke)(invokeInput),
startHost: async (root) =>
Expand Down
6 changes: 3 additions & 3 deletions packages/eve/src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm
.description("Build the current package as an eve extension.")
.action(async () => {
const { loadDevelopmentEnvironmentFiles } = await import("#cli/dev/environment.js");
loadDevelopmentEnvironmentFiles(appRoot);
await loadDevelopmentEnvironmentFiles(appRoot);

const { runExtensionBuildCommand } = await import("#cli/commands/extension-build.js");
await runExtensionBuildCommand(logger, appRoot);
Expand Down Expand Up @@ -254,7 +254,7 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm
.action(async (options: ProductionCliOptions) => {
const { loadDevelopmentEnvironmentFiles } = await import("#cli/dev/environment.js");

loadDevelopmentEnvironmentFiles(appRoot);
await loadDevelopmentEnvironmentFiles(appRoot);

const startProductionHost = runtime.startProductionHost ?? (await loadStartProductionHost());
const server = await startProductionHost(appRoot, {
Expand Down Expand Up @@ -410,7 +410,7 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm

if (remoteServerUrl) {
const { loadDevelopmentEnvironmentFiles } = await import("#cli/dev/environment.js");
loadDevelopmentEnvironmentFiles(appRoot);
await loadDevelopmentEnvironmentFiles(appRoot);
logger.log(
`↗ ${existingLocalDevelopmentServer ? "local" : "remote"} mode targeting ${theme.info(new URL(remoteServerUrl).host)}`,
);
Expand Down
Loading
Loading