Skip to content

feat(runtime): ctx.relay — agent-to-agent messaging over the relay (#254 pt.2)#256

Merged
kjgbot merged 2 commits into
mainfrom
feat/runtime-ctx-relay
Jun 25, 2026
Merged

feat(runtime): ctx.relay — agent-to-agent messaging over the relay (#254 pt.2)#256
kjgbot merged 2 commits into
mainfrom
feat/runtime-ctx-relay

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Jun 25, 2026

Copy link
Copy Markdown
Member

Second piece of #254. Gives handlers a first-class way to message peers over the relay — the inbound counterpart to the relay inbox trigger.

API

ctx.relay.dm(to, text)        // POST /v1/dm
ctx.relay.post(channel, text) // POST /v1/channels/{name}/messages

Both return { ok, messageId? }, are bounded by an 8s timeout, and never throw (return { ok: false } + log on missing token / timeout / non-2xx).

Details

  • Auth: the box's agent token — WORKFORCE_AGENT_TOKEN > RELAY_AGENT_TOKEN > RELAY_API_KEY. /v1/dm is agent-scoped, so the workspace key alone won't do.
  • Gateway: RELAYCAST_URL > RELAY_BASE_URL (the cloud launcher injects the minted-against value) > https://cast.agentrelay.com.
  • Unwraps the relaycast { ok, data } envelope for the message id.
  • Implemented inline in runtime, not imported from @agentworkforce/delivery — delivery depends on runtime, so importing it back would be circular. Mirrors delivery's relaycast sender (workforce#255); both default to cast.agentrelay.com. (Follow-up: dedupe by having delivery's relaycast target delegate to ctx.relay.)
  • Wired through 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 typed RelaycastMessageEvent is a follow-up (the event type lives in the external @agent-relay/events); tracked in #254.

Refs #254

🤖 Generated with Claude Code

Review in cubic

 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
@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The runtime context now includes relay messaging support, with new relay types, relay client implementation, package exports, ctx wiring, and updated smoke and unit tests.

Changes

Relay runtime support

Layer / File(s) Summary
Relay types and exports
packages/runtime/src/types.ts, packages/runtime/src/index.ts
RelaySendResult, RelayContext, and WorkforceCtx.relay are added, and the runtime package re-exports the relay API surface.
Relay client and tests
packages/runtime/src/relay.ts, packages/runtime/src/relay.test.ts
buildRelayContext resolves relay URLs and tokens, sends DM and post requests with timeout handling, and the tests cover token precedence, URL override, missing credentials, and non-2xx responses.
Runtime ctx wiring
packages/runtime/src/ctx.ts, examples/notion-essay-pr/notion-essay-pr.smoke.test.ts
buildCtx now supplies ctx.relay by default or override, blocks relay collisions, and the smoke test stubs relay dm/post methods.

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 }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

  • AgentWorkforce/workforce issue 254 — adds ctx.relay and relay send methods in the runtime, matching the relay reply path described there.
  • AgentWorkforce/cloud issue 2376 — introduces runtime relay DM/post support and default ctx wiring, which aligns with the missing relay response path described there.

Poem

A bunny hopped in the runtime glow,
With relay now ready to come and go.
DM, post, and tokens too,
A tiny hop for code review 🐇
And messages zipping briskly through.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding ctx.relay for relay-based agent messaging.
Description check ✅ Passed The description is directly related to the changeset and accurately describes the new relay messaging API and wiring.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/runtime-ctx-relay

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@agent-relay-code

Copy link
Copy Markdown
Contributor

pr-reviewer could not complete review for #256 in AgentWorkforce/workforce.
The review harness exited with code 1.
No review was posted; this needs operator attention.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment on lines +24 to +31
function resolveAgentToken(env: NodeJS.ProcessEnv): string | undefined {
return (
env.WORKFORCE_AGENT_TOKEN?.trim() ||
env.RELAY_AGENT_TOKEN?.trim() ||
env.RELAY_API_KEY?.trim() ||
undefined
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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
  );
}

Comment on lines +35 to +45
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);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This helper function is no longer needed if we inline the timeout and abort controller logic directly into the send function. Inlining it allows the timeout to cover both the connection phase and the response body parsing phase (res.json()), preventing potential hangs on slow networks.

Comment on lines +55 to +88
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 };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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'];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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.

Suggested change
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'];

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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() ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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>

@coderabbitai coderabbitai Bot left a comment

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.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 8d890d1 and 16e87cd.

📒 Files selected for processing (6)
  • examples/notion-essay-pr/notion-essay-pr.smoke.test.ts
  • packages/runtime/src/ctx.ts
  • packages/runtime/src/index.ts
  • packages/runtime/src/relay.test.ts
  • packages/runtime/src/relay.ts
  • packages/runtime/src/types.ts

Comment on lines +35 to +45
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);
}
}

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.

🩺 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.

@agent-relay-code

Copy link
Copy Markdown
Contributor

ℹ️ 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: ctx.relay (agent-to-agent messaging over the relay)

I read .workforce/pr.diff, .workforce/changed-files.txt, and .workforce/context.json, validated the diff against the current checkout (they match), traced RelayContext across callers, and ran the full CI sequence end-to-end.

What the PR does

Adds ctx.relay with dm(to, text) and post(channel, text), backed by a new packages/runtime/src/relay.ts HTTP client to the relaycast gateway. Wires a default into buildCtx (options.relay ?? buildRelayContext(log)), registers relay as a reserved core ctx field, exports the new types/builder, adds unit tests, and updates the notion-essay-pr smoke-test ctx stub.

Verification (the way CI runs it)

Installed deps (pnpm install --frozen-lockfile) and ran the ci.yml steps:

  • Build (pnpm -r run build) — passed
  • Lint (pnpm run lint) — passed
  • Typecheck (pnpm run typecheck, incl. typecheck:examples) — passed
  • Test (pnpm run test) — passed; runtime package 100/100, new relay.test.js 5/5, all other packages green

Safety check

The fail-closed contract is correct and preserved: missing token, timeout/network error, and non-2xx all return { ok: false } (logged) — no fail-open path. relay is correctly added to CORE_CTX_FIELDS, so a persona integration can't clobber ctx.relay. The default URL constant is self-consistent and exercised by tests. I made no edits — no mechanical or semantic fixes were warranted.

Addressed comments

  • No bot or human review comments were present in .workforce/context.json or any review-comments file in the checkout, so there were none to account for. (context.json carries only PR metadata; no reviewComments/threads.)

Advisory Notes

  • packages/runtime/src/relay.ts:7-8 — The doc comment claims DEFAULT_RELAYCAST_URL ("https://cast.agentrelay.com") "Mirrors @agentworkforce/delivery's default." An exploration of the repo found no relaycast gateway URL constant in packages/delivery (it uses Slack/Telegram transports), so the comment is unverifiable/misleading. This is a comment-only nit with no runtime impact; left unchanged as it's outside a demonstrated defect and a human may want to correct the wording.

The PR builds clean, passes lint/typecheck/test in full, and introduces no safety regressions. I cannot confirm CI status, merge-conflict/mergeable state, or that all required checks have completed from within this sandbox, so I'm not declaring it human-ready.

@kjgbot kjgbot merged commit 9236aa3 into main Jun 25, 2026
3 checks passed
@kjgbot kjgbot deleted the feat/runtime-ctx-relay branch June 25, 2026 18:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants