Skip to content
Merged
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/channels-ai-sdk-connectors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agents": minor
---

Add `AiSdkHarness` from the new `agents/harness/ai-sdk` entry point, which runs each message with `streamText` and keeps sessions and transcripts in the Durable Object, so `Channels.forHarness` can serve an AI SDK model. The same entry point exports the AI SDK message conversions it is built on and `createSendMessageTool`, an AI SDK tool that sends a message to a surface through the gateway.
1 change: 1 addition & 0 deletions packages/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Each export maps to a public entry point that users `import` from. These are the
| `agents` | `src/index.ts` | Agent base class, routing, connections, RPC, state, scheduling, SQL |
| `agents/client` | `src/client.ts` | Browser/Node WebSocket client (`AgentClient`) via partysocket |
| `agents/lifecycle` | `src/lifecycle/index.ts` | Composable Durable Object lifecycle and hibernating connections |
| `agents/harness/ai-sdk` | `src/harness/ai-sdk/index.ts` | Experimental: `AiSdkHarness`, AI SDK turn conversions and `createSendMessageTool` |
| `agents/harness/pi` | `src/harness/pi/index.ts` | Experimental: pi-durable hosted in a Durable Object (`PiHarness`) |
| `agents/react` | `src/react.tsx` | `useAgent` React hook, state sync, RPC from components |
| `agents/chat` | `src/chat/index.ts` | Shared chat primitives used by `@cloudflare/ai-chat` and `@cloudflare/think` |
Expand Down
5 changes: 5 additions & 0 deletions packages/agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@
"import": "./dist/lifecycle/index.js",
"require": "./dist/lifecycle/index.js"
},
"./harness/ai-sdk": {
"types": "./dist/harness/ai-sdk/index.d.ts",
"import": "./dist/harness/ai-sdk/index.js",
"require": "./dist/harness/ai-sdk/index.js"
},
Comment on lines +170 to +174

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Advertised import path differs from exported API

The PR description advertises agents/channels/ai-sdk, but the package exports agents/harness/ai-sdk and a hosted harness. Align the public API documentation with the intended release.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

"./harness/pi": {
"types": "./dist/harness/pi/index.d.ts",
"import": "./dist/harness/pi/index.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 @@ -10,6 +10,7 @@ const entries = [
"src/skills/index.ts",
"src/skills/compile.ts",
"src/lifecycle/index.ts",
"src/harness/ai-sdk/index.ts",
"src/harness/pi/index.ts",
"src/routing/index.ts",
"src/chat/index.ts",
Expand Down
149 changes: 149 additions & 0 deletions packages/agents/src/experimental/channels/__tests__/ai-sdk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { asSchema, type UIMessage, type UIMessageChunk } from "ai";
import { describe, expect, it, vi } from "vitest";
import { ChannelGateway, type ChannelMessage, type DeliveryResult } from "..";
import {
answerToolCall,
createSendMessageTool,
toResponseChunks
} from "../../../harness/ai-sdk";

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

const surface = {
channelKey: "test",
version: 1,
address: null,
label: "Test destination"
} as const;

function host(
deliver = vi.fn(
async (): Promise<DeliveryResult> => ({ status: "delivered" })
)
) {
return {
deliver,
channelHost: new ChannelGateway({
channels: { test: { deliver } },
agent: () => {
throw new Error("no agent");
}
})
};
}

describe("AI SDK message adapter", () => {
Comment thread
cjol marked this conversation as resolved.
it("adapts a Host-resolved surface to a caller-described tool", async () => {
const deliver = vi.fn(
async (): Promise<DeliveryResult> => ({
status: "delivered",
reference: "message-1"
})
);
const { channelHost } = host(deliver);

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

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

await expect(
executable(messageTool)({ title: "Urgent", markdown: "Please **help**" })
).resolves.toEqual({ status: "delivered", reference: "message-1" });
expect(deliver).toHaveBeenCalledWith(
surface,
{ title: "Urgent", markdown: "Please **help**" },
undefined
);
});

it("validates tool input without requiring a schema library", async () => {
const { channelHost } = host();
const messageTool = createSendMessageTool(channelHost, surface);
const schema = asSchema(messageTool.inputSchema);

expect(await schema.validate?.({ markdown: "" })).toMatchObject({
success: false
});
expect(
await schema.validate?.({ markdown: "Ready", ignored: true })
).toEqual({
success: true,
value: { markdown: "Ready" }
});
});
});

describe("AI SDK turn conversions", () => {
const asked = (state: string, extra: Record<string, unknown> = {}) =>
({
id: "m1",
role: "assistant",
parts: [
{
type: "tool-getLocation",
toolCallId: "t1",
input: {},
state,
approval: { id: "a1" },
...extra
}
]
}) as unknown as UIMessage;

it("records a rejected approval as denied", () => {
const answered = answerToolCall([asked("approval-requested")], {
type: "approval-response",
eventId: "e1",
turnId: "turn",
approvalId: "a1",
approved: false
});
expect(answered?.parts[0]).toMatchObject({
state: "output-denied",
approval: { id: "a1", approved: false }
});
});

it("records a client tool's result after its approval", () => {
const answered = answerToolCall(
[asked("approval-responded", { approval: { id: "a1", approved: true } })],
{
type: "tool-result",
eventId: "e1",
turnId: "turn",
toolCallId: "t1",
result: { ok: true, output: "here" }
}
);
expect(answered?.parts[0]).toMatchObject({
state: "output-available",
output: "here"
});
});

it("throws after an aborted stream, keeping what it produced", async () => {
async function* stream(): AsyncGenerator<UIMessageChunk> {
yield { type: "text-start", id: "a" };
yield { type: "text-delta", id: "a", delta: "Half" };
yield { type: "abort" };
}
const seen: string[] = [];
await expect(async () => {
for await (const chunk of toResponseChunks(stream())) {
seen.push(chunk.type);
}
}).rejects.toThrow("aborted");
expect(seen).toEqual(["text-start", "text-delta"]);
});
});
Loading
Loading