@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.
- package/dist/active-step.d.ts +60 -0
- package/dist/agent/agent-loop-steer.test.d.ts +1 -0
- package/dist/agent/agent-loop.d.ts +46 -0
- package/dist/agent/async-queue.d.ts +29 -0
- package/dist/agent/protocol.d.ts +9 -1
- package/dist/agent/resolve-agent-id.test.d.ts +1 -0
- package/dist/agent/run-agent.d.ts +16 -3
- package/dist/agent/steer-control.d.ts +57 -0
- package/dist/agent/steer-control.test.d.ts +1 -0
- package/dist/client.d.ts +161 -0
- package/dist/index.d.ts +16 -6
- package/dist/index.js +1586 -158
- package/dist/pause/__tests__/agent-loop-checkpoint.test.d.ts +1 -0
- package/dist/pause/__tests__/checkpoint.test.d.ts +1 -0
- package/dist/pause/__tests__/errors.test.d.ts +1 -0
- package/dist/pause/__tests__/manager.test.d.ts +1 -0
- package/dist/pause/__tests__/pause-core.test.d.ts +1 -0
- package/dist/pause/__tests__/state-dir.test.d.ts +1 -0
- package/dist/pause/__tests__/wrappers.test.d.ts +1 -0
- package/dist/pause/checkpoint.d.ts +28 -0
- package/dist/pause/errors.d.ts +52 -0
- package/dist/pause/manager.d.ts +63 -0
- package/dist/pause/pause-core.d.ts +101 -0
- package/dist/pause/state-dir.d.ts +80 -0
- package/dist/pause/wrappers.d.ts +41 -0
- package/dist/request-context/request-context.d.ts +12 -0
- package/dist/runtimes/_cli-agent.d.ts +72 -0
- package/dist/runtimes/amp.d.ts +22 -0
- package/dist/runtimes/claude.d.ts +6 -0
- package/dist/runtimes/codex.d.ts +20 -0
- package/dist/runtimes/openai-desktop.d.ts +2 -0
- package/dist/runtimes/openai-desktop.js +1578 -157
- package/dist/runtimes/vercel.d.ts +53 -1
- package/dist/runtimes/vercel.js +60 -8
- package/dist/runtimes/vercel.test.d.ts +1 -0
- package/dist/sse.d.ts +2 -3
- package/dist/step-invocation/index.d.ts +2 -2
- package/dist/step-invocation/invoker.d.ts +3 -0
- package/dist/step-invocation/protocol.d.ts +12 -0
- package/dist/step-invocation/server.d.ts +1 -0
- package/dist/step-invocation/types.d.ts +40 -5
- package/dist/types/events.d.ts +9 -0
- package/dist/types/execution-context.d.ts +25 -0
- package/dist/types/protocol.d.ts +8 -0
- package/dist/types/runtime.d.ts +55 -0
- package/dist/types/sandbox.d.ts +4 -4
- package/dist/utils/schemas.d.ts +2 -0
- package/dist/workflow-steps/__tests__/pause-wiring.test.d.ts +1 -0
- package/dist/workflow-steps/index.d.ts +2 -0
- package/dist/workflow-steps/observability.d.ts +43 -11
- package/dist/workflow-steps/run-callback.d.ts +39 -0
- package/dist/workflow-steps/runner.d.ts +8 -0
- package/package.json +1 -1
- package/src/active-step.ts +124 -0
- package/src/agent/agent-loop.ts +253 -19
- package/src/agent/async-queue.ts +61 -0
- package/src/agent/protocol.ts +12 -2
- package/src/agent/run-agent.ts +184 -8
- package/src/agent/steer-control.ts +125 -0
- package/src/client.ts +277 -0
- package/src/index.ts +38 -4
- package/src/pause/checkpoint.ts +44 -0
- package/src/pause/errors.ts +70 -0
- package/src/pause/manager.ts +177 -0
- package/src/pause/pause-core.ts +267 -0
- package/src/pause/state-dir.ts +262 -0
- package/src/pause/wrappers.ts +79 -0
- package/src/request-context/request-context.ts +17 -2
- package/src/runtimes/_cli-agent.ts +161 -0
- package/src/runtimes/amp.ts +94 -0
- package/src/runtimes/claude.ts +101 -6
- package/src/runtimes/codex.ts +109 -0
- package/src/runtimes/openai-desktop.ts +11 -0
- package/src/runtimes/vercel.ts +78 -2
- package/src/sandbox.ts +39 -20
- package/src/sse.ts +8 -6
- package/src/step-invocation/index.ts +2 -1
- package/src/step-invocation/invoker.ts +107 -29
- package/src/step-invocation/protocol.ts +16 -0
- package/src/step-invocation/server.ts +45 -12
- package/src/step-invocation/types.ts +43 -7
- package/src/tools/coding.ts +16 -5
- package/src/types/events.ts +9 -0
- package/src/types/execution-context.ts +25 -0
- package/src/types/protocol.ts +8 -0
- package/src/types/runtime.ts +52 -0
- package/src/types/sandbox.ts +8 -4
- package/src/types/workflow.ts +6 -1
- package/src/utils/bundler.ts +8 -3
- package/src/utils/schemas.ts +2 -0
- package/src/workflow-steps/index.ts +3 -0
- package/src/workflow-steps/observability.ts +84 -13
- package/src/workflow-steps/run-callback.ts +72 -0
- package/src/workflow-steps/runner.ts +70 -8
- package/dist/utils/discovery.d.ts +0 -2
- package/src/utils/discovery.ts +0 -4
package/src/agent/run-agent.ts
CHANGED
|
@@ -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
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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)}`);
|