Skip to content
Open
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/calm-spiders-receive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Keep receive-only channels discoverable and resolvable by schedules and cross-channel handoffs without requiring a placeholder HTTP route.
21 changes: 21 additions & 0 deletions docs/channels/custom.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,27 @@ Semantics:
- Calling `args.receive(...)` does not also start a session on the current channel. The inbound channel's response is whatever the route handler returns explicitly.
- The first argument is the target channel module's default export. Import it directly from `agent/channels/<name>.ts`. Identity is matched by reference.

### Receive-only channels

A channel that is only a target for schedules or cross-channel hand-offs does not need an HTTP or WebSocket route. Declare an empty `routes` array and implement `receive`:

```ts
import { defineChannel } from "eve/channels";

export default defineChannel({
routes: [],
async receive(input, { send }) {
return await send(input.message, {
auth: input.auth,
continuationToken: input.target.workspaceId,
mode: "task",
});
},
});
```

eve keeps the channel in discovery and makes it resolvable by `receive(channel, ...)`, but mounts no network route for it.

## Channel metadata

A channel can project a subset of its adapter state as metadata, available to instrumentation resolvers, dynamic tool resolvers, and dynamic skill or instruction resolvers. Define a `metadata(state)` function on the channel config:
Expand Down
45 changes: 45 additions & 0 deletions packages/eve/src/channel/schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import type { RunHandle, Runtime } from "#channel/types.js";
import { RuntimeNoActiveSessionError } from "#execution/runtime-errors.js";
import { slackChannel } from "#public/channels/slack/slackChannel.js";
import { defineChannel } from "#public/definitions/channel.js";
import type { ResolvedChannelDefinition } from "#runtime/types.js";

function createMockRunHandle(): RunHandle {
Expand Down Expand Up @@ -154,6 +155,50 @@ describe("ScheduleDispatcher", () => {
expect(result.sessions).toHaveLength(0);
});

it("dispatches to a registered receive-only channel", async () => {
const runtime = createMockRuntime();
const definition = defineChannel({
routes: [],
async receive(input, { send }) {
return await send(input.message, {
auth: input.auth,
continuationToken: String(input.target.workspaceId),
mode: "task",
});
},
}) as CompiledChannel;
const resolved = {
adapter: definition.adapter,
definition,
logicalPath: "channels/learning.ts",
name: "learning",
receive: definition.receive,
sourceId: "channels/learning.ts",
sourceKind: "module",
} satisfies ResolvedChannelDefinition;
const dispatcher = new ScheduleDispatcher({ runtime, channels: [resolved] });

await dispatcher.trigger({
scheduleId: "learn",
async run({ appAuth, receive }) {
await receive(definition, {
auth: appAuth,
message: "Study the latest outcomes.",
target: { workspaceId: "ws_123" },
});
},
});

expect(runtime.run).toHaveBeenCalledWith(
expect.objectContaining({
auth: SCHEDULE_APP_AUTH,
continuationToken: "learning:ws_123",
input: { message: "Study the latest outcomes." },
mode: "task",
}),
);
});

it("throws when args.receive(channel) is called with an unregistered channel", async () => {
const runtime = createMockRuntime();
const dispatcher = new ScheduleDispatcher({ runtime, channels: [] });
Expand Down
9 changes: 8 additions & 1 deletion packages/eve/src/cli/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,14 @@ export function buildApplicationInfoJson(inspection: ApplicationInspection): App
method: channel.method,
urlPath: channel.urlPath,
}
: { name: channel.name, kind: "disabled", method: null, urlPath: null },
: channel.kind === "receive-only-channel"
? {
name: channel.name,
kind: channel.adapterKind ?? null,
method: null,
urlPath: null,
}
: { name: channel.name, kind: "disabled", method: null, urlPath: null },
),
messaging: {
create: messaging.createSessionRoutePath,
Expand Down
4 changes: 2 additions & 2 deletions packages/eve/src/client/agent-info-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@ const subagent = entry.extend({

const channel = entry.extend({
adapterKind: z.string().optional(),
method: z.string(),
method: z.string().nullable(),
origin: z.enum(["authored", "framework"]),
urlPath: z.string(),
urlPath: z.string().nullable(),
});

const frameworkChannel = channel.extend({
Expand Down
34 changes: 32 additions & 2 deletions packages/eve/src/compiler/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,16 @@ export const ROOT_COMPILED_AGENT_NODE_ID = "__root__";
/**
* Current compiled manifest schema version.
*/
export const COMPILED_AGENT_MANIFEST_VERSION = 36;
export const COMPILED_AGENT_MANIFEST_VERSION = 37;

/**
* Compiled channel entry preserved in the compiled manifest.
*/
export type CompiledChannelEntry = CompiledChannelDefinition | DisabledCompiledChannelEntry;
export type CompiledActiveChannelDefinition =
| CompiledChannelDefinition
| CompiledReceiveOnlyChannelDefinition;

export type CompiledChannelEntry = CompiledActiveChannelDefinition | DisabledCompiledChannelEntry;

/**
* Active compiled channel entry — backed by an authored `Channel` module.
Expand Down Expand Up @@ -77,6 +81,19 @@ export interface CompiledChannelDefinition {
readonly cors?: NormalizedChannelCorsOptions;
}

/**
* Active compiled channel entry with a `receive` hook and no network route.
*/
export interface CompiledReceiveOnlyChannelDefinition {
readonly kind: "receive-only-channel";
readonly name: string;
readonly logicalPath: string;
readonly sourceId: string;
readonly sourceKind: "module";
readonly exportName?: string;
readonly adapterKind?: string;
}

/**
* Disabled compiled channel entry — marker that an authored file at this
* slug exported `disableRoute()` to remove the matching framework default.
Expand Down Expand Up @@ -337,6 +354,18 @@ const compiledChannelDefinitionSchema = z
})
.strict();

const compiledReceiveOnlyChannelDefinitionSchema = z
.object({
kind: z.literal("receive-only-channel"),
name: z.string(),
logicalPath: z.string(),
sourceId: z.string(),
sourceKind: z.literal("module"),
exportName: z.string().optional(),
adapterKind: z.string().optional(),
})
.strict();

const disabledCompiledChannelEntrySchema = z
.object({
kind: z.literal("disabled"),
Expand All @@ -347,6 +376,7 @@ const disabledCompiledChannelEntrySchema = z

const compiledChannelEntrySchema = z.union([
compiledChannelDefinitionSchema,
compiledReceiveOnlyChannelDefinitionSchema,
disabledCompiledChannelEntrySchema,
]) as unknown as z.ZodType<CompiledChannelEntry>;

Expand Down
25 changes: 19 additions & 6 deletions packages/eve/src/compiler/normalize-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ import {
*
* Authored channels are always `CompiledChannel` values (from
* `defineChannel`). Each route in the channel's `routes` array becomes
* a separate compiled channel entry. The channel name is derived from
* the filesystem path; the URL path comes from the route's `path` field.
* a separate compiled channel entry. A channel with no routes and a
* `receive` hook becomes one receive-only entry. The channel name is
* derived from the filesystem path; routed entries take their URL path
* from the route's `path` field.
*/
export async function compileChannelDefinition(
agentRoot: string,
Expand Down Expand Up @@ -48,16 +50,27 @@ export async function compileChannelDefinition(
`Expected the channel export "${source.exportName ?? "default"}" from "${source.logicalPath}" to match the public eve shape.`,
);

return definition.routes.map((route) => ({
kind: "channel" as const,
const commonDefinition = {
name: channelName,
logicalPath: source.logicalPath,
method: route.method.toUpperCase() as ChannelRouteMethod,
urlPath: route.path,
sourceId: source.sourceId,
sourceKind: "module" as const,
exportName: source.exportName,
adapterKind: extractAdapterKind(definition.adapter),
};

if (definition.routes.length === 0 && definition.receive !== undefined) {
return {
kind: "receive-only-channel",
...commonDefinition,
};
}

return definition.routes.map((route) => ({
kind: "channel" as const,
...commonDefinition,
method: route.method.toUpperCase() as ChannelRouteMethod,
urlPath: route.path,
cors: definition.cors,
}));
}
Expand Down
43 changes: 43 additions & 0 deletions packages/eve/src/compiler/normalize-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
} from "#discover/manifest.js";
import { classifyModelRouting } from "#internal/classify-model-routing.js";
import type { CompiledAgentDefinition } from "#compiler/manifest.js";
import { collectModuleRefsForManifest } from "#compiler/module-map.js";
import { compileAgentManifest } from "#compiler/normalize-manifest.js";
import { defineChannel } from "#public/definitions/channel.js";
import { experimental_workflow } from "#public/definitions/tool.js";

const mocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -93,6 +95,47 @@ describe("compileAgentManifest", () => {

expect(compiled.workflowTool).toEqual({ maxSubagents: 6 });
});

it("preserves receive-only channels in the manifest and module map", async () => {
const manifest = createAgentSourceManifest({
agentId: "root",
agentRoot: "/app/agent",
appRoot: "/app",
channels: [createModuleSourceRef({ logicalPath: "channels/learning.ts" })],
});
mocks.compileAgentConfig.mockResolvedValue(createConfig({ name: "root" }));
mocks.loadModuleBackedDefinition.mockResolvedValue(
defineChannel({
routes: [],
async receive(input, { send }) {
return await send(input.message, {
auth: input.auth,
continuationToken: "learning",
mode: "task",
});
},
}),
);

const compiled = await compileAgentManifest(manifest);

expect(compiled.channels).toEqual([
{
adapterKind: "http",
kind: "receive-only-channel",
logicalPath: "channels/learning.ts",
name: "learning",
sourceId: "channels/learning.ts",
sourceKind: "module",
},
]);
expect(collectModuleRefsForManifest(compiled)).toContainEqual({
exportName: undefined,
logicalPath: "channels/learning.ts",
sourceId: "channels/learning.ts",
sourceKind: "module",
});
});
});

function createConfig(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";

import {
type CompiledReceiveOnlyChannelDefinition,
type CompiledChannelDefinition,
type CompiledConnectionDefinition,
type CompiledScheduleDefinition,
Expand Down Expand Up @@ -83,6 +84,20 @@ function makeChannel(input: { name: string; adapterKind?: string }): CompiledCha
};
}

function makeReceiveOnlyChannel(input: {
name: string;
adapterKind?: string;
}): CompiledReceiveOnlyChannelDefinition {
return {
adapterKind: input.adapterKind,
kind: "receive-only-channel",
logicalPath: `channels/${input.name}.ts`,
name: input.name,
sourceId: `channels/${input.name}.ts`,
sourceKind: "module",
};
}

function makeFlatSkill(name: string): CompiledSkillDefinition {
return {
description: `${name} description`,
Expand Down Expand Up @@ -146,6 +161,7 @@ describe("buildVercelAgentSummary", () => {
makeChannel({ name: "messages", adapterKind: "http" }),
makeChannel({ name: "stripe", adapterKind: "stripe-webhook" }),
makeChannel({ name: "mystery" }),
makeReceiveOnlyChannel({ name: "learning", adapterKind: "learning-worker" }),
{ kind: "disabled", logicalPath: "channels/disabled.ts", name: "disabled" },
],
config: {
Expand Down Expand Up @@ -320,6 +336,15 @@ describe("buildVercelAgentSummary", () => {
type: "unknown",
urlPath: "/mystery",
},
{
adapterKind: "learning-worker",
logicalPath: "channels/learning.ts",
method: null,
name: "learning",
receiveOnly: true,
type: "unknown",
urlPath: null,
},
]);

expect(summary.sandbox).toBeNull();
Expand Down
18 changes: 10 additions & 8 deletions packages/eve/src/internal/nitro/host/build-vercel-agent-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";

import type {
CompiledActiveChannelDefinition,
CompiledAgentManifest,
CompiledChannelDefinition,
CompiledChannelEntry,
CompiledConnectionDefinition,
CompiledInstructionsDefinition,
Expand Down Expand Up @@ -100,8 +100,8 @@ export async function emitVercelAgentSummary(input: {
return input.outputPath;
}

function isActiveChannel(entry: CompiledChannelEntry): entry is CompiledChannelDefinition {
return entry.kind === "channel";
function isActiveChannel(entry: CompiledChannelEntry): entry is CompiledActiveChannelDefinition {
return entry.kind !== "disabled";
}

function toInstructionsEntry(
Expand Down Expand Up @@ -158,20 +158,22 @@ function toConnectionEntry(connection: CompiledConnectionDefinition): VercelEveC
return entry;
}

function toChannelEntry(channel: CompiledChannelDefinition): VercelEveChannelEntry {
function toChannelEntry(channel: CompiledActiveChannelDefinition): VercelEveChannelEntry {
const entry: VercelEveChannelEntry = {
name: channel.name,
method: channel.method,
urlPath: channel.urlPath,
method: channel.kind === "channel" ? channel.method : null,
urlPath: channel.kind === "channel" ? channel.urlPath : null,
type: normalizeChannelKindForDisplay(channel.adapterKind),
logicalPath: channel.logicalPath,
};
const channelEntry: VercelEveChannelEntry =
channel.kind === "receive-only-channel" ? { ...entry, receiveOnly: true } : entry;

if (channel.adapterKind !== undefined) {
return { ...entry, adapterKind: channel.adapterKind };
return { ...channelEntry, adapterKind: channel.adapterKind };
}

return entry;
return channelEntry;
}

function toSubagentEntry(subagent: CompiledSubagentNode): VercelEveSubagentEntry {
Expand Down
Loading