Skip to content

Commit 320a18e

Browse files
authored
Merge pull request #121 from AgentWorkforce/feat/real-relay-sdk
feat: run agents against the real @agent-relay/agent SDK (Unit D)
2 parents 66d0cd5 + 7ddbba8 commit 320a18e

12 files changed

Lines changed: 647 additions & 166 deletions

File tree

agents/manual-chatbot/agent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export default agent({
3131
async onEvent(ctx: Context, event: AgentEvent) {
3232
if (event.type !== "relaycast.message") return;
3333

34-
const msg = (await event.expand("full")) as MessageExpansion;
34+
const msg = (await event.expand("full")) as unknown as MessageExpansion;
3535
const question = msg.data.text;
3636

3737
// 1. Cheap intent classification: is this about proactive agents?

agents/notion-to-blog/agent.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,13 @@ export default agent({
183183

184184
// ── 7. Open PR (idempotent — skips if one already exists) ───────────
185185
const octokit = await octokitFor(e);
186+
// `ctx.once` returns undefined when this page revision was already
187+
// published (deduped) — skip the Notion update + log on a repeat delivery.
186188
const prUrl = await ctx.once(
187189
`notion-pr:${pageId}:${page.last_edited_time}`,
188190
() => openPr(octokit, slug, frontmatter.title, mdx, page.url ?? ""),
189191
);
192+
if (!prUrl) return;
190193

191194
// ── 8. Update Notion: Status → Published, Published URL → PR URL ────
192195
await client.request("PATCH", `/pages/${pageId}`, {

agents/pr-reviewer/agent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export default agent({
3232
return;
3333
}
3434

35-
const pr = (await event.expand("full")) as PrExpansion;
35+
const pr = (await event.expand("full")) as unknown as PrExpansion;
3636
const mdxChanged = pr.data.changed_files.filter((f) => f.filename.endsWith(".mdx"));
3737
const codeChanged = pr.data.changed_files.filter(
3838
(f) => f.filename.startsWith("app/") || f.filename.startsWith("components/"),

agents/shared/log.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export async function writeLogEntry(ctx: Context, input: LogInput): Promise<Agen
3838
const next = [entry, ...without];
3939

4040
await ctx.files.write(LOG_VFS_PATH, next, {
41-
message: `[${entry.agent}] ${entry.action}`,
41+
semantics: { commitMessage: `[${entry.agent}] ${entry.action}` },
4242
});
4343

4444
return entry;

agents/shared/runtime/cloudflare-context.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ export async function makeCloudflareContext(args: {
4646
const octokit = await getOctokit(env);
4747

4848
const logger: Logger = {
49+
debug(msg, meta) {
50+
console.debug(`[${agentId}] ${msg}`, meta ?? "");
51+
},
4952
info(msg, meta) {
5053
console.log(`[${agentId}] ${msg}`, meta ?? "");
5154
},
@@ -63,6 +66,21 @@ export async function makeCloudflareContext(args: {
6366
logger,
6467
signal,
6568

69+
// Burn tagging is a no-op here: this adapter talks to GitHub/Notion
70+
// directly rather than through the metered relay primitives.
71+
tagged: <T>(value: T): T => value,
72+
73+
// The relay primitive clients (relayfile/relaycron/relaycast) are not
74+
// available in the Pages-Functions adapter — it uses GitHub/Notion APIs
75+
// directly. No handler touches `ctx.raw`; fail loudly if one ever does.
76+
raw: new Proxy({} as Context["raw"], {
77+
get(_target, prop) {
78+
throw new Error(
79+
`ctx.raw.${String(prop)} is unavailable in the Pages-Functions context`,
80+
);
81+
},
82+
}),
83+
6684
files: {
6785
async read(path) {
6886
const repoPath = vfsToRepoPath(path);
@@ -72,12 +90,17 @@ export async function makeCloudflareContext(args: {
7290
path: repoPath,
7391
ref: REPO_BRANCH,
7492
});
75-
return result ? { body: result.data, meta: { sha: result.sha } } : null;
93+
// Map GitHub's content sha onto WorkspaceFile.revision (the SDK's
94+
// relayfile revision channel) so writers can use it for compare-and-swap.
95+
return result ? { path, body: result.data, revision: result.sha } : null;
7696
},
7797
async write(path, body, meta) {
7898
const repoPath = vfsToRepoPath(path);
79-
const message =
80-
(meta as { message?: string } | undefined)?.message ?? `[${agentId}] write ${path}`;
99+
// WriteMeta has no free-form commit message; callers pass one via the
100+
// SDK-sanctioned `semantics` extension point (`semantics.commitMessage`).
101+
const commitMessage = (meta?.semantics as { commitMessage?: string } | undefined)
102+
?.commitMessage;
103+
const message = commitMessage ?? `[${agentId}] write ${path}`;
81104
await writeRepoJson(octokit, {
82105
owner: REPO_OWNER,
83106
repo: REPO_NAME,

agents/shared/sdk.ts

Lines changed: 36 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,133 +1,60 @@
11
/**
2-
* Local mirror of the proactive-runtime SDK contract.
2+
* The proactive-runtime SDK contract for these agents.
33
*
4-
* Once `@agent-relay/agent` is published, replace this whole file with:
5-
* export { agent, type AgentDefinition, type AgentEvent, type Context } from "@agent-relay/agent";
4+
* The TYPES below are the published `@agent-relay/agent` contract — the package
5+
* shipped, so the local mirror that used to live here is gone and handlers now
6+
* type-check against the real SDK (`AgentDefinition`, `AgentEvent`, `Context`,
7+
* `ScheduleSpec`).
68
*
7-
* Until then we keep the types here so the handlers type-check, and `agent()`
8-
* runs as a no-op shim in local dev (registers the definition, doesn't dispatch).
9+
* `agent()`, however, stays a LOCAL registrar rather than the SDK's hosted
10+
* runtime. These agents dispatch from Cloudflare Pages Functions
11+
* (`functions/api/...`) — request-scoped Workers that cannot hold the SDK's
12+
* long-lived broker connection. The Pages Function reaches into
13+
* `handle.definition.onEvent(ctx, event)` directly, so we expose `.definition`,
14+
* which the hosted `agent()` (a connection-backed handle) does not. Move to the
15+
* hosted runtime only if these ever run on a long-lived host instead of Pages
16+
* Functions.
917
*/
10-
18+
export type {
19+
AgentDefinition,
20+
AgentEvent,
21+
AgentHandle,
22+
Context,
23+
EventType,
24+
Logger,
25+
ScheduleSpec
26+
} from "@agent-relay/agent";
27+
28+
import type { AgentDefinition, AgentHandle, Context } from "@agent-relay/agent";
29+
30+
/** Local trigger taxonomy used by the activity log; not part of the SDK. */
1131
export type Trigger = "time" | "change" | "message";
1232

13-
export type ScheduleSpec =
14-
| string
15-
| { cron: string; tz?: string }
16-
| { at: string | Date };
17-
18-
export type EventType =
19-
| "startup"
20-
| "cron.tick"
21-
| "relayfile.changed"
22-
| "relaycast.message"
23-
| (string & {}); // provider events: "github.pull_request.opened", "notion.page.updated", etc.
24-
25-
export type AgentEvent<T extends EventType = EventType> = {
26-
id: string;
27-
workspace: string;
28-
type: T;
29-
occurredAt: string;
30-
attempt: number;
31-
resource: {
32-
path: string;
33-
kind: string;
34-
id: string;
35-
provider: string;
36-
};
37-
summary: {
38-
title?: string;
39-
status?: string;
40-
priority?: string;
41-
labels?: string[];
42-
actor?: { id: string; displayName?: string };
43-
fieldsChanged?: string[];
44-
tags?: string[];
45-
};
46-
expand: <L extends "summary" | "full" | "diff" | "thread">(level?: L) => Promise<unknown>;
47-
digest?: string;
48-
};
49-
50-
export type Logger = {
51-
info(msg: string, meta?: Record<string, unknown>): void;
52-
warn(msg: string, meta?: Record<string, unknown>): void;
53-
error(msg: string, meta?: Record<string, unknown>): void;
54-
};
55-
56-
export type Context = {
57-
workspace: string;
58-
agentId: string;
59-
logger: Logger;
60-
signal: AbortSignal;
61-
files: {
62-
read(path: string): Promise<{ body: unknown; meta?: unknown } | null>;
63-
write(path: string, body: unknown, meta?: Record<string, unknown>): Promise<void>;
64-
delete(path: string): Promise<void>;
65-
list(glob: string): Promise<{ path: string }[]>;
66-
};
67-
messages: {
68-
post(channel: string, text: string, opts?: Record<string, unknown>): Promise<{ id: string }>;
69-
reply(threadId: string, text: string, opts?: Record<string, unknown>): Promise<{ id: string }>;
70-
dm(agentOrUser: string, text: string): Promise<{ id: string }>;
71-
};
72-
schedule: {
73-
at(when: string | Date, payload?: unknown): Promise<{ id: string }>;
74-
every(cron: string, payload?: unknown, opts?: { tz?: string }): Promise<{ id: string }>;
75-
cancel(id: string): Promise<void>;
76-
};
77-
once<T>(key: string, fn: () => Promise<T>): Promise<T>;
78-
};
79-
80-
export type AgentDefinition = {
81-
workspace: string;
82-
name?: string;
83-
schedule?: ScheduleSpec | ScheduleSpec[];
84-
watch?: string | string[];
85-
inbox?: string | string[];
86-
onEvent: (ctx: Context, event: AgentEvent) => Promise<void> | void;
87-
onStart?: (ctx: Context) => Promise<void> | void;
88-
onError?: (ctx: Context, error: Error, event: AgentEvent) => Promise<void> | void;
89-
options?: {
90-
concurrency?: number;
91-
handlerTimeoutMs?: number;
92-
replayOnStart?: "none" | string;
93-
};
94-
};
95-
96-
export type AgentHandle = {
97-
ready: Promise<void>;
98-
stop: () => Promise<void>;
99-
trigger: (event: Partial<AgentEvent>) => Promise<void>;
100-
ctx: Context;
101-
};
102-
10333
/**
104-
* Local shim. When @agent-relay/agent ships, replace this whole module with:
105-
* export { agent, type AgentDefinition, ... } from "@agent-relay/agent";
106-
*
107-
* Until then `agent()` returns a handle whose `trigger()` invokes the
108-
* registered `onEvent` synchronously with whatever event you pass it. This
109-
* is exactly the spec's "imperative trigger — useful in tests" semantic, and
110-
* it's how the Pages Function (functions/api/cron/[agent].ts) dispatches
111-
* cron.tick events until the real runtime takes over.
34+
* Local handle shape. Extends the SDK {@link AgentHandle} with the original
35+
* `definition` so the Pages Function dispatch can invoke `onEvent` directly
36+
* (see the module comment).
11237
*/
11338
export type AgentHandleWithDef = AgentHandle & {
11439
/** The original definition. Lets the Pages Function reach `onEvent`. */
11540
definition: AgentDefinition;
11641
};
11742

43+
/**
44+
* Register a proactive agent. In the Pages-Functions deployment this records
45+
* the definition and returns a handle whose `definition` the entry-point
46+
* dispatches against; it does not open a broker connection. `trigger()` throws
47+
* — callers invoke `handle.definition.onEvent(ctx, event)` directly.
48+
*/
11849
export function agent(definition: AgentDefinition): AgentHandleWithDef {
119-
// No real-time dispatch in the shim — schedules / watches / inbox are not
120-
// wired to anything. The runtime takes that over. Until then, callers
121-
// (e.g. functions/api/cron/[agent].ts) reach into `handle.definition` and
122-
// invoke `onEvent(realCtx, event)` directly.
12350
return {
12451
definition,
12552
ready: Promise.resolve(),
12653
stop: async () => {},
12754
ctx: {} as Context,
12855
trigger: async () => {
12956
throw new Error(
130-
"shim: handle.trigger() not implemented — invoke handle.definition.onEvent(ctx, event) directly",
57+
"local registrar: invoke handle.definition.onEvent(ctx, event) directly",
13158
);
13259
},
13360
};

agents/weekly-digest/agent.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,14 @@ export default agent({
7878
const clusters = await clusterByTopic(ctx, fresh);
7979

8080
// 4. Upsert the rolling issue.
81-
const { issueUrl, issueNumber } = await ctx.once(
81+
// `ctx.once` returns undefined when the keyed work was already done this
82+
// period (deduped) — nothing more to do on a repeat tick.
83+
const digestResult = await ctx.once(
8284
`digest:${weekKey(event.occurredAt)}`,
8385
() => upsertDigestIssue(ctx, clusters, event.occurredAt),
8486
);
87+
if (!digestResult) return;
88+
const { issueUrl, issueNumber } = digestResult;
8589

8690
// 5. Persist seen URLs so next week dedupes against them.
8791
await ctx.files.write(

functions/api/cron/[agent].ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@
88
* look up the named agent, build a real Context, and invoke its `onEvent`
99
* with a synthesised cron.tick event.
1010
*
11-
* When @agent-relay/agent ships, the runtime dispatches events directly
12-
* without going through HTTP and this whole file disappears.
11+
* This HTTP entry point stays even though `@agent-relay/agent` has shipped:
12+
* these agents run on Cloudflare Pages Functions (request-scoped Workers),
13+
* which can't hold the SDK's long-lived broker connection, so we dispatch by
14+
* calling `handle.definition.onEvent` directly. The event itself is built with
15+
* the real SDK constructor (`createCronTickEvent`).
1316
*/
17+
import { createCronTickEvent } from "@agent-relay/events";
1418
import weeklyDigest, { setEnv as setWeeklyDigestEnv } from "../../../agents/weekly-digest/agent";
1519
import notionToBlog, { setEnv as setNotionToBlogEnv } from "../../../agents/notion-to-blog/agent";
1620
import newsletterDrafter, { setEnv as setNewsletterDrafterEnv } from "../../../agents/newsletter-drafter/agent";
1721
import { makeCloudflareContext, type CfEnv } from "../../../agents/shared/runtime/cloudflare-context";
18-
import type { AgentEvent, AgentHandleWithDef } from "../../../agents/shared/sdk";
22+
import type { AgentHandleWithDef } from "../../../agents/shared/sdk";
1923

2024
type Params = { agent: string };
2125

@@ -67,21 +71,13 @@ export const onRequestPost: PagesFunction<CfEnv, "agent"> = async (ctx) => {
6771
});
6872

6973
const occurredAt = new Date().toISOString();
70-
const event: AgentEvent<"cron.tick"> = {
71-
id: `cron-${occurredAt}-${agentName}`,
74+
const event = createCronTickEvent({
7275
workspace: entry.workspace,
73-
type: "cron.tick",
76+
schedule: agentName,
77+
id: `cron-${occurredAt}-${agentName}`,
7478
occurredAt,
75-
attempt: 1,
76-
resource: {
77-
path: `/_internal/cron/${agentName}`,
78-
kind: "cron.tick",
79-
id: agentName,
80-
provider: "internal",
81-
},
8279
summary: { title: `${agentName} cron tick` },
83-
expand: async () => ({}),
84-
};
80+
});
8581

8682
try {
8783
await entry.handle.definition.onEvent(agentCtx, event);

functions/api/notion-webhook.ts

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
* Route: POST /api/notion-webhook
66
*
77
* Until M2 data triggers ship, this endpoint receives requests and
8-
* synthesizes relayfile.changed events for the notion-to-blog agent.
9-
* When the runtime handles this natively, this file gets deleted.
8+
* synthesizes relayfile.changed events (via the SDK's `createAgentEvent`) for
9+
* the notion-to-blog agent. The dispatch stays HTTP because Pages Functions
10+
* can't host the SDK's long-lived broker runtime.
1011
*
1112
* Also accepts GET for manual trigger: GET /api/notion-webhook?page_id=<id>&secret=<s>
1213
*/
14+
import { createAgentEvent } from "@agent-relay/events";
1315
import notionToBlog, { setEnv as setNotionToBlogEnv } from "../../agents/notion-to-blog/agent";
1416
import { makeCloudflareContext, type CfEnv } from "../../agents/shared/runtime/cloudflare-context";
15-
import type { AgentEvent } from "../../agents/shared/sdk";
1617
import {
1718
NotionApiClient,
1819
serializePropertyMap,
@@ -73,24 +74,23 @@ async function dispatchForPage(env: CfEnv, pageId: string): Promise<Response> {
7374
});
7475

7576
const occurredAt = new Date().toISOString();
76-
const event: AgentEvent<"relayfile.changed"> = {
77-
id: `notion-${pageId}-${occurredAt}`,
78-
workspace: "proactive-agents",
79-
type: "relayfile.changed",
80-
occurredAt,
81-
attempt: 1,
82-
resource: {
83-
path: `/notion/databases/drafts/pages/${pageId}`,
84-
kind: "page",
85-
id: pageId,
86-
provider: "notion",
87-
},
88-
summary: {
89-
title: propValue(props.Title),
90-
status: propValue(props.Status)?.toLowerCase(),
77+
const path = `/notion/databases/drafts/pages/${pageId}`;
78+
const event = createAgentEvent(
79+
{
80+
id: `notion-${pageId}-${occurredAt}`,
81+
workspace: "proactive-agents",
82+
type: "relayfile.changed",
83+
occurredAt,
84+
path,
85+
action: "updated",
86+
resource: { path, kind: "page", id: pageId, provider: "notion" },
87+
summary: {
88+
title: propValue(props.Title),
89+
status: propValue(props.Status)?.toLowerCase(),
90+
},
9191
},
92-
expand: async () => page,
93-
};
92+
{ loadFull: async () => ({ level: "full", path, data: page as unknown as Record<string, unknown> }) }
93+
);
9494

9595
try {
9696
await notionToBlog.definition.onEvent(agentCtx, event);

memory/workspace/.relay/state.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"workspaceId":"rw_7ccfea89","remoteRoot":"/memory/workspace","localRoot":"/home/daytona/workspace/memory/workspace","mode":"poll","syncMode":"mirror","intervalMs":5000,"lastReconcileAt":"2026-06-09T16:12:40.760983493Z","lastSuccessfulReconcileAt":"2026-06-09T16:12:40.760983493Z","staleAfter":"2026-06-09T16:12:50.760983493Z","status":"ready","states":{"stale":false,"offline":false,"hasConflicts":false,"hasPendingWriteback":false},"pendingWriteback":0,"pendingConflicts":0,"deniedPaths":0,"counters":{"snapshotDeleteBlocked":265},"circuit":{"open":false,"openedAt":"0001-01-01T00:00:00Z","windowMs":60000,"cooldownMs":30000,"threshold":5,"nextRetry":"0001-01-01T00:00:00Z"}}

0 commit comments

Comments
 (0)