@purista/harness 1.2.1 → 1.2.3

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 (46) hide show
  1. package/dist/agents/index.d.ts +1 -0
  2. package/dist/agents/index.js +276 -141
  3. package/dist/errors/catalog.d.ts +4 -3
  4. package/dist/harness/defineHarness.d.ts +45 -4
  5. package/dist/harness/defineHarness.js +51 -2
  6. package/dist/index.d.ts +1 -1
  7. package/dist/memory/sandbox/index.js +7 -1
  8. package/dist/models/registry.d.ts +10 -3
  9. package/dist/models/registry.js +45 -3
  10. package/dist/ports/base-model-provider.js +2 -0
  11. package/dist/ports/capabilities.d.ts +2 -0
  12. package/dist/ports/harness-context.d.ts +1 -0
  13. package/dist/ports/model-provider.d.ts +4 -0
  14. package/dist/ports/state.d.ts +6 -0
  15. package/dist/runtime/abort.d.ts +5 -0
  16. package/dist/runtime/abort.js +33 -0
  17. package/dist/runtime/durable.d.ts +2 -0
  18. package/dist/runtime/durable.js +6 -2
  19. package/dist/runtime/sessionDurable.d.ts +49 -0
  20. package/dist/runtime/sessionDurable.js +135 -0
  21. package/dist/runtime/steps.d.ts +19 -1
  22. package/dist/runtime/steps.js +21 -3
  23. package/dist/sandbox/index.d.ts +34 -0
  24. package/dist/sandbox/index.js +40 -3
  25. package/dist/sessions/index.d.ts +15 -2
  26. package/dist/sessions/index.js +336 -105
  27. package/dist/skills/index.js +19 -6
  28. package/dist/state/in-memory.d.ts +1 -0
  29. package/dist/state/in-memory.js +15 -0
  30. package/dist/telemetry/shim.js +9 -4
  31. package/dist/testing/durableWorkspaceStoreContract.d.ts +1 -1
  32. package/dist/testing/durableWorkspaceStoreContract.js +64 -28
  33. package/dist/tools/index.d.ts +2 -0
  34. package/dist/tools/index.js +15 -1
  35. package/dist/tools/mcp/runner.js +11 -6
  36. package/dist/tools/mcp/stdio.js +170 -1
  37. package/dist/ulid/index.d.ts +6 -1
  38. package/dist/ulid/index.js +31 -13
  39. package/dist/version.d.ts +2 -0
  40. package/dist/version.js +2 -0
  41. package/dist/workflows/index.js +7 -1
  42. package/dist/workspace/in-memory.d.ts +9 -10
  43. package/dist/workspace/in-memory.js +191 -48
  44. package/package.json +1 -1
  45. package/dist/harness/errors.d.ts +0 -62
  46. package/dist/harness/errors.js +0 -67
@@ -14,7 +14,7 @@ import { type Sandbox } from '../sandbox/index.js';
14
14
  import type { ModelHandle } from '../models/registry.js';
15
15
  import { type AdapterCapability, type DurableRuntimeAdapter, type HarnessInspection } from '../ports/capabilities.js';
16
16
  /** Stable harness version string for diagnostics and generated documentation. */
17
- export declare const HARNESS_VERSION = "0.0.0";
17
+ export { HARNESS_VERSION } from '../version.js';
18
18
  /** OpenTelemetry capture controls used by the harness. */
19
19
  export type TelemetryFlavor = 'dual' | 'gen_ai_only' | 'openinference_only';
20
20
  export type ContentCaptureMode = 'NO_CONTENT' | 'SPAN_ONLY' | 'EVENT_ONLY' | 'SPAN_AND_EVENT';
@@ -36,6 +36,8 @@ export interface HarnessDefaults {
36
36
  skillTimeoutMs?: number;
37
37
  /** Per-model timeout in milliseconds. Default: `300_000`. */
38
38
  modelTimeoutMs?: number;
39
+ /** Maximum tool calls from one model response executed at the same time. Default: `8`. */
40
+ maxParallelToolCalls?: number;
39
41
  /**
40
42
  * Max non-system messages forwarded into model calls.
41
43
  * `undefined` keeps all history, `0` keeps only system messages.
@@ -47,6 +49,17 @@ export interface HarnessOptions {
47
49
  /** Optional harness name for logs, telemetry, and diagnostics. Default: `agent-harness`. */
48
50
  name?: string;
49
51
  }
52
+ /** Durable execution opt-in for a single workflow call. */
53
+ export interface DurableInvokeOptions {
54
+ /** Stable run id reused across resumes/retries. Matches `/^[A-Za-z0-9_.:-]{1,200}$/`. */
55
+ runId: string;
56
+ /** Worker/process id owning the durable lease. Defaults to the harness worker id. */
57
+ workerId?: string;
58
+ /** Initial durable step id label. Defaults to the workflow id. */
59
+ stepId?: string;
60
+ /** Optional attempt hint; the runtime may raise it on retry. */
61
+ attempt?: number;
62
+ }
50
63
  /** Shared invoke options for workflow and agent execution. */
51
64
  export interface InvokeOptions {
52
65
  /** Abort signal used to cooperatively cancel the call. */
@@ -61,6 +74,12 @@ export interface InvokeOptions {
61
74
  tracestate?: string;
62
75
  /** Scalar metadata exposed to handlers and telemetry sanitizers. */
63
76
  metadata?: Record<string, JsonValue>;
77
+ /**
78
+ * Opt a workflow run into durable execution against the configured
79
+ * `.runtime(...)` (and optional `.workspaceStore(...)`). Workflow-only;
80
+ * supplying it on an agent run throws `ValidationError`.
81
+ */
82
+ durable?: DurableInvokeOptions;
64
83
  }
65
84
  /** Canonical built-in tool names provided by the harness. */
66
85
  export type BuiltinToolName = 'bash' | 'read' | 'write' | 'edit' | 'glob' | 'grep' | 'list';
@@ -330,6 +349,12 @@ export interface WorkflowContext<S extends BuilderState, I, O> {
330
349
  metadata: Readonly<Record<string, JsonValue>>;
331
350
  memory: MemoryFacade;
332
351
  metrics: Metrics;
352
+ /**
353
+ * Runs `fn` as a durable step. Under a durable invocation the output is
354
+ * checkpointed and replayed on resume without re-running `fn`; otherwise it is
355
+ * a transparent pass-through. See spec 10 "Durable steps".
356
+ */
357
+ step<T extends JsonValue>(stepId: string, fn: () => Promise<T>): Promise<T>;
333
358
  output?: O;
334
359
  }
335
360
  /** Full context passed to custom agent handlers. */
@@ -527,7 +552,15 @@ export interface RunSummary {
527
552
  agentCalls: number;
528
553
  error?: SerializedError;
529
554
  }
530
- /** Harness streaming events emitted from `session.workflows.<id>.stream(...)`. */
555
+ /**
556
+ * Harness streaming events emitted from `session.workflows.<id>.stream(...)`.
557
+ *
558
+ * `text(...)` and `object(...)` model calls return final results and do not
559
+ * expose partial output. Consumed model streams are private by default.
560
+ * `model.delta`, `model.object.partial`, and streamed `model.object` are
561
+ * emitted only when that `textStream(...)` or `objectStream(...)` call passes
562
+ * `{ emitRunEvents: true }`.
563
+ */
531
564
  export type RunEvent = {
532
565
  type: 'run.started';
533
566
  runId: string;
@@ -553,7 +586,10 @@ export type RunEvent = {
553
586
  } | {
554
587
  type: 'model.delta';
555
588
  runId: string;
556
- agentId: string;
589
+ streamId: string;
590
+ agentId?: string;
591
+ workflowId?: string;
592
+ modelAlias?: string;
557
593
  delta: string;
558
594
  } | {
559
595
  type: 'tool.started';
@@ -578,12 +614,18 @@ export type RunEvent = {
578
614
  } | {
579
615
  type: 'model.object.partial';
580
616
  runId: string;
617
+ streamId: string;
581
618
  agentId?: string;
619
+ workflowId?: string;
620
+ modelAlias?: string;
582
621
  partial: JsonValue;
583
622
  } | {
584
623
  type: 'model.object';
585
624
  runId: string;
586
625
  agentId?: string;
626
+ workflowId?: string;
627
+ modelAlias?: string;
628
+ streamId?: string;
587
629
  object: JsonValue;
588
630
  usage?: TokenUsage;
589
631
  } | {
@@ -713,4 +755,3 @@ export interface HarnessBuilder<S extends BuilderState = {}> {
713
755
  * ```
714
756
  */
715
757
  export declare function defineHarness(opts?: HarnessOptions): HarnessBuilder<{}>;
716
- export {};
@@ -4,12 +4,13 @@ import { sandboxMemory } from '../memory/sandbox/index.js';
4
4
  import { validateMemoryAdapter } from '../ports/memory.js';
5
5
  import { validateDurableWorkspaceStore } from '../ports/workspace.js';
6
6
  import { InMemoryStateStore } from '../state/in-memory.js';
7
- import { HarnessConfigError } from '../errors/catalog.js';
7
+ import { HarnessConfigError, SkillManifestError } from '../errors/catalog.js';
8
+ import { BUILTIN_TOOL_NAMES } from '../tools/index.js';
8
9
  import { autoDetectSandbox } from '../sandbox/index.js';
9
10
  import { createSessionHarness } from '../sessions/index.js';
10
11
  import { hasAdapterCapabilities, missingCapabilities, uniqueCapabilities } from '../ports/capabilities.js';
11
12
  /** Stable harness version string for diagnostics and generated documentation. */
12
- export const HARNESS_VERSION = '0.0.0';
13
+ export { HARNESS_VERSION } from '../version.js';
13
14
  class Builder {
14
15
  options;
15
16
  configured;
@@ -53,6 +54,9 @@ class Builder {
53
54
  if (defaults.historyWindow !== undefined && defaults.historyWindow < 0) {
54
55
  throw new HarnessConfigError('historyWindow must be >= 0', { reason: 'invalid_defaults', path: 'defaults.historyWindow' });
55
56
  }
57
+ if (defaults.maxParallelToolCalls !== undefined && (!Number.isInteger(defaults.maxParallelToolCalls) || defaults.maxParallelToolCalls < 1)) {
58
+ throw new HarnessConfigError('maxParallelToolCalls must be a positive integer', { reason: 'invalid_defaults', path: 'defaults.maxParallelToolCalls' });
59
+ }
56
60
  return this.clone({ defaults });
57
61
  }
58
62
  models(models) {
@@ -62,6 +66,11 @@ class Builder {
62
66
  return this.clone({ models });
63
67
  }
64
68
  tools(tools) {
69
+ for (const id of Object.keys(tools)) {
70
+ if (!/^[a-z][a-z0-9_]*$/.test(id) || id.length > 64) {
71
+ throw new HarnessConfigError('Invalid tool id. Tool ids must match /^[a-z][a-z0-9_]*$/ and be at most 64 characters.', { reason: 'invalid_tool_id', path: `tools.${id}`, id });
72
+ }
73
+ }
65
74
  return this.clone({ tools });
66
75
  }
67
76
  skills(skills) {
@@ -85,6 +94,7 @@ class Builder {
85
94
  if (!models || Object.keys(models).length === 0) {
86
95
  throw new HarnessConfigError('At least one model alias is required.', { reason: 'missing_models', path: 'models' });
87
96
  }
97
+ this.validateToolSkillNamespace();
88
98
  const sandbox = this.configured.sandbox ?? autoDetectSandbox();
89
99
  const memory = this.configured.memory ?? sandboxMemory();
90
100
  validateMemoryAdapter(memory);
@@ -106,12 +116,15 @@ class Builder {
106
116
  state: this.configured.state ?? new InMemoryStateStore(),
107
117
  sandbox,
108
118
  memory,
119
+ ...(this.configured.runtime ? { runtime: this.configured.runtime } : {}),
120
+ ...(this.configured.workspaceStore ? { workspaceStore: this.configured.workspaceStore } : {}),
109
121
  defaults: {
110
122
  agentMaxIterations: this.configured.defaults?.agentMaxIterations ?? 16,
111
123
  runTimeoutMs: this.configured.defaults?.runTimeoutMs ?? 600_000,
112
124
  toolTimeoutMs: this.configured.defaults?.toolTimeoutMs ?? 120_000,
113
125
  skillTimeoutMs: this.configured.defaults?.skillTimeoutMs ?? 60_000,
114
126
  modelTimeoutMs: this.configured.defaults?.modelTimeoutMs ?? 300_000,
127
+ maxParallelToolCalls: this.configured.defaults?.maxParallelToolCalls ?? 8,
115
128
  ...(this.configured.defaults?.historyWindow !== undefined ? { historyWindow: this.configured.defaults.historyWindow } : {})
116
129
  },
117
130
  models,
@@ -126,6 +139,42 @@ class Builder {
126
139
  clone(patch) {
127
140
  return new Builder(this.options, { ...this.configured, ...patch });
128
141
  }
142
+ /**
143
+ * Tool ids, skill ids, and built-in tool names share one model-facing
144
+ * namespace (spec 08 §6). A custom tool id must not collide with a built-in
145
+ * tool name or a skill id, and a skill id must not collide with a built-in
146
+ * tool name.
147
+ */
148
+ validateToolSkillNamespace() {
149
+ const toolIds = Object.keys(this.configured.tools ?? {});
150
+ const skillIds = new Set(Object.keys(this.configured.skills ?? {}));
151
+ const builtinNames = new Set(BUILTIN_TOOL_NAMES);
152
+ for (const id of toolIds) {
153
+ if (builtinNames.has(id)) {
154
+ throw new SkillManifestError(`Custom tool id "${id}" collides with a built-in tool name.`, {
155
+ reason: 'reserved_name',
156
+ skill_id: id,
157
+ source: 'tool'
158
+ });
159
+ }
160
+ if (skillIds.has(id)) {
161
+ throw new SkillManifestError(`Custom tool id "${id}" collides with a skill id.`, {
162
+ reason: 'reserved_name',
163
+ skill_id: id,
164
+ source: 'tool'
165
+ });
166
+ }
167
+ }
168
+ for (const id of skillIds) {
169
+ if (builtinNames.has(id)) {
170
+ throw new SkillManifestError(`Skill id "${id}" collides with a built-in tool name.`, {
171
+ reason: 'reserved_name',
172
+ skill_id: id,
173
+ source: 'skill'
174
+ });
175
+ }
176
+ }
177
+ }
129
178
  validateAgentSkillReferences(agents) {
130
179
  const configuredSkills = new Set(Object.keys(this.configured.skills ?? {}));
131
180
  for (const [agentId, agent] of Object.entries(agents)) {
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export * from './telemetry/index.js';
4
4
  export * from './ulid/index.js';
5
5
  export * from './ports/index.js';
6
6
  export { createDurableWorkflowContext, DurableStepError, DurableRunLeaseError, DurableTerminalRunError, inMemoryDurableRuntime, isTerminalRunStatus } from './runtime/index.js';
7
- export type { DurableActiveRunStatus, DurableWorkflowContext, DurableRunLease, DurableRunStart, DurableRunStatus, DurableRuntime, DurableTerminalRunStatus, FinishRunPatch, InMemoryDurableRuntimeOptions, RunCheckpoint } from './runtime/index.js';
7
+ export type { DurableActiveRunStatus, DurableWorkflowContext, DurableWorkflowContextOptions, DurableStepCommit, DurableRunLease, DurableRunStart, DurableRunStatus, DurableRuntime, DurableTerminalRunStatus, FinishRunPatch, InMemoryDurableRuntimeOptions, RunCheckpoint } from './runtime/index.js';
8
8
  export * from './state/in-memory.js';
9
9
  export * from './models/json.js';
10
10
  export type { SessionRecord, Message, RunRecord, PersistedRunEvent, RunStatus } from './models/state.js';
@@ -45,7 +45,13 @@ class SandboxMemoryAdapter {
45
45
  const path = `${root}/${key}.json`;
46
46
  if (!(await sandbox.exists(path)))
47
47
  return undefined;
48
- return JSON.parse(await sandbox.readText(path));
48
+ const raw = await sandbox.readText(path);
49
+ try {
50
+ return JSON.parse(raw);
51
+ }
52
+ catch (error) {
53
+ throw new StateError('Stored memory value is not valid JSON.', { op: 'memory.get', reason: 'corrupt_value' }, error);
54
+ }
49
55
  },
50
56
  set: async (key, value, op) => {
51
57
  op.signal.throwIfAborted();
@@ -2,11 +2,18 @@ import type { EmbeddingRequest, EmbeddingResponse, ContentPart, ModelAlias, Mode
2
2
  import type { TelemetryShim } from '../telemetry/index.js';
3
3
  import type { JsonValue } from './json.js';
4
4
  export interface ModelInvokeContext {
5
- harnessName: string;
6
- sessionId: string;
7
- runId: string;
5
+ /** Harness instance name used for telemetry and run-event attribution. */
6
+ harnessName?: string;
7
+ /** Session id used for telemetry and run-event attribution. */
8
+ sessionId?: string;
9
+ /** Run id used for telemetry and run-event attribution. */
10
+ runId?: string;
11
+ /** Workflow id when the model call belongs to a workflow run. */
8
12
  workflowId?: string;
13
+ /** Agent id when the model call belongs to an agent run. */
9
14
  agentId?: string;
15
+ /** Mirrors consumed stream chunks into the enclosing session `RunEvent` stream. Defaults to false. */
16
+ emitRunEvents?: boolean;
10
17
  }
11
18
  type TextPart = Extract<ContentPart, {
12
19
  kind: 'text';
@@ -1,4 +1,4 @@
1
- import { ModelCapabilityError } from '../errors/index.js';
1
+ import { ModelCapabilityError, ModelError } from '../errors/index.js';
2
2
  import { ATTR_GEN_AI_REQUEST_MODEL, ATTR_GEN_AI_RESPONSE_FINISH_REASONS, ATTR_GEN_AI_SYSTEM, ATTR_GEN_AI_TOKEN_TYPE, ATTR_GEN_AI_USAGE_INPUT_TOKENS, ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_TOKEN_TYPE_VALUE_INPUT, GEN_AI_TOKEN_TYPE_VALUE_OUTPUT } from '@opentelemetry/semantic-conventions/incubating';
3
3
  /**
4
4
  * Creates per-alias model handles that enforce capability gates before provider invocation.
@@ -92,7 +92,7 @@ function createHandle(aliasKey, alias, options) {
92
92
  signal,
93
93
  traceparent: req.traceparent ?? options.telemetry?.currentTraceparent()
94
94
  };
95
- return withModelSpan(options, aliasKey, alias, 'embeddings', ctx, () => alias.provider.embed(fullReq));
95
+ return withModelSpan(options, aliasKey, alias, 'embeddings', ctx, () => alias.provider.embed(fullReq)).then((response) => validateEmbeddingResponse(aliasKey, alias, fullReq, response));
96
96
  },
97
97
  rerank(req, signal, ctx) {
98
98
  ensureCapabilities(aliasKey, alias, 'rerank', req);
@@ -107,10 +107,51 @@ function createHandle(aliasKey, alias, options) {
107
107
  signal,
108
108
  traceparent: req.traceparent ?? options.telemetry?.currentTraceparent()
109
109
  };
110
- return withModelSpan(options, aliasKey, alias, 'rerank', ctx, () => alias.provider.rerank(fullReq));
110
+ return withModelSpan(options, aliasKey, alias, 'rerank', ctx, () => alias.provider.rerank(fullReq)).then((response) => validateRerankResponse(aliasKey, alias, fullReq, response));
111
111
  }
112
112
  };
113
113
  }
114
+ /**
115
+ * Provider-neutral guard: the number of embeddings must match the number of
116
+ * inputs, and indices must cover every input exactly once. Protects callers
117
+ * that associate vectors with inputs by position.
118
+ */
119
+ function validateEmbeddingResponse(aliasKey, alias, req, response) {
120
+ const expected = Array.isArray(req.input) ? req.input.length : 1;
121
+ const indices = new Set(response.embeddings.map((item) => item.index));
122
+ const validIndices = response.embeddings.every((item) => Number.isInteger(item.index) && item.index >= 0 && item.index < expected);
123
+ if (response.embeddings.length !== expected || indices.size !== expected || !validIndices) {
124
+ throw new ModelError('Embedding response does not match the request input count.', {
125
+ provider: alias.provider.id,
126
+ model: alias.model,
127
+ method: 'embed',
128
+ reason: 'embedding_count_mismatch',
129
+ providerBody: { expected, received: response.embeddings.length, alias: aliasKey }
130
+ });
131
+ }
132
+ return response;
133
+ }
134
+ /**
135
+ * Provider-neutral guard: every rerank result must reference a distinct, valid
136
+ * document index, and the count must not exceed the requested document count
137
+ * (or `topN` when supplied).
138
+ */
139
+ function validateRerankResponse(aliasKey, alias, req, response) {
140
+ const documentCount = req.documents.length;
141
+ const limit = req.topN !== undefined ? Math.min(req.topN, documentCount) : documentCount;
142
+ const indices = new Set(response.results.map((item) => item.index));
143
+ const validIndices = response.results.every((item) => Number.isInteger(item.index) && item.index >= 0 && item.index < documentCount);
144
+ if (response.results.length > limit || indices.size !== response.results.length || !validIndices) {
145
+ throw new ModelError('Rerank response does not map back to the request documents.', {
146
+ provider: alias.provider.id,
147
+ model: alias.model,
148
+ method: 'rerank',
149
+ reason: 'rerank_result_mismatch',
150
+ providerBody: { documentCount, limit, received: response.results.length, alias: aliasKey }
151
+ });
152
+ }
153
+ return response;
154
+ }
114
155
  function withModelStreamSpan(options, aliasKey, alias, method, ctx, fn) {
115
156
  if (!options.telemetry)
116
157
  return fn();
@@ -306,6 +347,7 @@ function mergeDefaults(alias, call) {
306
347
  || merged.maxTokens !== undefined
307
348
  || merged.topP !== undefined
308
349
  || merged.stopSequences !== undefined
350
+ || merged.parallelToolCalls !== undefined
309
351
  || Object.keys(merged.providerOptions ?? {}).length > 0;
310
352
  return hasTopLevel ? merged : undefined;
311
353
  }
@@ -161,6 +161,8 @@ export class BaseModelProvider {
161
161
  const controller = new AbortController();
162
162
  const relay = () => controller.abort(req.signal.reason);
163
163
  req.signal.addEventListener('abort', relay, { once: true });
164
+ if (req.signal.aborted)
165
+ relay();
164
166
  let rejectTimeout;
165
167
  const timeoutPromise = new Promise((_, reject) => { rejectTimeout = reject; });
166
168
  const timeout = setTimeout(() => {
@@ -17,6 +17,8 @@ export type AdapterCapability =
17
17
  | 'sandbox.resume'
18
18
  /** Sandbox can snapshot and release active compute. */
19
19
  | 'sandbox.hibernate'
20
+ /** Sandbox can host a long-lived process with streaming stdin/stdout. */
21
+ | 'sandbox.spawn'
20
22
  /** Runtime can commit stable checkpoints. */
21
23
  | 'runtime.checkpoint'
22
24
  /** Runtime can retry durable boundaries. */
@@ -14,6 +14,7 @@ export interface HarnessAdapterContext {
14
14
  toolTimeoutMs: number;
15
15
  skillTimeoutMs: number;
16
16
  modelTimeoutMs: number;
17
+ maxParallelToolCalls: number;
17
18
  historyWindow?: number;
18
19
  };
19
20
  }
@@ -29,6 +29,8 @@ export interface ModelDefaults {
29
29
  maxTokens?: number;
30
30
  topP?: number;
31
31
  stopSequences?: string[];
32
+ /** Whether providers should allow the model to emit multiple independent tool calls in one turn. */
33
+ parallelToolCalls?: boolean;
32
34
  providerOptions?: Record<string, unknown>;
33
35
  }
34
36
  /** Per-call generation overrides. */
@@ -37,6 +39,8 @@ export interface ModelCallOptions {
37
39
  maxTokens?: number;
38
40
  topP?: number;
39
41
  stopSequences?: string[];
42
+ /** Overrides whether providers should allow multiple tool calls in one model turn. */
43
+ parallelToolCalls?: boolean;
40
44
  providerOptions?: Record<string, unknown>;
41
45
  }
42
46
  /** Tool call envelope emitted by model adapters. */
@@ -20,6 +20,12 @@ export interface StateStore {
20
20
  before?: string;
21
21
  }): Promise<Message[]>;
22
22
  clearMessages(sessionId: string): Promise<void>;
23
+ /**
24
+ * Atomically replace all messages for a session under one lock (clear +
25
+ * append). Adapters that implement this provide the spec-mandated atomic
26
+ * `replaceHistory`; the session layer falls back to clear+append when absent.
27
+ */
28
+ replaceMessages?(sessionId: string, messages: Message[]): Promise<void>;
23
29
  createRun(record: RunRecord): Promise<void>;
24
30
  finishRun(runId: string, patch: FinishRunPatch): Promise<void>;
25
31
  getRun(runId: string): Promise<RunRecord | undefined>;
@@ -0,0 +1,5 @@
1
+ import { OperationCancelledError, OperationTimeoutError } from '../errors/index.js';
2
+ type AbortScope = 'run' | 'model' | 'tool' | 'workflow' | 'agent' | 'sandbox' | 'memory' | 'workspace';
3
+ export declare function abortError(signal: AbortSignal, scope: AbortScope, message: string): OperationCancelledError | OperationTimeoutError;
4
+ export declare function withAbortSignal<T>(signal: AbortSignal, scope: AbortScope, message: string, fn: () => Promise<T>): Promise<T>;
5
+ export {};
@@ -0,0 +1,33 @@
1
+ import { OperationCancelledError, OperationTimeoutError } from '../errors/index.js';
2
+ export function abortError(signal, scope, message) {
3
+ if (signal.reason instanceof OperationTimeoutError)
4
+ return signal.reason;
5
+ if (signal.reason instanceof OperationCancelledError)
6
+ return signal.reason;
7
+ return new OperationCancelledError(message, { scope }, signal.reason);
8
+ }
9
+ export async function withAbortSignal(signal, scope, message, fn) {
10
+ if (signal.aborted)
11
+ throw abortError(signal, scope, message);
12
+ let abortListener;
13
+ const abortPromise = new Promise((_, reject) => {
14
+ abortListener = () => reject(abortError(signal, scope, message));
15
+ signal.addEventListener('abort', abortListener, { once: true });
16
+ if (signal.aborted)
17
+ abortListener();
18
+ });
19
+ try {
20
+ return await Promise.race([fn(), abortPromise]);
21
+ }
22
+ catch (error) {
23
+ if (error instanceof OperationCancelledError || error instanceof OperationTimeoutError)
24
+ throw error;
25
+ if (signal.aborted)
26
+ throw abortError(signal, scope, message);
27
+ throw error;
28
+ }
29
+ finally {
30
+ if (abortListener)
31
+ signal.removeEventListener('abort', abortListener);
32
+ }
33
+ }
@@ -45,6 +45,8 @@ export interface DurableRunLease {
45
45
  };
46
46
  /** Last committed checkpoint, if any. */
47
47
  readonly checkpoint?: RunCheckpoint;
48
+ /** All committed checkpoints for this run, in commit order, for step replay. */
49
+ readonly checkpoints?: readonly RunCheckpoint[];
48
50
  /** Releases this in-memory lease without making the run terminal. */
49
51
  release(): Promise<void>;
50
52
  }
@@ -61,7 +61,8 @@ class InMemoryDurableRuntime {
61
61
  const state = current ?? {
62
62
  start: record,
63
63
  status: 'running',
64
- attempt: Math.max(1, record.attempt ?? 1)
64
+ attempt: Math.max(1, record.attempt ?? 1),
65
+ checkpoints: new Map()
65
66
  };
66
67
  if (current) {
67
68
  state.attempt += 1;
@@ -95,7 +96,9 @@ class InMemoryDurableRuntime {
95
96
  throw new DurableTerminalRunError(checkpoint.runId, state.status);
96
97
  }
97
98
  const committedAt = checkpoint.committedAt ?? new Date().toISOString();
98
- state.checkpoint = { ...checkpoint, committedAt };
99
+ const stored = { ...checkpoint, committedAt };
100
+ state.checkpoint = stored;
101
+ state.checkpoints.set(stored.stepId, stored);
99
102
  this.checkpointCommitCount += 1;
100
103
  if (this.options.failAfterCheckpoint === this.checkpointCommitCount) {
101
104
  this.releaseLease(lease);
@@ -148,6 +151,7 @@ class InMemoryDurableRuntime {
148
151
  attempt: state.attempt
149
152
  },
150
153
  ...(state.checkpoint ? { checkpoint: state.checkpoint } : {}),
154
+ checkpoints: [...state.checkpoints.values()].sort((a, b) => a.sequence - b.sequence),
151
155
  release: async () => {
152
156
  await this.withSessionLock(lease.sessionId, async () => {
153
157
  this.releaseLease(lease);
@@ -0,0 +1,49 @@
1
+ import type { Logger } from '../logger/index.js';
2
+ import type { JsonValue } from '../models/json.js';
3
+ import type { DurableWorkspaceStore } from '../ports/workspace.js';
4
+ import type { DurableRuntime } from './durable.js';
5
+ import { type DurableWorkflowContext } from './steps.js';
6
+ /** Run-id format accepted for durable invocations. */
7
+ export declare const DURABLE_RUN_ID_PATTERN: RegExp;
8
+ /** Caller-supplied durable invocation options (mirror of `InvokeOptions.durable`). */
9
+ export interface DurableInvokeOptions {
10
+ runId: string;
11
+ workerId?: string;
12
+ stepId?: string;
13
+ attempt?: number;
14
+ }
15
+ /** Durable binding driving one workflow run's lease and workspace lifecycle. */
16
+ export interface DurableWorkflowBinding {
17
+ readonly runId: string;
18
+ readonly attempt: number;
19
+ readonly resumed: boolean;
20
+ readonly step: DurableWorkflowContext['step'];
21
+ /** Marks the run successfully terminal and, when policy permits, cleans up the workspace. */
22
+ finishSuccess(output: JsonValue): Promise<void>;
23
+ /** Marks the run cancelled-terminal and aborts the workspace (blocks resume). */
24
+ finishCancelled(error: unknown): Promise<void>;
25
+ /**
26
+ * Releases the lease without making the run terminal when it was not settled,
27
+ * leaving a failed run resumable by a later retry with the same run id.
28
+ */
29
+ dispose(): Promise<void>;
30
+ }
31
+ /** Narrows a configured runtime adapter to an executable durable runtime. */
32
+ export declare function isExecutableDurableRuntime(runtime: unknown): runtime is DurableRuntime;
33
+ /**
34
+ * Acquires a durable runtime lease for a workflow run and, when a workspace
35
+ * store is configured, starts or resumes the durable workspace and links each
36
+ * new step checkpoint to a workspace checkpoint (spec 21 §16.1).
37
+ */
38
+ export declare function beginDurableWorkflow(args: {
39
+ runtime: DurableRuntime;
40
+ workspaceStore?: DurableWorkspaceStore;
41
+ durable: DurableInvokeOptions;
42
+ defaultWorkerId: string;
43
+ sessionId: string;
44
+ workflowId: string;
45
+ input: JsonValue;
46
+ signal: AbortSignal;
47
+ logger: Logger;
48
+ harnessName: string;
49
+ }): Promise<DurableWorkflowBinding>;