@hunterzhu/pulse-runtime 0.5.0 → 0.6.0

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.
@@ -0,0 +1,9 @@
1
+ import type { JsonValue } from '../core/types.js';
2
+ import type { StepContext } from './program.js';
3
+ /** A bounded window must expose new bytes, rather than slide through already read text. */
4
+ export declare function isUnreadFileWindow(ctx: Pick<StepContext<unknown>, 'results'>, call: {
5
+ toolName: JsonValue;
6
+ input: JsonValue;
7
+ }, refs: readonly string[]): boolean;
8
+ /** Count actual new windows/version changes, not arbitrary reads at positive offsets. */
9
+ export declare function isReadProgress(ctx: Pick<StepContext<unknown>, 'results'>, ref: string, priorRefs: readonly string[]): boolean;
@@ -0,0 +1,84 @@
1
+ import { normalize } from 'node:path';
2
+ const pathKey = (value) => typeof value === 'string' ? normalize(value).replaceAll('\\', '/') : undefined;
3
+ const limitBytes = 3000;
4
+ const row = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
5
+ const integer = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
6
+ /** A bounded window must expose new bytes, rather than slide through already read text. */
7
+ export function isUnreadFileWindow(ctx, call, refs) {
8
+ if (call.toolName !== 'fs.read')
9
+ return false;
10
+ const { path, offset = 0, startLine, maxBytes = limitBytes } = row(call.input);
11
+ if (typeof path !== 'string' || !integer(offset) || !integer(maxBytes) || maxBytes === 0)
12
+ return false;
13
+ const size = Math.min(maxBytes, limitBytes);
14
+ const ordered = [...new Set(refs)].sort((a, b) => Number(a.match(/\d+$/)?.[0] ?? 0) - Number(b.match(/\d+$/)?.[0] ?? 0));
15
+ const observations = ordered.flatMap((ref, order) => {
16
+ const meta = ctx.results.meta(ref), body = row(ctx.results.read(ref)), args = row(meta?.toolArguments);
17
+ if (meta?.outcomeStatus !== 'succeeded' || !['fs.read', 'fs.write', 'fs.apply_patch', 'fs.apply_patches', 'fs.stage'].includes(meta.toolName ?? ''))
18
+ return [];
19
+ if (pathKey(body.path) !== pathKey(path) && pathKey(body.target) !== pathKey(path) && pathKey(args.path) !== pathKey(path))
20
+ return [];
21
+ if (meta.toolName === 'fs.stage' && body.committed !== true)
22
+ return [];
23
+ return [{ body, args, read: meta.toolName === 'fs.read', order }];
24
+ });
25
+ const latest = observations.at(-1);
26
+ if (latest && typeof latest.body.hash !== 'string')
27
+ return false;
28
+ const pages = observations.filter(o => o.read && o.body.hash === latest?.body.hash);
29
+ if (startLine !== undefined) {
30
+ if (!integer(startLine) || startLine < 1 || offset !== 0 || pages.some(p => integer(p.args.startLine) && startLine >= p.args.startLine && startLine <= p.args.startLine + (typeof p.body.content === 'string' ? p.body.content.split('\n').length - (p.body.content.endsWith('\n') ? 2 : 1) : 0)))
31
+ return false;
32
+ // Search anchors are exact observed locations, not model-invented line numbers.
33
+ // A later write invalidates older anchors, even when the read result was retained.
34
+ const lastWrite = observations.filter(o => !o.read && o.body.changed !== false).at(-1)?.order ?? -1;
35
+ return ordered.some((ref, order) => {
36
+ if (order <= lastWrite)
37
+ return false;
38
+ const meta = ctx.results.meta(ref), body = row(ctx.results.read(ref));
39
+ return meta?.toolName === 'fs.search' && meta.outcomeStatus === 'succeeded' && Array.isArray(body.matches) && body.matches.some(m => pathKey(row(m).path) === pathKey(path) && row(m).line === startLine);
40
+ });
41
+ }
42
+ if (!latest || pages.length === 0)
43
+ return offset === 0;
44
+ const ranges = pages.flatMap(({ body }) => {
45
+ const start = body.offset ?? 0;
46
+ const end = integer(body.nextOffset) ? body.nextOffset : typeof body.content === 'string' ? start + Buffer.byteLength(body.content) : undefined;
47
+ return integer(start) && integer(end) && end > start ? [[start, end]] : [];
48
+ }).sort((a, b) => a[0] - b[0]);
49
+ const merged = [];
50
+ for (const range of ranges) {
51
+ const last = merged.at(-1);
52
+ if (last && range[0] <= last[1])
53
+ last[1] = Math.max(last[1], range[1]);
54
+ else
55
+ merged.push([...range]);
56
+ }
57
+ const eof = pages.flatMap(({ body }) => body.nextOffset === null && typeof body.content === 'string' && integer(body.offset ?? 0) ? [(body.offset ?? 0) + Buffer.byteLength(body.content)] : []);
58
+ const end = Math.min(offset + size, ...eof);
59
+ if (!Number.isSafeInteger(end) || end <= offset)
60
+ return false;
61
+ const covered = merged.reduce((n, [start, stop]) => n + Math.max(0, Math.min(stop, end) - Math.max(start, offset)), 0);
62
+ if (covered >= end - offset)
63
+ return false;
64
+ if (pages.some(({ body }) => body.nextOffset === offset))
65
+ return true;
66
+ // A little context overlap is useful, but 1-byte shifts cannot defeat the gate.
67
+ return merged.some(([start, stop]) => offset >= start && offset < stop && end > stop) && covered <= Math.min(512, Math.floor((end - offset) / 4));
68
+ }
69
+ /** Count actual new windows/version changes, not arbitrary reads at positive offsets. */
70
+ export function isReadProgress(ctx, ref, priorRefs) {
71
+ const meta = ctx.results.meta(ref), body = row(ctx.results.read(ref)), args = row(meta?.toolArguments);
72
+ if (meta?.toolName !== 'fs.read' || meta.outcomeStatus !== 'succeeded')
73
+ return false;
74
+ const path = body.path ?? args.path;
75
+ const priorFile = [...new Set(priorRefs)].sort((a, b) => Number(b.match(/\d+$/)?.[0] ?? 0) - Number(a.match(/\d+$/)?.[0] ?? 0)).find(prior => {
76
+ const m = ctx.results.meta(prior), b = row(ctx.results.read(prior)), a = row(m?.toolArguments);
77
+ return m?.outcomeStatus === 'succeeded' && ['fs.read', 'fs.write', 'fs.apply_patch', 'fs.apply_patches', 'fs.stage'].includes(m.toolName ?? '') && (m.toolName !== 'fs.stage' || b.committed === true) && pathKey(b.path ?? b.target ?? a.path) === pathKey(path);
78
+ });
79
+ if (priorFile && typeof body.hash === 'string' && typeof row(ctx.results.read(priorFile)).hash === 'string' && body.hash !== row(ctx.results.read(priorFile)).hash)
80
+ return true;
81
+ if (!priorFile && args.startLine === undefined)
82
+ return false;
83
+ return isUnreadFileWindow(ctx, { toolName: 'fs.read', input: { ...args, path, ...(args.startLine === undefined ? { offset: body.offset ?? args.offset ?? 0 } : {}) } }, priorRefs);
84
+ }
@@ -20,6 +20,10 @@ export interface ReActLaneConfig {
20
20
  maxTurns?: number;
21
21
  maxTruncationRetries?: number;
22
22
  maxToolsPerTurn?: number;
23
+ prepareQuestion?: (toolName: string, input: JsonValue, ctx: StepContext<JsonValue>) => JsonValue;
24
+ onHumanReply?: (question: JsonValue, answer: JsonValue, ctx: StepContext<JsonValue>) => NextStepTarget<JsonValue> | undefined;
25
+ /** Bound investigation-only rounds for an implementation run; pagination is exempt. */
26
+ maxReadOnlyRounds?: number | ((ctx: StepContext<JsonValue>) => number | undefined);
23
27
  serialTools?: string[];
24
28
  stopAfterFirstSerialTool?: boolean;
25
29
  /** Return a durable token when a new attempt should get a fresh turn budget. */
@@ -4,7 +4,7 @@ export function defineReActLane(config) {
4
4
  const onFinish = config.onFinish ?? (config.outputSchema === undefined
5
5
  ? { text: (resultRef) => ({ complete: { value: { textRef: resultRef } } }) }
6
6
  : { text: (resultRef) => ({ complete: { value: { textRef: resultRef } } }), structured: { schema: config.outputSchema, onParsed: (value) => ({ complete: { value: value } }) } });
7
- builder.addReActLoopStep('react', { ...(config.task === undefined ? {} : { task: config.task }), instruction: config.instruction, ...(config.inputs === undefined ? {} : { inputs: config.inputs }), ...(config.toolAllow === undefined ? {} : { toolAllow: config.toolAllow }), ...(config.maxToolsPerTurn === undefined ? {} : { maxToolsPerTurn: config.maxToolsPerTurn }), ...(config.serialTools === undefined ? {} : { serialTools: config.serialTools }), ...(config.stopAfterFirstSerialTool === undefined ? {} : { stopAfterFirstSerialTool: config.stopAfterFirstSerialTool }), ...(config.maxTruncationRetries === undefined ? {} : { maxTruncationRetries: config.maxTruncationRetries }), ...(config.maxTurns === undefined ? {} : { maxTurns: config.maxTurns }), ...(config.resetTurnsOnEntry === undefined ? {} : { resetTurnsOnEntry: config.resetTurnsOnEntry }), ...(config.outputSchema === undefined ? {} : { outputSchema: config.outputSchema }), ...(config.requirements === undefined ? {} : { requirements: config.requirements }), ...(config.toolApproval === undefined ? {} : { toolApproval: config.toolApproval }), onFinish });
7
+ builder.addReActLoopStep('react', { ...(config.task === undefined ? {} : { task: config.task }), instruction: config.instruction, ...(config.prepareQuestion === undefined ? {} : { prepareQuestion: config.prepareQuestion }), ...(config.onHumanReply === undefined ? {} : { onHumanReply: config.onHumanReply }), ...(config.inputs === undefined ? {} : { inputs: config.inputs }), ...(config.toolAllow === undefined ? {} : { toolAllow: config.toolAllow }), ...(config.maxToolsPerTurn === undefined ? {} : { maxToolsPerTurn: config.maxToolsPerTurn }), ...(config.maxReadOnlyRounds === undefined ? {} : { maxReadOnlyRounds: config.maxReadOnlyRounds }), ...(config.serialTools === undefined ? {} : { serialTools: config.serialTools }), ...(config.stopAfterFirstSerialTool === undefined ? {} : { stopAfterFirstSerialTool: config.stopAfterFirstSerialTool }), ...(config.maxTruncationRetries === undefined ? {} : { maxTruncationRetries: config.maxTruncationRetries }), ...(config.maxTurns === undefined ? {} : { maxTurns: config.maxTurns }), ...(config.resetTurnsOnEntry === undefined ? {} : { resetTurnsOnEntry: config.resetTurnsOnEntry }), ...(config.outputSchema === undefined ? {} : { outputSchema: config.outputSchema }), ...(config.requirements === undefined ? {} : { requirements: config.requirements }), ...(config.toolApproval === undefined ? {} : { toolApproval: config.toolApproval }), onFinish });
8
8
  config.extend?.(builder);
9
9
  });
10
10
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './core/types.js';
2
+ export * from './core/tool-image.js';
2
3
  export * from './core/errors.js';
3
4
  export * from './core/mutations.js';
4
5
  export * from './core/inbox.js';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './core/types.js';
2
+ export * from './core/tool-image.js';
2
3
  export * from './core/errors.js';
3
4
  export * from './core/mutations.js';
4
5
  export * from './core/inbox.js';
@@ -1,6 +1,7 @@
1
1
  import type { JsonValue, LLMRequestProjection, PrivacyLabel, ProvenanceRef } from '../core/types.js';
2
2
  export type ReasoningLevel = 'low' | 'medium' | 'high';
3
3
  export interface ModelCapabilities {
4
+ imageInput?: boolean;
4
5
  toolCalling?: boolean;
5
6
  structuredOutput?: boolean;
6
7
  reasoning?: ReasoningLevel;
@@ -33,6 +34,7 @@ export interface ModelAdapter {
33
34
  request: LLMRequestProjection;
34
35
  signal: AbortSignal;
35
36
  onObservation?: (chunk: string) => void;
37
+ onReasoning?: (chunk: string) => void;
36
38
  model?: string;
37
39
  outputSchema?: JsonValue;
38
40
  maxOutputTokens?: number;
@@ -6,7 +6,7 @@ function isModelCandidate(value) {
6
6
  if (!capabilities || typeof capabilities !== 'object' || Array.isArray(capabilities))
7
7
  return false;
8
8
  const modelCapabilities = capabilities;
9
- return typeof candidate.id === 'string' && candidate.id.length > 0 && typeof candidate.providerId === 'string' && candidate.providerId.length > 0 && Array.isArray(candidate.tasks) && candidate.tasks.length > 0 && candidate.tasks.every((task) => typeof task === 'string' && task.length > 0) && typeof candidate.priority === 'number' && Number.isFinite(candidate.priority) && Number.isInteger(modelCapabilities.maxContextTokens) && modelCapabilities.maxContextTokens > 0 && (modelCapabilities.maxOutputTokens === undefined || (Number.isInteger(modelCapabilities.maxOutputTokens) && modelCapabilities.maxOutputTokens > 0)) && (modelCapabilities.local === undefined || typeof modelCapabilities.local === 'boolean') && (modelCapabilities.toolCalling === undefined || typeof modelCapabilities.toolCalling === 'boolean') && (modelCapabilities.structuredOutput === undefined || typeof modelCapabilities.structuredOutput === 'boolean') && (modelCapabilities.reasoning === undefined || ['low', 'medium', 'high'].includes(String(modelCapabilities.reasoning))) && (candidate.adapter === undefined || (typeof candidate.adapter === 'object' && candidate.adapter !== null && typeof candidate.adapter.executeAttempt === 'function'));
9
+ return typeof candidate.id === 'string' && candidate.id.length > 0 && typeof candidate.providerId === 'string' && candidate.providerId.length > 0 && Array.isArray(candidate.tasks) && candidate.tasks.length > 0 && candidate.tasks.every((task) => typeof task === 'string' && task.length > 0) && typeof candidate.priority === 'number' && Number.isFinite(candidate.priority) && Number.isInteger(modelCapabilities.maxContextTokens) && modelCapabilities.maxContextTokens > 0 && (modelCapabilities.maxOutputTokens === undefined || (Number.isInteger(modelCapabilities.maxOutputTokens) && modelCapabilities.maxOutputTokens > 0)) && (modelCapabilities.imageInput === undefined || typeof modelCapabilities.imageInput === 'boolean') && (modelCapabilities.local === undefined || typeof modelCapabilities.local === 'boolean') && (modelCapabilities.toolCalling === undefined || typeof modelCapabilities.toolCalling === 'boolean') && (modelCapabilities.structuredOutput === undefined || typeof modelCapabilities.structuredOutput === 'boolean') && (modelCapabilities.reasoning === undefined || ['low', 'medium', 'high'].includes(String(modelCapabilities.reasoning))) && (candidate.adapter === undefined || (typeof candidate.adapter === 'object' && candidate.adapter !== null && typeof candidate.adapter.executeAttempt === 'function'));
10
10
  }
11
11
  export class InMemoryModelRegistry {
12
12
  candidates = [];
@@ -21,7 +21,7 @@ export class InMemoryModelRegistry {
21
21
  }
22
22
  /** Conservative admission estimate used before a provider attempt is started. */
23
23
  export function estimateProjectionTokens(projection) {
24
- return Math.ceil(Buffer.byteLength(JSON.stringify(projection.blocks), 'utf8') / 4);
24
+ return Math.ceil(Buffer.byteLength(JSON.stringify(projection.blocks), 'utf8') / 4) + (projection.images?.length ?? 0) * 4096;
25
25
  }
26
26
  export class ModelRouter {
27
27
  registry;
@@ -6,7 +6,7 @@ import { PulseSession } from '../dsl/session.js';
6
6
  import type { ProgramRef } from '../dsl/templates.js';
7
7
  import { FactInbox, ObservationInbox, type FactInboxDedupeArchive, type FactInboxDedupeArchiveBatch } from '../core/inbox.js';
8
8
  import { EffectOutbox } from '../storage/outbox.js';
9
- import { type RuntimePersistenceBackend, type RuntimePersistenceSnapshot } from '../storage/persistence.js';
9
+ import { type RuntimePersistenceBackend, type RuntimePersistenceCompatibility, type RuntimePersistenceSnapshot } from '../storage/persistence.js';
10
10
  import { type RuntimeLogSink, type RuntimeSessionStore, type SessionLogExport, type SessionLogExportOptions } from '../storage/session.js';
11
11
  import { ResourceLockManager } from './locks.js';
12
12
  import { type Mutation } from '../core/mutations.js';
@@ -186,6 +186,11 @@ export interface RuntimeConfig {
186
186
  hostPolicy?: ModelHostPolicy;
187
187
  modelRouter?: ModelRouter;
188
188
  tools?: RuntimeToolRegistry;
189
+ recoveryBuildMigration?: {
190
+ from: string;
191
+ to: string;
192
+ };
193
+ recoveryFingerprints?: RuntimePersistenceCompatibility['fingerprints'];
189
194
  toolVersions?: Record<string, string>;
190
195
  policyVersion?: string;
191
196
  routerVersion?: string;
@@ -298,6 +303,8 @@ export declare class PulseRuntime {
298
303
  private readonly enforcingRecoveryPrograms;
299
304
  private readonly toolVersions;
300
305
  private readonly recoveryCompatibility;
306
+ private readonly recoveryBuildMigration;
307
+ private readonly recoveryFingerprints;
301
308
  private readonly policyVersion;
302
309
  private readonly routerVersion;
303
310
  private readonly budget;
@@ -381,6 +388,13 @@ export declare class PulseRuntime {
381
388
  private wakeError;
382
389
  private tickBudget;
383
390
  static restore(backend: RuntimePersistenceBackend, config?: Omit<RuntimeConfig, 'persistence'>): Promise<PulseRuntime>;
391
+ private lazyResultCache;
392
+ private lazyResultSizes;
393
+ private lazyResultBytes;
394
+ private lazyResultRefs;
395
+ private installLazyResults;
396
+ /** Only the active step's referenced bodies enter its pure snapshot. Older refs remain available through Host inspection. */
397
+ private stepState;
384
398
  constructor(config?: RuntimeConfig);
385
399
  register(program: LaneProgram): void;
386
400
  /** Register the program used for urgent, concurrent human interactions. */
@@ -416,6 +430,7 @@ export declare class PulseRuntime {
416
430
  private assertRecoveryPrograms;
417
431
  private persistenceCompatibility;
418
432
  private assertRecoveryCompatibility;
433
+ private assertRecoveryFingerprints;
419
434
  private currentToolVersions;
420
435
  private tryEmit;
421
436
  private rejectHostCommand;
@@ -12,6 +12,7 @@ import { EffectOutbox } from '../storage/outbox.js';
12
12
  import { exportRuntimeCheckpoint, exportRuntimePersistence, externalizeRuntimeResultBodies, externalizeRuntimeSnapshotBodies, hydrateRuntimeResultBodies, hydrateRuntimeSnapshotBodies, importRuntimePersistence, validateRuntimePersistenceSnapshot, withRuntimePersistenceIntegrity } from '../storage/persistence.js';
13
13
  import { exportRuntimeLog, exportRuntimeLogTo, exportWarmStartSession } from '../storage/session.js';
14
14
  import { ResourceLockManager } from './locks.js';
15
+ import { extractToolImage } from '../core/tool-image.js';
15
16
  import { appendRuntimeEvent, normalizeRuntimeEvent } from '../core/events.js';
16
17
  import { apply, forkRuntimeStateForAdmission } from '../core/mutations.js';
17
18
  import { ContextMerger } from '../context/merger.js';
@@ -25,6 +26,12 @@ import { runtimeErrorFromCause } from '../core/errors.js';
25
26
  import { RuntimeToolRegistry } from '../tools/registry.js';
26
27
  import { SchedulerDecisionCoordinator, schedulerDecisionCandidateFromLane } from './decision.js';
27
28
  import { HumanArbitrationCoordinator, ruleHumanArbitration } from './human-arbitration.js';
29
+ function repeatedControlFailure(error, count) {
30
+ const details = { lastError: error, consecutiveControlErrors: count };
31
+ if (error.code === 'CONTEXT_TOO_LARGE')
32
+ return { code: error.code, message: `${error.message} Configure historyCompaction with an Effect-backed summary, or restore a checkpoint with a smaller history before continuing.`, details };
33
+ return { code: 'CONTROL_ERROR_LOOP', message: 'Lane exceeded the consecutive control error limit.', details };
34
+ }
28
35
  function encodeEffectExecution(execution) {
29
36
  const encoded = structuredClone(execution);
30
37
  const artifact = execution.artifact;
@@ -461,7 +468,7 @@ export class PulseRuntime {
461
468
  results = {
462
469
  get: (resultRef) => {
463
470
  const result = this.state.results.get(resultRef);
464
- return result === undefined ? undefined : structuredClone(result);
471
+ return result === undefined ? undefined : { ...structuredClone(result), ...(result.value === undefined ? {} : { value: structuredClone(result.value) }) };
465
472
  },
466
473
  };
467
474
  shuttingDown = false;
@@ -480,6 +487,8 @@ export class PulseRuntime {
480
487
  enforcingRecoveryPrograms;
481
488
  toolVersions;
482
489
  recoveryCompatibility;
490
+ recoveryBuildMigration;
491
+ recoveryFingerprints;
483
492
  policyVersion;
484
493
  routerVersion;
485
494
  budget;
@@ -567,15 +576,97 @@ export class PulseRuntime {
567
576
  if (loaded?.snapshotBodies === 'external' && backend.snapshotStore === undefined)
568
577
  throw new Error('RUNTIME_SNAPSHOT_STORE_REQUIRED');
569
578
  const withSnapshots = loaded === undefined || backend.snapshotStore === undefined ? loaded : await hydrateRuntimeSnapshotBodies(loaded, backend.snapshotStore);
570
- const snapshot = withSnapshots === undefined || backend.resultStore === undefined ? withSnapshots : await hydrateRuntimeResultBodies(withSnapshots, backend.resultStore);
579
+ const snapshot = withSnapshots === undefined || backend.resultStore === undefined || backend.lazyResultBodies && backend.resultStore.loadSync ? withSnapshots : await hydrateRuntimeResultBodies(withSnapshots, backend.resultStore);
571
580
  if (snapshot?.resultBodies === 'external' && backend.resultStore === undefined)
572
581
  throw new Error('RUNTIME_RESULT_STORE_REQUIRED');
573
582
  const restoredConfig = snapshot === undefined ? config : { ...config, persistence: snapshot, ...(config.persistenceBackend === undefined || loaded?.integrity?.digest === undefined ? {} : { persistenceExpectedDigest: loaded.integrity.digest }) };
574
- return new PulseRuntime({
583
+ const runtime = new PulseRuntime({
575
584
  ...restoredConfig,
576
585
  ...(restoredConfig.factInboxDedupeArchive === undefined && backend.factInboxDedupeArchive !== undefined ? { factInboxDedupeArchive: backend.factInboxDedupeArchive } : {}),
577
586
  ...(restoredConfig.sessionStore === undefined && backend.sessionStore !== undefined ? { sessionStore: backend.sessionStore } : {}),
578
587
  });
588
+ if (snapshot?.compatibility?.fingerprints)
589
+ runtime.assertRecoveryFingerprints(snapshot.compatibility);
590
+ if (backend.lazyResultBodies && backend.resultStore?.loadSync)
591
+ runtime.installLazyResults(backend, snapshot?.externalResultRefs ?? []);
592
+ return runtime;
593
+ }
594
+ lazyResultCache = new Map();
595
+ lazyResultSizes = new Map();
596
+ lazyResultBytes = 0;
597
+ lazyResultRefs = new Set();
598
+ installLazyResults(backend, persistedRefs) {
599
+ const store = backend.resultStore;
600
+ if (!store?.loadSync)
601
+ return;
602
+ for (const [ref, result] of this.state.results) {
603
+ if (this.lazyResultRefs.has(ref) || !persistedRefs.includes(ref))
604
+ continue;
605
+ this.lazyResultRefs.add(ref);
606
+ delete result.value;
607
+ Object.defineProperty(result, 'value', { configurable: true, enumerable: false, get: () => {
608
+ let value = this.lazyResultCache.get(ref);
609
+ if (value === undefined) {
610
+ value = store.loadSync(ref);
611
+ if (value === undefined)
612
+ throw new Error(`RUNTIME_RESULT_NOT_FOUND:${ref}`);
613
+ if (result.contentHash && contentHash(value) !== result.contentHash)
614
+ throw new Error(`RUNTIME_RESULT_INTEGRITY_MISMATCH:${ref}`);
615
+ this.lazyResultCache.set(ref, value);
616
+ const bytes = Buffer.byteLength(stableSerialize(value));
617
+ this.lazyResultSizes.set(ref, bytes);
618
+ this.lazyResultBytes += bytes;
619
+ }
620
+ while ((this.lazyResultBytes > 4_000_000 || this.lazyResultCache.size > 128) && this.lazyResultCache.size > 1) {
621
+ const oldest = this.lazyResultCache.keys().next().value;
622
+ this.lazyResultBytes -= this.lazyResultSizes.get(oldest) ?? 0;
623
+ this.lazyResultCache.delete(oldest);
624
+ this.lazyResultSizes.delete(oldest);
625
+ }
626
+ return value;
627
+ } });
628
+ }
629
+ }
630
+ /** Only the active step's referenced bodies enter its pure snapshot. Older refs remain available through Host inspection. */
631
+ stepState(lane) {
632
+ const copy = structuredClone(this.state);
633
+ if (!this.lazyResultRefs.size)
634
+ return copy;
635
+ const refs = new Set();
636
+ const collect = (value) => {
637
+ if (typeof value === 'string') {
638
+ if (this.state.results.has(value))
639
+ refs.add(value);
640
+ return;
641
+ }
642
+ if (Array.isArray(value)) {
643
+ for (const item of value)
644
+ collect(item);
645
+ return;
646
+ }
647
+ if (value && typeof value === 'object')
648
+ for (const item of Object.values(value))
649
+ collect(item);
650
+ };
651
+ collect(lane.resume.locals);
652
+ collect(lane.context.state);
653
+ collect(lane.pendingResumeInput);
654
+ collect(lane.pendingHumanInputs);
655
+ collect([...(lane.visibleResultRefs ?? [])].slice(-64));
656
+ collect(lane.context.history.slice(-64));
657
+ const agent = this.state.agents.get(lane.agentId);
658
+ if (agent) {
659
+ collect(agent.globalVersions.get(agent.latestGlobalVersion));
660
+ collect(agent.globalVersions.get(lane.contextSnapshotVersion));
661
+ }
662
+ for (const ref of refs) {
663
+ const source = this.state.results.get(ref), target = copy.results.get(ref);
664
+ if (source && target && source.value !== undefined) {
665
+ target.value = structuredClone(source.value);
666
+ collect(target.value);
667
+ }
668
+ }
669
+ return copy;
579
670
  }
580
671
  constructor(config = {}) {
581
672
  validateRuntimeConfig(config);
@@ -588,6 +679,8 @@ export class PulseRuntime {
588
679
  this.modelRouter = config.modelRouter ?? new ModelRouter(this.models, config.hostPolicy);
589
680
  this.tools = config.tools ?? new RuntimeToolRegistry();
590
681
  this.toolVersions = { ...(config.toolVersions ?? {}) };
682
+ this.recoveryBuildMigration = config.recoveryBuildMigration;
683
+ this.recoveryFingerprints = config.recoveryFingerprints;
591
684
  this.policyVersion = config.policyVersion;
592
685
  this.routerVersion = config.routerVersion;
593
686
  this.state = restored?.state ?? createRuntimeState(config.maxTotalLanes ?? 64, { ...(config.maxQueuedEffects === undefined ? {} : { maxQueuedEffects: config.maxQueuedEffects }), ...(config.maxRunning === undefined ? {} : { maxRunning: config.maxRunning }), ...(config.forkAffinity === undefined ? {} : { forkAffinity: config.forkAffinity }), ...(config.historySoftTokens === undefined ? {} : { historySoftTokens: config.historySoftTokens }), ...(config.historyHardTokens === undefined ? {} : { historyHardTokens: config.historyHardTokens }), ...(config.maxResultSummaryBytes === undefined ? {} : { maxResultSummaryBytes: config.maxResultSummaryBytes }), ...(config.trustedSanitizerIds === undefined ? {} : { trustedSanitizerIds: config.trustedSanitizerIds }) });
@@ -1101,6 +1194,10 @@ export class PulseRuntime {
1101
1194
  return compacted;
1102
1195
  }
1103
1196
  async persist(backend) {
1197
+ if (backend.eventArchive && backend.checkpointEventInterval && this.state.events.length >= backend.checkpointEventInterval) {
1198
+ await this.checkpoint(backend);
1199
+ return;
1200
+ }
1104
1201
  const persistedPolicy = this.storagePolicy.clone();
1105
1202
  persistedPolicy.markPersisted();
1106
1203
  // The saved state is already authoritative: restore does not replay the
@@ -1122,6 +1219,8 @@ export class PulseRuntime {
1122
1219
  if (journalWatermark !== undefined && journalWatermark > this.mutationLog.watermark && journalWatermark <= this.mutationLog.lastSequence)
1123
1220
  this.mutationLog.truncateThrough(journalWatermark);
1124
1221
  }
1222
+ if (backend.lazyResultBodies && backend.resultStore?.loadSync)
1223
+ this.installLazyResults(backend, snapshot.externalResultRefs ?? []);
1125
1224
  this.storagePolicy.markPersisted();
1126
1225
  this.markArtifactsPersisted();
1127
1226
  this.syncStoragePolicy();
@@ -1169,11 +1268,14 @@ export class PulseRuntime {
1169
1268
  }
1170
1269
  }
1171
1270
  const eventWatermark = options.compactEventsThrough ?? this.state.events.at(-1)?.seq;
1271
+ let archivedEventIds = [];
1172
1272
  if (backend.eventArchive !== undefined && eventWatermark !== undefined) {
1173
1273
  const fromSeq = (this.state.eventsCompactedThrough ?? 0) + 1;
1174
1274
  const events = this.state.events.filter((event) => event.seq >= fromSeq && event.seq <= eventWatermark);
1175
1275
  if (events.length)
1176
1276
  await backend.eventArchive.append(events);
1277
+ archivedEventIds = events.map(event => event.id);
1278
+ persistedPolicy.forgetArchivedEvents(archivedEventIds);
1177
1279
  }
1178
1280
  const exported = exportRuntimeCheckpoint(this.persistenceState(), this.mutationLog, this.outbox, this.quarantine, persistedPolicy, eventWatermark === undefined ? {} : { compactEventsThrough: eventWatermark }, this.factInbox.snapshot(), this.persistenceCompatibility());
1179
1281
  const archived = backend.eventArchive === undefined || eventWatermark === undefined ? exported : withRuntimePersistenceIntegrity({ ...exported, eventArchive: { through: eventWatermark } });
@@ -1185,7 +1287,6 @@ export class PulseRuntime {
1185
1287
  this.persistenceDigest = snapshot.integrity?.digest;
1186
1288
  this.storagePolicy.markPersisted();
1187
1289
  this.markArtifactsPersisted();
1188
- this.syncStoragePolicy();
1189
1290
  const watermark = snapshot.checkpoint?.logWatermark ?? 0;
1190
1291
  if (watermark > 0 && this.mutationLog.lastSequence >= watermark)
1191
1292
  this.mutationLog.truncateThrough(watermark);
@@ -1193,6 +1294,10 @@ export class PulseRuntime {
1193
1294
  this.state.events = this.state.events.filter((event) => event.seq > eventWatermark);
1194
1295
  this.state.eventsCompactedThrough = Math.max(this.state.eventsCompactedThrough ?? 0, eventWatermark);
1195
1296
  }
1297
+ this.storagePolicy.forgetArchivedEvents(archivedEventIds);
1298
+ this.syncStoragePolicy();
1299
+ if (backend.lazyResultBodies && backend.resultStore?.loadSync)
1300
+ this.installLazyResults(backend, snapshot.externalResultRefs ?? []);
1196
1301
  this.schedulePersistence();
1197
1302
  return snapshot;
1198
1303
  }
@@ -1242,6 +1347,7 @@ export class PulseRuntime {
1242
1347
  schemaVersion: 1,
1243
1348
  programVersions: Object.fromEntries([...this.programs.entries()].map(([key, program]) => [key, program.version])),
1244
1349
  toolVersions,
1350
+ ...(this.recoveryFingerprints ? { fingerprints: this.recoveryFingerprints } : {}),
1245
1351
  ...(this.policyVersion === undefined ? {} : { policyVersion: this.policyVersion }),
1246
1352
  ...(this.routerVersion === undefined ? {} : { routerVersion: this.routerVersion }),
1247
1353
  };
@@ -1252,6 +1358,7 @@ export class PulseRuntime {
1252
1358
  if (!program || program.version !== version)
1253
1359
  throw new Error(`PROGRAM_VERSION_UNAVAILABLE:${key}`);
1254
1360
  }
1361
+ this.assertRecoveryFingerprints(expected);
1255
1362
  const toolVersions = this.currentToolVersions();
1256
1363
  for (const [name, version] of Object.entries(expected.toolVersions))
1257
1364
  if (toolVersions[name] !== version)
@@ -1261,6 +1368,19 @@ export class PulseRuntime {
1261
1368
  if (expected.routerVersion !== undefined && this.routerVersion !== expected.routerVersion)
1262
1369
  throw new Error(`ROUTER_VERSION_UNAVAILABLE:${expected.routerVersion}`);
1263
1370
  }
1371
+ assertRecoveryFingerprints(expected) {
1372
+ if (expected.fingerprints) {
1373
+ if (!this.recoveryFingerprints)
1374
+ throw new Error('RECOVERY_FINGERPRINT_REQUIRED');
1375
+ if (expected.fingerprints.build !== this.recoveryFingerprints.build && (this.recoveryBuildMigration?.from !== expected.fingerprints.build || this.recoveryBuildMigration?.to !== this.recoveryFingerprints.build))
1376
+ throw new Error(`RECOVERY_BUILD_MIGRATION_REQUIRED:from=${expected.fingerprints.build}:to=${this.recoveryFingerprints.build}`);
1377
+ for (const [name, hash] of Object.entries(expected.fingerprints.tools))
1378
+ if (this.recoveryFingerprints.tools[name] !== hash)
1379
+ throw new Error(`TOOL_SCHEMA_INCOMPATIBLE:${name}`);
1380
+ if (expected.fingerprints.policy !== this.recoveryFingerprints.policy)
1381
+ throw new Error('RECOVERY_POLICY_INCOMPATIBLE');
1382
+ }
1383
+ }
1264
1384
  currentToolVersions() {
1265
1385
  return { ...this.toolVersions, ...Object.fromEntries(this.tools.list().map((manifest) => [manifest.name, manifest.version])) };
1266
1386
  }
@@ -1355,7 +1475,10 @@ export class PulseRuntime {
1355
1475
  value = null;
1356
1476
  artifact = artifactOutput(detailed.output);
1357
1477
  }
1358
- return { value, ...(artifact === undefined ? {} : { artifact }), ...(detailed.normalized === undefined ? {} : { normalized: strictJsonValue(detailed.normalized) }), ...(detailed.summary === undefined ? {} : { summary: strictJsonValue(detailed.summary) }), sideEffectState: isSideEffectful(definition.manifest.sideEffectPolicy) ? 'applied' : 'none', executionState: 'succeeded', status: 'succeeded', ...(executionRef === undefined ? {} : { executionRef }), metadata: { toolVersion: detailed.manifest.version, retrySafety: detailed.manifest.retrySafety, defaultTimeoutMs: detailed.manifest.defaultTimeoutMs, observationCount: observations.length, ...(artifact === undefined ? {} : { artifactMediaType: artifact.mediaType }) }, ...(observations.length ? { observations } : {}) };
1478
+ const image = extractToolImage(value);
1479
+ value = image.value;
1480
+ artifact = image.artifact ?? artifact;
1481
+ return { value, ...(artifact === undefined ? {} : { artifact }), ...(detailed.normalized === undefined ? {} : { normalized: image.artifact ? value : strictJsonValue(detailed.normalized) }), ...(detailed.summary === undefined ? {} : { summary: strictJsonValue(detailed.summary) }), sideEffectState: isSideEffectful(definition.manifest.sideEffectPolicy) ? 'applied' : 'none', executionState: 'succeeded', status: 'succeeded', ...(executionRef === undefined ? {} : { executionRef }), metadata: { toolVersion: detailed.manifest.version, retrySafety: detailed.manifest.retrySafety, defaultTimeoutMs: detailed.manifest.defaultTimeoutMs, observationCount: observations.length, ...(artifact === undefined ? {} : { artifactMediaType: artifact.mediaType }) }, ...(observations.length ? { observations } : {}) };
1359
1482
  }
1360
1483
  catch (cause) {
1361
1484
  if (signal.aborted && isSideEffectful(definition.manifest.sideEffectPolicy))
@@ -1443,7 +1566,7 @@ export class PulseRuntime {
1443
1566
  this.mutationLog.append(transactionId, mutations, this.state.now);
1444
1567
  }
1445
1568
  enqueueFact(fact, eventId, urgent = false) {
1446
- const candidateInbox = FactInbox.fromSnapshot(this.factInbox.snapshot());
1569
+ const candidateInbox = FactInbox.fromSnapshot(this.factInbox.snapshot(), this.factInboxDedupeArchive ? { dedupeArchive: this.factInboxDedupeArchive } : {});
1447
1570
  if (!(urgent ? candidateInbox.enqueueUrgent(fact, eventId) : candidateInbox.enqueue(fact, eventId)))
1448
1571
  return false;
1449
1572
  const candidatePolicy = this.storagePolicy.clone();
@@ -1908,7 +2031,7 @@ export class PulseRuntime {
1908
2031
  continue;
1909
2032
  }
1910
2033
  let output;
1911
- const stepContext = { lane: stepLane, state: structuredClone(this.state), ...(lane.pendingResumeInput ? { resumeInput: structuredClone(lane.pendingResumeInput) } : {}), ...(lane.pendingHumanInputs?.length ? { humanInputs: structuredClone(lane.pendingHumanInputs) } : {}), now: this.state.now, observe: (event) => { this.observationInbox.enqueue({ ...event, agentId: lane.agentId, laneId: lane.id, timestamp: this.state.now }); this.notifyActivity(); } };
2034
+ const stepContext = { lane: stepLane, state: this.stepState(lane), ...(lane.pendingResumeInput ? { resumeInput: structuredClone(lane.pendingResumeInput) } : {}), ...(lane.pendingHumanInputs?.length ? { humanInputs: structuredClone(lane.pendingHumanInputs) } : {}), now: this.state.now, observe: (event) => { this.observationInbox.enqueue({ ...event, agentId: lane.agentId, laneId: lane.id, timestamp: this.state.now }); this.notifyActivity(); } };
1912
2035
  try {
1913
2036
  output = withPureStepGuard(() => lane.series || program.seriesMember ? this.seriesStep(program, stepContext, lane.series) : program.step(stepContext));
1914
2037
  }
@@ -1942,7 +2065,7 @@ export class PulseRuntime {
1942
2065
  const consecutive = (lane.consecutiveControlErrors ?? 0) + 1;
1943
2066
  const controlInput = { type: 'control_error', error: rejection, ...(lane.pendingResumeInput ? { original: lane.pendingResumeInput } : {}) };
1944
2067
  if (consecutive >= this.maxConsecutiveControlErrors)
1945
- this.failLane(lane, { code: 'CONTROL_ERROR_LOOP', message: 'Lane exceeded the consecutive control error limit.', details: { lastError: rejection } });
2068
+ this.failLane(lane, repeatedControlFailure(rejection, consecutive));
1946
2069
  else
1947
2070
  this.commitLaneControlInput(lane, controlInput, { type: 'step.rejected', laneId: lane.id, data: rejection }, { consecutiveControlErrors: consecutive });
1948
2071
  progressed++;
@@ -1957,7 +2080,7 @@ export class PulseRuntime {
1957
2080
  }
1958
2081
  else {
1959
2082
  if (consecutive >= this.maxConsecutiveControlErrors)
1960
- this.failLane(lane, { code: 'CONTROL_ERROR_LOOP', message: 'Lane exceeded the consecutive control error limit.', details: { lastError: result.rejection } });
2083
+ this.failLane(lane, repeatedControlFailure(result.rejection, consecutive));
1961
2084
  else
1962
2085
  this.commitLaneControlInput(lane, controlInput, { type: 'step.rejected', laneId: lane.id, data: result.rejection }, { consecutiveControlErrors: consecutive });
1963
2086
  }
@@ -2501,7 +2624,7 @@ export class PulseRuntime {
2501
2624
  const artifact = prepareArtifactPublication(this.state, { ...execution.artifact, laneId: effect.ownerLaneId, derivedFrom: [...(effect.derivedFrom ?? []), ...(execution.artifact.derivedFrom ?? [])] });
2502
2625
  publishedArtifact = artifact;
2503
2626
  resultDerivedFrom = [...resultDerivedFrom, { kind: 'artifact', ref: artifact.ref }];
2504
- effectiveExecution = { ...effectiveExecution, value: { artifactRef: artifact.ref }, privacy: artifact.privacy, ...(artifact.privacyTaints === undefined ? {} : { privacyTaints: artifact.privacyTaints }) };
2627
+ effectiveExecution = { ...effectiveExecution, value: { ...(effectiveExecution.value && typeof effectiveExecution.value === 'object' && !Array.isArray(effectiveExecution.value) ? effectiveExecution.value : {}), artifactRef: artifact.ref }, privacy: artifact.privacy, ...(artifact.privacyTaints === undefined ? {} : { privacyTaints: artifact.privacyTaints }) };
2505
2628
  }
2506
2629
  catch (cause) {
2507
2630
  effectiveExecution = { ...effectiveExecution, status: 'failed', executionState: 'failed', error: runtimeErrorFromCause(cause, 'ARTIFACT_PUBLICATION_FAILED') };
@@ -2543,6 +2666,8 @@ export class PulseRuntime {
2543
2666
  effect.retryPolicy = { maxAttempts: Math.max(1, eligible), initialBackoffMs: 0, maxBackoffMs: 0, jitter: false };
2544
2667
  }
2545
2668
  const attemptMetadata = execution.metadata && typeof execution.metadata === 'object' && !Array.isArray(execution.metadata) ? execution.metadata : undefined;
2669
+ if (attempt && typeof attemptMetadata?.providerAttempts === 'number')
2670
+ attempt.providerAttempts = attemptMetadata.providerAttempts;
2546
2671
  const selectedModel = attemptMetadata?.selected && typeof attemptMetadata.selected === 'object' && !Array.isArray(attemptMetadata.selected) ? attemptMetadata.selected : undefined;
2547
2672
  if (attempt && typeof selectedModel?.id === 'string')
2548
2673
  attempt.modelId = selectedModel.id;
@@ -3621,6 +3746,20 @@ export class PulseRuntime {
3621
3746
  ...(observation.outcome?.resultRef === undefined ? [] : [observation.outcome.resultRef]),
3622
3747
  ...(observation.outcome?.rejectedOutputRefs ?? []),
3623
3748
  ]);
3749
+ // A failed/cancelled child may have durable tool effects without a
3750
+ // completion ResultRef. Expose only direct same-agent child receipts.
3751
+ for (const observation of Object.values(observations)) {
3752
+ if (observation.state === 'pending' || observation.target.kind !== 'lane')
3753
+ continue;
3754
+ const child = this.state.lanes.get(observation.target.id);
3755
+ if (!child || child.ownerLaneId !== nextLane.id || child.agentId !== nextLane.agentId)
3756
+ continue;
3757
+ for (const effectId of child.ownedEffectIds) {
3758
+ const effect = this.state.effects.get(effectId);
3759
+ if (effect?.kind === 'tool' && effect.outcome?.resultRef)
3760
+ refs.push(effect.outcome.resultRef);
3761
+ }
3762
+ }
3624
3763
  if (!refs.length)
3625
3764
  return;
3626
3765
  if (nextLane.visibleResultRefs)
@@ -38,10 +38,16 @@ export interface RuntimePersistenceCompatibility {
38
38
  toolVersions: Record<string, string>;
39
39
  policyVersion?: string;
40
40
  routerVersion?: string;
41
+ fingerprints?: {
42
+ build: string;
43
+ tools: Record<string, string>;
44
+ policy: string;
45
+ };
41
46
  }
42
47
  export interface RuntimeResultStore {
43
48
  save(ref: string, value: JsonValue): Promise<void>;
44
49
  load(ref: string): Promise<JsonValue | undefined>;
50
+ loadSync?(ref: string): JsonValue | undefined;
45
51
  }
46
52
  export interface RuntimeSnapshotStore {
47
53
  save(ref: string, value: JsonValue): Promise<void>;
@@ -57,6 +63,7 @@ export declare class FileRuntimeContentStore implements RuntimeResultStore, Runt
57
63
  constructor(directory: string);
58
64
  save(ref: string, value: JsonValue): Promise<void>;
59
65
  load(ref: string): Promise<JsonValue | undefined>;
66
+ loadSync(ref: string): JsonValue | undefined;
60
67
  private pathFor;
61
68
  private readEnvelope;
62
69
  private withLock;
@@ -70,6 +77,7 @@ export declare class SqliteRuntimeContentStore implements RuntimeResultStore, Ru
70
77
  constructor(filePath: string, namespace: 'result' | 'snapshot');
71
78
  save(ref: string, value: JsonValue): Promise<void>;
72
79
  load(ref: string): Promise<JsonValue | undefined>;
80
+ loadSync(ref: string): JsonValue | undefined;
73
81
  close(): Promise<void>;
74
82
  private open;
75
83
  private enqueue;
@@ -127,10 +135,13 @@ export declare class SqliteRuntimeFactInboxDedupeArchive implements FactInboxDed
127
135
  private enqueue;
128
136
  }
129
137
  export interface RuntimePersistenceBackend {
138
+ close?(): Promise<void>;
130
139
  load(): Promise<RuntimePersistenceSnapshot | undefined>;
131
140
  save(snapshot: RuntimePersistenceSnapshot, expectedDigest?: string): Promise<void>;
132
141
  sessionStore?: RuntimeSessionStore;
133
142
  resultStore?: RuntimeResultStore;
143
+ lazyResultBodies?: boolean;
144
+ checkpointEventInterval?: number;
134
145
  snapshotStore?: RuntimeSnapshotStore;
135
146
  eventArchive?: RuntimeEventArchive;
136
147
  factInboxDedupeArchive?: FactInboxDedupeArchiveWriter;
@@ -102,6 +102,20 @@ export class FileRuntimeContentStore {
102
102
  throw new Error('RUNTIME_CONTENT_REF_COLLISION');
103
103
  return structuredClone(envelope.value);
104
104
  }
105
+ loadSync(ref) {
106
+ let envelope;
107
+ try {
108
+ envelope = JSON.parse(readFileSync(this.pathFor(ref), 'utf8'));
109
+ }
110
+ catch (error) {
111
+ if (error.code === 'ENOENT')
112
+ return undefined;
113
+ throw error;
114
+ }
115
+ if (envelope.schemaVersion !== 1 || envelope.ref !== ref || envelope.value === undefined)
116
+ throw new Error('INVALID_RUNTIME_CONTENT');
117
+ return envelope.value;
118
+ }
105
119
  pathFor(ref) { return join(this.directory, `${createHash('sha256').update(ref).digest('hex')}.json`); }
106
120
  async readEnvelope(path) {
107
121
  try {
@@ -176,6 +190,10 @@ export class SqliteRuntimeContentStore {
176
190
  return structuredClone(JSON.parse(row.payload));
177
191
  });
178
192
  }
193
+ loadSync(ref) {
194
+ const row = this.open().prepare('SELECT payload FROM runtime_content WHERE namespace = ? AND ref = ?').get(this.namespace, ref);
195
+ return row && typeof row.payload === 'string' ? JSON.parse(row.payload) : undefined;
196
+ }
179
197
  async close() {
180
198
  await this.enqueue(async () => {
181
199
  this.database?.close();
@@ -888,8 +906,10 @@ export async function externalizeRuntimeResultBodies(snapshot, store) {
888
906
  const states = [copy.state, ...(copy.checkpoint === undefined ? [] : [copy.checkpoint.state])];
889
907
  for (const session of states) {
890
908
  for (const [ref, result] of session.state.results) {
891
- if (result.value === undefined)
909
+ if (result.value === undefined) {
910
+ refs.add(ref);
892
911
  continue;
912
+ }
893
913
  await store.save(ref, result.value);
894
914
  delete result.value;
895
915
  refs.add(ref);