-
Notifications
You must be signed in to change notification settings - Fork 1
feat(delivery): relaycast target — agents reply over the relay (#254 pt.1) #255
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+273
−8
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import test from 'node:test'; | ||
|
|
||
| import { createDelivery } from './delivery.js'; | ||
| import { resolveRelaycastUrl, DEFAULT_RELAYCAST_URL } from './relaycast.js'; | ||
| import type { WorkforceCtx } from '@agentworkforce/runtime'; | ||
| import type { RelaycastSender } from './types.js'; | ||
|
|
||
| function makeCtx(inputs: Record<string, string> = {}): WorkforceCtx { | ||
| return { persona: { inputs, inputSpecs: {} }, log: () => {} } as unknown as WorkforceCtx; | ||
| } | ||
|
|
||
| test('DEFAULT_RELAYCAST_URL is cast.agentrelay.com', () => { | ||
| assert.equal(DEFAULT_RELAYCAST_URL, 'https://cast.agentrelay.com'); | ||
| }); | ||
|
|
||
| test('resolveRelaycastUrl: default, RELAY_BASE_URL, then RELAYCAST_URL precedence (trailing slash trimmed)', () => { | ||
| const saved = { u: process.env.RELAYCAST_URL, b: process.env.RELAY_BASE_URL }; | ||
| try { | ||
| delete process.env.RELAYCAST_URL; | ||
| delete process.env.RELAY_BASE_URL; | ||
| assert.equal(resolveRelaycastUrl(), 'https://cast.agentrelay.com'); | ||
|
|
||
| process.env.RELAY_BASE_URL = 'https://relay.example.com/'; | ||
| assert.equal(resolveRelaycastUrl(), 'https://relay.example.com'); | ||
|
|
||
| process.env.RELAYCAST_URL = 'https://cast.example.com'; | ||
| assert.equal(resolveRelaycastUrl(), 'https://cast.example.com'); // RELAYCAST_URL wins | ||
| } finally { | ||
| saved.u === undefined ? delete process.env.RELAYCAST_URL : (process.env.RELAYCAST_URL = saved.u); | ||
| saved.b === undefined ? delete process.env.RELAY_BASE_URL : (process.env.RELAY_BASE_URL = saved.b); | ||
| } | ||
| }); | ||
|
|
||
| test('relaycast target DMs the inbound sender and returns a RelaycastRef', async () => { | ||
| const sent: Array<{ to: string; text: string }> = []; | ||
| const sender: RelaycastSender = { | ||
| async dm(to, text) { | ||
| sent.push({ to, text }); | ||
| return { ok: true, messageId: 'm1' }; | ||
| } | ||
| }; | ||
| const delivery = createDelivery(makeCtx(), { relaycast: { to: 'local-tester', sender } }); | ||
|
|
||
| assert.deepEqual([...delivery.targets], ['relaycast']); | ||
| const res = await delivery.send('hello over relay'); | ||
| assert.equal(res.ok, true); | ||
| assert.deepEqual(sent, [{ to: 'local-tester', text: 'hello over relay' }]); | ||
| assert.deepEqual(res.refs, [{ provider: 'relaycast', to: 'local-tester', messageId: 'm1' }]); | ||
| }); | ||
|
|
||
| test('relaycast is NOT a target unless a reply address is supplied (event-driven, not config)', () => { | ||
| assert.equal(createDelivery(makeCtx(), {}).targets.includes('relaycast'), false); | ||
| // Even with slack configured, relaycast only appears when transports.relaycast.to is set. | ||
| assert.deepEqual([...createDelivery(makeCtx({ SLACK_CHANNEL: 'C1' }), {}).targets], ['slack']); | ||
| }); | ||
|
|
||
| test('onlyTargets can scope delivery to relaycast (origin-only reply)', () => { | ||
| const sender: RelaycastSender = { async dm() { return { ok: true, messageId: 'x' }; } }; | ||
| const delivery = createDelivery( | ||
| makeCtx({ SLACK_CHANNEL: 'C1' }), | ||
| { relaycast: { to: 'peer', sender } }, | ||
| ['relaycast'] | ||
| ); | ||
| assert.deepEqual([...delivery.targets], ['relaycast']); // slack filtered out | ||
| }); | ||
|
|
||
| test('relaycast-only send failure surfaces (matches slack/telegram all-targets-failed contract)', async () => { | ||
| const sender: RelaycastSender = { async dm() { return { ok: false }; } }; | ||
| const delivery = createDelivery(makeCtx(), { relaycast: { to: 'peer', sender } }); | ||
| await assert.rejects(() => delivery.send('x'), /Delivery failed to all targets/); | ||
| }); | ||
|
|
||
| test('relaycast ok:true with no messageId is treated as a failed delivery', async () => { | ||
| const sender: RelaycastSender = { async dm() { return { ok: true }; } }; // no messageId | ||
| const delivery = createDelivery(makeCtx(), { relaycast: { to: 'peer', sender } }); | ||
| await assert.rejects(() => delivery.send('x'), /Delivery failed to all targets/); | ||
| }); | ||
|
|
||
| test('publish()/non-blocking does not invoke the relaycast sender', async () => { | ||
| let calls = 0; | ||
| const sender: RelaycastSender = { async dm() { calls++; return { ok: true, messageId: 'm' }; } }; | ||
| const delivery = createDelivery(makeCtx(), { relaycast: { to: 'peer', sender } }); | ||
| // relaycast is the only target → nothing delivered in non-blocking mode → throws, | ||
| // and crucially the sender is never called (no draft-ref/threading path for relay). | ||
| await assert.rejects(() => delivery.publish('x'), /Delivery failed to all targets/); | ||
| assert.equal(calls, 0); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,82 @@ | ||||||||||||
| import type { WorkforceCtx } from '@agentworkforce/runtime'; | ||||||||||||
| import type { RelaycastSender } from './types.js'; | ||||||||||||
|
Comment on lines
+1
to
+2
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Import 'fetchWithTimeout' from './helpers.js' to enable making the relaycast HTTP request with a timeout, preventing potential hanging.
Suggested change
|
||||||||||||
| import { fetchWithTimeout } from './helpers.js'; | ||||||||||||
|
|
||||||||||||
| /** | ||||||||||||
| * Canonical relaycast gateway — SINGLE SOURCE OF TRUTH. Change this one | ||||||||||||
| * constant to move the default gateway. Per-env override via `RELAYCAST_URL` | ||||||||||||
| * (preferred) or `RELAY_BASE_URL`. | ||||||||||||
| */ | ||||||||||||
| export const DEFAULT_RELAYCAST_URL = 'https://cast.agentrelay.com'; | ||||||||||||
|
|
||||||||||||
| /** Resolve the relaycast base URL: RELAYCAST_URL > RELAY_BASE_URL > default. */ | ||||||||||||
| export function resolveRelaycastUrl(): string { | ||||||||||||
| const raw = | ||||||||||||
| process.env.RELAYCAST_URL?.trim() || | ||||||||||||
| process.env.RELAY_BASE_URL?.trim() || | ||||||||||||
| DEFAULT_RELAYCAST_URL; | ||||||||||||
| return raw.replace(/\/+$/, ''); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| /** | ||||||||||||
| * Resolve the token to authenticate relaycast agent actions (DMs). `/v1/dm` is | ||||||||||||
| * secured with the AGENT token, not the workspace key — so prefer the agent | ||||||||||||
| * token and only fall back to the workspace `RELAY_API_KEY` (which lets tests | ||||||||||||
| * and single-identity boxes still work). Mirrors the runtime's agent-token | ||||||||||||
| * resolution order. | ||||||||||||
| */ | ||||||||||||
| function resolveRelayAgentToken(): string | undefined { | ||||||||||||
| return ( | ||||||||||||
| process.env.WORKFORCE_AGENT_TOKEN?.trim() || | ||||||||||||
| process.env.RELAY_AGENT_TOKEN?.trim() || | ||||||||||||
| process.env.RELAY_API_KEY?.trim() || | ||||||||||||
| undefined | ||||||||||||
| ); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| /** | ||||||||||||
| * Default relaycast sender — DMs a peer agent via `POST /v1/dm` using the box's | ||||||||||||
| * injected agent token. Bounded by `fetchWithTimeout` and never throws: returns | ||||||||||||
| * `{ ok: false }` (logged) on missing token, timeout, or non-2xx, so a relay | ||||||||||||
| * reply degrades gracefully rather than crashing the handler. | ||||||||||||
| */ | ||||||||||||
| export function defaultRelaycastSender(ctx: WorkforceCtx): RelaycastSender { | ||||||||||||
| const token = resolveRelayAgentToken(); | ||||||||||||
| const baseUrl = resolveRelaycastUrl(); | ||||||||||||
| return { | ||||||||||||
| async dm(to: string, text: string): Promise<{ ok: boolean; messageId?: string }> { | ||||||||||||
| if (!token) { | ||||||||||||
| ctx.log?.('warn', 'delivery.relaycast.no-token', { | ||||||||||||
| reason: 'no agent token (WORKFORCE_AGENT_TOKEN/RELAY_AGENT_TOKEN/RELAY_API_KEY) in the agent box' | ||||||||||||
| }); | ||||||||||||
| return { ok: false }; | ||||||||||||
| } | ||||||||||||
| const res = await fetchWithTimeout(`${baseUrl}/v1/dm`, { | ||||||||||||
| method: 'POST', | ||||||||||||
| headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, | ||||||||||||
| body: JSON.stringify({ to, text }) | ||||||||||||
| }); | ||||||||||||
| if (!res) { | ||||||||||||
| ctx.log?.('warn', 'delivery.relaycast.send-failed', { to, reason: 'timeout or network error' }); | ||||||||||||
| return { ok: false }; | ||||||||||||
| } | ||||||||||||
| if (!res.ok) { | ||||||||||||
| ctx.log?.('warn', 'delivery.relaycast.send-failed', { to, status: res.status }); | ||||||||||||
| return { ok: false }; | ||||||||||||
| } | ||||||||||||
| // Relaycast REST wraps success as `{ ok, data }`; the /dm message id lives | ||||||||||||
| // under `data.message.id` (or legacy `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 }; | ||||||||||||
| } | ||||||||||||
| }; | ||||||||||||
| } | ||||||||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.