From f4c3ae6eaca731f4807cfd1822eca279d0f12f69 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Thu, 25 Jun 2026 13:56:45 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(runtime):=20ctx.relay=20=E2=80=94=20ag?= =?UTF-8?q?ent-to-agent=20messaging=20over=20the=20relay=20(#254=20pt.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ctx.relay so a handler can DM a peer agent or post to a relay channel — the inbound counterpart to the relay `inbox` trigger. Backed by the box's injected agent token (WORKFORCE_AGENT_TOKEN > RELAY_AGENT_TOKEN > RELAY_API_KEY) against the relaycast gateway (RELAYCAST_URL > RELAY_BASE_URL > cast.agentrelay.com). - ctx.relay.dm(to, text) → POST /v1/dm - ctx.relay.post(channel, text) → POST /v1/channels/{name}/messages - Bounded by an 8s timeout; never throws (returns {ok:false} + logs); unwraps the relaycast {ok,data} envelope for the message id. Implemented inline in runtime (not imported from @agentworkforce/delivery, which depends on runtime — importing back would be circular). Mirrors delivery's relaycast sender; both default to cast.agentrelay.com. Wired through buildCtx + CtxBuildOptions + CORE_CTX_FIELDS. 5/5 tests, typecheck clean. To reply to whoever messaged the agent, read the sender off the inbound relay event and pass it as `to` (surfacing the sender on the typed event is a follow-up — @agent-relay/events is external). Refs AgentWorkforce/workforce#254 --- packages/runtime/src/ctx.ts | 5 ++ packages/runtime/src/index.ts | 6 ++ packages/runtime/src/relay.test.ts | 98 ++++++++++++++++++++++++++++++ packages/runtime/src/relay.ts | 95 +++++++++++++++++++++++++++++ packages/runtime/src/types.ts | 24 ++++++++ 5 files changed, 228 insertions(+) create mode 100644 packages/runtime/src/relay.test.ts create mode 100644 packages/runtime/src/relay.ts diff --git a/packages/runtime/src/ctx.ts b/packages/runtime/src/ctx.ts index c4788fc3..c1868900 100644 --- a/packages/runtime/src/ctx.ts +++ b/packages/runtime/src/ctx.ts @@ -5,6 +5,7 @@ import type { FilesContext, CredentialsContext, MemoryItem, + RelayContext, RequiredRuntimeCredentials, ScheduleContext, SandboxContext, @@ -14,6 +15,7 @@ import type { WorkflowContext } from './types.js'; import { attachTrajectoryRecorder, createTrajectoryRecorder } from './trajectory.js'; +import { buildRelayContext } from './relay.js'; type AgentInputValue = string | number | boolean | null | undefined; @@ -53,6 +55,7 @@ export interface CtxBuildOptions { memory?: MemoryContext; workflow?: WorkflowContext; schedule?: ScheduleContext; + relay?: RelayContext; integrations?: Record; log?: WorkforceCtx['log']; harnessRunner: WorkforceCtx['harness']['run']; @@ -166,6 +169,7 @@ export function buildCtx(options: CtxBuildOptions): WorkforceCtx { memory: options.memory ?? defaultMemoryFor(options.persona.memory, options.workspaceId, log), workflow: options.workflow ?? UNAVAILABLE_WORKFLOW, schedule: options.schedule ?? UNAVAILABLE_SCHEDULE, + relay: options.relay ?? buildRelayContext(log), trajectory: trajectoryRecorder.context, log }; @@ -212,6 +216,7 @@ const CORE_CTX_FIELDS: ReadonlySet = new Set([ 'memory', 'workflow', 'schedule', + 'relay', 'trajectory', 'log' ]); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index a0a11aba..e0d60e8d 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -25,6 +25,8 @@ export type { MemoryItem, MemoryRecallOptions, MemorySaveOptions, + RelayContext, + RelaySendResult, RelayfileCredentials, RequiredRuntimeCredentials, SandboxContext, @@ -56,6 +58,10 @@ export { isStartupEvent } from './types.js'; +// Relay (agent-to-agent) client used by ctx.relay; exported for external ctx +// builders and tests. +export { buildRelayContext, DEFAULT_RELAYCAST_URL } from './relay.js'; + // Runtime envelope helpers shared by provider-triggered agents. export { unwrapResourceRecord diff --git a/packages/runtime/src/relay.test.ts b/packages/runtime/src/relay.test.ts new file mode 100644 index 00000000..e1f37373 --- /dev/null +++ b/packages/runtime/src/relay.test.ts @@ -0,0 +1,98 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildRelayContext, DEFAULT_RELAYCAST_URL } from './relay.js'; + +const noopLog = () => {}; + +type Captured = { url: string; init: RequestInit }; + +/** Install a capturing fetch stub; returns the captured calls + a restore fn. */ +function stubFetch(response: () => Response): { calls: Captured[]; restore: () => void } { + const calls: Captured[] = []; + const original = globalThis.fetch; + globalThis.fetch = (async (url: string | URL, init: RequestInit = {}) => { + calls.push({ url: String(url), init }); + return response(); + }) as typeof fetch; + return { calls, restore: () => { globalThis.fetch = original; } }; +} + +function withEnv(vars: Record, fn: () => Promise): Promise { + const keys = ['RELAYCAST_URL', 'RELAY_BASE_URL', 'WORKFORCE_AGENT_TOKEN', 'RELAY_AGENT_TOKEN', 'RELAY_API_KEY']; + const saved: Record = {}; + for (const k of keys) { saved[k] = process.env[k]; delete process.env[k]; } + for (const [k, v] of Object.entries(vars)) { if (v !== undefined) process.env[k] = v; } + return fn().finally(() => { + for (const k of keys) { saved[k] === undefined ? delete process.env[k] : (process.env[k] = saved[k]); } + }); +} + +test('dm posts /v1/dm with bearer agent token + {to,text}, unwraps {ok,data} id', async () => { + await withEnv({ RELAY_AGENT_TOKEN: 'tok_agent' }, async () => { + const { calls, restore } = stubFetch(() => + new Response(JSON.stringify({ ok: true, data: { message: { id: 'm1' } } }), { status: 200 }) + ); + try { + const relay = buildRelayContext(noopLog); + const res = await relay.dm('peer-agent', 'hello over relay'); + assert.deepEqual(res, { ok: true, messageId: 'm1' }); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, `${DEFAULT_RELAYCAST_URL}/v1/dm`); + assert.equal(calls[0].init.method, 'POST'); + assert.equal((calls[0].init.headers as Record).authorization, 'Bearer tok_agent'); + assert.deepEqual(JSON.parse(String(calls[0].init.body)), { to: 'peer-agent', text: 'hello over relay' }); + } finally { + restore(); + } + }); +}); + +test('agent token precedence: WORKFORCE_AGENT_TOKEN over RELAY_API_KEY', async () => { + await withEnv({ WORKFORCE_AGENT_TOKEN: 'tok_wf', RELAY_API_KEY: 'rk_live_x' }, async () => { + const { calls, restore } = stubFetch(() => new Response(JSON.stringify({ ok: true, data: { id: 'm2' } }), { status: 200 })); + try { + await buildRelayContext(noopLog).dm('p', 'hi'); + assert.equal((calls[0].init.headers as Record).authorization, 'Bearer tok_wf'); + } finally { + restore(); + } + }); +}); + +test('RELAYCAST_URL overrides the default gateway (trailing slash trimmed)', async () => { + await withEnv({ RELAY_API_KEY: 'rk', RELAYCAST_URL: 'https://cast.example.com/' }, async () => { + const { calls, restore } = stubFetch(() => new Response(JSON.stringify({ ok: true, data: { id: 'm' } }), { status: 200 })); + try { + await buildRelayContext(noopLog).post('general', 'yo'); + assert.equal(calls[0].url, 'https://cast.example.com/v1/channels/general/messages'); + assert.deepEqual(JSON.parse(String(calls[0].init.body)), { text: 'yo' }); + } finally { + restore(); + } + }); +}); + +test('no agent token → {ok:false} and no fetch', async () => { + await withEnv({}, async () => { + const { calls, restore } = stubFetch(() => new Response('{}', { status: 200 })); + try { + const res = await buildRelayContext(noopLog).dm('p', 'hi'); + assert.deepEqual(res, { ok: false }); + assert.equal(calls.length, 0); + } finally { + restore(); + } + }); +}); + +test('non-2xx response → {ok:false}', async () => { + await withEnv({ RELAY_AGENT_TOKEN: 'tok' }, async () => { + const { restore } = stubFetch(() => new Response('nope', { status: 401 })); + try { + assert.deepEqual(await buildRelayContext(noopLog).dm('p', 'hi'), { ok: false }); + } finally { + restore(); + } + }); +}); diff --git a/packages/runtime/src/relay.ts b/packages/runtime/src/relay.ts new file mode 100644 index 00000000..5e236a1d --- /dev/null +++ b/packages/runtime/src/relay.ts @@ -0,0 +1,95 @@ +import type { RelayContext, RelaySendResult, WorkforceCtx } from './types.js'; + +/** + * Canonical relaycast gateway — SINGLE SOURCE OF TRUTH for the runtime relay + * client. Override per-env via `RELAYCAST_URL` (preferred) or `RELAY_BASE_URL` + * (the cloud launcher injects the latter, minted-against value into the box). + * Mirrors `@agentworkforce/delivery`'s default; kept inline here because + * delivery depends on runtime (importing it back would be circular). + */ +export const DEFAULT_RELAYCAST_URL = 'https://cast.agentrelay.com'; + +type Log = WorkforceCtx['log']; + +function resolveRelaycastUrl(env: NodeJS.ProcessEnv): string { + const raw = env.RELAYCAST_URL?.trim() || env.RELAY_BASE_URL?.trim() || DEFAULT_RELAYCAST_URL; + return raw.replace(/\/+$/, ''); +} + +/** + * Relaycast agent actions (`/v1/dm`, channel posts) are authenticated with the + * AGENT token, not the workspace key — prefer it, falling back to the workspace + * `RELAY_API_KEY` so single-identity boxes and tests still work. + */ +function resolveAgentToken(env: NodeJS.ProcessEnv): string | undefined { + return ( + env.WORKFORCE_AGENT_TOKEN?.trim() || + env.RELAY_AGENT_TOKEN?.trim() || + env.RELAY_API_KEY?.trim() || + undefined + ); +} + +const RELAY_TIMEOUT_MS = 8_000; + +async function fetchWithTimeout(url: string, init: RequestInit): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } catch { + return undefined; + } finally { + clearTimeout(timer); + } +} + +/** + * Build the relay context from the box environment. Always safe to call: when + * no agent token is present every send returns `{ ok: false }` (logged). + */ +export function buildRelayContext(log: Log, env: NodeJS.ProcessEnv = process.env): RelayContext { + const token = resolveAgentToken(env); + const baseUrl = resolveRelaycastUrl(env); + + async function send(path: string, body: unknown, action: string): Promise { + if (!token) { + log('warn', `relay.${action}.no-token`, { + reason: 'no agent token (WORKFORCE_AGENT_TOKEN/RELAY_AGENT_TOKEN/RELAY_API_KEY) in the box' + }); + return { ok: false }; + } + const res = await fetchWithTimeout(`${baseUrl}${path}`, { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify(body) + }); + if (!res) { + log('warn', `relay.${action}.failed`, { reason: 'timeout or network error' }); + return { ok: false }; + } + if (!res.ok) { + log('warn', `relay.${action}.failed`, { status: res.status }); + return { ok: false }; + } + // Relaycast REST wraps success as `{ ok, data }`; ids live under + // data.message.id / data.id. Unwrap before reading. + const json = (await res.json().catch(() => null)) as Record | null; + const data = + json && typeof json.data === 'object' && json.data !== null + ? (json.data as Record) + : json; + const message = + data && typeof data.message === 'object' && data.message !== null + ? (data.message as Record) + : undefined; + const rawId = message?.id ?? data?.messageId ?? data?.id; + return { ok: true, messageId: rawId != null ? String(rawId) : undefined }; + } + + return { + dm: (to: string, text: string) => send('/v1/dm', { to, text }, 'dm'), + post: (channel: string, text: string) => + send(`/v1/channels/${encodeURIComponent(channel)}/messages`, { text }, 'post') + }; +} diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index b60efe32..0bc32e0f 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -378,6 +378,28 @@ export interface TrajectoryContext { done(summary: string, confidence: number): Promise; } +/** Result of a relay send (DM or channel post). */ +export interface RelaySendResult { + ok: boolean; + /** Delivered relaycast message id, when the gateway returns one. */ + messageId?: string; +} + +/** + * Agent-to-agent messaging over the relay (relaycast). Lets a handler DM a peer + * agent or post to a relay channel using the box's injected agent token — + * the inbound counterpart to the relay `inbox` trigger. Never throws: returns + * `{ ok: false }` (logged) on missing token / timeout / non-2xx, so a relay + * reply degrades gracefully. To reply to whoever messaged this agent, read the + * sender off the inbound relay event and pass it as `to`. + */ +export interface RelayContext { + /** DM a peer agent by registered name. */ + dm(to: string, text: string): Promise; + /** Post a message to a relay channel by name. */ + post(channel: string, text: string): Promise; +} + /** * The context object handlers receive on every event invocation. * Provider data is accessed via the VFS helpers exported from the runtime @@ -413,6 +435,8 @@ export interface WorkforceCtx { workflow: WorkflowContext; /** Schedule one-off follow-up ticks. */ schedule: ScheduleContext; + /** Agent-to-agent messaging over the relay (DM a peer / post to a channel). */ + relay: RelayContext; /** * Auto-recorded decision trajectory (the WHY). No-op when recording is * disabled (no `persona.memory.trajectories` opt-in or no resolvable From 16e87cda09c8d8baf361008011605924c32ccf1a Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 25 Jun 2026 19:53:49 +0200 Subject: [PATCH 2/2] fix(examples): add relay stub to notion-essay-pr smoke test ctx WorkforceCtx now requires a relay field (added in ctx.relay #254 pt.2). The smoke test mock context was missing it, causing the CI typecheck to fail. Add a no-op relay stub that satisfies the interface. Co-Authored-By: Claude Sonnet 4.6 --- examples/notion-essay-pr/notion-essay-pr.smoke.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts b/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts index f55f0299..b3260adc 100644 --- a/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts +++ b/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts @@ -252,6 +252,10 @@ class MockNotionEssayRuntime { async error() {}, async done() {} }, + relay: { + async dm() { return { ok: false }; }, + async post() { return { ok: false }; } + }, log: () => undefined }; }