@relayflows/sdk 2.0.10 → 2.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,187 +1,273 @@
1
- /**
2
- * Agent-relay HTTP transport for f.agent step dispatch (flows#385).
3
- *
4
- * The SDK's default agent path is a direct `child_process.spawn(cli, ...)`
5
- * in worker-cli.ts. That is unobservable — you cannot DM the spawned agent
6
- * mid-flight, and every ecosystem tool that wants to steer or watch it
7
- * ends up shelling out to the `agent-relay` CLI, which is fragile.
8
- *
9
- * This module speaks to agent-relay via structured HTTP: the same call
10
- * shape the `mcp__agent-relay__spawn` MCP tool posts. The SDK becomes a
11
- * first-class agent-relay participant, registered under a derived agent
12
- * name a caller can DM against.
13
- *
14
- * Scope: spawn + close. Streaming inbox and observation are follow-ups.
15
- */
1
+ import { createHash } from "node:crypto";
2
+ import { isIP } from "node:net";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { claimRelayTask } from "./agent-relay-state.js";
5
+ import { canonicalize } from "./canonical.js";
6
+ import {
7
+ isRecord,
8
+ nonempty,
9
+ readTaskReceipt,
10
+ type RelayTaskReceipt,
11
+ } from "./agent-relay-receipt.js";
16
12
 
17
- export type AgentTransport = 'direct' | 'relay';
18
-
19
- export interface AgentRelaySpawnRequest {
20
- /** Registered agent name in the workspace. */
21
- name: string;
22
- /** CLI to launch. */
23
- cli: 'claude' | 'codex' | 'gemini' | 'aider' | 'goose' | 'grok' | 'opencode';
24
- /** Initial task instructions. */
13
+ export type AgentTransport = "direct" | "relay";
14
+ export interface AgentRelayTaskRequest {
15
+ cli: string;
25
16
  task: string;
26
- /** Optional model powering the worker. */
27
17
  model?: string;
28
- /** Optional working directory for the spawned worker process. */
29
18
  worker_cwd?: string;
30
- /** Optional target fleet node name. */
31
- target_node?: string;
32
- /** Declared objective for workforce reporting. */
33
- objective?: string;
34
- /** Declared role for workforce reporting. */
35
- role?: string;
36
- /** Declared project for workforce reporting. */
37
- project?: string;
38
- /** Declared workstream for workforce reporting. */
39
- workstream?: string;
19
+ result_schema?: unknown;
20
+ runId: string;
21
+ stepId: string;
22
+ idempotencyKey: string;
23
+ dataDir: string;
24
+ /** Engine task contract ceiling; bounded independently of the renewing lease. */
25
+ timeoutMs?: number;
40
26
  }
41
-
42
27
  export interface AgentRelayEnv {
43
- /** `RELAY_BASE_URL` env; defaults to https://cast.agentrelay.com. */
44
28
  baseUrl?: string;
45
- /** `RELAY_API_KEY` env (rk_live_...). Required. */
46
- apiKey?: string;
47
- /** `RELAY_DEFAULT_WORKSPACE` env; workspace scoping for the spawn. */
48
- workspaceId?: string;
49
- /** Optional bearer token for the individual agent identity, if pre-registered. */
50
29
  agentToken?: string;
51
30
  }
52
-
53
- export interface AgentSpawnHandle {
54
- /** Registered agent name in the workspace — DM this to steer. */
55
- readonly registeredName: string;
56
- /** Invocation id returned by relay; use for status polling. */
57
- readonly invocationId: string;
58
- /** Best-effort deregistration/notification. */
59
- close(): Promise<void>;
60
- }
61
-
62
31
  export class AgentRelayTransportError extends Error {
63
- constructor(message: string, public readonly cause?: unknown) {
32
+ constructor(
33
+ message: string,
34
+ public readonly cause?: unknown,
35
+ ) {
64
36
  super(message);
65
- this.name = 'AgentRelayTransportError';
37
+ this.name = "AgentRelayTransportError";
66
38
  }
67
39
  }
68
-
69
- /**
70
- * Read the same env the relay MCP already consumes. Explicit override wins.
71
- * Missing RELAY_API_KEY refuses immediately — the transport cannot proceed
72
- * unauthenticated and the direct-spawn fallback is a separate decision.
73
- */
74
- export function readAgentRelayEnv(env: NodeJS.ProcessEnv = process.env): Required<Pick<AgentRelayEnv, 'baseUrl' | 'apiKey'>> & AgentRelayEnv {
75
- const baseUrl = env['RELAY_BASE_URL']?.trim() || 'https://cast.agentrelay.com';
76
- const apiKey = env['RELAY_API_KEY']?.trim();
77
- if (!apiKey) {
40
+ export function readAgentRelayEnv(
41
+ env: NodeJS.ProcessEnv = process.env,
42
+ ): Required<AgentRelayEnv> {
43
+ const agentToken = env.RELAY_AGENT_TOKEN?.trim();
44
+ if (!agentToken)
78
45
  throw new AgentRelayTransportError(
79
- 'agent-relay transport requires RELAY_API_KEY in the environment; falling back to direct transport is the caller\'s responsibility.',
46
+ "Relay task transport requires a pre-provisioned RELAY_AGENT_TOKEN.",
80
47
  );
81
- }
82
48
  return {
83
- baseUrl,
84
- apiKey,
85
- workspaceId: env['RELAY_DEFAULT_WORKSPACE']?.trim() || undefined,
86
- agentToken: env['RELAY_AGENT_TOKEN']?.trim() || undefined,
49
+ baseUrl: env.RELAY_BASE_URL?.trim() || "https://cast.agentrelay.com",
50
+ agentToken,
87
51
  };
88
52
  }
89
53
 
90
- /**
91
- * Spawn a worker via agent-relay HTTP. The endpoint mirrors what the
92
- * `mcp__agent-relay__spawn` MCP tool wraps — a POST that requests a fleet
93
- * node dispatch. Returns a handle keyed on the registered agent name.
94
- *
95
- * The `fetch` argument is injected so tests can mock the transport without
96
- * hitting the network.
97
- */
98
- export async function agentRelaySpawn(
99
- request: AgentRelaySpawnRequest,
100
- env: AgentRelayEnv & { fetch?: typeof fetch } = {},
101
- ): Promise<AgentSpawnHandle> {
102
- // Only fall back to process.env if the caller didn't supply the required
103
- // fields directly. Tests pass { apiKey, fetch } inline; they shouldn't need
104
- // to also set RELAY_API_KEY in the runner env.
105
- const fromEnv: Partial<AgentRelayEnv> = env.apiKey
106
- ? { baseUrl: env.baseUrl || 'https://cast.agentrelay.com' }
107
- : readAgentRelayEnv();
108
- const resolved = { ...fromEnv, ...env };
109
- const url = new URL('/api/v1/agents/spawn', resolved.baseUrl).toString();
110
- const doFetch: typeof fetch = env.fetch ?? (globalThis as typeof globalThis & { fetch: typeof fetch }).fetch;
111
- if (typeof doFetch !== 'function') {
112
- throw new AgentRelayTransportError('global fetch is unavailable; provide { fetch } explicitly.');
113
- }
114
- const headers: Record<string, string> = {
115
- 'content-type': 'application/json',
116
- 'authorization': `Bearer ${resolved.apiKey}`,
117
- };
118
- if (resolved.workspaceId !== undefined) headers['x-relay-workspace'] = resolved.workspaceId;
119
- if (resolved.agentToken !== undefined) headers['x-relay-agent-token'] = resolved.agentToken;
120
-
121
- let response: Response;
122
- try {
123
- response = await doFetch(url, {
124
- method: 'POST',
125
- headers,
126
- body: JSON.stringify(request),
127
- });
128
- } catch (cause) {
129
- throw new AgentRelayTransportError(
130
- `agent-relay spawn network error at ${url}: ${(cause as Error).message ?? String(cause)}`,
131
- cause,
132
- );
133
- }
134
-
135
- if (response.status < 200 || response.status >= 300) {
136
- let body: string;
137
- try { body = (await response.text()).slice(0, 512); } catch { body = '<no body>'; }
138
- throw new AgentRelayTransportError(
139
- `agent-relay spawn refused with status ${response.status}: ${body}`,
140
- );
141
- }
142
-
143
- let json: { invocation?: { invocationId?: string; input?: { name?: string } } };
144
- try { json = await response.json() as typeof json; } catch (cause) {
54
+ /** Exact Relaycast #436 HTTP contract. Only terminal GET receipts can return. */
55
+ export async function runAgentRelayTask(
56
+ request: AgentRelayTaskRequest,
57
+ options: AgentRelayEnv & {
58
+ fetch?: typeof fetch;
59
+ signal?: AbortSignal;
60
+ pollMs?: number;
61
+ } = {},
62
+ ): Promise<RelayTaskReceipt> {
63
+ const env = options.agentToken
64
+ ? {
65
+ baseUrl: options.baseUrl || "https://cast.agentrelay.com",
66
+ agentToken: options.agentToken,
67
+ }
68
+ : { ...readAgentRelayEnv(), ...options };
69
+ const base = new URL(env.baseUrl!);
70
+ if (
71
+ base.username ||
72
+ base.password ||
73
+ base.search ||
74
+ base.hash ||
75
+ base.pathname !== "/" ||
76
+ !["https:", "http:"].includes(base.protocol)
77
+ )
78
+ throw new AgentRelayTransportError("Invalid Relay task base URL");
79
+ const loopback =
80
+ base.hostname === "[::1]" ||
81
+ (isIP(base.hostname) === 4 && base.hostname.startsWith("127."));
82
+ if (base.protocol !== "https:" && !loopback) {
145
83
  throw new AgentRelayTransportError(
146
- 'agent-relay spawn response was not JSON',
147
- cause,
84
+ "Relay task credentials require HTTPS outside literal loopback addresses",
148
85
  );
149
86
  }
150
- const invocationId = json.invocation?.invocationId;
151
- const registeredName = json.invocation?.input?.name ?? request.name;
152
- if (typeof invocationId !== 'string' || invocationId.length === 0) {
87
+ const baseUrl = base.origin;
88
+ const timeoutMs = request.timeoutMs ?? 86_400_000;
89
+ if (
90
+ !Number.isSafeInteger(timeoutMs) ||
91
+ timeoutMs < 1 ||
92
+ timeoutMs > 86_400_000 ||
93
+ ![
94
+ request.runId,
95
+ request.stepId,
96
+ request.idempotencyKey,
97
+ request.dataDir,
98
+ request.cli,
99
+ ].every(nonempty)
100
+ ) {
153
101
  throw new AgentRelayTransportError(
154
- 'agent-relay spawn response missing invocation.invocationId',
102
+ "Relay task requires durable dispatch identity and a valid deadline",
155
103
  );
156
104
  }
157
-
158
- return {
159
- registeredName,
160
- invocationId,
161
- async close(): Promise<void> {
162
- // Best-effort: post to /api/v1/invocations/<id>/close if defined server-side;
163
- // no throw on error because the invocation may already be terminal.
164
- const closeUrl = new URL(`/api/v1/invocations/${encodeURIComponent(invocationId)}/close`, resolved.baseUrl).toString();
105
+ const pollMs = options.pollMs ?? 1000;
106
+ if (!Number.isFinite(pollMs) || pollMs < 1)
107
+ throw new AgentRelayTransportError("Invalid Relay task polling interval");
108
+ const doFetch = options.fetch ?? globalThis.fetch;
109
+ const outer = options.signal ?? new AbortController().signal;
110
+ let deadline = Date.now() + timeoutMs + 30_000;
111
+ let missingDeadline = Date.now() + 30_000;
112
+ async function http(
113
+ path: string,
114
+ body?: unknown,
115
+ ): Promise<Record<string, unknown>> {
116
+ for (;;) {
117
+ outer.throwIfAborted();
118
+ if (Date.now() >= deadline)
119
+ throw new AgentRelayTransportError(
120
+ "Relay task status unavailable before its reconciliation deadline",
121
+ );
122
+ const bounded = AbortSignal.any([
123
+ outer,
124
+ AbortSignal.timeout(
125
+ Math.min(15_000, Math.max(1, deadline - Date.now())),
126
+ ),
127
+ ]);
128
+ let response: Response | undefined;
129
+ let value: unknown;
165
130
  try {
166
- await doFetch(closeUrl, {
167
- method: 'POST',
168
- headers,
169
- body: JSON.stringify({ reason: 'sdk_transport_close' }),
131
+ response = await doFetch(new URL(path, baseUrl), {
132
+ method: body === undefined ? "GET" : "POST",
133
+ redirect: "error",
134
+ signal: bounded,
135
+ headers: {
136
+ authorization: `Bearer ${env.agentToken}`,
137
+ "content-type": "application/json",
138
+ ...(body === undefined
139
+ ? {}
140
+ : { "Idempotency-Key": request.idempotencyKey }),
141
+ },
142
+ ...(body === undefined ? {} : { body: canonicalize(body) }),
170
143
  });
171
- } catch {
172
- // Deliberate: close is advisory in this minimum-viable slice.
144
+ if (
145
+ response.status === 404 &&
146
+ body === undefined &&
147
+ path.includes("/invocations/")
148
+ ) {
149
+ if (Date.now() >= missingDeadline)
150
+ throw new AgentRelayTransportError(
151
+ "Relay task dispatch remains unconfirmed; no repeat POST is permitted",
152
+ );
153
+ } else if (
154
+ !response.ok &&
155
+ response.status !== 429 &&
156
+ response.status < 500
157
+ ) {
158
+ // Never copy untrusted response bodies, URLs, or tokens into diagnostics.
159
+ throw new AgentRelayTransportError(
160
+ `Relay task request refused with HTTP ${response.status}`,
161
+ );
162
+ }
163
+ if (response.ok) value = await response.json();
164
+ if (body !== undefined && !response.ok) return {}; // ambiguous POST: GET only below
165
+ } catch (error) {
166
+ outer.throwIfAborted();
167
+ if (error instanceof AgentRelayTransportError) throw error;
168
+ if (body !== undefined) return {}; // response loss or invalid JSON never causes another POST
169
+ if (response?.ok && !bounded.aborted)
170
+ throw new AgentRelayTransportError(
171
+ "Relay task response was not valid JSON",
172
+ );
173
+ }
174
+ outer.throwIfAborted();
175
+ if (response?.ok && value !== undefined) {
176
+ if (!isRecord(value) || value.ok !== true || !isRecord(value.data))
177
+ throw new AgentRelayTransportError(
178
+ "Relay task response has an invalid data envelope",
179
+ );
180
+ return value.data;
173
181
  }
182
+ await delay(pollMs, undefined, { signal: outer });
183
+ }
184
+ }
185
+ // Resolving the agent is read-only. Pin identity before any invocation so a
186
+ // restarted runner cannot create a second task using another caller's key.
187
+ const agent = await http("/v1/agent");
188
+ if (!nonempty(agent.id))
189
+ throw new AgentRelayTransportError("Relay task caller identity is missing");
190
+ if (!nonempty(agent.workspace_id))
191
+ throw new AgentRelayTransportError(
192
+ "Relay task workspace identity is missing",
193
+ );
194
+ // Pinned action-invoke-v1 identity contract from Relaycast #436.
195
+ const invocationId =
196
+ "inv_idem_" +
197
+ createHash("sha256")
198
+ .update(
199
+ [
200
+ "action-invoke-v1",
201
+ agent.workspace_id,
202
+ agent.id,
203
+ "task.run",
204
+ request.idempotencyKey,
205
+ ].join("\0"),
206
+ )
207
+ .digest("hex");
208
+ const input = {
209
+ cli: request.cli,
210
+ task: request.task,
211
+ ...(request.model === undefined ? {} : { model: request.model }),
212
+ ...(request.worker_cwd === undefined
213
+ ? {}
214
+ : { worker_cwd: request.worker_cwd }),
215
+ ...(request.result_schema === undefined
216
+ ? {}
217
+ : { result_schema: request.result_schema }),
218
+ task_context: {
219
+ run_id: request.runId,
220
+ step_id: request.stepId,
221
+ dispatch_id: request.idempotencyKey,
222
+ timeout_ms: timeoutMs,
174
223
  },
175
224
  };
176
- }
177
-
178
- /**
179
- * Derive a workspace-unique agent name from a run identity + step id. The
180
- * result is stable across replays of the same step so DMs can be addressed
181
- * even during retries.
182
- */
183
- export function deriveAgentName(runId: string, stepId: string): string {
184
- // Keep readable + collision-safe: prefix with 'flow-' and use lower-kebab.
185
- const clean = (s: string): string => s.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
186
- return `flow-${clean(runId)}-${clean(stepId)}`.slice(0, 96);
225
+ const { claim, created } = await claimRelayTask(request.dataDir, {
226
+ version: 1,
227
+ baseUrl,
228
+ callerId: agent.id,
229
+ workspaceId: agent.workspace_id,
230
+ invocationId,
231
+ runId: request.runId,
232
+ stepId: request.stepId,
233
+ idempotencyKey: request.idempotencyKey,
234
+ input,
235
+ startedAt: Date.now(),
236
+ });
237
+ // A restarted runner may recover a terminal receipt after the task deadline.
238
+ // Give that read one bounded window; never reopen or extend the remote task.
239
+ deadline = Math.max(
240
+ claim.startedAt + timeoutMs + 30_000,
241
+ Date.now() + 15_000,
242
+ );
243
+ missingDeadline = claim.startedAt + 30_000;
244
+ if (created) {
245
+ const ack = await http("/v1/actions/task.run/invoke", { input });
246
+ if (
247
+ Object.keys(ack).length &&
248
+ (ack.invocation_id !== invocationId ||
249
+ ack.action_name !== "task.run" ||
250
+ canonicalize(ack.input) !== canonicalize(input))
251
+ ) {
252
+ throw new AgentRelayTransportError(
253
+ "Relay task acknowledgment has mismatched invocation or input",
254
+ );
255
+ }
256
+ }
257
+ let previous: RelayTaskReceipt | undefined;
258
+ for (;;) {
259
+ const value = await http(
260
+ `/v1/actions/task.run/invocations/${encodeURIComponent(invocationId)}`,
261
+ );
262
+ if (value.caller_id !== agent.id)
263
+ throw new AgentRelayTransportError(
264
+ "Relay task receipt belongs to another caller",
265
+ );
266
+ const receipt = readTaskReceipt(value, invocationId, input, previous);
267
+ outer.throwIfAborted();
268
+ if (receipt.status === "completed" || receipt.status === "failed")
269
+ return receipt;
270
+ previous = receipt;
271
+ await delay(pollMs, undefined, { signal: outer });
272
+ }
187
273
  }
package/src/worker-cli.ts CHANGED
@@ -15,8 +15,7 @@ import {
15
15
  } from './wrapper-session.js';
16
16
  import { wrapperEnvironment } from './wrapper-runtime.js';
17
17
  import {
18
- agentRelaySpawn,
19
- deriveAgentName,
18
+ runAgentRelayTask,
20
19
  AgentRelayTransportError,
21
20
  type AgentTransport,
22
21
  } from './agent-relay-transport.js';
@@ -32,6 +31,7 @@ export const WAKE_CONTEXT_ENV = 'RELAYFLOW_WAKE_CONTEXT';
32
31
  export const MODEL_ENV = 'RELAYFLOW_MODEL';
33
32
 
34
33
  export interface WorkerCliResult {
34
+ relay_task?: import('./agent-relay-receipt.js').RelayTaskReceipt;
35
35
  tokens_input?: number;
36
36
  tokens_output?: number;
37
37
  exit_code: number | null;
@@ -40,12 +40,14 @@ export interface WorkerCliResult {
40
40
  }
41
41
 
42
42
  /**
43
- * Optional identity threaded through so the relay transport can derive a
44
- * stable, DM-addressable agent name (flows#385).
43
+ * Journal identity and durable dispatch storage used by the Relay task transport.
45
44
  */
46
45
  export interface AgentRelayContext {
47
46
  runId: string;
48
47
  stepId: string;
48
+ idempotencyKey: string;
49
+ dataDir?: string;
50
+ resultSchema?: unknown;
49
51
  }
50
52
 
51
53
  export async function runAgentCli(
@@ -67,6 +69,10 @@ export async function runAgentCli(
67
69
  }
68
70
  const kind = cliAdapterKind(cli);
69
71
 
72
+ if (mode === 'agent' && transport === 'relay') {
73
+ return runViaAgentRelay(kind, instruction, wakeContext, model, relayContext, cwd, signal);
74
+ }
75
+
70
76
  if (kind === 'relayflows-wrapper-v1') {
71
77
  return requirePricedUsage(decodeWrapperResult(await runWrapperSession(
72
78
  cli,
@@ -79,10 +85,6 @@ export async function runAgentCli(
79
85
  )), model);
80
86
  }
81
87
 
82
- if (mode === 'agent' && transport === 'relay') {
83
- return runViaAgentRelay(kind, instruction, model, relayContext, cwd);
84
- }
85
-
86
88
  const env: NodeJS.ProcessEnv = { ...process.env };
87
89
  delete env[WAKE_CONTEXT_ENV];
88
90
  delete env[MODEL_ENV];
@@ -107,54 +109,46 @@ export async function runAgentCli(
107
109
  return requirePricedUsage(decodeProviderResult(await spawnInvocation(cli, { ...invocation, args }, env, signal, sidechannel, cwd), kind), model);
108
110
  }
109
111
 
110
- /**
111
- * Relay-transport path (flows#385). Fires an agent-relay spawn HTTP request
112
- * that registers the CLI as a first-class workspace participant DMs can
113
- * steer, then reports the spawn outcome as a WorkerCliResult. Streaming
114
- * completion tracking is a follow-up — this initial slice proves the
115
- * transport wire and returns quickly with the registered name in stdout.
116
- */
112
+ /** Wait under the same worker lease for an authoritative task receipt. */
117
113
  async function runViaAgentRelay(
118
- kind: CliAdapterKind,
119
- instruction: string,
120
- model: string | undefined,
121
- relayContext: AgentRelayContext | undefined,
122
- worker_cwd: string | undefined,
114
+ kind: CliAdapterKind, instruction: string, wakeContext: unknown,
115
+ model: string | undefined, context: AgentRelayContext | undefined,
116
+ worker_cwd: string | undefined, signal: AbortSignal | undefined,
123
117
  ): Promise<WorkerCliResult> {
124
- if (kind === 'relayflows-wrapper-v1') {
125
- return {
126
- exit_code: null,
127
- stdout_tail: '',
128
- stderr_tail: 'agent-relay transport does not support the relayflows-wrapper-v1 same-process session.',
129
- };
130
- }
131
- if (relayContext === undefined) {
132
- return {
133
- exit_code: null,
134
- stdout_tail: '',
135
- stderr_tail: 'agent-relay transport requires a relayContext (runId, stepId); the caller did not thread it through.',
136
- };
137
- }
138
- const name = deriveAgentName(relayContext.runId, relayContext.stepId);
139
118
  try {
140
- const handle = await agentRelaySpawn({
141
- name,
142
- cli: kind,
143
- task: instruction,
144
- ...(model === undefined ? {} : { model }),
145
- ...(worker_cwd === undefined ? {} : { worker_cwd }),
146
- });
147
- return {
148
- exit_code: 0,
149
- stdout_tail: JSON.stringify({ registeredName: handle.registeredName, invocationId: handle.invocationId }),
150
- stderr_tail: '',
119
+ if (kind === 'relayflows-wrapper-v1') throw new Error('Relay task transport does not support same-process wrappers.');
120
+ if (!context?.dataDir) throw new Error('Relay task transport requires a durable data directory and journal dispatch identity.');
121
+ const task = instruction + (wakeContext === undefined ? '' : `\n\nWake context (journaled):\n${JSON.stringify(wakeContext)}`)
122
+ + '\n\nReport the final task output with the injected agent_result tool and final=true. Wait for its successful durable acknowledgment before exiting.';
123
+ const received = await runAgentRelayTask({
124
+ cli: kind, task, model, worker_cwd, result_schema: context.resultSchema,
125
+ runId: context.runId, stepId: context.stepId, idempotencyKey: context.idempotencyKey,
126
+ dataDir: context.dataDir,
127
+ }, { signal });
128
+ const receipt = { ...received, error: received.error === null ? null : redactRelayError(received.error) };
129
+ const accounting = receipt.task_execution.accounting;
130
+ const result: WorkerCliResult = {
131
+ relay_task: receipt, exit_code: receipt.status === 'completed' ? 0 : 1,
132
+ stdout_tail: receipt.status === 'completed' ? JSON.stringify(receipt.output) : '',
133
+ stderr_tail: receipt.status === 'failed' ? `Relay task failed: ${receipt.error}` : '',
134
+ ...(accounting?.tokens_input === undefined ? {} : { tokens_input: accounting.tokens_input }),
135
+ ...(accounting?.tokens_output === undefined ? {} : { tokens_output: accounting.tokens_output }),
151
136
  };
137
+ return requirePricedUsage(result, model);
152
138
  } catch (error) {
153
- const detail = error instanceof AgentRelayTransportError
154
- ? error.message
155
- : `agent-relay spawn failed: ${(error as Error).message ?? String(error)}`;
156
- return { exit_code: null, stdout_tail: '', stderr_tail: detail };
139
+ signal?.throwIfAborted();
140
+ const detail = error instanceof AgentRelayTransportError ? error.message
141
+ : error instanceof Error ? error.message : 'Relay task transport failed';
142
+ return { exit_code: null, stdout_tail: '', stderr_tail: redactRelayError(detail) };
143
+ }
144
+ }
145
+
146
+ function redactRelayError(message: string): string {
147
+ for (const key of ['RELAY_AGENT_TOKEN', 'RELAY_API_KEY']) {
148
+ const secret = process.env[key];
149
+ if (secret) message = message.replaceAll(secret, '[redacted]');
157
150
  }
151
+ return message.replace(/\b(?:at|rk|nt|ot|br|arr)_(?:live_)?[A-Za-z0-9_-]+/g, '[redacted]');
158
152
  }
159
153
 
160
154
  async function spawnInvocation(
package/src/worker.ts CHANGED
@@ -112,7 +112,8 @@ export class AgentWorker extends EventEmitter {
112
112
  onReady: this.options.onPtyReady, onDrive: () => { humanIntervention = true; },
113
113
  }, typeof spec.cwd === 'string' ? spec.cwd : undefined,
114
114
  spec.transport === 'relay' ? 'relay' : 'direct',
115
- { runId: dispatch.run_id, stepId: dispatch.step_id })
115
+ { runId: dispatch.run_id, stepId: dispatch.step_id, idempotencyKey: dispatch.idempotency_key,
116
+ dataDir: this.options.dataDir, resultSchema: spec.verification?.json_schema })
116
117
  : Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'agent step has no declared CLI' }));
117
118
  const { result, usage } = workerSpend(completed, spec.model);
118
119
  const completionReason = result.exit_code === 0 ? 'success' : 'worker_error';
@@ -130,7 +131,8 @@ export class AgentWorker extends EventEmitter {
130
131
  // emitting an error JSON with exit 0. `completionReason` is
131
132
  // derived from exit code, so a CLI that exits 0 while emitting
132
133
  // `{"error":...}` will report success with an error payload.
133
- const output = parseJsonOutput(result.stdout_tail) ?? result;
134
+ const output = result.relay_task?.status === 'completed' && result.exit_code === 0
135
+ ? result.relay_task.output : parseJsonOutput(result.stdout_tail) ?? result;
134
136
 
135
137
  await this.client.stepComplete(
136
138
  dispatch.run_id,
@@ -140,6 +142,10 @@ export class AgentWorker extends EventEmitter {
140
142
  completionReason,
141
143
  {
142
144
  output,
145
+ ...(result.relay_task === undefined ? {} : { trajectory_tail: { relay_task: {
146
+ invocation_id: result.relay_task.invocation_id, status: result.relay_task.status,
147
+ task_execution: result.relay_task.task_execution, error: result.relay_task.error,
148
+ } } }),
143
149
  ...(humanIntervention ? { human_intervention: true } : {}),
144
150
  ...(usage !== undefined ? { usage } : {}),
145
151
  started_pins: dispatch.pins,