feat(eve): discover Next.js workspace agents (#3343)

Signed-off-by: owenkephart <owen.kephart@vercel.com>
This commit is contained in:
OwenKephart
2026-09-14 11:43:23 -07:00
committed by GitHub
parent 0e0f3cc2ea
commit 0f7907551c
14 changed files with 182 additions and 54 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"eve": patch
---
Make `withEve()` discover project-level `agents/` workspace members, mounting each named agent through a Next.js app without repeating the agent map in `next.config.ts`. Workspace peers declared with `defineWorkspaceAgent()` route through the named Next.js mount automatically.
+1 -1
View File
@@ -3,7 +3,7 @@
These apps verify eve's frontend framework integrations and act as runnable examples for maintainers.
- `framework-next` covers `eve/next` and `withEve()`.
- `framework-next-multi-agent` covers `withEve({ agents })` and named `useEveAgent({ agent })` calls.
- `framework-next-multi-agent` covers `withEve()` workspace discovery and named `useEveAgent({ agent })` calls.
- `framework-nuxt` covers the `eve/nuxt` module.
- `framework-sveltekit` covers the `eve/sveltekit` Vite plugin.
+2 -2
View File
@@ -1,7 +1,7 @@
# Next.js multi-agent eve demo
This app demonstrates `withEve({ agents })` with three independent eve agents
mounted into one Next.js app:
This app demonstrates `withEve()` discovering three independent eve agents
from the project-level `agents/` workspace and mounting them into one Next.js app:
- `support` at `/eve/agents/support/eve/v1/*`
- `billing` at `/eve/agents/billing/eve/v1/*`
@@ -3,14 +3,4 @@ import { withEve } from "eve/next";
const nextConfig: NextConfig = {};
export default withEve(nextConfig, {
agents: {
support: "./agents/support",
billing: {
root: "./agents/billing",
buildCommand: "pnpm --dir ../.. build:billing-agent",
servicePrefix: "/_eve_internal/billing",
},
research: "./agents/research",
},
});
export default withEve(nextConfig);
+12 -10
View File
@@ -22,7 +22,9 @@ const nextConfig: NextConfig = {};
export default withEve(nextConfig);
```
By default `withEve()` looks for an `agent/` folder inside your Next.js project root. If the agent lives somewhere else, point at it with `eveRoot`:
By default `withEve()` looks for an `agent/` folder inside your Next.js project root. When the project root is an eve workspace with `agents/<name>/` members, it discovers every member and mounts each at `/eve/agents/<name>/eve/v1/*` instead.
If one agent lives somewhere else, point at it with `eveRoot`:
```ts
export default withEve(nextConfig, {
@@ -30,7 +32,7 @@ export default withEve(nextConfig, {
});
```
For multiple agents, use `agents`. String values are agent roots; object values can override the build command or private production service prefix for that agent:
To mount agents that are not members of the project-level `agents/` workspace, use `agents`. String values are agent roots; object values can override the build command or private production service prefix for that agent:
```ts
export default withEve(nextConfig, {
@@ -60,13 +62,13 @@ Generated agent services build with `EVE_PUBLIC_ROUTE_PREFIX` set to the agent's
All fields are optional.
| Option | Type | Default | Purpose |
| -------------------- | --------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eveRoot` | `string` | Next.js app root | Path to one unnamed eve app root, relative to `process.cwd()` unless absolute. Do not combine with `agents`. |
| `agents` | `Record<string, ...>` | unset | Named eve agents to mount under `/eve/agents/<name>/eve/v1/*`. Each value is a root string or `{ root, buildCommand?, servicePrefix? }`. |
| `eveBuildCommand` | `string` | generated | Build command for generated eve Vercel services. In multi-agent mode this is the default for agents without their own `buildCommand`. |
| `servicePrefix` | `string` | `"/_eve_internal/eve"` | Private route namespace for legacy manual Vercel service configs and non-Vercel production proxying. Named agents derive unique defaults from this prefix. |
| `devServerTimeoutMs` | `number` | `180000` | Maximum time to wait for each eve development server to become available. |
| Option | Type | Default | Purpose |
| -------------------- | --------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eveRoot` | `string` | Next.js app root | Path to one unnamed eve app root, relative to `process.cwd()` unless absolute. Do not combine with `agents`. |
| `agents` | `Record<string, ...>` | inferred for a workspace | Named eve agents to mount under `/eve/agents/<name>/eve/v1/*`. `withEve()` discovers project-level `agents/<name>/` members when neither `agents` nor `eveRoot` is set; otherwise each value is a root string or `{ root, buildCommand?, servicePrefix? }`. |
| `eveBuildCommand` | `string` | generated | Build command for generated eve Vercel services. In multi-agent mode this is the default for agents without their own `buildCommand`. |
| `servicePrefix` | `string` | `"/_eve_internal/eve"` | Private route namespace for legacy manual Vercel service configs and non-Vercel production proxying. Named agents derive unique defaults from this prefix. |
| `devServerTimeoutMs` | `number` | `180000` | Maximum time to wait for each eve development server to become available. |
For slow cold starts, increase the development timeout:
@@ -124,7 +126,7 @@ See [Resumable sessions](./overview#resumable-sessions) for persistence and repl
});
```
- **Local production build.** `next build && next start` serves the eve runtime from its built `.output/server/index.mjs` on a stable local port (`4274`) and proxies the eve routes to it. Run `eve build` first so that output exists. Change the port with `EVE_NEXT_PRODUCTION_PORT`:
- **Local production build.** `next build && next start` serves the eve runtime from its built `.output/server/index.mjs` on a stable local port (`4274`) and proxies the eve routes to it. Run `eve build` first so that output exists. In an `agents/` workspace, build every member from its `agents/<name>/` directory before starting Next.js. Change the port with `EVE_NEXT_PRODUCTION_PORT`:
```bash
EVE_NEXT_PRODUCTION_PORT=5000 npm run build && npm start
+1 -1
View File
@@ -19,7 +19,7 @@ eve add memory/file
If an item is not found, `eve add` searches the available catalogs and prints close matches without installing anything.
Web Chat installs a project-level Next.js application and cannot currently be added to a top-level `agents/` workspace. For that topology, create a root Next.js application and configure [`withEve({ agents })`](./guides/frontend/nextjs) instead. eve rejects `eve add channel/web` before writing files when the selected agent belongs to such a workspace.
Web Chat installs a project-level Next.js application and cannot currently be added to a top-level `agents/` workspace. For that topology, create a root Next.js application and wrap its config with [`withEve()`](./guides/frontend/nextjs); eve discovers the workspace agents automatically. eve rejects `eve add channel/web` before writing files when the selected agent belongs to such a workspace.
If you do not know an item name yet, run `eve add` without an argument. Its help output shows how to search the registry with `eve registry search <query>`.
+1 -1
View File
@@ -79,7 +79,7 @@ export default defineWorkspaceAgent({
});
```
The helper uses the peer's root `defineAgent({ description })` value as the model-visible tool description. Pass `description` to override it for this caller. When `transport` is omitted, eve selects the built-in transport for the runtime environment. Vercel deployments route through the current deployment and authenticate with the caller's Vercel OIDC token. Outside Vercel, provide an explicit transport with `url` and optional `auth` or `headers`:
The helper uses the peer's root `defineAgent({ description })` value as the model-visible tool description. Pass `description` to override it for this caller. When `transport` is omitted, eve selects the built-in transport for the runtime environment. Vercel deployments route through the current deployment and authenticate with the caller's Vercel OIDC token. A workspace hosted through `withEve()` in a Next.js app uses its `/eve/agents/<name>` mount; a hostless workspace uses `/<name>`. Outside Vercel, provide an explicit transport with `url` and optional `auth` or `headers`:
```ts title="agents/foreman/agent/subagents/research.ts"
import { defineWorkspaceAgent } from "eve";
@@ -27,6 +27,19 @@ describe("defineWorkspaceAgent", () => {
});
});
it("uses the Next.js named-agent route when the caller is mounted through eve/next", async () => {
vi.stubEnv("EVE_PUBLIC_ROUTE_PREFIX", "/eve/agents/support");
vi.stubEnv("VERCEL", "1");
vi.stubEnv("VERCEL_ENV", "preview");
vi.stubEnv("VERCEL_URL", "preview.example.com");
const subagent = defineWorkspaceAgent({ name: "research" });
expect((subagent.url as () => string)()).toBe(
"https://preview.example.com/eve/agents/research",
);
});
it("requires an explicit transport outside Vercel", async () => {
vi.stubEnv("VERCEL", undefined);
const subagent = defineWorkspaceAgent({ name: "research" });
@@ -8,6 +8,7 @@ import {
type RemoteAgentUrl,
} from "#public/definitions/remote-agent.js";
import type { JsonObject } from "#shared/json.js";
import { normalizePublicRoutePrefix } from "#shared/public-route-prefix.js";
const WORKSPACE_AGENT_NAME = Symbol.for("eve.workspace-agent.name");
@@ -75,7 +76,10 @@ function defaultWorkspaceAgentTransport(name: string): WorkspaceAgentTransport {
"The default workspace-agent transport requires VERCEL_URL, or VERCEL_PROJECT_PRODUCTION_URL in production.",
);
}
return `https://${host}/${name}`;
const callerRoutePrefix = normalizePublicRoutePrefix(process.env.EVE_PUBLIC_ROUTE_PREFIX);
const peerRoutePrefix =
callerRoutePrefix?.startsWith("/eve/agents/") === true ? `/eve/agents/${name}` : `/${name}`;
return `https://${host}${peerRoutePrefix}`;
},
};
}
@@ -44,21 +44,19 @@ describe("withEve Vercel config", () => {
vi.unstubAllGlobals();
});
it("does not create Build Output config outside Vercel when no Vercel project is detected", async () => {
it("requires a local eve build outside Vercel", async () => {
const appRoot = await createTempAppRoot();
process.chdir(appRoot);
vi.stubEnv("NODE_ENV", "production");
const config = await resolveConfig(withEve<TestConfig>({}));
const rewrites = await config.rewrites?.();
await expect(config.rewrites?.()).rejects.toThrow(
`Run eve build from ${appRoot} before starting Next.js.`,
);
await expect(
readFile(join(appRoot, ".vercel", "output", "config.json"), "utf8"),
).rejects.toThrow();
expect(getBeforeFiles(rewrites)).toContainEqual({
destination: "http://127.0.0.1:4274/eve/v1/:path+",
source: "/eve/v1/:path+",
});
});
it("writes Build Output config in Vercel even when no linked project is detected", async () => {
@@ -291,6 +289,55 @@ describe("withEve Vercel config", () => {
expect(rewrites).toBeUndefined();
});
it("discovers workspace agents when the Next.js app owns the workspace", async () => {
const appRoot = await createTempAppRoot();
process.chdir(appRoot);
await Promise.all([
mkdir(join(appRoot, "agents", "support", "agent"), { recursive: true }),
mkdir(join(appRoot, "agents", "research", "agent"), { recursive: true }),
writeFile(join(appRoot, "package.json"), JSON.stringify({ dependencies: { eve: "*" } })),
]);
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("VERCEL", "1");
vi.stubEnv("VERCEL_URL", "preview.example.com");
const config = await resolveConfig(withEve<TestConfig>({}));
const rewrites = await config.rewrites?.();
const outputConfig = await readJsonFile(join(appRoot, ".vercel", "output", "config.json"));
expect(outputConfig).toMatchObject({
routes: expect.arrayContaining([
expect.objectContaining({
destination: { service: "eve-research", type: "service" },
src: "^/eve/agents/research/eve/v1/(.*)$",
}),
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/?$",
}),
expect.objectContaining({
destination: { service: "eve-support", type: "service" },
src: "^/eve/agents/support/?$",
}),
]),
services: expect.objectContaining({
"eve-research": expect.objectContaining({
buildCommand: expect.stringContaining("EVE_INTERNAL_AGENT_WORKSPACE_MEMBER=1"),
routePrefix: "/eve/agents/research",
}),
"eve-support": expect.objectContaining({
buildCommand: expect.stringContaining("EVE_INTERNAL_AGENT_WORKSPACE_MEMBER=1"),
routePrefix: "/eve/agents/support",
}),
}),
});
expect(rewrites).toBeUndefined();
});
it("accepts a custom eve service build command", async () => {
const appRoot = await createTempAppRoot();
process.chdir(appRoot);
+68 -19
View File
@@ -3,6 +3,7 @@ import { isAbsolute, resolve } from "node:path";
import type { NextConfig } from "next";
import { assertValidPublicAgentName } from "#internal/agent-name.js";
import { findEveProjectContext } from "#internal/project-context.js";
import { quoteVercelShellArgument, toVercelRelativePath } from "#internal/vercel/build-command.js";
import { EVE_ROUTE_PREFIX } from "#protocol/routes.js";
import { resolveEveBinaryPath } from "#shared/resolve-eve-binary.js";
@@ -110,15 +111,17 @@ export interface WithEveOptions {
readonly devServerTimeoutMs?: number;
/**
* Path to the eve application root, relative to `process.cwd()` unless
* absolute. Defaults to the Next.js app root.
* absolute. Defaults to the Next.js app root when it is not an eve workspace.
*/
readonly eveRoot?: string;
/**
* Named eve agents to mount under `/eve/agents/<name>/eve/v1/*`.
*
* Use this when one Next.js app needs to talk to multiple eve agents. When
* set, do not also set {@link eveRoot}; the single-agent form remains the
* shorthand for one unnamed agent mounted at `/eve/v1/*`.
* Use this when one Next.js app needs to talk to multiple eve agents outside
* a project-level `agents/` workspace. When unset, withEve discovers that
* workspace's members automatically. Do not combine with {@link eveRoot};
* the single-agent form remains the shorthand for one unnamed agent mounted
* at `/eve/v1/*`.
*/
readonly agents?: WithEveAgentsConfig;
/**
@@ -146,6 +149,7 @@ interface ResolvedEveNextAgent {
readonly name?: string;
readonly publicRoutePrefix: string;
readonly servicePrefix: string;
readonly workspaceMember?: boolean;
}
function resolveApplicationRoot(appPath: string | undefined): string {
@@ -313,6 +317,18 @@ function assertValidAgentName(name: string): void {
assertValidPublicAgentName(name, "eve Next.js agent name");
}
function assertValidWithEveOptions(options: WithEveOptions): void {
if (options.agents === undefined) return;
if (options.eveRoot !== undefined) {
throw new Error("withEve cannot combine eveRoot with agents. Use one configuration form.");
}
const agentNames = Object.keys(options.agents);
if (agentNames.length === 0) {
throw new Error("withEve agents must contain at least one named eve agent.");
}
for (const name of agentNames) assertValidAgentName(name);
}
function createDefaultBuildCommand(input: { readonly agentRoot: string }): string {
const eveBinaryPath = toVercelRelativePath(
input.agentRoot,
@@ -321,12 +337,34 @@ function createDefaultBuildCommand(input: { readonly agentRoot: string }): strin
return `node ${quoteVercelShellArgument(eveBinaryPath)} build`;
}
function normalizeAgentsConfig(options: WithEveOptions): readonly ResolvedEveNextAgent[] {
async function normalizeAgentsConfig(
options: WithEveOptions,
nextRoot: string,
): Promise<readonly ResolvedEveNextAgent[]> {
const servicePrefixBase = normalizeRoutePrefix(options.servicePrefix ?? EVE_NEXT_SERVICE_PREFIX);
const resolveBuildCommand = (agentRoot: string, buildCommand: string | undefined) =>
buildCommand ?? options.eveBuildCommand ?? createDefaultBuildCommand({ agentRoot });
if (options.agents === undefined) {
if (options.eveRoot === undefined) {
const context = await findEveProjectContext(nextRoot);
if (
context?.kind === "workspace" &&
context.workspace.root === nextRoot &&
context.workspace.members.length > 0
) {
return context.workspace.members.map((member, index) => ({
appRoot: member.appRoot,
buildCommand: resolveBuildCommand(member.appRoot, undefined),
localProductionPortOffset: index,
name: member.name,
publicRoutePrefix: createNamedAgentRoutePrefix(member.name),
servicePrefix: createNamedAgentServicePrefix(servicePrefixBase, member.name),
workspaceMember: true,
}));
}
}
const appRoot = resolveApplicationRoot(options.eveRoot);
return [
{
@@ -339,10 +377,6 @@ function normalizeAgentsConfig(options: WithEveOptions): readonly ResolvedEveNex
];
}
if (options.eveRoot !== undefined) {
throw new Error("withEve cannot combine eveRoot with agents. Use one configuration form.");
}
const entries = Object.entries(options.agents);
if (entries.length === 0) {
throw new Error("withEve agents must contain at least one named eve agent.");
@@ -385,19 +419,34 @@ export function withEve<TConfig extends EveNextConfig>(
): EveNextConfigFunction<TConfig> {
const nextRoot = process.cwd();
const devServerTimeoutMs = resolveDevServerTimeout(options.devServerTimeoutMs);
const agents = normalizeAgentsConfig(options);
assertValidWithEveOptions(options);
return async function eveNextConfig(phase, context) {
const nextConfig = await resolveNextConfig(configOrFunction, phase, context);
const [agents, nextConfig] = await Promise.all([
normalizeAgentsConfig(options, nextRoot),
resolveNextConfig(configOrFunction, phase, context),
]);
const existingRewrites = nextConfig.rewrites;
const configuredVercel = await ensureEveVercelOutputConfig({
agents: agents.map((agent) => ({
appRoot: agent.appRoot,
buildCommand: agent.buildCommand,
name: agent.name,
publicRoutePrefix: agent.publicRoutePrefix,
servicePrefix: agent.servicePrefix,
})),
agents: agents.map((agent) => {
const outputAgent: {
appRoot: string;
buildCommand: string;
name?: string;
publicRoutePrefix: string;
servicePrefix: string;
workspaceMember?: boolean;
} = {
appRoot: agent.appRoot,
buildCommand: agent.buildCommand,
name: agent.name,
publicRoutePrefix: agent.publicRoutePrefix,
servicePrefix: agent.servicePrefix,
};
if (agent.workspaceMember === true) {
outputAgent.workspaceMember = true;
}
return outputAgent;
}),
nextRoot,
});
@@ -51,6 +51,21 @@ describe("resolveEveDestinationPrefix", () => {
);
});
it("reports a missing local production build instead of proxying to an unstarted port", async () => {
vi.stubEnv("NODE_ENV", "production");
const appRoot = await createTempAppRoot();
await expect(
resolveEveDestinationPrefix({
appRoot,
phase: "phase-production-server",
productionDestinationPrefix: "http://127.0.0.1:4274",
productionServerOrigin: "http://127.0.0.1:4274",
}),
).rejects.toThrow(`Run eve build from ${appRoot} before starting Next.js.`);
expect(spawnMock).not.toHaveBeenCalled();
});
it("ignores non-server URLs in dev server output while waiting for the listening URL", async () => {
vi.stubEnv("NODE_ENV", "development");
const appRoot = await createTempAppRoot();
+4 -2
View File
@@ -451,13 +451,15 @@ function startEveDevServer(
function startEveProductionServer(input: {
readonly appRoot: string;
readonly origin: string;
}): Promise<EveProcessHandle> | undefined {
}): Promise<EveProcessHandle> {
const parsedOrigin = new URL(input.origin);
const port = parsedOrigin.port;
const serverEntry = join(input.appRoot, ".output", "server", "index.mjs");
if (!existsSync(serverEntry)) {
return undefined;
throw new Error(
`eve production output is missing at ${serverEntry}. Run eve build from ${input.appRoot} before starting Next.js.`,
);
}
return startServerProcess({
@@ -36,6 +36,7 @@ export interface EnsureVercelOutputConfigAgentInput {
readonly name?: string;
readonly publicRoutePrefix: string;
readonly servicePrefix: string;
readonly workspaceMember?: boolean;
}
export interface EnsureVercelOutputConfigAgentResult {