@agent-compose/sdk 0.5.1 → 0.5.2
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 +7 -4
- package/dist/index.js +1339 -157
- 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/claude.d.ts +6 -0
- package/dist/runtimes/openai-desktop.d.ts +2 -0
- package/dist/runtimes/openai-desktop.js +1336 -156
- package/dist/runtimes/vercel.d.ts +12 -0
- package/dist/runtimes/vercel.js +50 -7
- 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 +18 -2
- 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/claude.ts +101 -6
- package/src/runtimes/openai-desktop.ts +11 -0
- package/src/runtimes/vercel.ts +26 -0
- 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/index.ts
CHANGED
|
@@ -117,6 +117,9 @@ export type {
|
|
|
117
117
|
ApiKey, ApiKeyCreated,
|
|
118
118
|
UsageRollupRow, UsageResponse,
|
|
119
119
|
CancelRunResponse,
|
|
120
|
+
RequestAgentPauseOptions, RequestAgentPauseResponse,
|
|
121
|
+
SendAgentMessageOptions, SendAgentMessageResponse,
|
|
122
|
+
AnswerSteerOptions,
|
|
120
123
|
} from "./client.js";
|
|
121
124
|
|
|
122
125
|
// SSE parser — exposed so tests and downstream callers can reuse it.
|
|
@@ -126,8 +129,7 @@ export { parseSseStream } from "./sse.js";
|
|
|
126
129
|
export { AgentComposeError } from "./errors.js";
|
|
127
130
|
export { formatError } from "./utils/errors.js";
|
|
128
131
|
|
|
129
|
-
//
|
|
130
|
-
export { discoverRuntimeName } from "./utils/discovery.js";
|
|
132
|
+
// Bundling utilities
|
|
131
133
|
export {
|
|
132
134
|
bundleWorkflow,
|
|
133
135
|
BUNDLER_VERSION,
|
|
@@ -209,16 +211,30 @@ export {
|
|
|
209
211
|
buildStepEnvs,
|
|
210
212
|
StepExecutionError,
|
|
211
213
|
STEP_RESULT_PREFIX,
|
|
214
|
+
STEP_PAUSE_PREFIX,
|
|
212
215
|
STEP_ENV,
|
|
213
216
|
RUNNER_BUNDLE_PATH,
|
|
214
217
|
RUNNER_COMMAND,
|
|
215
218
|
stepInputPath,
|
|
216
219
|
requestContextPath,
|
|
217
220
|
} from "./step-invocation/index.js";
|
|
221
|
+
|
|
222
|
+
// Pause errors (ADR-0006 / ADR-0011) — thrown into user step/agent code when
|
|
223
|
+
// a pause cannot return a value, so callers can catch and classify them.
|
|
224
|
+
export {
|
|
225
|
+
PauseError,
|
|
226
|
+
PauseExpiredError,
|
|
227
|
+
PauseSchemaError,
|
|
228
|
+
PauseRequestError,
|
|
229
|
+
} from "./pause/errors.js";
|
|
230
|
+
export type { PauseErrorCode } from "./pause/errors.js";
|
|
231
|
+
export type { PauseRequest } from "./pause/pause-core.js";
|
|
232
|
+
export type { RequestDecisionRequest, WaitForEventRequest } from "./pause/wrappers.js";
|
|
218
233
|
export type {
|
|
219
234
|
StepRequest,
|
|
220
235
|
StepResult,
|
|
221
236
|
StepInvocationError,
|
|
237
|
+
StepPauseRequest,
|
|
222
238
|
StepHandler,
|
|
223
239
|
StepHandlerResult,
|
|
224
240
|
ServeStepRequest,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ctx.checkpoint(name, fn)` — disk-backed memoise across pause-resume.
|
|
3
|
+
*
|
|
4
|
+
* First call: `fn()` runs, the result is atomically written to
|
|
5
|
+
* `/tmp/wf/state/checkpoints/<name>.json`. On any subsequent invocation
|
|
6
|
+
* of the same step body (e.g. after pause-resume re-enters the
|
|
7
|
+
* subprocess), the file exists in the restored sandbox; `fn` is NOT
|
|
8
|
+
* called, the recorded value is returned.
|
|
9
|
+
*
|
|
10
|
+
* Use for: large context loads, expensive deterministic transforms,
|
|
11
|
+
* idempotent-but-slow computations that you want to skip on resume.
|
|
12
|
+
*
|
|
13
|
+
* Do NOT use for: side effects with external systems (DB writes, HTTP
|
|
14
|
+
* POSTs, sending email). Those need `ctx.invokeChild(...)` — Temporal's
|
|
15
|
+
* exactly-once guarantee covers cross-process side effects; disk-backed
|
|
16
|
+
* memoisation is in-sandbox only and would silently retry on a sandbox
|
|
17
|
+
* recreation that lost the checkpoint file.
|
|
18
|
+
*
|
|
19
|
+
* See ADR-0006 §"`ctx.checkpoint(name, fn)` — disk-backed memoisation".
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { assertSafeCheckpointName, readCheckpoint, writeCheckpoint } from "./state-dir.js";
|
|
23
|
+
|
|
24
|
+
/** Read-or-run-and-write helper. `name` is validated by the state-dir
|
|
25
|
+
* layer (rejects path-escape attempts); `fn` is awaited if it returns
|
|
26
|
+
* a Promise so sync and async callers compose identically. */
|
|
27
|
+
export async function checkpoint<T>(name: string, fn: () => Promise<T> | T): Promise<T> {
|
|
28
|
+
const existing = await readCheckpoint<T>(name);
|
|
29
|
+
if (existing.found) return existing.value;
|
|
30
|
+
const value = await fn();
|
|
31
|
+
await writeCheckpoint(name, value);
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Bind user checkpoint names to a runner-owned namespace. The user's
|
|
36
|
+
* `name` is still validated independently so a scope prefix cannot turn
|
|
37
|
+
* an empty or path-escaping name into a valid filename. */
|
|
38
|
+
export function scopedCheckpoint(scope: string): typeof checkpoint {
|
|
39
|
+
assertSafeCheckpointName(scope);
|
|
40
|
+
return async <T>(name: string, fn: () => Promise<T> | T): Promise<T> => {
|
|
41
|
+
assertSafeCheckpointName(name);
|
|
42
|
+
return checkpoint(`${scope}.${name}`, fn);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-facing pause errors (ADR-0006 / ADR-0011).
|
|
3
|
+
*
|
|
4
|
+
* These are thrown into USER step/agent code when a pause cannot return a
|
|
5
|
+
* value — the caller can catch them. They are distinct from `PauseSignal`
|
|
6
|
+
* (the internal control-flow signal in `pause-core.ts` that unwinds the
|
|
7
|
+
* stack to `serveStep`): `PauseSignal` never reaches user code, these do.
|
|
8
|
+
*
|
|
9
|
+
* Each carries a stable `code` so the activity boundary and the dashboard
|
|
10
|
+
* can classify the failure without string-matching the message — the same
|
|
11
|
+
* pattern as `StepExecutionError` (`step-invocation/types.ts`).
|
|
12
|
+
*
|
|
13
|
+
* `target: ESNext` (sdk/tsconfig.json) makes `extends Error` + `instanceof`
|
|
14
|
+
* work without a manual prototype fix; `this.name = new.target.name` gives
|
|
15
|
+
* each subclass its own class name on the instance.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Stable, machine-readable discriminator for each pause error. */
|
|
19
|
+
export type PauseErrorCode =
|
|
20
|
+
| "pause_expired"
|
|
21
|
+
| "pause_schema"
|
|
22
|
+
| "pause_request";
|
|
23
|
+
|
|
24
|
+
/** Base for every user-facing pause error. Not thrown directly. */
|
|
25
|
+
export abstract class PauseError extends Error {
|
|
26
|
+
abstract readonly code: PauseErrorCode;
|
|
27
|
+
constructor(message: string) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = new.target.name;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The pause's TTL elapsed before it was resolved, and `onExpiry` was
|
|
35
|
+
* `{ mode: "throw" }` (the default). When `onExpiry` is
|
|
36
|
+
* `{ mode: "resolve", value }`, `ctx.pause` returns `value` instead of
|
|
37
|
+
* throwing this.
|
|
38
|
+
*/
|
|
39
|
+
export class PauseExpiredError extends PauseError {
|
|
40
|
+
readonly code = "pause_expired";
|
|
41
|
+
constructor(message = "pause expired before it was resolved") {
|
|
42
|
+
super(message);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The resume payload failed the caller's `schema`. `issues` carries the
|
|
48
|
+
* validation detail for an in-sandbox `catch`; note only `message` survives
|
|
49
|
+
* the serveStep wire boundary, so do not rely on `issues` outside the
|
|
50
|
+
* throwing process.
|
|
51
|
+
*/
|
|
52
|
+
export class PauseSchemaError extends PauseError {
|
|
53
|
+
readonly code = "pause_schema";
|
|
54
|
+
readonly issues: unknown;
|
|
55
|
+
constructor(message: string, issues?: unknown) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.issues = issues;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The pause request itself is invalid before it can be emitted to the engine:
|
|
63
|
+
* empty reason, non-positive ttl, invalid kind, or a non-JSON payload.
|
|
64
|
+
*/
|
|
65
|
+
export class PauseRequestError extends PauseError {
|
|
66
|
+
readonly code = "pause_request";
|
|
67
|
+
constructor(message = "pause request is invalid") {
|
|
68
|
+
super(message);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local pause/resume manager.
|
|
3
|
+
*
|
|
4
|
+
* This module owns the SDK-local state machine that makes a re-entered
|
|
5
|
+
* runner subprocess pick up agent work correctly. It deliberately does not
|
|
6
|
+
* know about Temporal, Postgres, HTTP resume routes, or dashboard feeds.
|
|
7
|
+
* Those layers own durable wait/audit state; this layer owns only the
|
|
8
|
+
* `/tmp/wf/state` files captured by sandbox snapshots.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import type { AgentStatus } from "../types/protocol.js";
|
|
13
|
+
import type { ModelExecutionContract } from "../types/runtime.js";
|
|
14
|
+
import { AgentStatusSchema } from "../utils/schemas.js";
|
|
15
|
+
import {
|
|
16
|
+
readAgentState,
|
|
17
|
+
readRuntimeCheckpoint,
|
|
18
|
+
writeAgentState,
|
|
19
|
+
writeRuntimeCheckpoint,
|
|
20
|
+
} from "./state-dir.js";
|
|
21
|
+
|
|
22
|
+
// v2 (PR 7) adds `pendingSteerPause`. The change is purely additive — a v1
|
|
23
|
+
// `agent-<id>.json` is a valid v2 object minus that one `.default(null)` field —
|
|
24
|
+
// so this build WRITES the current version but ACCEPTS both on read. A hard
|
|
25
|
+
// `literal(2)` would reject every v1 checkpoint as invalid and reset the agent
|
|
26
|
+
// to iteration 0: `flushState` writes this file at every iteration boundary for
|
|
27
|
+
// crash recovery (shipped in #100), independent of whether a pause ever fires,
|
|
28
|
+
// so durable v1 snapshots exist for any in-flight run across a deploy.
|
|
29
|
+
const AGENT_LOOP_STATE_SCHEMA_VERSION = 2;
|
|
30
|
+
/** Versions this build can READ. Every field added since v1 is optional or
|
|
31
|
+
* defaulted, so older checkpoints stay loadable across a rolling deploy. */
|
|
32
|
+
const SUPPORTED_AGENT_LOOP_STATE_VERSIONS = z.union([z.literal(1), z.literal(2)]);
|
|
33
|
+
|
|
34
|
+
const RunningAgentLoopStateSchema = z.object({
|
|
35
|
+
schemaVersion: SUPPORTED_AGENT_LOOP_STATE_VERSIONS,
|
|
36
|
+
phase: z.literal("running"),
|
|
37
|
+
iteration: z.number().int().nonnegative(),
|
|
38
|
+
completedIterations: z.number().int().nonnegative(),
|
|
39
|
+
lastSessionId: z.string(),
|
|
40
|
+
lastStatus: AgentStatusSchema.nullable(),
|
|
41
|
+
lastResponseText: z.string(),
|
|
42
|
+
lastResponseValidationError: z.string(),
|
|
43
|
+
iterationsWithoutStatus: z.number().int().nonnegative(),
|
|
44
|
+
blockerStreak: z.object({ key: z.string(), count: z.number().int().nonnegative() }).nullable(),
|
|
45
|
+
// PR 7: a human-requested steer-pause intent, persisted so it survives a
|
|
46
|
+
// resume and the loop re-issues the boundary pause on re-entry.
|
|
47
|
+
pendingSteerPause: z.object({ reason: z.string(), correlationKey: z.string().nullable(), at: z.number().int() }).nullable().default(null),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const SettledAgentLoopStateSchema = z.object({
|
|
51
|
+
schemaVersion: SUPPORTED_AGENT_LOOP_STATE_VERSIONS,
|
|
52
|
+
phase: z.literal("settled"),
|
|
53
|
+
result: z.object({
|
|
54
|
+
sessionId: z.string(),
|
|
55
|
+
lastStatus: AgentStatusSchema.nullable(),
|
|
56
|
+
iterations: z.number().int().nonnegative(),
|
|
57
|
+
response: z.unknown().optional(),
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const AgentLoopStateSchema = z.discriminatedUnion("phase", [
|
|
62
|
+
RunningAgentLoopStateSchema,
|
|
63
|
+
SettledAgentLoopStateSchema,
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
type RunningAgentLoopState = z.infer<typeof RunningAgentLoopStateSchema>;
|
|
67
|
+
|
|
68
|
+
export interface AgentLoopProgressState {
|
|
69
|
+
iteration: number;
|
|
70
|
+
completedIterations: number;
|
|
71
|
+
lastSessionId: string;
|
|
72
|
+
lastStatus: AgentStatus | null;
|
|
73
|
+
lastResponseText: string;
|
|
74
|
+
lastResponseValidationError: string;
|
|
75
|
+
iterationsWithoutStatus: number;
|
|
76
|
+
blockerStreak: { key: string; count: number } | null;
|
|
77
|
+
/** PR 7 steer-pause intent — see RunningAgentLoopStateSchema. Optional so the
|
|
78
|
+
* loop can omit it until it wires steer (the schema defaults it to null). */
|
|
79
|
+
pendingSteerPause?: { reason: string; correlationKey: string | null; at: number } | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface SettledAgentLoopResult<TResponse = unknown> {
|
|
83
|
+
sessionId: string;
|
|
84
|
+
lastStatus: AgentStatus | null;
|
|
85
|
+
iterations: number;
|
|
86
|
+
response?: TResponse;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type AgentLoopRestore<TResponse = unknown> =
|
|
90
|
+
| { kind: "fresh" }
|
|
91
|
+
| { kind: "invalid"; error: string }
|
|
92
|
+
| { kind: "running"; state: AgentLoopProgressState }
|
|
93
|
+
| { kind: "settled"; result: SettledAgentLoopResult<TResponse> };
|
|
94
|
+
|
|
95
|
+
function toProgressState(state: RunningAgentLoopState): AgentLoopProgressState {
|
|
96
|
+
return {
|
|
97
|
+
iteration: state.iteration,
|
|
98
|
+
completedIterations: state.completedIterations,
|
|
99
|
+
lastSessionId: state.lastSessionId,
|
|
100
|
+
lastStatus: state.lastStatus,
|
|
101
|
+
lastResponseText: state.lastResponseText,
|
|
102
|
+
lastResponseValidationError: state.lastResponseValidationError,
|
|
103
|
+
iterationsWithoutStatus: state.iterationsWithoutStatus,
|
|
104
|
+
blockerStreak: state.blockerStreak,
|
|
105
|
+
pendingSteerPause: state.pendingSteerPause,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Coordinates the agent-loop files in `/tmp/wf/state`.
|
|
111
|
+
*
|
|
112
|
+
* Raw filesystem safety lives in `state-dir.ts`; runtime blob shape lives in
|
|
113
|
+
* each runtime adapter. `PauseManager` is the seam that binds those two to
|
|
114
|
+
* the agent-loop state machine.
|
|
115
|
+
*/
|
|
116
|
+
export class PauseManager {
|
|
117
|
+
constructor(private readonly agentId: string) {}
|
|
118
|
+
|
|
119
|
+
async restoreAgentLoop<TResponse = unknown>(runtime: ModelExecutionContract): Promise<AgentLoopRestore<TResponse>> {
|
|
120
|
+
const priorState = await readAgentState<unknown>(this.agentId);
|
|
121
|
+
if (priorState === null) return { kind: "fresh" };
|
|
122
|
+
|
|
123
|
+
const parsed = AgentLoopStateSchema.safeParse(priorState);
|
|
124
|
+
if (!parsed.success) return { kind: "invalid", error: parsed.error.message };
|
|
125
|
+
|
|
126
|
+
const state = parsed.data;
|
|
127
|
+
if (state.phase === "settled") {
|
|
128
|
+
return {
|
|
129
|
+
kind: "settled",
|
|
130
|
+
result: {
|
|
131
|
+
sessionId: state.result.sessionId,
|
|
132
|
+
lastStatus: state.result.lastStatus,
|
|
133
|
+
iterations: state.result.iterations,
|
|
134
|
+
...(Object.prototype.hasOwnProperty.call(state.result, "response")
|
|
135
|
+
? { response: state.result.response as TResponse }
|
|
136
|
+
: {}),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const runtimeBlob = await readRuntimeCheckpoint<unknown>(this.agentId);
|
|
142
|
+
if (runtimeBlob !== null && !runtime.restoreCheckpoint) {
|
|
143
|
+
throw new Error(`runtime checkpoint exists for resumed agentId=${this.agentId}, but runtime cannot restore it`);
|
|
144
|
+
}
|
|
145
|
+
if (runtime.restoreCheckpoint && runtimeBlob === null) {
|
|
146
|
+
throw new Error(`missing runtime checkpoint for resumed agentId=${this.agentId}`);
|
|
147
|
+
}
|
|
148
|
+
if (runtimeBlob !== null) runtime.restoreCheckpoint?.(runtimeBlob);
|
|
149
|
+
|
|
150
|
+
return { kind: "running", state: toProgressState(state) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async saveRunningAgentLoop(state: AgentLoopProgressState, runtime: ModelExecutionContract): Promise<void> {
|
|
154
|
+
await writeAgentState(this.agentId, {
|
|
155
|
+
schemaVersion: AGENT_LOOP_STATE_SCHEMA_VERSION,
|
|
156
|
+
phase: "running",
|
|
157
|
+
...state,
|
|
158
|
+
});
|
|
159
|
+
if (runtime.captureCheckpoint) {
|
|
160
|
+
const blob = runtime.captureCheckpoint();
|
|
161
|
+
if (blob !== undefined) await writeRuntimeCheckpoint(this.agentId, blob);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async saveSettledAgentLoop<TResponse = unknown>(result: SettledAgentLoopResult<TResponse>): Promise<void> {
|
|
166
|
+
await writeAgentState(this.agentId, {
|
|
167
|
+
schemaVersion: AGENT_LOOP_STATE_SCHEMA_VERSION,
|
|
168
|
+
phase: "settled",
|
|
169
|
+
result: {
|
|
170
|
+
sessionId: result.sessionId,
|
|
171
|
+
lastStatus: result.lastStatus,
|
|
172
|
+
iterations: result.iterations,
|
|
173
|
+
...(result.response !== undefined ? { response: result.response } : {}),
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pause-core` — the engine behind `ctx.pause` (ADR-0006 / ADR-0011 PR 6).
|
|
3
|
+
*
|
|
4
|
+
* How a pause works (and why this shape):
|
|
5
|
+
*
|
|
6
|
+
* First reach (fresh): read `pauses/<pauseId>.json` → absent/unresolved →
|
|
7
|
+
* write the placeholder record and `throw new PauseSignal(...)`. The
|
|
8
|
+
* exception unwinds the step body to `serveStep`, which emits the
|
|
9
|
+
* `__AC_STEP_PAUSE__` sentinel and exits 0. The engine snapshots, inserts
|
|
10
|
+
* a `run_pauses` row keyed by `pauseId`, and waits durably.
|
|
11
|
+
*
|
|
12
|
+
* Re-entry (resolved): the engine has written the resolution onto the same
|
|
13
|
+
* file. The runner re-runs the step body from the top in a fresh
|
|
14
|
+
* subprocess; the SAME `ctx.pause` call recomputes the SAME `pauseId`,
|
|
15
|
+
* reads the file, and returns the resume payload (or applies `onExpiry`).
|
|
16
|
+
*
|
|
17
|
+
* The whole thing hinges on `pauseId` being recomputed identically across the
|
|
18
|
+
* exit/re-enter boundary, with NO surviving in-process state. So `pauseId` is
|
|
19
|
+
* a **deterministic UUIDv5** of a stable coordinate — never random. UUIDv5
|
|
20
|
+
* (not a free-form string) because `run_pauses.id` is a `uuid` PRIMARY KEY.
|
|
21
|
+
*
|
|
22
|
+
* `PauseSignal` is internal control flow, NOT a user-facing error — `serveStep`
|
|
23
|
+
* and the agent loop must let it propagate (the user-facing `Pause*Error` classes in
|
|
24
|
+
* `errors.ts` are the ones user code catches). It deliberately does not extend
|
|
25
|
+
* `Error`, so a stray `catch (e) { if (e instanceof Error) … }` cannot quietly
|
|
26
|
+
* absorb it; callers add explicit `instanceof PauseSignal` re-throw guards.
|
|
27
|
+
*
|
|
28
|
+
* Runs in the runner subprocess (may use `node:crypto`), never in the Temporal
|
|
29
|
+
* workflow VM.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
33
|
+
import type { z } from "zod";
|
|
34
|
+
import { StepPauseRequestSchema, type StepPauseRequest } from "../step-invocation/types.js";
|
|
35
|
+
import type { StepObservability } from "../workflow-steps/observability.js";
|
|
36
|
+
import { nextPauseOrdinalInActiveStep } from "../active-step.js";
|
|
37
|
+
import { readPauseRecord, writePauseRecord } from "./state-dir.js";
|
|
38
|
+
import { PauseExpiredError, PauseRequestError, PauseSchemaError } from "./errors.js";
|
|
39
|
+
|
|
40
|
+
/** Public `ctx.pause` argument shape (ADR-0006 §"SDK surface"). `schema` and
|
|
41
|
+
* `onExpiry` are SDK-local — consumed here, never sent on the wire. The wire
|
|
42
|
+
* `kind` is NOT a public field: a raw `ctx.pause` is always `kind: "custom"`;
|
|
43
|
+
* `decision`/`sleep`/`event` come only from the wrappers. */
|
|
44
|
+
export interface PauseRequest<T = unknown> {
|
|
45
|
+
reason: string;
|
|
46
|
+
payload?: Record<string, unknown>;
|
|
47
|
+
schema?: z.ZodType<T>;
|
|
48
|
+
ttlMs?: number;
|
|
49
|
+
onExpiry?: { mode: "throw" } | { mode: "resolve"; value: T };
|
|
50
|
+
correlationKey?: string;
|
|
51
|
+
snapshot?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The coordinate that makes `pauseId` deterministic + re-entry-stable. */
|
|
55
|
+
export interface PauseCoordinate {
|
|
56
|
+
runId: string;
|
|
57
|
+
stepIndex: number;
|
|
58
|
+
/** Present when the pause fires inside an agent loop (PR 7 steer-pause).
|
|
59
|
+
* Pins the id to the loop boundary (agentId + iteration) so it stays stable
|
|
60
|
+
* across the loop's iteration-skipping on resume — a bare per-step ordinal
|
|
61
|
+
* desyncs because completed iterations don't re-run, landing the ordinal at
|
|
62
|
+
* a different value. Step-body pauses leave it absent (the `:bare` form). */
|
|
63
|
+
agentScope?: { agentId: string; iteration: number };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Realm-global brand so `isPauseSignal` recognises a PauseSignal thrown by a
|
|
67
|
+
* DIFFERENT SDK instance. A bundled workflow inlines its own SDK copy (its
|
|
68
|
+
* `agent()` steer-pause throws the bundle's `PauseSignal`), but the runner
|
|
69
|
+
* catches with its own class — a plain `instanceof` is false across that
|
|
70
|
+
* boundary, so the pause was mis-classified as a step failure and no
|
|
71
|
+
* `run_pauses` row was written. `ctx.pause` avoided this only because its
|
|
72
|
+
* signal is runner-thrown. `Symbol.for` is shared across the realm, so the
|
|
73
|
+
* brand survives the instance split. */
|
|
74
|
+
const PAUSE_SIGNAL_BRAND = Symbol.for("agent-compose.PauseSignal");
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Internal control-flow signal thrown by `corePause` on a fresh pause.
|
|
78
|
+
* Caught by `serveStep` (emits the sentinel + exits) and re-thrown past every
|
|
79
|
+
* intermediate catch. NOT an `Error` and NOT user-facing.
|
|
80
|
+
*/
|
|
81
|
+
export class PauseSignal {
|
|
82
|
+
readonly [PAUSE_SIGNAL_BRAND] = true as const;
|
|
83
|
+
constructor(
|
|
84
|
+
readonly pauseId: string,
|
|
85
|
+
readonly pauseRequest: StepPauseRequest,
|
|
86
|
+
readonly observability?: StepObservability,
|
|
87
|
+
) {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Cross-instance-safe `PauseSignal` check. Use this instead of
|
|
91
|
+
* `err instanceof PauseSignal` anywhere a signal may have been thrown by a
|
|
92
|
+
* different SDK instance (the runner catching a bundled workflow's signal). */
|
|
93
|
+
export function isPauseSignal(err: unknown): err is PauseSignal {
|
|
94
|
+
return typeof err === "object" && err !== null && (err as Record<symbol, unknown>)[PAUSE_SIGNAL_BRAND] === true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Deterministic pauseId ───────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/** Fixed namespace for agent-compose pause ids (UUIDv5). Constant — must
|
|
100
|
+
* never change, or in-flight paused workflows would recompute different ids
|
|
101
|
+
* on resume and fail to find their resolved records. */
|
|
102
|
+
const PAUSE_NAMESPACE = "5f3e2b8a-0c41-4e9d-9a7b-6d2c1f8e3a40";
|
|
103
|
+
|
|
104
|
+
function uuidToBytes(uuid: string): Buffer {
|
|
105
|
+
return Buffer.from(uuid.replace(/-/g, ""), "hex");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function bytesToUuid(b: Buffer): string {
|
|
109
|
+
const h = b.toString("hex");
|
|
110
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** RFC 4122 v5 (SHA-1, name-based). Deterministic: same name → same uuid. */
|
|
114
|
+
function uuidv5(name: string, namespace: string): string {
|
|
115
|
+
const hash = createHash("sha1")
|
|
116
|
+
.update(uuidToBytes(namespace))
|
|
117
|
+
.update(Buffer.from(name, "utf8"))
|
|
118
|
+
.digest();
|
|
119
|
+
const bytes = Buffer.from(hash.subarray(0, 16));
|
|
120
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x50; // version 5
|
|
121
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
|
|
122
|
+
return bytesToUuid(bytes);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The call-site family this pause belongs to. The ordinal is counted per
|
|
126
|
+
* scope; `nextPauseId` appends `:${ordinal}`. An in-agent-loop pause keys on
|
|
127
|
+
* `agentId:iteration` (stable across iteration-skipping on resume); a
|
|
128
|
+
* step-body pause keeps the `:bare` form — byte-identical to shipped ids. */
|
|
129
|
+
function scopeOf(coord: PauseCoordinate): string {
|
|
130
|
+
return coord.agentScope
|
|
131
|
+
? `${coord.runId}:${coord.stepIndex}:${coord.agentScope.agentId}:${coord.agentScope.iteration}`
|
|
132
|
+
: `${coord.runId}:${coord.stepIndex}:bare`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Per-scope ordinal. Module-level so it resets on a fresh subprocess (the
|
|
136
|
+
// resume boundary) — which is exactly what makes the k-th pause in a scope
|
|
137
|
+
// recompute the same id across re-entry. Mirrors `nextAgentCallIndexByStep`.
|
|
138
|
+
const pauseOrdinalByScope = new Map<string, number>();
|
|
139
|
+
|
|
140
|
+
function nextPauseOrdinal(scope: string): number {
|
|
141
|
+
const activeOrdinal = nextPauseOrdinalInActiveStep(scope);
|
|
142
|
+
if (activeOrdinal !== null) return activeOrdinal;
|
|
143
|
+
const next = pauseOrdinalByScope.get(scope) ?? 0;
|
|
144
|
+
pauseOrdinalByScope.set(scope, next + 1);
|
|
145
|
+
return next;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Wipe the ordinal counters. Called by the runner at first-boot alongside
|
|
149
|
+
* `resetStateDir()`; a fresh module load already starts empty, so this is
|
|
150
|
+
* belt-and-braces for any in-process reuse. */
|
|
151
|
+
export function resetPauseOrdinals(): void {
|
|
152
|
+
pauseOrdinalByScope.clear();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Compute the deterministic pauseId for the next pause in `coord`'s scope. */
|
|
156
|
+
function nextPauseId(coord: PauseCoordinate): string {
|
|
157
|
+
const scope = scopeOf(coord);
|
|
158
|
+
const ordinal = nextPauseOrdinal(scope);
|
|
159
|
+
return uuidv5(`${scope}:${ordinal}`, PAUSE_NAMESPACE);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Wire request ─────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
function nonJsonPath(value: unknown, path = "$", seen = new Set<object>()): string | null {
|
|
165
|
+
if (value === null) return null;
|
|
166
|
+
const kind = typeof value;
|
|
167
|
+
if (kind === "string" || kind === "boolean") return null;
|
|
168
|
+
if (kind === "number") return Number.isFinite(value) ? null : path;
|
|
169
|
+
if (kind === "undefined" || kind === "bigint" || kind === "function" || kind === "symbol") return path;
|
|
170
|
+
if (kind !== "object") return path;
|
|
171
|
+
|
|
172
|
+
const obj = value as object;
|
|
173
|
+
if (seen.has(obj)) return path;
|
|
174
|
+
seen.add(obj);
|
|
175
|
+
|
|
176
|
+
if (Array.isArray(value)) {
|
|
177
|
+
for (let i = 0; i < value.length; i++) {
|
|
178
|
+
const child = nonJsonPath(value[i], `${path}[${i}]`, seen);
|
|
179
|
+
if (child) return child;
|
|
180
|
+
}
|
|
181
|
+
seen.delete(obj);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const proto = Object.getPrototypeOf(obj);
|
|
186
|
+
if (proto !== Object.prototype && proto !== null) return path;
|
|
187
|
+
for (const key of Reflect.ownKeys(obj)) {
|
|
188
|
+
if (typeof key !== "string") return `${path}.${String(key)}`;
|
|
189
|
+
const desc = Object.getOwnPropertyDescriptor(obj, key);
|
|
190
|
+
if (!desc?.enumerable) return `${path}.${key}`;
|
|
191
|
+
const child = nonJsonPath((value as Record<string, unknown>)[key], `${path}.${key}`, seen);
|
|
192
|
+
if (child) return child;
|
|
193
|
+
}
|
|
194
|
+
seen.delete(obj);
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function toWireRequest(req: PauseRequest<unknown>, kind: StepPauseRequest["kind"]): StepPauseRequest {
|
|
199
|
+
// Allowlist-pick the wire fields explicitly — never spread `req`, or the
|
|
200
|
+
// SDK-local `schema` (a non-JSON-serialisable ZodType) and `onExpiry` would
|
|
201
|
+
// ride onto the sentinel / `run_pauses.request_payload`. `kind` is supplied
|
|
202
|
+
// by the caller (the wrappers; "custom" for a raw pause), not read from req.
|
|
203
|
+
const wire = {
|
|
204
|
+
reason: req.reason,
|
|
205
|
+
kind,
|
|
206
|
+
...(req.payload !== undefined ? { payload: req.payload } : {}),
|
|
207
|
+
...(req.ttlMs !== undefined ? { ttlMs: req.ttlMs } : {}),
|
|
208
|
+
...(req.correlationKey !== undefined ? { correlationKey: req.correlationKey } : {}),
|
|
209
|
+
...(req.snapshot !== undefined ? { snapshot: req.snapshot } : {}),
|
|
210
|
+
};
|
|
211
|
+
const parsed = StepPauseRequestSchema.safeParse(wire);
|
|
212
|
+
if (!parsed.success) {
|
|
213
|
+
throw new PauseRequestError(`pause request failed validation: ${parsed.error.message}`);
|
|
214
|
+
}
|
|
215
|
+
const badPath = nonJsonPath(parsed.data);
|
|
216
|
+
if (badPath) {
|
|
217
|
+
throw new PauseRequestError(`pause request must be JSON-serializable (invalid value at ${badPath})`);
|
|
218
|
+
}
|
|
219
|
+
return parsed.data;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── The engine ───────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The body of `ctx.pause`. On a fresh pause it throws `PauseSignal`; on
|
|
226
|
+
* re-entry it returns the resume payload (or applies `onExpiry` / throws
|
|
227
|
+
* `PauseSchemaError`). See the module header.
|
|
228
|
+
*/
|
|
229
|
+
export async function corePause<T = unknown>(
|
|
230
|
+
req: PauseRequest<T>,
|
|
231
|
+
coord: PauseCoordinate,
|
|
232
|
+
kind: StepPauseRequest["kind"] = "custom",
|
|
233
|
+
): Promise<T> {
|
|
234
|
+
const wire = toWireRequest(req, kind);
|
|
235
|
+
const pauseId = nextPauseId(coord);
|
|
236
|
+
const record = await readPauseRecord(pauseId);
|
|
237
|
+
|
|
238
|
+
// Fresh pause (no file yet) OR our own un-resolved placeholder: write the
|
|
239
|
+
// placeholder and signal. `resolved !== true` is the guard — a placeholder
|
|
240
|
+
// we just wrote in a prior pass has no `resolved` key.
|
|
241
|
+
if (record === null || record.resolved !== true) {
|
|
242
|
+
await writePauseRecord({ pauseId, request: wire });
|
|
243
|
+
throw new PauseSignal(pauseId, wire);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Resolved by a TTL expiry.
|
|
247
|
+
if (record.expired === true) {
|
|
248
|
+
const onExpiry = req.onExpiry ?? { mode: "throw" };
|
|
249
|
+
if (onExpiry.mode === "resolve") return onExpiry.value;
|
|
250
|
+
throw new PauseExpiredError(`pause "${req.reason}" expired before it was resolved`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Resolved by a real resume. Validate against the caller's schema if given.
|
|
254
|
+
const payload = record.resumePayload ?? null;
|
|
255
|
+
if (req.schema) {
|
|
256
|
+
const parsed = req.schema.safeParse(payload);
|
|
257
|
+
if (!parsed.success) {
|
|
258
|
+
// v1: schema failure always throws; lax reopen-on-failure is deferred (ADR-0011).
|
|
259
|
+
throw new PauseSchemaError(
|
|
260
|
+
`resume payload for pause "${req.reason}" failed schema validation: ${parsed.error.message}`,
|
|
261
|
+
parsed.error.issues,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return parsed.data;
|
|
265
|
+
}
|
|
266
|
+
return payload as T;
|
|
267
|
+
}
|