@agent-compose/sdk 0.5.1 → 0.5.5

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.
Files changed (96) hide show
  1. package/dist/active-step.d.ts +60 -0
  2. package/dist/agent/agent-loop-steer.test.d.ts +1 -0
  3. package/dist/agent/agent-loop.d.ts +46 -0
  4. package/dist/agent/async-queue.d.ts +29 -0
  5. package/dist/agent/protocol.d.ts +9 -1
  6. package/dist/agent/resolve-agent-id.test.d.ts +1 -0
  7. package/dist/agent/run-agent.d.ts +16 -3
  8. package/dist/agent/steer-control.d.ts +57 -0
  9. package/dist/agent/steer-control.test.d.ts +1 -0
  10. package/dist/client.d.ts +161 -0
  11. package/dist/index.d.ts +16 -6
  12. package/dist/index.js +1586 -158
  13. package/dist/pause/__tests__/agent-loop-checkpoint.test.d.ts +1 -0
  14. package/dist/pause/__tests__/checkpoint.test.d.ts +1 -0
  15. package/dist/pause/__tests__/errors.test.d.ts +1 -0
  16. package/dist/pause/__tests__/manager.test.d.ts +1 -0
  17. package/dist/pause/__tests__/pause-core.test.d.ts +1 -0
  18. package/dist/pause/__tests__/state-dir.test.d.ts +1 -0
  19. package/dist/pause/__tests__/wrappers.test.d.ts +1 -0
  20. package/dist/pause/checkpoint.d.ts +28 -0
  21. package/dist/pause/errors.d.ts +52 -0
  22. package/dist/pause/manager.d.ts +63 -0
  23. package/dist/pause/pause-core.d.ts +101 -0
  24. package/dist/pause/state-dir.d.ts +80 -0
  25. package/dist/pause/wrappers.d.ts +41 -0
  26. package/dist/request-context/request-context.d.ts +12 -0
  27. package/dist/runtimes/_cli-agent.d.ts +72 -0
  28. package/dist/runtimes/amp.d.ts +22 -0
  29. package/dist/runtimes/claude.d.ts +6 -0
  30. package/dist/runtimes/codex.d.ts +20 -0
  31. package/dist/runtimes/openai-desktop.d.ts +2 -0
  32. package/dist/runtimes/openai-desktop.js +1578 -157
  33. package/dist/runtimes/vercel.d.ts +53 -1
  34. package/dist/runtimes/vercel.js +60 -8
  35. package/dist/runtimes/vercel.test.d.ts +1 -0
  36. package/dist/sse.d.ts +2 -3
  37. package/dist/step-invocation/index.d.ts +2 -2
  38. package/dist/step-invocation/invoker.d.ts +3 -0
  39. package/dist/step-invocation/protocol.d.ts +12 -0
  40. package/dist/step-invocation/server.d.ts +1 -0
  41. package/dist/step-invocation/types.d.ts +40 -5
  42. package/dist/types/events.d.ts +9 -0
  43. package/dist/types/execution-context.d.ts +25 -0
  44. package/dist/types/protocol.d.ts +8 -0
  45. package/dist/types/runtime.d.ts +55 -0
  46. package/dist/types/sandbox.d.ts +4 -4
  47. package/dist/utils/schemas.d.ts +2 -0
  48. package/dist/workflow-steps/__tests__/pause-wiring.test.d.ts +1 -0
  49. package/dist/workflow-steps/index.d.ts +2 -0
  50. package/dist/workflow-steps/observability.d.ts +43 -11
  51. package/dist/workflow-steps/run-callback.d.ts +39 -0
  52. package/dist/workflow-steps/runner.d.ts +8 -0
  53. package/package.json +1 -1
  54. package/src/active-step.ts +124 -0
  55. package/src/agent/agent-loop.ts +253 -19
  56. package/src/agent/async-queue.ts +61 -0
  57. package/src/agent/protocol.ts +12 -2
  58. package/src/agent/run-agent.ts +184 -8
  59. package/src/agent/steer-control.ts +125 -0
  60. package/src/client.ts +277 -0
  61. package/src/index.ts +38 -4
  62. package/src/pause/checkpoint.ts +44 -0
  63. package/src/pause/errors.ts +70 -0
  64. package/src/pause/manager.ts +177 -0
  65. package/src/pause/pause-core.ts +267 -0
  66. package/src/pause/state-dir.ts +262 -0
  67. package/src/pause/wrappers.ts +79 -0
  68. package/src/request-context/request-context.ts +17 -2
  69. package/src/runtimes/_cli-agent.ts +161 -0
  70. package/src/runtimes/amp.ts +94 -0
  71. package/src/runtimes/claude.ts +101 -6
  72. package/src/runtimes/codex.ts +109 -0
  73. package/src/runtimes/openai-desktop.ts +11 -0
  74. package/src/runtimes/vercel.ts +78 -2
  75. package/src/sandbox.ts +39 -20
  76. package/src/sse.ts +8 -6
  77. package/src/step-invocation/index.ts +2 -1
  78. package/src/step-invocation/invoker.ts +107 -29
  79. package/src/step-invocation/protocol.ts +16 -0
  80. package/src/step-invocation/server.ts +45 -12
  81. package/src/step-invocation/types.ts +43 -7
  82. package/src/tools/coding.ts +16 -5
  83. package/src/types/events.ts +9 -0
  84. package/src/types/execution-context.ts +25 -0
  85. package/src/types/protocol.ts +8 -0
  86. package/src/types/runtime.ts +52 -0
  87. package/src/types/sandbox.ts +8 -4
  88. package/src/types/workflow.ts +6 -1
  89. package/src/utils/bundler.ts +8 -3
  90. package/src/utils/schemas.ts +2 -0
  91. package/src/workflow-steps/index.ts +3 -0
  92. package/src/workflow-steps/observability.ts +84 -13
  93. package/src/workflow-steps/run-callback.ts +72 -0
  94. package/src/workflow-steps/runner.ts +70 -8
  95. package/dist/utils/discovery.d.ts +0 -2
  96. package/src/utils/discovery.ts +0 -4
@@ -11,8 +11,13 @@
11
11
  */
12
12
 
13
13
  import { z } from "zod";
14
+ import { randomUUID } from "node:crypto";
15
+ import { getActiveStep, nextAgentCallInActiveStep } from "../active-step.js";
16
+ import { corePause } from "../pause/pause-core.js";
14
17
  import { agentLoop } from "./agent-loop.js";
18
+ import { consumeSteerPending, runControlPoller } from "./steer-control.js";
15
19
  import type { AgentLifecycleEvent, AgentLoopResult } from "./agent-loop.js";
20
+ import { AsyncQueue } from "./async-queue.js";
16
21
  import type { AgentMessage, AgentStatus } from "../types/protocol.js";
17
22
  import type { AgentRuntime, RuntimeOptions } from "../types/runtime.js";
18
23
  import type { SandboxProvider } from "../types/sandbox.js";
@@ -21,8 +26,82 @@ import type { Processor } from "../processors/processor.js";
21
26
  import { RequestContext } from "../request-context/request-context.js";
22
27
  import PROTOCOL_SUFFIX_RAW from "./protocol-suffix.md" with { type: "text" };
23
28
 
29
+ /**
30
+ * Long-poll the per-run inbox for a specific agent and push each
31
+ * delivered message into the agent loop's input queue. Runs concurrent
32
+ * with the agent loop and stops when the agent settles (`signal`
33
+ * aborts).
34
+ *
35
+ * Resilient to network blips: a failed fetch is logged + retried after
36
+ * a short backoff. The user's message survives in the DB so the next
37
+ * successful poll picks it up — the poller is the consumer, not the
38
+ * durable store.
39
+ */
40
+ async function runInboxPoller(opts: {
41
+ baseUrl: string;
42
+ token: string;
43
+ runId: string;
44
+ agentId: string;
45
+ inbox: AsyncQueue<{ text: string; senderName?: string | null }>;
46
+ signal: AbortSignal;
47
+ }): Promise<void> {
48
+ let afterSeq = 0;
49
+ const headers = {
50
+ "authorization": `Bearer ${opts.token}`,
51
+ "accept": "application/json",
52
+ } as const;
53
+ let backoffMs = 0;
54
+ while (!opts.signal.aborted) {
55
+ if (backoffMs > 0) {
56
+ await new Promise((resolve) => setTimeout(resolve, backoffMs));
57
+ if (opts.signal.aborted) return;
58
+ }
59
+ const url = `${opts.baseUrl.replace(/\/+$/, "")}` +
60
+ `/api/v1/internal/runs/${opts.runId}/agents/${encodeURIComponent(opts.agentId)}/inbox?afterSeq=${afterSeq}`;
61
+ try {
62
+ const res = await fetch(url, { method: "GET", headers, signal: opts.signal });
63
+ if (!res.ok) {
64
+ // 401/403 = token expired or wrong run — stop polling, the
65
+ // agent's run is no longer reachable via this token. Other
66
+ // codes: back off and retry.
67
+ if (res.status === 401 || res.status === 403) return;
68
+ backoffMs = Math.min(8_000, (backoffMs || 500) * 2);
69
+ continue;
70
+ }
71
+ backoffMs = 0;
72
+ const body = await res.json() as { messages?: Array<{ seq: number; body: string; senderName: string | null }> };
73
+ for (const m of body.messages ?? []) {
74
+ opts.inbox.push({ text: m.body, senderName: m.senderName });
75
+ if (m.seq > afterSeq) afterSeq = m.seq;
76
+ }
77
+ } catch (err) {
78
+ if (opts.signal.aborted) return;
79
+ backoffMs = Math.min(8_000, (backoffMs || 500) * 2);
80
+ // Don't spam stderr — one warning per session is enough; the
81
+ // backoff escalates on its own.
82
+ if (backoffMs >= 4_000) {
83
+ process.stderr.write(`[inbox-poller] poll failed, backing off ${backoffMs}ms: ${err instanceof Error ? err.message : String(err)}\n`);
84
+ }
85
+ }
86
+ }
87
+ }
88
+
24
89
  const PROTOCOL_SUFFIX = stripFrontmatter(PROTOCOL_SUFFIX_RAW);
25
90
 
91
+ /** Appended to a `mode: "hitl"` agent's first prompt so it knows it may stop
92
+ * and ask a human. Self-pause is opt-in per agent — an autonomous (`auto`)
93
+ * agent never sees this and has no way to halt the workflow itself. */
94
+ const HITL_ASK_INSTRUCTION = [
95
+ "## Asking a human",
96
+ "",
97
+ "If you hit a decision or need information only a human can provide, set " +
98
+ "`\"needs_input\": true` in your `<status>` block, put your question in a " +
99
+ "`\"question\"` field, set `\"exit_signal\": false`, and stop. The workflow " +
100
+ "pauses and a human answers; their reply arrives as your next message. Use " +
101
+ "this only when you genuinely cannot proceed on your own — prefer making a " +
102
+ "reasonable decision and continuing.",
103
+ ].join("\n");
104
+
26
105
  function stripFrontmatter(content: string): string {
27
106
  if (!content.startsWith("---")) return content;
28
107
  const end = content.indexOf("\n---", 3);
@@ -91,6 +170,11 @@ export interface AgentOpts<T = unknown> {
91
170
  tools?: string[];
92
171
  /** Turn/iteration caps. Defaults: 40 turns/iteration, 8 iterations. */
93
172
  budget?: AgentBudget;
173
+ /** Steerability (PR 7). `auto` (default) runs autonomously and ignores steer
174
+ * requests; `hitl` lets a human pause this agent mid-run via the steer route
175
+ * — the loop checks for a pending steer at each iteration boundary and only
176
+ * `hitl` agents run the control poller. */
177
+ mode?: "auto" | "hitl";
94
178
  /** If set, the loop demands a `<response>` block when `exit_signal=true`
95
179
  * and validates it against this schema. The response format appendix is
96
180
  * appended to the prompt on first iteration. */
@@ -129,16 +213,92 @@ export interface AgentOpts<T = unknown> {
129
213
  }
130
214
 
131
215
  /**
132
- * Run an agent loop inside a workflow. Returns the loop's final
133
- * `AgentLoopResult`, including `response` when a `responseSchema` was
134
- * supplied and the model validated against it.
216
+ * Resolve the `agentId` for an `agent()` call. Resolution order:
217
+ * 1. Caller-supplied `explicitId` (the workflow author owns the id).
218
+ * 2. Step-mode derivation `step<idx>-agent-<callOrder>` — deterministic
219
+ * across a pause-resume re-entry: the same step body re-executes in the
220
+ * same call order, so the same id recovers `agent-<id>.json`. The step
221
+ * index comes from the active-step context (set by the step runner),
222
+ * NOT `process.env.AC_STEP_INDEX` — `serveStep` scrubs that before the
223
+ * step body runs, which would orphan the state file on every resume
224
+ * (see `active-step.ts`).
225
+ * 3. `randomUUID()` outside step mode (local tests, dev).
135
226
  */
227
+ export function resolveAgentId(explicitId?: string): string {
228
+ if (explicitId) return explicitId;
229
+ const activeCall = nextAgentCallInActiveStep();
230
+ if (activeCall === null) return randomUUID();
231
+ return `step${activeCall.stepIndex}-agent-${activeCall.callIndex}`;
232
+ }
233
+
136
234
  export async function agent<T = unknown>(opts: AgentOpts<T>): Promise<AgentLoopResult<T>> {
137
235
  const workingDir = opts.workingDir ?? "";
138
236
 
139
- return agentLoop({
237
+ // Stable agentId for this invocation. Used by the inbox URL (server
238
+ // scopes pending messages by agentId), the agentLoop (which would
239
+ // otherwise generate its own), AND the pause state-dir keys
240
+ // (`agent-<id>.json` / `runtime-<id>.json`). The pause-resume path
241
+ // re-enters the step from the top in a new subprocess, so the id MUST
242
+ // be deterministic across invocations — see `resolveAgentId`, which
243
+ // derives it from the active-step context rather than scrubbed env.
244
+ const agentId = resolveAgentId(opts.agentId);
245
+
246
+ // Wire up the runner-side inbox if the surrounding sandbox carries
247
+ // the run-callback token. Absent token = no inbox (local tests, non-
248
+ // sandbox callers); agent loop runs in legacy single-prompt mode.
249
+ const callbackUrl = process.env.AGENT_COMPOSE_URL;
250
+ const callbackToken = process.env.AGENT_COMPOSE_RUN_TOKEN;
251
+ const runId = process.env.RUN_ID;
252
+ const inbox = (callbackUrl && callbackToken && runId)
253
+ ? new AsyncQueue<{ text: string; senderName?: string | null }>()
254
+ : undefined;
255
+ const inboxAbort = new AbortController();
256
+ if (inbox && callbackUrl && callbackToken && runId) {
257
+ void runInboxPoller({
258
+ baseUrl: callbackUrl, token: callbackToken, runId, agentId, inbox,
259
+ signal: inboxAbort.signal,
260
+ });
261
+ }
262
+ // Control poller (PR 7): long-polls the per-run control channel so a human
263
+ // steer-pause request reaches this agent and sets its take-once flag. Every
264
+ // agent polls — a human can steer ANY running agent regardless of `mode`
265
+ // (`mode` only governs whether the agent may pause itself). Shares the
266
+ // agent's abort signal — the finally below stops it with the inbox.
267
+ if (callbackUrl && callbackToken && runId) {
268
+ void runControlPoller({
269
+ baseUrl: callbackUrl, token: callbackToken, runId, agentId,
270
+ signal: inboxAbort.signal,
271
+ });
272
+ }
273
+
274
+ // PR 7: the boundary's pause callable. Built from the ambient run/step
275
+ // context (RUN_ID + active step) so the loop never reads process.env or the
276
+ // active-step ALS itself — it stays unit-testable with a fake `pause`. The
277
+ // loop supplies the per-iteration agentScope so the pauseId is stable across
278
+ // resume. Absent outside step mode (local/non-workflow callers) ⇒ no steer.
279
+ const activeStepForPause = getActiveStep();
280
+ const steerPause = activeStepForPause && runId
281
+ ? function steerPauseFn<T>(
282
+ req: { reason: string; correlationKey?: string; schema?: z.ZodType<T> },
283
+ agentScope: { agentId: string; iteration: number },
284
+ ): Promise<T> {
285
+ return corePause<T>(req, {
286
+ runId,
287
+ stepIndex: activeStepForPause.stepIndex,
288
+ agentScope,
289
+ });
290
+ }
291
+ : undefined;
292
+
293
+ // `auto` (default) runs autonomously; `hitl` may pause itself to ask a human
294
+ // (the boundary self-pause trigger + the prompt instruction above). Human
295
+ // steering works regardless — see the ungated control poller.
296
+ const mode = opts.mode ?? "auto";
297
+
298
+ try {
299
+ return await agentLoop({
140
300
  runtime: (runtimeOpts: RuntimeOptions) => opts.runtime.create(opts.sandbox, runtimeOpts),
141
- ...(opts.agentId !== undefined ? { agentId: opts.agentId } : {}),
301
+ agentId,
142
302
  ...(opts.label !== undefined ? { label: opts.label } : {}),
143
303
  ...(opts.budget?.turnsPerIteration !== undefined ? { turnsPerIteration: opts.budget.turnsPerIteration } : {}),
144
304
  ...(opts.budget?.maxIterations !== undefined ? { maxIterations: opts.budget.maxIterations } : {}),
@@ -150,9 +310,12 @@ export async function agent<T = unknown>(opts: AgentOpts<T>): Promise<AgentLoopR
150
310
  // protocol suffix is needed so tool-use + status block grammar stays fresh.
151
311
  if (iteration > 0) return PROTOCOL_SUFFIX;
152
312
  const base = stripFrontmatter(opts.prompt);
153
- return opts.responseSchema
154
- ? `${base}\n\n${PROTOCOL_SUFFIX}\n\n${buildResponseFormatAppendix(opts.responseSchema)}`
155
- : `${base}\n\n${PROTOCOL_SUFFIX}`;
313
+ const parts = [base, PROTOCOL_SUFFIX];
314
+ // hitl agents learn the self-pause protocol on their first turn; the model
315
+ // carries it across the session, so it isn't re-sent every iteration.
316
+ if (mode === "hitl") parts.push(HITL_ASK_INSTRUCTION);
317
+ if (opts.responseSchema) parts.push(buildResponseFormatAppendix(opts.responseSchema));
318
+ return parts.join("\n\n");
156
319
  },
157
320
  ...(opts.events ? { onAgentLifecycleEvent: (event: AgentLifecycleEvent) => { void opts.events?.emit(event); } } : {}),
158
321
  ...(opts.onAgentEvent ? { onAgentEvent: opts.onAgentEvent } : {}),
@@ -162,5 +325,18 @@ export async function agent<T = unknown>(opts: AgentOpts<T>): Promise<AgentLoopR
162
325
  teamId: "", runId: "", workflowId: "",
163
326
  factoryId: null, apiKeyScopes: [], parentRunId: null,
164
327
  }),
328
+ ...(inbox ? { inbox } : {}),
329
+ // PR 7 steer-pause wiring.
330
+ mode,
331
+ consumeSteerPending: () => consumeSteerPending(agentId),
332
+ ...(steerPause ? { pause: steerPause } : {}),
165
333
  });
334
+ } finally {
335
+ // Tear down the poller exactly once the agent loop returns
336
+ // (success, throw, or abort). Closing the queue first prevents
337
+ // the runtime from waiting on more inbox items; aborting stops
338
+ // the long-poll's pending fetch.
339
+ inbox?.close();
340
+ inboxAbort.abort();
341
+ }
166
342
  }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Steer-control (PR 7) — the in-sandbox half of "a human pauses a running
3
+ * agent." A `runControlPoller` long-polls the per-run control channel; when a
4
+ * `steer_pause` for this agent arrives it sets a per-agentId **take-once**
5
+ * flag. The agent loop consumes the flag at its next iteration boundary
6
+ * (`consumeSteerPending`) and takes a `ctx.pause` there (commit 6).
7
+ *
8
+ * Lives in its own module so both `run-agent.ts` (the poller, the signal) and
9
+ * `agent-loop.ts` (the consume) import it without a cycle.
10
+ *
11
+ * The flag is **transient** and best-effort: a steer NOTIFY that arrives while
12
+ * the poller is between long-poll requests is missed (no durable backing — the
13
+ * route writes no row). The human re-requests; the loop only commits a durable
14
+ * `run_pauses` row once it reaches a boundary and takes the pause.
15
+ */
16
+
17
+ import { z } from "zod";
18
+
19
+ /** What a steer/resume answer carries. Used as the boundary pause's `schema`
20
+ * so it is validated client-side in the re-spawned runner — a null/empty
21
+ * message is rejected (PauseSchemaError) so an agent never resumes on an
22
+ * empty steer. `message` becomes the agent's next user turn. */
23
+ export const SteerDecisionSchema = z.object({
24
+ message: z.string().min(1),
25
+ actor: z.string().nullish(),
26
+ });
27
+ export type SteerDecision = z.infer<typeof SteerDecisionSchema>;
28
+
29
+ /** Metadata a pending steer carries from the control message to the boundary
30
+ * that turns it into a durable pause row. The flag store used to be a bare
31
+ * `Set<string>` carrying none of this, so the committed `run_pauses` row
32
+ * always had `correlation_key = null` and `reason = "human steer"` —
33
+ * `resumePauseByKey` could never match and the operator's reason was lost. */
34
+ export interface SteerPayload {
35
+ reason: string | null;
36
+ correlationKey: string | null;
37
+ }
38
+
39
+ // Per-agentId FIFO queue of pending steers. Keyed by agentId (concurrent
40
+ // `agent()` calls in one step body each have a distinct id), not a single
41
+ // process-global flag — so one agent's steer can't be consumed by another.
42
+ // A queue (not a single slot) so two distinct keyed steers each produce their
43
+ // own correlatable pause instead of the second being silently dropped. Steers
44
+ // that share a correlationKey (including the common null-key "just pause" case)
45
+ // coalesce to the latest, so a double-click never piles up redundant pauses.
46
+ const steerPending = new Map<string, SteerPayload[]>();
47
+
48
+ /** Enqueue a pending steer-pause for `agentId` (called by the poller). A steer
49
+ * whose correlationKey matches one already queued replaces it (idempotent);
50
+ * a distinct key is appended. */
51
+ export function signalSteerPending(agentId: string, payload: SteerPayload): void {
52
+ const queue = steerPending.get(agentId) ?? [];
53
+ const existing = queue.findIndex((p) => p.correlationKey === payload.correlationKey);
54
+ if (existing >= 0) queue[existing] = payload;
55
+ else queue.push(payload);
56
+ steerPending.set(agentId, queue);
57
+ }
58
+
59
+ /** Take-once read: dequeues and returns the next pending steer's payload for
60
+ * `agentId` (FIFO), else null. The boundary calls this exactly once per check
61
+ * so a single steer can't re-fire on the post-resume continuation; a second
62
+ * queued steer surfaces at the next boundary. */
63
+ export function consumeSteerPending(agentId: string): SteerPayload | null {
64
+ const queue = steerPending.get(agentId);
65
+ if (queue === undefined || queue.length === 0) return null;
66
+ const next = queue.shift()!;
67
+ if (queue.length === 0) steerPending.delete(agentId);
68
+ return next;
69
+ }
70
+
71
+ interface ControlMessage {
72
+ kind?: string;
73
+ agentId?: string | null;
74
+ reason?: string | null;
75
+ correlationKey?: string | null;
76
+ }
77
+
78
+ /**
79
+ * Long-poll the per-run control channel and set the steer flag for this agent.
80
+ * Mirrors `runInboxPoller`: same per-run HMAC token, same backoff, stops on
81
+ * 401/403 (token expired / wrong run). The server holds each request open
82
+ * until a control message lands or the long-poll window elapses, so this
83
+ * loops mostly blocked in `fetch`.
84
+ */
85
+ export async function runControlPoller(opts: {
86
+ baseUrl: string;
87
+ token: string;
88
+ runId: string;
89
+ agentId: string;
90
+ signal: AbortSignal;
91
+ }): Promise<void> {
92
+ const headers = { authorization: `Bearer ${opts.token}`, accept: "application/json" } as const;
93
+ const url = `${opts.baseUrl.replace(/\/+$/, "")}/api/v1/internal/runs/${opts.runId}/control`;
94
+ let backoffMs = 0;
95
+ while (!opts.signal.aborted) {
96
+ if (backoffMs > 0) {
97
+ await new Promise((resolve) => setTimeout(resolve, backoffMs));
98
+ if (opts.signal.aborted) return;
99
+ }
100
+ try {
101
+ const res = await fetch(url, { method: "GET", headers, signal: opts.signal });
102
+ if (!res.ok) {
103
+ if (res.status === 401 || res.status === 403) return; // token expired / wrong run
104
+ backoffMs = Math.min(8_000, (backoffMs || 500) * 2);
105
+ continue;
106
+ }
107
+ backoffMs = 0;
108
+ const body = await res.json() as { control?: ControlMessage | null };
109
+ const ctrl = body.control;
110
+ // agentId === null ⇒ steer all agents on the run; else this agent only.
111
+ if (ctrl && ctrl.kind === "steer_pause" && (ctrl.agentId == null || ctrl.agentId === opts.agentId)) {
112
+ signalSteerPending(opts.agentId, {
113
+ reason: ctrl.reason ?? null,
114
+ correlationKey: ctrl.correlationKey ?? null,
115
+ });
116
+ }
117
+ } catch (err) {
118
+ if (opts.signal.aborted) return;
119
+ backoffMs = Math.min(8_000, (backoffMs || 500) * 2);
120
+ if (backoffMs >= 4_000) {
121
+ process.stderr.write(`[control-poller] poll failed, backing off ${backoffMs}ms: ${err instanceof Error ? err.message : String(err)}\n`);
122
+ }
123
+ }
124
+ }
125
+ }
package/src/client.ts CHANGED
@@ -236,6 +236,105 @@ export interface RunStatus<TOutput = unknown> {
236
236
  output?: TOutput;
237
237
  }
238
238
 
239
+ /** ADR-0006 step 10 — actor record returned on a successful resume.
240
+ * Shape mirrors the server's `PauseResumeActor` type after the row
241
+ * has been stamped. Audit FKs (`userId` / `keyId` / `runId`) may be
242
+ * null when the referenced row was deleted between resume and the
243
+ * response render — the immutable `label` survives. */
244
+ export interface ResumePauseActor {
245
+ kind: "session_user" | "api_key" | "agent";
246
+ /** Better Auth user id when kind='session_user'. */
247
+ userId?: string | null;
248
+ /** api_keys.id when kind='api_key'. */
249
+ keyId?: string | null;
250
+ /** Caller-run id when kind='agent' (NOT the run being resumed). */
251
+ runId?: string | null;
252
+ /** Agent-instance within `runId` when kind='agent'. */
253
+ agentId?: string | null;
254
+ label: string;
255
+ }
256
+
257
+ /** Success branch of the resume HTTP response. The pause has reached
258
+ * a terminal state — `resolved` (workflow signal arrived), `expired`
259
+ * (TTL fired first), or `cancelled` (workflow terminated mid-pause).
260
+ * Only `resolved` carries the resume payload the user supplied. */
261
+ export interface ResumePauseSuccess {
262
+ status: "resolved" | "expired" | "cancelled";
263
+ pauseId: string;
264
+ resolvedAt: string | null;
265
+ resumePayload: unknown;
266
+ actor: ResumePauseActor | null;
267
+ }
268
+
269
+ /** Pending branch — the workflow accepted the signal but the row
270
+ * flip didn't observe within the route's 5s wait window. The
271
+ * operation is in-flight; retry with the same `Idempotency-Key`
272
+ * and the cache collapses the duplicate to a single canonical
273
+ * response. */
274
+ export interface ResumePausePending {
275
+ status: "pending";
276
+ pauseId: string;
277
+ timedOut: true;
278
+ }
279
+
280
+ export type ResumePauseResponse = ResumePauseSuccess | ResumePausePending;
281
+
282
+ export interface ResumePauseOptions {
283
+ /** Stripe-style retry-dedup key — same syntax as the workflow-invoke
284
+ * route: 1..255 chars of `[A-Za-z0-9_\-:.]`, no embedded CR/LF.
285
+ * Sent as the `Idempotency-Key` request header. (The server reads the
286
+ * header first, falling back to a body field for callers behind a
287
+ * header-stripping proxy; this client only sends the header.) */
288
+ idempotencyKey?: string;
289
+ /** Abort the HTTP request mid-wait (e.g. from a UI cancel button).
290
+ * The server's LISTEN tears down via the request's AbortSignal. */
291
+ signal?: AbortSignal;
292
+ }
293
+
294
+ export interface RequestAgentPauseOptions {
295
+ /** Human-readable note surfaced to the agent as the pause reason. */
296
+ reason?: string;
297
+ /** Your handle for answering this pause without the minted pauseId:
298
+ * pass the same value to `resumePauseByKey`. */
299
+ correlationKey?: string;
300
+ /** Abort the HTTP request. */
301
+ signal?: AbortSignal;
302
+ }
303
+
304
+ export interface AnswerSteerOptions extends ResumePauseOptions {
305
+ /** Who answered — surfaced to the agent as `[human steer from <actor>]`. */
306
+ actor?: string;
307
+ }
308
+
309
+ /** 202 envelope from a steer-pause request. The request is best-effort and
310
+ * fire-and-forget: it publishes a transient control message and returns
311
+ * immediately — the agent parks at its next iteration boundary (if it is
312
+ * `mode: "hitl"` and still running). Answer it via `answerSteerByKey`
313
+ * (pass the `correlationKey` you set here) or `answerSteer` (by pauseId). */
314
+ export interface RequestAgentPauseResponse {
315
+ status: "steer_requested";
316
+ runId: string;
317
+ agentId: string;
318
+ }
319
+
320
+ export interface SendAgentMessageOptions {
321
+ /** Transcript attribution. For non-session callers (API key, orchestrator)
322
+ * this names the sender; defaults to the caller's identity server-side. */
323
+ senderName?: string;
324
+ /** Abort the HTTP request. */
325
+ signal?: AbortSignal;
326
+ }
327
+
328
+ /** 202 envelope from a message-to-running-agent request. The message is queued
329
+ * durably and delivered mid-stream as the agent's next user turn — the workflow
330
+ * is NOT paused. `seq` is the message's per-run ordinal. */
331
+ export interface SendAgentMessageResponse {
332
+ status: "message_enqueued";
333
+ runId: string;
334
+ agentId: string;
335
+ seq: number;
336
+ }
337
+
239
338
  export interface RunDetail<TOutput = unknown> {
240
339
  runId: string;
241
340
  title: string;
@@ -429,6 +528,17 @@ export interface AgentComposeClientOptions {
429
528
 
430
529
  const PUBLIC_API_HOST = "https://api.agentcompose.ai";
431
530
 
531
+ /** Build the `SteerDecisionSchema` resume payload from a steer answer. Rejects
532
+ * an empty message client-side so the caller gets a synchronous error rather
533
+ * than a delayed, fatal run failure when the in-sandbox schema check rejects
534
+ * it. The `{ message }` shape is exactly what the steer boundary validates. */
535
+ function steerAnswerPayload(message: string, actor?: string): { message: string; actor?: string } {
536
+ if (typeof message !== "string" || message.trim().length === 0) {
537
+ throw new Error("answerSteer: `message` must be a non-empty string");
538
+ }
539
+ return { message, ...(actor !== undefined ? { actor } : {}) };
540
+ }
541
+
432
542
  export class AgentComposeClient {
433
543
  private readonly fetch: typeof ofetch;
434
544
  private readonly baseUrl: string;
@@ -583,6 +693,173 @@ export class AgentComposeClient {
583
693
  return this.fetch(`/api/v1/workflows/${runId}/status`);
584
694
  }
585
695
 
696
+ // ── Pause-resume (ADR-0006 step 10) ───────────────────────────────────
697
+ //
698
+ // Resume a paused run via its pauseId OR its correlationKey. The
699
+ // server emits the workflow signal, holds the response for up to
700
+ // ~5s waiting on the row to flip terminal, then returns the
701
+ // resolved/expired/cancelled state (200) — or a pending+timedOut
702
+ // envelope (202) if the wait fires first. Either response shape
703
+ // carries the same `pauseId`.
704
+
705
+ /** Resume a paused run by pause id.
706
+ *
707
+ * Returns the terminal state on success (200) or a pending
708
+ * envelope (202) if the server's 5s wait timed out. For the
709
+ * pending case, retry with the same `idempotencyKey` — the
710
+ * server-side cache collapses duplicates to a single canonical
711
+ * response once the workflow's row-flip lands.
712
+ *
713
+ * Throws `AgentComposeError` on 4xx/5xx — 404 when the run or
714
+ * pause doesn't exist, 403 when the caller lacks scope or the
715
+ * run-token cross-run/cross-agent gate fails, 409 when the
716
+ * `Idempotency-Key` was reused against a different operation.
717
+ */
718
+ async resumePause(
719
+ runId: string,
720
+ pauseId: string,
721
+ payload: unknown,
722
+ opts?: ResumePauseOptions,
723
+ ): Promise<ResumePauseResponse> {
724
+ const headers: Record<string, string> = {};
725
+ if (opts?.idempotencyKey) headers["Idempotency-Key"] = opts.idempotencyKey;
726
+ return this.fetch<ResumePauseResponse>(
727
+ `/api/v1/runs/${encodeURIComponent(runId)}/pauses/${encodeURIComponent(pauseId)}/resume`,
728
+ {
729
+ method: "POST",
730
+ body: { resumePayload: payload },
731
+ ...(Object.keys(headers).length ? { headers } : {}),
732
+ ...(opts?.signal ? { signal: opts.signal } : {}),
733
+ },
734
+ );
735
+ }
736
+
737
+ /** Resume a paused run by correlation key. The correlation key is
738
+ * the second resume key set on `ctx.pause({ correlationKey })`;
739
+ * the server resolves it to a unique pending pause within the
740
+ * run, then delegates to the same handler as `resumePause`.
741
+ *
742
+ * The key is `encodeURIComponent`'d at the call site, so callers
743
+ * pass the literal value (containing `/`, `:`, etc. as written).
744
+ */
745
+ async resumePauseByKey(
746
+ runId: string,
747
+ correlationKey: string,
748
+ payload: unknown,
749
+ opts?: ResumePauseOptions,
750
+ ): Promise<ResumePauseResponse> {
751
+ const headers: Record<string, string> = {};
752
+ if (opts?.idempotencyKey) headers["Idempotency-Key"] = opts.idempotencyKey;
753
+ return this.fetch<ResumePauseResponse>(
754
+ `/api/v1/runs/${encodeURIComponent(runId)}/pauses/by-key/${encodeURIComponent(correlationKey)}/resume`,
755
+ {
756
+ method: "POST",
757
+ body: { resumePayload: payload },
758
+ ...(Object.keys(headers).length ? { headers } : {}),
759
+ ...(opts?.signal ? { signal: opts.signal } : {}),
760
+ },
761
+ );
762
+ }
763
+
764
+ /** Answer a steer-pause by its `correlationKey` (PR 7) — the typed,
765
+ * footgun-free way to resume a `requestAgentPause`.
766
+ *
767
+ * The steer boundary validates its resume payload against
768
+ * `SteerDecisionSchema` (`{ message }`) inside the re-spawned runner; a bare
769
+ * string or wrong-shaped payload passed to `resumePause`/`resumePauseByKey`
770
+ * fails that check and terminates the run. This wraps `message` in the
771
+ * expected shape so the answer is always well-formed. `message` becomes the
772
+ * agent's next user turn. Pass the `correlationKey` you gave
773
+ * `requestAgentPause`.
774
+ *
775
+ * Throws `AgentComposeError` on 4xx/5xx — 404 when no pending steer matches
776
+ * the key. Throws synchronously if `message` is empty. */
777
+ async answerSteerByKey(
778
+ runId: string,
779
+ correlationKey: string,
780
+ message: string,
781
+ opts?: AnswerSteerOptions,
782
+ ): Promise<ResumePauseResponse> {
783
+ return this.resumePauseByKey(runId, correlationKey, steerAnswerPayload(message, opts?.actor), opts);
784
+ }
785
+
786
+ /** Answer a steer-pause by its minted `pauseId`. Use this when you learned
787
+ * the pauseId out of band (e.g. from the `pause_requested` lifecycle event);
788
+ * otherwise prefer {@link answerSteerByKey}. Same `{ message }` wrapping and
789
+ * validation as `answerSteerByKey`. */
790
+ async answerSteer(
791
+ runId: string,
792
+ pauseId: string,
793
+ message: string,
794
+ opts?: AnswerSteerOptions,
795
+ ): Promise<ResumePauseResponse> {
796
+ return this.resumePause(runId, pauseId, steerAnswerPayload(message, opts?.actor), opts);
797
+ }
798
+
799
+ /** Request that a running agent pause for human steering (PR 7).
800
+ *
801
+ * Three callers share this route — an operator (session/api-key), another
802
+ * agent or external orchestrator (run-callback token), and this method.
803
+ * It is fire-and-forget: the server publishes a transient control message
804
+ * and returns 202 immediately. The agent takes the pause at its next
805
+ * iteration boundary — but only if it was started `mode: "hitl"` and is
806
+ * still running; an `auto` agent ignores it. Delivery is best-effort (a
807
+ * steer arriving between the in-sandbox poller's long-polls is missed —
808
+ * re-request). Once the agent parks, answer it with `answerSteerByKey` (by
809
+ * the `correlationKey` you pass here) or `answerSteer` (by the minted
810
+ * pauseId) — these wrap the answer in the shape the steer boundary
811
+ * validates. The human's `message` becomes the agent's next user turn.
812
+ *
813
+ * Throws `AgentComposeError` on 4xx/5xx — 404 when the run doesn't exist,
814
+ * 403 when the caller lacks scope or the run-token gate fails.
815
+ */
816
+ async requestAgentPause(
817
+ runId: string,
818
+ agentId: string,
819
+ opts?: RequestAgentPauseOptions,
820
+ ): Promise<RequestAgentPauseResponse> {
821
+ const body: { reason?: string; correlationKey?: string } = {};
822
+ if (opts?.reason !== undefined) body.reason = opts.reason;
823
+ if (opts?.correlationKey !== undefined) body.correlationKey = opts.correlationKey;
824
+ return this.fetch<RequestAgentPauseResponse>(
825
+ `/api/v1/runs/${encodeURIComponent(runId)}/agents/${encodeURIComponent(agentId)}/pause`,
826
+ {
827
+ method: "POST",
828
+ body,
829
+ ...(opts?.signal ? { signal: opts.signal } : {}),
830
+ },
831
+ );
832
+ }
833
+
834
+ /** Send a message to a RUNNING agent — the lightweight steer (PR 7).
835
+ *
836
+ * Durable and **non-halting**: the message is queued and delivered to the
837
+ * agent mid-stream as its next user turn, *without* pausing the workflow.
838
+ * This is the efficient way to nudge or redirect an agent that should keep
839
+ * running — reserve `requestAgentPause` for when it must stop and wait. The
840
+ * message lands at the agent's next iteration boundary (it finishes the
841
+ * current model turn first). `senderName` sets the transcript attribution.
842
+ *
843
+ * Throws `AgentComposeError` on 4xx/5xx — 404 unknown run, 403 scope/gate.
844
+ */
845
+ async sendAgentMessage(
846
+ runId: string,
847
+ agentId: string,
848
+ message: string,
849
+ opts?: SendAgentMessageOptions,
850
+ ): Promise<SendAgentMessageResponse> {
851
+ const body: { message: string; senderName?: string } = { message };
852
+ if (opts?.senderName !== undefined) body.senderName = opts.senderName;
853
+ return this.fetch<SendAgentMessageResponse>(
854
+ `/api/v1/runs/${encodeURIComponent(runId)}/agents/${encodeURIComponent(agentId)}/messages`,
855
+ {
856
+ method: "POST",
857
+ body,
858
+ ...(opts?.signal ? { signal: opts.signal } : {}),
859
+ },
860
+ );
861
+ }
862
+
586
863
  /** Full run detail, including input/output and lifecycle events. */
587
864
  getRun<TOutput = unknown>(runId: string): Promise<RunDetail<TOutput>> {
588
865
  return this.fetch(`/api/v1/workflows/${encodeURIComponent(runId)}`);