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/quiet-channels-deliver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agents": minor
---

Add an experimental transport-neutral Channels module with canonical Markdown messages, explicit delivery outcomes, AI SDK tool adaptation, and a destination-bound Email Service adapter.
5 changes: 5 additions & 0 deletions packages/agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@
"import": "./dist/experimental/webmcp.js",
"require": "./dist/experimental/webmcp.js"
},
"./experimental/channels": {
"types": "./dist/experimental/channels/index.d.ts",
"import": "./dist/experimental/channels/index.js",
"require": "./dist/experimental/channels/index.js"
},
"./x402": {
"types": "./dist/mcp/x402.d.ts",
"import": "./dist/mcp/x402.js",
Expand Down
1 change: 1 addition & 0 deletions packages/agents/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const entries = [
"src/codemode/ai.ts",
"src/experimental/memory/session/index.ts",
"src/experimental/memory/utils/index.ts",
"src/experimental/channels/index.ts",
"src/browser/index.ts",
"src/browser/ai.ts",
"src/browser/tanstack-ai.ts",
Expand Down
53 changes: 53 additions & 0 deletions packages/agents/src/email-send.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { signAgentHeaders, type SendEmailOptions } from "./email";

type AgentEmailIdentity = {
agentName: string;
agentId: string;
};

/** Send with the routing and signing behavior used by `Agent.sendEmail()`. */
export async function sendAgentEmail(
options: SendEmailOptions,
identity: AgentEmailIdentity
): Promise<EmailSendResult> {
if (!options.binding) {
throw new Error(
"binding is required. Pass your send_email binding, " +
"e.g. this.sendEmail({ binding: this.env.EMAIL, ... })."
);
}

const headers: Record<string, string> = {
...options.headers,
"X-Agent-Name": identity.agentName,
"X-Agent-ID": identity.agentId
};

if (options.inReplyTo) {
headers["In-Reply-To"] = options.inReplyTo;
}

if (typeof options.secret === "string") {
const signedHeaders = await signAgentHeaders(
options.secret,
identity.agentName,
identity.agentId
);
headers["X-Agent-Sig"] = signedHeaders["X-Agent-Sig"];
headers["X-Agent-Sig-Ts"] = signedHeaders["X-Agent-Sig-Ts"];
}

// Preserve Agent.sendEmail()'s existing optional display-name input while
// delegating to the platform binding type, whose EmailAddress requires one.
return options.binding.send({
from: options.from,
to: options.to,
subject: options.subject,
text: options.text,
html: options.html,
replyTo: options.replyTo,
cc: options.cc,
bcc: options.bcc,
headers
} as EmailMessageBuilder);
}
19 changes: 19 additions & 0 deletions packages/agents/src/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@
// Re-export AgentEmail type
export type { AgentEmail } from "./internal_context";

/** Cloudflare Email Service binding. */
export type EmailSendBinding = SendEmail;

/** Options for sending an outbound email from an Agent. */
export type SendEmailOptions = {
binding: EmailSendBinding;
to: string | string[];
from: string | { email: string; name?: string };
subject: string;
text?: string;
html?: string;
replyTo?: string | { email: string; name?: string };
cc?: string | string[];
bcc?: string | string[];
inReplyTo?: string;
headers?: Record<string, string>;
secret?: string;
};

// ============================================================================
// Email header utilities
// ============================================================================
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import {
createChannelTool,
type Channel,
type ChannelMessage,
type DeliveryResult
} from "..";

function executable(tool: ReturnType<typeof createChannelTool>) {
return tool.execute as unknown as (
message: ChannelMessage
) => Promise<DeliveryResult>;
}

describe("experimental channels", () => {
it("adapts a configured channel to a caller-described AI SDK tool", async () => {
const deliver = vi.fn(
async (): Promise<DeliveryResult> => ({
status: "delivered",
reference: "message-1"
})
);
const channel: Channel = { deliver };

const channelTool = createChannelTool(channel, {
description: "Escalate to a human",
needsApproval: true,
metadata: { purpose: "escalation" },
inputExamples: [{ input: { markdown: "Please **help**" } }]
});

expect(channelTool.description).toBe("Escalate to a human");
expect(channelTool.needsApproval).toBe(true);
expect(channelTool.metadata).toEqual({ purpose: "escalation" });

await expect(
executable(channelTool)({ title: "Urgent", markdown: "Please **help**" })
).resolves.toEqual({ status: "delivered", reference: "message-1" });
expect(deliver).toHaveBeenCalledWith({
title: "Urgent",
markdown: "Please **help**"
});
});
});
131 changes: 131 additions & 0 deletions packages/agents/src/experimental/channels/__tests__/email.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from "vitest";
import type { EmailSendBinding } from "../../../email";
import { email } from "..";

function channelThatRejects(error: unknown) {
const send = vi.fn(async (_message: unknown): Promise<EmailSendResult> => {
throw error;
});

return email({
binding: { send: send as EmailSendBinding["send"] },
from: "agent@example.com",
to: "support@example.com",
defaultTitle: "Agent escalation"
});
}

describe("experimental email channel", () => {
it("sends to the configured email address and maps Markdown through the renderer", async () => {
const send = vi.fn(async (_message: unknown) => ({
messageId: "email-1"
}));
const channel = email({
binding: { send: send as EmailSendBinding["send"] },
from: "agent@example.com",
to: "support@example.com",
defaultTitle: "Agent escalation",
renderMarkdown: (markdown) => ({
text: markdown.replaceAll("**", ""),
html: `<strong>${markdown.slice(2, -2)}</strong>`
})
});

await expect(channel.deliver({ markdown: "**Help**" })).resolves.toEqual({
status: "delivered",
reference: "email-1"
});

expect(send).toHaveBeenCalledWith({
from: "agent@example.com",
to: "support@example.com",
subject: "Agent escalation",
text: "Help",
html: "<strong>Help</strong>",
replyTo: undefined,
cc: undefined,
bcc: undefined,
headers: undefined
});
});

it("allows a message title to override the configured email title", async () => {
const send = vi.fn(async (_message: unknown) => ({ messageId: "email-2" }));
const channel = email({
binding: { send: send as EmailSendBinding["send"] },
from: "agent@example.com",
to: "support@example.com",
defaultTitle: "Default"
});

await channel.deliver({ title: "Incident", markdown: "Details" });

expect(send.mock.calls[0]?.[0]).toMatchObject({
subject: "Incident",
text: "Details"
});
});

it("marks rate limits as safe to retry", async () => {
const error = Object.assign(new Error("Slow down"), {
code: "E_RATE_LIMIT_EXCEEDED"
});

await expect(
channelThatRejects(error).deliver({ markdown: "Help" })
).resolves.toEqual({
status: "failed",
retryable: true,
error: { code: "E_RATE_LIMIT_EXCEEDED", message: "Slow down" }
});
});

it("marks permanent Email Service errors as unsafe to retry", async () => {
const error = Object.assign(new Error("Verify the sender"), {
code: "E_SENDER_NOT_VERIFIED"
});

await expect(
channelThatRejects(error).deliver({ markdown: "Help" })
).resolves.toEqual({
status: "failed",
retryable: false,
error: {
code: "E_SENDER_NOT_VERIFIED",
message: "Verify the sender"
}
});
});

it("recognizes recipient validation errors without an error code", async () => {
await expect(
channelThatRejects(
new Error(
'Email must have at least one recipient in "to", "cc", or "bcc".'
)
).deliver({ markdown: "Help" })
).resolves.toEqual({
status: "failed",
retryable: false,
error: {
code: "E_FIELD_MISSING",
message:
'Email must have at least one recipient in "to", "cc", or "bcc".'
}
});
});

it("treats unknown delivery errors as uncertain to avoid duplicates", async () => {
await expect(
channelThatRejects(new Error("Connection closed")).deliver({
markdown: "Help"
})
).resolves.toEqual({
status: "uncertain",
error: {
code: "EMAIL_DELIVERY_ERROR",
message: "Connection closed"
}
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import path from "node:path";
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
name: "experimental-channels",
environment: "node",
include: [path.join(import.meta.dirname, "**/*.test.ts")]
}
});
80 changes: 80 additions & 0 deletions packages/agents/src/experimental/channels/channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { tool, type Tool } from "ai";
import { z } from "zod";

/** A transport-neutral outbound message whose canonical content is Markdown. */
export type ChannelMessage = {
/** Optional topic. Each transport decides how to represent it. */
title?: string;
/** Canonical Markdown content. */
markdown: string;
};

/** A transport failure safe to expose to an AI model. */
export type DeliveryFailure = {
code: string;
message: string;
};

/**
* The result of a direct delivery attempt.
*
* `delivered` means the transport accepted the message, not that a person read
* it. A caller should retry only `failed` results marked as retryable.
* `uncertain` means retrying could produce a duplicate.
*/
export type DeliveryResult =
| {
status: "delivered";
reference?: string;
}
| {
status: "failed";
retryable: boolean;
error: DeliveryFailure;
}
| {
status: "uncertain";
error: DeliveryFailure;
};

/** A configured outbound delivery route. */
export interface Channel {
deliver(message: ChannelMessage): Promise<DeliveryResult>;
}

type ChannelTool = Tool<ChannelMessage, DeliveryResult>;

/** Model-facing options controlled by the caller creating the tool. */
export type CreateChannelToolOptions = {
description?: string;
inputExamples?: Array<{ input: ChannelMessage }>;
metadata?: ChannelTool["metadata"];
needsApproval?: ChannelTool["needsApproval"];
providerOptions?: ChannelTool["providerOptions"];
strict?: boolean;
};

const channelMessageSchema = z.object({
title: z
.string()
.optional()
.describe("Optional title or topic for the message"),
markdown: z.string().min(1).describe("Message content formatted as Markdown")
});

/**
* Adapt a configured Channel to an AI SDK tool.
*
* The caller chooses the key used in its ToolSet and owns model-facing policy
* such as the description, examples, metadata, and approval requirement.
*/
export function createChannelTool(
channel: Channel,
options: CreateChannelToolOptions = {}
): Tool<ChannelMessage, DeliveryResult> {
return tool({
...options,
inputSchema: channelMessageSchema,
execute: (message) => channel.deliver(message)
});
}
Loading
Loading