feat(runtime): ctx.relay — agent-to-agent messaging over the relay (#254 pt.2)#256
Conversation
pt.2) 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 #254
📝 WalkthroughWalkthroughThe runtime context now includes relay messaging support, with new relay types, relay client implementation, package exports, ctx wiring, and updated smoke and unit tests. ChangesRelay runtime support
Sequence Diagram(s)sequenceDiagram
participant buildCtx
participant buildRelayContext
participant "Workforce handler" as Handler
participant "ctx.relay" as RelayCtx
participant fetchWithTimeout
participant "Relaycast API" as RelaycastAPI
buildCtx->>buildRelayContext: create default relay context
buildRelayContext-->>buildCtx: RelayContext
Handler->>RelayCtx: dm(to, text) / post(channel, text)
RelayCtx->>fetchWithTimeout: POST JSON with bearer token
fetchWithTimeout->>RelaycastAPI: HTTP request
RelaycastAPI-->>fetchWithTimeout: response
fetchWithTimeout-->>RelayCtx: parsed payload or failure
RelayCtx-->>Handler: { ok, messageId }
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related issues
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
pr-reviewer could not complete review for #256 in AgentWorkforce/workforce. |
There was a problem hiding this comment.
Code Review
This pull request introduces agent-to-agent messaging over the relay (relaycast) by adding a new relay context to the runtime, allowing handlers to send DMs and post to channels. The feedback suggests expanding the token resolution logic in resolveAgentToken to support additional fallback environment variables (such as RELAYFILE_TOKEN and RELAY_AGENT_TOKENS) and updating the test helper withEnv accordingly to prevent test pollution. Additionally, it is recommended to inline the timeout and abort controller logic directly into the send function so that the timeout covers the entire request lifecycle, including response body parsing.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| function resolveAgentToken(env: NodeJS.ProcessEnv): string | undefined { | ||
| return ( | ||
| env.WORKFORCE_AGENT_TOKEN?.trim() || | ||
| env.RELAY_AGENT_TOKEN?.trim() || | ||
| env.RELAY_API_KEY?.trim() || | ||
| undefined | ||
| ); | ||
| } |
There was a problem hiding this comment.
The token resolution logic here is missing several fallback environment variables that are supported by the rest of the runtime (specifically in ctx.ts's resolveAgentToken), such as RELAYFILE_TOKEN, RELAY_AGENT_TOKENS (with RELAY_AGENT_NAME), and WORKFORCE_WORKSPACE_TOKEN. This will cause ctx.relay to fail in environments that rely on these variables for authentication.
function tokenFromAgentTokenMap(raw: string | undefined, agentName: string | undefined): string | undefined {
const value = raw?.trim();
if (!value) return undefined;
if (!value.startsWith('{')) return value;
try {
const parsed = JSON.parse(value) as unknown;
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined;
const tokens = parsed as Record<string, unknown>;
const namedToken = agentName ? tokens[agentName] : undefined;
if (typeof namedToken === 'string' && namedToken.trim()) return namedToken.trim();
const singleToken = Object.values(tokens).find((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0);
return singleToken?.trim();
} catch {
return undefined;
}
}
function resolveAgentToken(env: NodeJS.ProcessEnv): string | undefined {
return (
env.WORKFORCE_AGENT_TOKEN?.trim() ||
env.RELAY_AGENT_TOKEN?.trim() ||
env.RELAYFILE_TOKEN?.trim() ||
tokenFromAgentTokenMap(env.RELAY_AGENT_TOKENS, env.RELAY_AGENT_NAME) ||
env.RELAY_API_KEY?.trim() ||
env.WORKFORCE_WORKSPACE_TOKEN?.trim() ||
undefined
);
}| async function fetchWithTimeout(url: string, init: RequestInit): Promise<Response | undefined> { | ||
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
| async function send(path: string, body: unknown, action: string): Promise<RelaySendResult> { | ||
| 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<string, unknown> | null; | ||
| const data = | ||
| json && typeof json.data === 'object' && json.data !== null | ||
| ? (json.data as Record<string, unknown>) | ||
| : json; | ||
| const message = | ||
| data && typeof data.message === 'object' && data.message !== null | ||
| ? (data.message as Record<string, unknown>) | ||
| : undefined; | ||
| const rawId = message?.id ?? data?.messageId ?? data?.id; | ||
| return { ok: true, messageId: rawId != null ? String(rawId) : undefined }; | ||
| } |
There was a problem hiding this comment.
Inline the timeout and abort controller logic here to ensure the 8-second timeout covers the entire request lifecycle, including reading and parsing the response body via res.json().
async function send(path: string, body: unknown, action: string): Promise<RelaySendResult> {
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 controller = new AbortController();
const timer = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS);
try {
const res = await fetch(`${baseUrl}${path}`, {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal
});
if (!res.ok) {
log('warn', `relay.${action}.failed`, { status: res.status });
return { ok: false };
}
const json = (await res.json().catch(() => null)) as Record<string, unknown> | null;
const data =
json && typeof json.data === 'object' && json.data !== null
? (json.data as Record<string, unknown>)
: json;
const message =
data && typeof data.message === 'object' && data.message !== null
? (data.message as Record<string, unknown>)
: undefined;
const rawId = message?.id ?? data?.messageId ?? data?.id;
return { ok: true, messageId: rawId != null ? String(rawId) : undefined };
} catch (err) {
const isTimeout = err instanceof Error && err.name === 'AbortError';
log('warn', `relay.${action}.failed`, { reason: isTimeout ? 'timeout' : 'network error' });
return { ok: false };
} finally {
clearTimeout(timer);
}
}| } | ||
|
|
||
| function withEnv(vars: Record<string, string | undefined>, fn: () => Promise<void>): Promise<void> { | ||
| const keys = ['RELAYCAST_URL', 'RELAY_BASE_URL', 'WORKFORCE_AGENT_TOKEN', 'RELAY_AGENT_TOKEN', 'RELAY_API_KEY']; |
There was a problem hiding this comment.
Since we are expanding resolveAgentToken to support additional environment variables (RELAYFILE_TOKEN, RELAY_AGENT_TOKENS, RELAY_AGENT_NAME, and WORKFORCE_WORKSPACE_TOKEN), we must also clean them up in the test helper withEnv. Otherwise, if any of these variables are set in the local development environment, tests like 'no agent token → {ok:false} and no fetch' will fail due to unexpected token resolution.
| const keys = ['RELAYCAST_URL', 'RELAY_BASE_URL', 'WORKFORCE_AGENT_TOKEN', 'RELAY_AGENT_TOKEN', 'RELAY_API_KEY']; | |
| const keys = ['RELAYCAST_URL', 'RELAY_BASE_URL', 'WORKFORCE_AGENT_TOKEN', 'RELAY_AGENT_TOKEN', 'RELAY_API_KEY', 'RELAYFILE_TOKEN', 'RELAY_AGENT_TOKENS', 'RELAY_AGENT_NAME', 'WORKFORCE_WORKSPACE_TOKEN']; |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f4c3ae6eac
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return ( | ||
| env.WORKFORCE_AGENT_TOKEN?.trim() || | ||
| env.RELAY_AGENT_TOKEN?.trim() || | ||
| env.RELAY_API_KEY?.trim() || |
There was a problem hiding this comment.
Honor RELAY_AGENT_TOKENS when resolving relay auth
When the sandbox provides per-agent credentials via RELAY_AGENT_TOKENS plus RELAY_AGENT_NAME (the existing ctx.memory resolver already supports this in packages/runtime/src/ctx.ts:447-451), ctx.relay falls through to no token or to RELAY_API_KEY, so DM/post either skips the fetch or gets rejected by agent-scoped endpoints even though a valid agent token is present. Please share the existing token resolver or include the token-map fallback here.
Useful? React with 👍 / 👎.
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 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/runtime/src/relay.ts`:
- Around line 35-45: The timeout in fetchWithTimeout only guards the network
request up to headers, so send() can still hang while reading the response body
via res.json(). Update relay.ts so the same AbortController/timer remains active
until body parsing completes, either by moving JSON parsing into the
timeout-guarded scope or by deferring clearTimeout(timer) until after res.json()
finishes. Keep the fix centered around fetchWithTimeout and send() so the 8s
never-hang behavior applies to both headers and body consumption.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 1149f09f-3228-4785-a875-a24a4a68417c
📒 Files selected for processing (6)
examples/notion-essay-pr/notion-essay-pr.smoke.test.tspackages/runtime/src/ctx.tspackages/runtime/src/index.tspackages/runtime/src/relay.test.tspackages/runtime/src/relay.tspackages/runtime/src/types.ts
| async function fetchWithTimeout(url: string, init: RequestInit): Promise<Response | undefined> { | ||
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Timeout does not cover response-body reading.
clearTimeout(timer) runs in finally as soon as fetch resolves (i.e., once headers arrive). The subsequent res.json() in send() (Line 77) then runs with no abort guard, so a peer that returns headers quickly but stalls the body stream can hang the handler indefinitely — defeating the documented 8s / never-hang contract.
Consider keeping the abort signal armed until the body is consumed, e.g. read the body inside the timeout-guarded scope (or only clear the timer after res.json()).
🔧 One option: parse inside the guarded scope
-async function fetchWithTimeout(url: string, init: RequestInit): Promise<Response | undefined> {
- 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);
- }
-}
+async function fetchWithTimeout(
+ url: string,
+ init: RequestInit
+): Promise<{ status: number; ok: boolean; json: () => Promise<unknown> } | undefined> {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS);
+ try {
+ const res = await fetch(url, { ...init, signal: controller.signal });
+ // Buffer the body while the abort signal is still armed.
+ const text = await res.text();
+ return {
+ status: res.status,
+ ok: res.ok,
+ json: async () => JSON.parse(text)
+ };
+ } catch {
+ return undefined;
+ } finally {
+ clearTimeout(timer);
+ }
+}(Adjust send()'s res.json().catch(...) accordingly.)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/runtime/src/relay.ts` around lines 35 - 45, The timeout in
fetchWithTimeout only guards the network request up to headers, so send() can
still hang while reading the response body via res.json(). Update relay.ts so
the same AbortController/timer remains active until body parsing completes,
either by moving JSON parsing into the timeout-guarded scope or by deferring
clearTimeout(timer) until after res.json() finishes. Keep the fix centered
around fetchWithTimeout and send() so the 8s never-hang behavior applies to both
headers and body consumption.
|
ℹ️ pr-reviewer: review only — no file changes were applied to the PR (nothing to commit after review). The notes below are advisory and were not pushed. Review summary — PR #256:
|
Second piece of #254. Gives handlers a first-class way to message peers over the relay — the inbound counterpart to the relay
inboxtrigger.API
Both return
{ ok, messageId? }, are bounded by an 8s timeout, and never throw (return{ ok: false }+ log on missing token / timeout / non-2xx).Details
WORKFORCE_AGENT_TOKEN>RELAY_AGENT_TOKEN>RELAY_API_KEY./v1/dmis agent-scoped, so the workspace key alone won't do.RELAYCAST_URL>RELAY_BASE_URL(the cloud launcher injects the minted-against value) >https://cast.agentrelay.com.{ ok, data }envelope for the message id.@agentworkforce/delivery— delivery depends on runtime, so importing it back would be circular. Mirrors delivery's relaycast sender (workforce#255); both default tocast.agentrelay.com. (Follow-up: dedupe by having delivery's relaycast target delegate toctx.relay.)buildCtx+CtxBuildOptions+CORE_CTX_FIELDS. Typecheck clean; 5/5 new tests.Replying to a sender
To reply to whoever DM'd the agent, read the sender off the inbound relay event and pass it as
to. Surfacing the sender on the typedRelaycastMessageEventis a follow-up (the event type lives in the external@agent-relay/events); tracked in #254.Refs #254
🤖 Generated with Claude Code