@hunterzhu/pulse-runtime 0.1.6 → 0.1.8

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.
@@ -1,16 +1,21 @@
1
- import type { AgentRecord, ArtifactRef, LaneRecord, LLMRequestProjection, PrivacyLabel, PrivacyTaint, ResultRef, RuntimeState, JsonValue, ContextDelta } from '../core/types.js';
1
+ import type { AgentRecord, ArtifactRef, ConversationMessage, LaneRecord, LLMRequestProjection, PrivacyLabel, PrivacyTaint, ResultRef, RuntimeState, JsonValue, ContextDelta } from '../core/types.js';
2
2
  export interface ContextBuildInput {
3
3
  agent: AgentRecord;
4
4
  lane: LaneRecord;
5
5
  resultRefs?: ResultRef[];
6
6
  artifactRefs?: ArtifactRef[];
7
7
  eventIds?: string[];
8
+ conversation?: ConversationMessage[];
8
9
  instruction: string;
9
10
  system?: string;
10
11
  policy?: JsonValue;
11
12
  tools?: JsonValue;
12
13
  toolSetId: string;
13
14
  }
15
+ export declare const MAX_DSL_INSTRUCTION_BYTES = 2048;
16
+ /** Keep large tool results from crowding out the actual task and conversation. */
17
+ export declare const MAX_INLINE_RESULT_BYTES = 4096;
18
+ export declare function assertDslInstructionSize(value: string): string;
14
19
  export declare class ContextBuilder {
15
20
  private readonly state;
16
21
  readonly version = "1";
@@ -8,6 +8,35 @@ function stable(value) {
8
8
  return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${stable(item)}`).join(',')}}`;
9
9
  }
10
10
  function hash(value) { return createHash('sha256').update(stable(value)).digest('hex'); }
11
+ export const MAX_DSL_INSTRUCTION_BYTES = 2_048;
12
+ /** Keep large tool results from crowding out the actual task and conversation. */
13
+ export const MAX_INLINE_RESULT_BYTES = 4_096;
14
+ function utf8Prefix(value, maxBytes) {
15
+ return Buffer.from(value, 'utf8').subarray(0, maxBytes).toString('utf8');
16
+ }
17
+ function projectResultValue(result) {
18
+ const value = result.value ?? null;
19
+ const originalBytes = Buffer.byteLength(JSON.stringify(value), 'utf8');
20
+ if (originalBytes <= MAX_INLINE_RESULT_BYTES)
21
+ return { value, summarized: false };
22
+ if (result.summary !== undefined)
23
+ return { value: result.summary, summarized: true, originalBytes };
24
+ return {
25
+ value: {
26
+ truncated: true,
27
+ originalBytes,
28
+ preview: utf8Prefix(JSON.stringify(value), MAX_INLINE_RESULT_BYTES),
29
+ note: 'The full result is retained by the runtime. Use this preview. Do not re-run the producing tool.',
30
+ },
31
+ summarized: true,
32
+ originalBytes,
33
+ };
34
+ }
35
+ export function assertDslInstructionSize(value) {
36
+ if (Buffer.byteLength(value, 'utf8') > MAX_DSL_INSTRUCTION_BYTES)
37
+ throw Object.assign(new Error('Instruction exceeds the 2 KB DSL limit.'), { code: 'INSTRUCTION_TOO_LARGE', retryable: false });
38
+ return value;
39
+ }
11
40
  export class ContextBuilder {
12
41
  state;
13
42
  version = '1';
@@ -15,8 +44,7 @@ export class ContextBuilder {
15
44
  this.state = state;
16
45
  }
17
46
  build(input) {
18
- if (input.instruction.length > 2048)
19
- throw new Error('INSTRUCTION_TOO_LARGE');
47
+ assertDslInstructionSize(input.instruction);
20
48
  const global = input.agent.globalVersions.get(input.lane.contextSnapshotVersion);
21
49
  if (global === undefined)
22
50
  throw new Error('UNKNOWN_CONTEXT_VERSION');
@@ -55,15 +83,20 @@ export class ContextBuilder {
55
83
  ...effectiveResults.flatMap((item) => (item.result.privacyTaints ?? []).map((taint) => ({ path: [item.result.id, ...taint.path], privacy: taint.privacy }))),
56
84
  ...effectiveArtifacts.flatMap((item) => (item.artifact.privacyTaints ?? []).map((taint) => ({ path: [item.artifact.ref, ...taint.path], privacy: taint.privacy }))),
57
85
  ];
58
- const contextSpec = { globalSnapshotVersion: input.lane.contextSnapshotVersion, laneSnapshotVersion: input.lane.context.version, resultRefs, ...(artifactRefs.length ? { artifactRefs } : {}), eventIds: input.eventIds ?? [], toolSetId: input.toolSetId, instruction: input.instruction, privacy, privacyRefs, ...(privacyTaints.length ? { privacyTaints } : {}) };
86
+ const conversation = input.conversation ?? [];
87
+ const contextSpec = { globalSnapshotVersion: input.lane.contextSnapshotVersion, laneSnapshotVersion: input.lane.context.version, resultRefs, ...(artifactRefs.length ? { artifactRefs } : {}), eventIds: input.eventIds ?? [], toolSetId: input.toolSetId, instruction: input.instruction, ...(conversation.length ? { conversation: structuredClone(conversation) } : {}), privacy, privacyRefs, ...(privacyTaints.length ? { privacyTaints } : {}) };
59
88
  const prefixBlocks = [
60
89
  { kind: 'system', content: input.system ?? '' },
61
90
  { kind: 'policy', content: input.policy ?? {} },
62
91
  { kind: 'tools', content: input.tools ?? {} },
63
92
  { kind: 'global', content: global },
93
+ ...(conversation.length ? [{ kind: 'conversation', content: structuredClone(conversation) }] : []),
64
94
  { kind: 'history', content: input.lane.context.history.map((record) => ({ seq: record.seq, ...(record.effectId === undefined ? {} : { effectId: record.effectId }), instruction: record.instruction, resultRefs: record.resultRefs, ...(record.resultSelection === undefined ? {} : { resultSelection: record.resultSelection }), ...(record.result === undefined ? {} : { result: record.result }), ...(record.findings === undefined ? {} : { findings: record.findings }), output: record.output, privacy: record.privacy, ...(record.privacyTaints === undefined ? {} : { privacyTaints: record.privacyTaints }) })) },
65
95
  ];
66
- const blocks = [...prefixBlocks, { kind: 'lane', content: input.lane.context.state }, { kind: 'events', content: input.eventIds ?? [] }, { kind: 'results', content: results.map((result) => ({ id: result.id, value: result.value ?? null, ...(result.privacyTaints === undefined ? {} : { privacyTaints: result.privacyTaints.map((taint) => ({ path: [...taint.path], privacy: taint.privacy })) }) })) }, { kind: 'artifacts', content: artifacts.map((artifact) => ({ ref: artifact.ref, mediaType: artifact.mediaType, sizeBytes: artifact.sizeBytes, contentHash: artifact.contentHash })) }, { kind: 'instruction', content: input.instruction }];
96
+ const blocks = [...prefixBlocks, { kind: 'lane', content: input.lane.context.state }, { kind: 'events', content: input.eventIds ?? [] }, { kind: 'results', content: results.map((result) => {
97
+ const projected = projectResultValue(result);
98
+ return { id: result.id, value: projected.value, ...(projected.summarized ? { summarized: true, ...(projected.originalBytes === undefined ? {} : { originalBytes: projected.originalBytes }) } : {}), ...(result.privacyTaints === undefined ? {} : { privacyTaints: result.privacyTaints.map((taint) => ({ path: [...taint.path], privacy: taint.privacy })) }) };
99
+ }) }, { kind: 'artifacts', content: artifacts.map((artifact) => ({ ref: artifact.ref, mediaType: artifact.mediaType, sizeBytes: artifact.sizeBytes, contentHash: artifact.contentHash })) }, { kind: 'instruction', content: input.instruction }];
67
100
  return { contextSpec, blocks, prefixHash: hash(prefixBlocks), projectionHash: hash(blocks), builderVersion: this.version, policyVersion: '1', toolSetVersion: input.toolSetId, privacy, privacyRefs, ...(privacyTaints.length ? { privacyTaints } : {}) };
68
101
  }
69
102
  }
@@ -504,14 +504,19 @@ export interface LLMContextSpec {
504
504
  eventIds: string[];
505
505
  toolSetId: string;
506
506
  instruction: string;
507
+ conversation?: ConversationMessage[];
507
508
  privacy: PrivacyLabel;
508
509
  privacyRefs: ProvenanceRef[];
509
510
  privacyTaints?: PrivacyTaint[];
510
511
  }
512
+ export interface ConversationMessage {
513
+ role: 'system' | 'user' | 'assistant';
514
+ content: string;
515
+ }
511
516
  export interface LLMRequestProjection {
512
517
  contextSpec: LLMContextSpec;
513
518
  blocks: Array<{
514
- kind: 'system' | 'policy' | 'tools' | 'global' | 'history' | 'lane' | 'events' | 'results' | 'artifacts' | 'instruction';
519
+ kind: 'system' | 'policy' | 'tools' | 'global' | 'conversation' | 'history' | 'lane' | 'events' | 'results' | 'artifacts' | 'instruction';
515
520
  content: JsonValue;
516
521
  }>;
517
522
  prefixHash: string;
@@ -1,6 +1,6 @@
1
1
  import { z, type ZodTypeAny } from 'zod';
2
2
  import type { LaneProgram } from '../scheduler/runtime.js';
3
- import type { ContextDelta, JsonValue, LaneRecord, ResultRef, ProvenanceRef, RuntimeAction, ResumeInput, ProgressWatchdogState, ContextOp, LaneId, PrivacyLabel, RuntimeError, MergeProposal, ResourceLockSpec, Outcome, WaitResolution } from '../core/types.js';
3
+ import type { ContextDelta, ConversationMessage, JsonValue, LaneRecord, ResultRef, ProvenanceRef, RuntimeAction, ResumeInput, ProgressWatchdogState, ContextOp, LaneId, PrivacyLabel, RuntimeError, MergeProposal, ResourceLockSpec, Outcome, WaitResolution, HumanInputRecord } from '../core/types.js';
4
4
  import type { ProgramRef } from './templates.js';
5
5
  import type { RuntimeToolDiscoveryQuery } from '../tools/registry.js';
6
6
  export type NextStepTarget<TState = unknown> = string | {
@@ -33,11 +33,13 @@ export interface StepInputs {
33
33
  findings?: ResultRef[];
34
34
  artifacts?: string[];
35
35
  events?: string[];
36
+ conversation?: ConversationMessage[];
36
37
  toolDiscovery?: RuntimeToolDiscoveryQuery;
37
38
  }
38
39
  export interface HistoryCompactionOptions {
39
40
  summarizeTask: string;
40
41
  keepRecentRounds: number;
42
+ instruction?: string;
41
43
  }
42
44
  export interface HistoryRecordMeta {
43
45
  seq: number;
@@ -76,6 +78,7 @@ export interface StepContext<TState = JsonValue> {
76
78
  now: number;
77
79
  watchdog?: ProgressWatchdogState;
78
80
  resumeInput?: ResumeInput;
81
+ humanInputs?: readonly HumanInputRecord[];
79
82
  results: {
80
83
  meta(ref: ResultRef): ResultMeta | undefined;
81
84
  summary(ref: ResultRef): JsonValue | undefined;
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { globalContextRef, laneContextRef } from '../core/types.js';
3
3
  import { createDraftProxy } from './context-proxy.js';
4
- import { contentHash, stableSerialize } from '../context/builder.js';
4
+ import { assertDslInstructionSize, contentHash, stableSerialize } from '../context/builder.js';
5
5
  function target(step, fallback) {
6
6
  if (typeof step === 'string')
7
7
  return { step };
@@ -29,9 +29,7 @@ function scalarProjection(value) {
29
29
  return Object.fromEntries(Object.entries(value).filter(([, child]) => child === null || typeof child === 'string' || typeof child === 'number' || typeof child === 'boolean').map(([key, child]) => [key, child]));
30
30
  }
31
31
  function boundedInstruction(value) {
32
- if (Buffer.byteLength(value, 'utf8') > 2048)
33
- throw Object.assign(new Error('Instruction exceeds the 2 KB DSL limit.'), { code: 'INSTRUCTION_TOO_LARGE', retryable: false });
34
- return value;
32
+ return assertDslInstructionSize(value);
35
33
  }
36
34
  function programLLMInput(config, input) {
37
35
  return { ...input, ...(config.system === undefined ? {} : { system: config.system }), ...(config.toolSet === undefined ? {} : { toolSetId: config.toolSet }) };
@@ -201,6 +199,16 @@ function collectResumeResultRefs(input, refs) {
201
199
  refs.add(ref);
202
200
  }
203
201
  }
202
+ /** Return a wait resolution even when the scheduler wrapped it in a control error. */
203
+ function waitResolution(input) {
204
+ if (!input)
205
+ return undefined;
206
+ if (input.type === 'wait')
207
+ return input.resolution;
208
+ if (input.type === 'control_error')
209
+ return waitResolution(input.original);
210
+ return undefined;
211
+ }
204
212
  function annotateAction(action, derivedFrom) {
205
213
  if (!derivedFrom.length)
206
214
  return action;
@@ -213,7 +221,7 @@ function annotateAction(action, derivedFrom) {
213
221
  const resultReaders = new WeakMap();
214
222
  function readResult(ctx, ref) { return resultReaders.get(ctx)?.(ref); }
215
223
  function waitFailure(ctx) {
216
- const resolution = ctx.resumeInput?.type === 'wait' ? ctx.resumeInput.resolution : undefined;
224
+ const resolution = waitResolution(ctx.resumeInput);
217
225
  if (!resolution)
218
226
  return undefined;
219
227
  let dependencyError;
@@ -222,6 +230,10 @@ function waitFailure(ctx) {
222
230
  dependencyError = dependency.outcome.error;
223
231
  break;
224
232
  }
233
+ if (dependency.state !== 'pending' && dependency.outcome.status === 'cancelled') {
234
+ dependencyError = dependency.outcome.error ?? { code: 'EFFECT_CANCELLED', message: dependency.outcome.reason ?? 'A waited effect was cancelled.', retryable: false };
235
+ break;
236
+ }
225
237
  }
226
238
  if (dependencyError)
227
239
  return dependencyError;
@@ -288,7 +300,7 @@ function makeContext(context, initialState) {
288
300
  delta = { target: 'global', baseVersion: agent?.latestGlobalVersion ?? 0, sourceLaneId: context.lane.id, ops: clone(ops), ...(value.privacy === undefined ? {} : { privacy: value.privacy }), proposal: value.proposal };
289
301
  };
290
302
  const ctx = {
291
- lane: context.lane, goal: context.lane.goal, global, globalVersion, laneState: readonlyState, history, now: context.now, ...(context.lane.progressWatchdog === undefined ? {} : { watchdog: context.lane.progressWatchdog }), ...(context.resumeInput ? { resumeInput: context.resumeInput } : {}),
303
+ lane: context.lane, goal: context.lane.goal, global, globalVersion, laneState: readonlyState, history, now: context.now, ...(context.lane.progressWatchdog === undefined ? {} : { watchdog: context.lane.progressWatchdog }), ...(context.resumeInput ? { resumeInput: context.resumeInput } : {}), ...(context.humanInputs?.length ? { humanInputs: context.humanInputs } : {}),
292
304
  results: { meta: resultMeta, summary: (ref) => { if (context.state.results.has(ref) && resultVisible(context, ref))
293
305
  derivedRefs.add(ref); return resultMeta(ref)?.summary; } },
294
306
  mergeProposals: [...context.state.mergeProposals.values()].filter((proposal) => proposal.agentId === context.lane.agentId).map((proposal) => { for (const ref of proposal.delta.derivedFrom ?? [])
@@ -371,16 +383,34 @@ export class StepBuilder {
371
383
  }
372
384
  addReActLoopStep(name, options) {
373
385
  this.compactionBoundaries.add(name);
386
+ // The decode step handles ReAct compaction after consuming the current
387
+ // model result. This preserves the wait's result references while the
388
+ // summary is being generated.
374
389
  const readTurns = (ctx) => { const sdk = sdkLocals(ctx.lane.resume.locals); const turn = sdk[`${name}Turns`]; return typeof turn === 'number' && Number.isInteger(turn) && turn >= 0 ? turn : 0; };
375
390
  const inputKey = `${name}Inputs`;
376
391
  const readInputs = (ctx) => { const value = sdkLocals(ctx.lane.resume.locals)[inputKey]; return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; };
377
- const writeTurns = (ctx, turns, inputs = readInputs(ctx)) => { const locals = ctx.lane.resume.locals; const base = locals && typeof locals === 'object' && !Array.isArray(locals) ? locals : {}; return { ...base, $sdk: { ...sdkLocals(locals), [`${name}Turns`]: turns, [inputKey]: asJson(inputs) } }; };
378
- const resultRefFromWait = (ctx) => { const dependency = ctx.resumeInput?.type === 'wait' ? Object.values(ctx.resumeInput.resolution.dependencies)[0] : undefined; return dependency?.state === 'settled' ? dependency.outcome.resultRef : undefined; };
379
- const resultRefsFromWait = (ctx) => ctx.resumeInput?.type === 'wait' ? Object.values(ctx.resumeInput.resolution.dependencies).flatMap((dependency) => dependency.state === 'settled' && dependency.outcome.resultRef ? [dependency.outcome.resultRef] : []) : [];
392
+ const writeTurns = (ctx, turns, inputs = readInputs(ctx)) => { const { conversation: _conversation, ...persistedInputs } = inputs; const locals = ctx.lane.resume.locals; const base = locals && typeof locals === 'object' && !Array.isArray(locals) ? locals : {}; return { ...base, $sdk: { ...sdkLocals(locals), [`${name}Turns`]: turns, [inputKey]: asJson(persistedInputs) } }; };
393
+ const pendingResultKey = `${name}PendingResultRef`;
394
+ const pendingResultRef = (ctx) => { const value = sdkLocals(ctx.lane.resume.locals)[pendingResultKey]; return typeof value === 'string' ? value : undefined; };
395
+ const clearPendingResult = (ctx) => {
396
+ const locals = ordinaryLocals(ctx.lane.resume.locals);
397
+ const sdk = { ...sdkLocals(ctx.lane.resume.locals) };
398
+ delete sdk[pendingResultKey];
399
+ return { ...locals, $sdk: sdk };
400
+ };
401
+ const resultRefFromWait = (ctx) => {
402
+ const pending = pendingResultRef(ctx);
403
+ if (pending)
404
+ return pending;
405
+ const resolution = waitResolution(ctx.resumeInput);
406
+ const dependency = resolution === undefined ? undefined : Object.values(resolution.dependencies).find((candidate) => candidate.state === 'settled');
407
+ return dependency?.state === 'settled' ? dependency.outcome.resultRef : undefined;
408
+ };
409
+ const resultRefsFromWait = (ctx) => { const resolution = waitResolution(ctx.resumeInput); return resolution === undefined ? [] : Object.values(resolution.dependencies).flatMap((dependency) => dependency.state === 'settled' && dependency.outcome.resultRef ? [dependency.outcome.resultRef] : []); };
380
410
  const instruction = (ctx) => boundedInstruction(typeof options.instruction === 'string' ? options.instruction : options.instruction({ goal: ctx.goal, state: scalarProjection(ctx.laneState) }));
381
- const submitModel = (ctx, turn, inputs = {}) => { const resultRefs = [...new Set(inputs.results ?? [])]; const findingRefs = [...new Set(inputs.findings ?? [])]; const artifactRefs = [...new Set(inputs.artifacts ?? [])]; const dataRefs = [...resultRefs, ...findingRefs, ...artifactRefs.map((ref) => ({ kind: 'artifact', ref }))]; const requirements = { ...(options.requirements ?? {}), ...(options.toolAllow === undefined ? {} : { toolCalling: true }) }; return { actions: [{ type: 'submit_effects', effects: [{ key: `${name}-turn-${turn}`, kind: 'llm', concurrencyClass: 'llm', input: programLLMInput(this.config, { task: options.task ?? 'reason', instruction: instruction(ctx), inputs: { ...(resultRefs.length ? { results: resultRefs } : {}), ...(findingRefs.length ? { findings: findingRefs } : {}), ...(artifactRefs.length ? { artifacts: artifactRefs } : {}), ...(inputs.events?.length ? { events: [...new Set(inputs.events)] } : {}) }, turn, ...(inputs.toolDiscovery === undefined ? {} : { toolDiscovery: inputs.toolDiscovery }), ...(options.outputSchema === undefined ? {} : { outputSchema: zodJsonSchema(options.outputSchema) }), ...(Object.keys(requirements).length ? { requirements } : {}) }), ...(dataRefs.length ? { derivedFrom: dataRefs } : {}) }], wait: { onUnsatisfied: 'resume_with_error' } }], next: { programId: this.config.id, programVersion: this.config.version, step: `${name}:decode`, locals: writeTurns(ctx, turn, inputs) }, locals: writeTurns(ctx, turn, inputs) }; };
411
+ const submitModel = (ctx, turn, inputs = {}) => { const resultRefs = [...new Set(inputs.results ?? [])]; const findingRefs = [...new Set(inputs.findings ?? [])]; const artifactRefs = [...new Set(inputs.artifacts ?? [])]; const dataRefs = [...resultRefs, ...findingRefs, ...artifactRefs.map((ref) => ({ kind: 'artifact', ref }))]; const requirements = { ...(options.requirements ?? {}), ...(options.toolAllow === undefined ? {} : { toolCalling: true }) }; return { actions: [{ type: 'submit_effects', effects: [{ key: `${name}-turn-${turn}`, kind: 'llm', concurrencyClass: 'llm', input: programLLMInput(this.config, { task: options.task ?? 'reason', instruction: instruction(ctx), inputs: { ...(resultRefs.length ? { results: resultRefs } : {}), ...(findingRefs.length ? { findings: findingRefs } : {}), ...(artifactRefs.length ? { artifacts: artifactRefs } : {}), ...(inputs.events?.length ? { events: [...new Set(inputs.events)] } : {}), ...(inputs.conversation?.length ? { conversation: inputs.conversation } : {}) }, turn, ...(inputs.toolDiscovery === undefined ? {} : { toolDiscovery: inputs.toolDiscovery }), ...(options.outputSchema === undefined ? {} : { outputSchema: zodJsonSchema(options.outputSchema) }), ...(Object.keys(requirements).length ? { requirements } : {}) }), ...(dataRefs.length ? { derivedFrom: dataRefs } : {}) }], wait: { onUnsatisfied: 'resume_with_error' } }], next: { programId: this.config.id, programVersion: this.config.version, step: `${name}:decode`, locals: writeTurns(ctx, turn, inputs) }, locals: writeTurns(ctx, turn, inputs) }; };
382
412
  this.handlers.set(name, (ctx) => { const turn = readTurns(ctx) + 1; const inputs = options.inputs?.(ctx) ?? {}; const output = submitModel(ctx, turn, inputs); return { ...output, next: `${name}:decode` }; });
383
- this.handlers.set(`${name}:tools`, (ctx) => { const turn = readTurns(ctx); const previous = readInputs(ctx); const inputs = { ...previous, results: [...new Set([...(previous.results ?? []), ...resultRefsFromWait(ctx)])] }; return { ...submitModel(ctx, turn + 1, inputs), next: `${name}:decode` }; });
413
+ this.handlers.set(`${name}:tools`, (ctx) => { const turn = readTurns(ctx); const previous = readInputs(ctx); const current = options.inputs?.(ctx) ?? {}; const inputs = { ...current, ...previous, results: [...new Set([...(previous.results ?? []), ...resultRefsFromWait(ctx)])] }; return { ...submitModel(ctx, turn + 1, inputs), next: `${name}:decode` }; });
384
414
  this.handlers.set(`${name}:decode`, (ctx) => {
385
415
  const turns = readTurns(ctx);
386
416
  const ref = resultRefFromWait(ctx);
@@ -388,19 +418,33 @@ export class StepBuilder {
388
418
  const record = value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
389
419
  const finishReason = record?.finishReason;
390
420
  const toolCalls = Array.isArray(record?.toolCalls) ? record.toolCalls : [];
391
- const fail = (runtimeError) => options.onError ? { next: options.onError(runtimeError, ctx) } : (() => { const error = Object.assign(new Error(runtimeError.message), runtimeError); throw error; })();
421
+ const fail = (runtimeError) => options.onError ? { next: options.onError(runtimeError, ctx), locals: clearPendingResult(ctx) } : (() => { const error = Object.assign(new Error(runtimeError.message), runtimeError); throw error; })();
392
422
  const dependencyError = waitFailure(ctx);
393
423
  if (dependencyError)
394
424
  return fail(dependencyError);
395
425
  const maxTurns = Math.max(1, Math.floor(options.maxTurns ?? 10));
396
- const maxTurnsReached = () => options.onMaxTurns ? { next: options.onMaxTurns(ctx) } : fail({ code: 'MAX_TURNS_REACHED', message: `ReAct loop ${name} reached its maximum of ${maxTurns} turns.`, retryable: false });
426
+ const maxTurnsReached = () => options.onMaxTurns ? { next: options.onMaxTurns(ctx), locals: clearPendingResult(ctx) } : fail({ code: 'MAX_TURNS_REACHED', message: `ReAct loop ${name} reached its maximum of ${maxTurns} turns.`, retryable: false });
427
+ // The current model result must be retained while older history is
428
+ // summarized. The generic compaction macro then returns to decode and
429
+ // this pending reference lets us continue processing the same result.
430
+ const compaction = this.config.historyCompaction;
431
+ const pressure = ctx.lane.historyPressure;
432
+ const shouldCompactAfterResult = compaction !== undefined && ref !== undefined && pendingResultRef(ctx) === undefined && sdkLocals(ctx.lane.resume.locals).compactPending !== true && pressure !== undefined && pressure.historyTokens > pressure.softTokens && ctx.lane.context.history.length > Math.max(0, Math.floor(compaction.keepRecentRounds));
433
+ if (shouldCompactAfterResult) {
434
+ const locals = ordinaryLocals(ctx.lane.resume.locals);
435
+ const sdk = sdkLocals(ctx.lane.resume.locals);
436
+ return { actions: [], next: '$compact:summarize', locals: { ...locals, $sdk: { ...sdk, compactPending: true, compactReturnStep: `${name}:decode`, [pendingResultKey]: ref } } };
437
+ }
397
438
  if (finishReason === 'tool_calls') {
398
439
  if (turns >= maxTurns || toolCalls.length === 0)
399
440
  return maxTurnsReached();
400
441
  const invalidTool = toolCalls.find((call) => { const item = call && typeof call === 'object' && !Array.isArray(call) ? call : {}; const toolName = typeof item.name === 'string' ? item.name : ''; return !toolName || (options.toolAllow !== undefined && !options.toolAllow.includes(toolName)); });
401
442
  if (invalidTool !== undefined)
402
443
  return fail({ code: 'ACTION_TOOL_NOT_ALLOWED', message: 'Model requested a tool outside the ReAct allow-list.', retryable: false });
403
- const sourceEffectId = ctx.resumeInput?.type === 'wait' ? Object.values(ctx.resumeInput.resolution.dependencies).find((dependency) => dependency.state === 'settled' && dependency.target.kind === 'effect')?.target.id : undefined;
444
+ const resolution = waitResolution(ctx.resumeInput);
445
+ const sourceEffectId = ref !== undefined && ctx.results.meta(ref)?.producer.kind === 'effect'
446
+ ? ctx.results.meta(ref)?.producer.id
447
+ : resolution === undefined ? undefined : Object.values(resolution.dependencies).find((dependency) => dependency.state === 'settled' && dependency.target.kind === 'effect')?.target.id;
404
448
  const sourcePrivacy = ref === undefined ? undefined : ctx.results.meta(ref)?.privacy;
405
449
  const toolDerivedFrom = ref === undefined ? [] : [ref];
406
450
  const calls = toolCalls.map((call, index) => {
@@ -409,6 +453,72 @@ export class StepBuilder {
409
453
  const toolName = typeof item.name === 'string' ? item.name : '';
410
454
  return { originalId, toolName, toolCallId: `${name}:${turns}:${originalId}`, input: item.input ?? {} };
411
455
  });
456
+ const askCalls = calls.filter((call) => String(call.toolName).startsWith('ask.'));
457
+ if (askCalls.length > 0) {
458
+ if (askCalls.length !== calls.length)
459
+ return fail({ code: 'ASK_MIXED_TOOL_CALLS', message: 'An ask interaction must be requested in a separate model turn from workspace tools.', retryable: false });
460
+ if (askCalls.length !== 1)
461
+ return fail({ code: 'ASK_MULTIPLE_REQUESTS', message: 'Only one ask interaction may be requested at a time.', retryable: false });
462
+ const call = askCalls[0];
463
+ const rawInput = call.input && typeof call.input === 'object' && !Array.isArray(call.input) ? call.input : {};
464
+ const askType = call.toolName === 'ask.choice' ? 'choice' : call.toolName === 'ask.multi' ? 'multi' : call.toolName === 'ask.input' ? 'input' : undefined;
465
+ if (!askType)
466
+ return fail({ code: 'ASK_TOOL_UNKNOWN', message: `Unknown ask tool ${String(call.toolName)}.`, retryable: false });
467
+ if (typeof rawInput.prompt === 'string' && rawInput.prompt.length > 2_000)
468
+ return fail({ code: 'ASK_PROMPT_TOO_LARGE', message: 'Ask prompts must be 2000 characters or fewer.', retryable: false });
469
+ const prompt = typeof rawInput.prompt === 'string' && rawInput.prompt.trim() ? rawInput.prompt : `Pulse needs your input for ${askType}.`;
470
+ const askInput = { kind: 'ask', type: askType, toolName: String(call.toolName), toolCallId: String(call.toolCallId), prompt };
471
+ if (askType === 'choice' || askType === 'multi') {
472
+ const rawOptions = Array.isArray(rawInput.options) ? rawInput.options : [];
473
+ if (rawOptions.length > 50)
474
+ return fail({ code: 'ASK_OPTIONS_TOO_MANY', message: 'ask.choice and ask.multi accept at most 50 options.', retryable: false });
475
+ const seen = new Set();
476
+ const options = rawOptions.flatMap((option) => {
477
+ const item = typeof option === 'string' ? { label: option, value: option } : option && typeof option === 'object' && !Array.isArray(option) ? option : undefined;
478
+ if (!item || typeof item.label !== 'string' || typeof item.value !== 'string')
479
+ return [];
480
+ if (item.label.length === 0 || item.label.length > 500 || item.value.length === 0 || item.value.length > 500 || seen.has(item.value))
481
+ return [];
482
+ seen.add(item.value);
483
+ return [{ label: item.label, value: item.value }];
484
+ });
485
+ if (options.length === 0)
486
+ return fail({ code: 'ASK_OPTIONS_REQUIRED', message: 'ask.choice and ask.multi require at least one valid option.', retryable: false });
487
+ askInput.options = options;
488
+ if (askType === 'multi') {
489
+ const min = typeof rawInput.min === 'number' && Number.isInteger(rawInput.min) ? rawInput.min : undefined;
490
+ const max = typeof rawInput.max === 'number' && Number.isInteger(rawInput.max) ? rawInput.max : undefined;
491
+ if ((rawInput.min !== undefined && (min === undefined || min < 0)) || (rawInput.max !== undefined && (max === undefined || max < 1)) || (min !== undefined && max !== undefined && min > max))
492
+ return fail({ code: 'ASK_RANGE_INVALID', message: 'ask.multi min and max must be integers with min <= max.', retryable: false });
493
+ if (min !== undefined)
494
+ askInput.min = min;
495
+ if (max !== undefined)
496
+ askInput.max = max;
497
+ }
498
+ }
499
+ else {
500
+ if (typeof rawInput.placeholder === 'string') {
501
+ if (rawInput.placeholder.length > 500)
502
+ return fail({ code: 'ASK_PROMPT_TOO_LARGE', message: 'Ask placeholders must be 500 characters or fewer.', retryable: false });
503
+ askInput.placeholder = rawInput.placeholder;
504
+ }
505
+ if (typeof rawInput.defaultValue === 'string') {
506
+ if (rawInput.defaultValue.length > 2_000)
507
+ return fail({ code: 'ASK_PROMPT_TOO_LARGE', message: 'Ask default values must be 2000 characters or fewer.', retryable: false });
508
+ askInput.defaultValue = rawInput.defaultValue;
509
+ }
510
+ }
511
+ const humanEffect = {
512
+ key: `${name}-ask-${turns}`,
513
+ ...(sourceEffectId === undefined ? {} : { llmEffectId: sourceEffectId }),
514
+ ...(sourcePrivacy === undefined ? {} : { privacy: sourcePrivacy }),
515
+ ...(toolDerivedFrom.length ? { derivedFrom: [...toolDerivedFrom] } : {}),
516
+ kind: 'human',
517
+ concurrencyClass: 'none',
518
+ input: askInput,
519
+ };
520
+ return { actions: [{ type: 'submit_effects', effects: [humanEffect], wait: { onUnsatisfied: 'resume_with_error' } }], next: `${name}:tools`, locals: clearPendingResult(ctx) };
521
+ }
412
522
  const makeToolEffects = (approvedCalls) => ({ type: 'submit_effects', effects: approvedCalls.map((call, index) => ({ key: `${name}-tool-${turns}-${index + 1}`, toolCallId: String(call.toolCallId ?? `${name}:${turns}:${String(call.originalId)}`), ...(sourceEffectId === undefined ? {} : { llmEffectId: sourceEffectId }), ...(sourcePrivacy === undefined ? {} : { privacy: sourcePrivacy }), ...(toolDerivedFrom.length ? { derivedFrom: [...toolDerivedFrom] } : {}), kind: 'tool', concurrencyClass: 'tool', input: { toolCallId: String(call.toolCallId ?? `${name}:${turns}:${String(call.originalId)}`), name: String(call.toolName), arguments: call.input, ...(sourcePrivacy === undefined ? {} : { privacy: sourcePrivacy }), ...(toolDerivedFrom.length ? { derivedFrom: [...toolDerivedFrom] } : {}) } })), wait: { onUnsatisfied: 'resume_with_error' } });
413
523
  if (options.toolApproval) {
414
524
  const approvalKey = `${name}PendingToolCalls`;
@@ -419,13 +529,21 @@ export class StepBuilder {
419
529
  const extra = typeof options.toolApproval.prompt === 'string' ? options.toolApproval.prompt : options.toolApproval.prompt(calls.map((call) => ({ name: call.toolName, toolCallId: call.originalId })), ctx);
420
530
  const prompt = boundedInstruction(`Approve ${calls.length} tool call(s). Digest ${digest}.\n${listing}\n${extra}`);
421
531
  this.handlers.set(`${name}:approval`, (approvalCtx) => {
422
- const dependency = approvalCtx.resumeInput?.type === 'wait' ? Object.values(approvalCtx.resumeInput.resolution.dependencies)[0] : undefined;
532
+ const resolution = waitResolution(approvalCtx.resumeInput);
533
+ const dependency = resolution === undefined ? undefined : Object.values(resolution.dependencies).find((candidate) => candidate.state === 'settled');
423
534
  const approvalError = waitFailure(approvalCtx);
424
535
  if (approvalError)
425
536
  return fail(approvalError);
426
- if (dependency?.state !== 'settled')
427
- return fail({ code: 'APPROVAL_RESPONSE_MISSING', message: 'Approval response was not received.', retryable: false });
428
- const value = dependency.outcome.resultRef ? readResult(approvalCtx, dependency.outcome.resultRef) : undefined;
537
+ const settledDependency = dependency;
538
+ if (settledDependency?.state !== 'settled') {
539
+ return fail({
540
+ code: 'APPROVAL_RESPONSE_MISSING',
541
+ message: 'Approval response was not received.',
542
+ retryable: false,
543
+ details: { dependencyState: settledDependency?.state ?? 'missing', waitId: resolution?.waitId ?? null },
544
+ });
545
+ }
546
+ const value = settledDependency.outcome.resultRef ? readResult(approvalCtx, settledDependency.outcome.resultRef) : undefined;
429
547
  const parsed = z.object({ approved: z.boolean(), reason: z.string().optional() }).safeParse(value);
430
548
  if (!parsed.success)
431
549
  return fail({ code: 'APPROVAL_RESPONSE_INVALID', message: 'Approval response must contain approved=true or false.', retryable: false, details: parsed.error.message });
@@ -447,7 +565,7 @@ export class StepBuilder {
447
565
  });
448
566
  return { actions: [{ type: 'submit_effects', effects: [{ key: `${name}-approval-${turns}`, kind: 'human', concurrencyClass: 'none', input: { prompt, digest, tools: calls }, ...(toolDerivedFrom.length ? { derivedFrom: [...toolDerivedFrom] } : {}) }], wait: { onUnsatisfied: 'resume_with_error' } }], next: `${name}:approval`, locals: { ...locals, $sdk: { ...sdkLocals(locals), [approvalKey]: calls, [digestKey]: digest } } };
449
567
  }
450
- return { actions: [makeToolEffects(calls)], next: `${name}:tools` };
568
+ return { actions: [makeToolEffects(calls)], next: `${name}:tools`, locals: clearPendingResult(ctx) };
451
569
  }
452
570
  if (turns >= maxTurns)
453
571
  return maxTurnsReached();
@@ -460,15 +578,15 @@ export class StepBuilder {
460
578
  if (!ref)
461
579
  return fail({ code: 'MISSING_RESULT_REF', message: 'ReAct result did not produce a ResultRef.', retryable: false });
462
580
  if (typeof options.onFinish === 'function')
463
- return { next: options.onFinish(ref, ctx) };
581
+ return { next: options.onFinish(ref, ctx), locals: clearPendingResult(ctx) };
464
582
  if (options.onFinish.structured) {
465
583
  const structuredValue = record?.structured ?? value;
466
584
  const parsed = options.onFinish.structured.schema.safeParse(structuredValue);
467
585
  if (!parsed.success)
468
586
  return fail({ code: 'OUTPUT_SCHEMA_VIOLATION', message: 'ReAct structured result did not match schema.', retryable: false, details: parsed.error.message });
469
- return { next: options.onFinish.structured.onParsed(parsed.data, ctx) };
587
+ return { next: options.onFinish.structured.onParsed(parsed.data, ctx), locals: clearPendingResult(ctx) };
470
588
  }
471
- return { next: options.onFinish.text(ref, ctx) };
589
+ return { next: options.onFinish.text(ref, ctx), locals: clearPendingResult(ctx) };
472
590
  });
473
591
  return this;
474
592
  }
@@ -624,7 +742,7 @@ export class StepBuilder {
624
742
  if (upToSeq === undefined)
625
743
  return { next: returnStep === compactSummarize ? entry : returnStep, locals: { ...locals, $sdk: sdk } };
626
744
  return {
627
- actions: [{ type: 'submit_effects', effects: [{ key: '$compact-summary', kind: 'llm', concurrencyClass: 'llm', input: programLLMInput(this.config, { task: compaction.summarizeTask, historySeqs: candidates.map((record) => record.seq), upToSeq }) }], wait: { onUnsatisfied: 'resume_with_error' } }],
745
+ actions: [{ type: 'submit_effects', effects: [{ key: '$compact-summary', kind: 'llm', concurrencyClass: 'llm', input: programLLMInput(this.config, { task: compaction.summarizeTask, ...(compaction.instruction === undefined ? {} : { instruction: compaction.instruction }), historySeqs: candidates.map((record) => record.seq), upToSeq }) }], wait: { onUnsatisfied: 'resume_with_error' } }],
628
746
  next: compactApply,
629
747
  locals: { ...locals, $sdk: { ...sdk, compactPending: true, compactReturnStep: returnStep, compactUpToSeq: upToSeq } },
630
748
  };
@@ -662,7 +780,12 @@ export class StepBuilder {
662
780
  step: (context) => {
663
781
  const sdk = sdkLocals(context.lane.resume.locals);
664
782
  const pressure = context.lane.historyPressure;
665
- const shouldCompact = compaction !== undefined && this.compactionBoundaries.has(context.lane.resume.step) && !context.lane.resume.step.startsWith('$compact:') && context.lane.activeWaitId === undefined && sdk.compactPending !== true && pressure !== undefined && pressure.historyTokens > pressure.softTokens && context.lane.context.history.length > Math.max(0, Math.floor(compaction.keepRecentRounds));
783
+ const keepRecentRounds = compaction === undefined ? 0 : Math.max(0, Math.floor(compaction.keepRecentRounds));
784
+ const foldable = context.lane.context.history.slice(0, Math.max(0, context.lane.context.history.length - keepRecentRounds));
785
+ // A prefix that is already one compaction summary cannot get smaller by
786
+ // summarizing it again. Wait until newer rounds accumulate.
787
+ const onlyExistingSummary = foldable.length === 1 && foldable[0]?.instruction === '[history compacted]';
788
+ const shouldCompact = compaction !== undefined && this.compactionBoundaries.has(context.lane.resume.step) && !context.lane.resume.step.startsWith('$compact:') && context.lane.activeWaitId === undefined && sdk.compactPending !== true && pressure !== undefined && pressure.historyTokens > pressure.softTokens && context.lane.context.history.length > keepRecentRounds && !onlyExistingSummary;
666
789
  if (shouldCompact)
667
790
  return { actions: [], next: { programId: this.config.id, programVersion: this.config.version, step: compactSummarize, locals: { ...ordinaryLocals(context.lane.resume.locals), $sdk: { ...sdk, compactPending: true, compactReturnStep: context.lane.resume.step } } } };
668
791
  const requestedStep = shouldCompact ? compactSummarize : context.lane.resume.step;
@@ -71,7 +71,9 @@ export class PulseSession {
71
71
  const root = [...this.runtime.state.lanes.values()].find((lane) => lane.agentId === this.agentId && lane.ownerLaneId === undefined);
72
72
  if (root && ['succeeded', 'failed', 'cancelled'].includes(root.status) && !this.hasActiveDescendant() && (this.runtime.state.events.at(-1)?.seq ?? compactedThrough) === cursor)
73
73
  return;
74
- await new Promise((resolve) => setImmediate(resolve));
74
+ // Wait for runtime activity instead of polling with setImmediate while
75
+ // an external effect is still in flight.
76
+ await this.runtime.waitForActivity();
75
77
  }
76
78
  }
77
79
  async snapshot() {
@@ -98,17 +100,21 @@ export class PulseSession {
98
100
  async outcome() { return this.execution; }
99
101
  async reply(effectId, value) {
100
102
  const effect = this.runtime.state.effects.get(effectId);
101
- if (!effect || effect.agentId !== this.agentId)
103
+ if (!effect || !this.ownsAgent(effect.agentId))
102
104
  throw new Error('EFFECT_NOT_OWNED');
103
105
  if (effect.kind !== 'human' || effect.outcome)
104
106
  throw new Error('EFFECT_NOT_REPLYABLE');
105
- this.runtime.enqueueHostCommand({ type: 'reply', agentId: this.agentId, effectId, value });
107
+ if (!this.runtime.enqueueHostCommand({ type: 'reply', agentId: effect.agentId, effectId, value })) {
108
+ throw new Error('HOST_COMMAND_NOT_ENQUEUED');
109
+ }
106
110
  }
107
111
  /** Submit a human message while the agent is still running. The scheduler
108
112
  * records it immediately; targetEffectId is optional for direct replies to
109
113
  * a waiting Human Effect. */
110
114
  async submitHumanInput(inputId, value, targetEffectId) {
111
- this.runtime.submitHumanInput(this.agentId, inputId, value, targetEffectId);
115
+ if (!this.runtime.submitHumanInput(this.agentId, inputId, value, targetEffectId)) {
116
+ throw new Error('HOST_COMMAND_NOT_ENQUEUED');
117
+ }
112
118
  }
113
119
  async cancel(reason) { this.runtime.requestCancel(this.agentId, reason); }
114
120
  }
@@ -1,4 +1,4 @@
1
- import { type LaneProgramDefinition, type StepContext, type InstructionView, type NextStepTarget, type StepInputs } from './program.js';
1
+ import { type HistoryCompactionOptions, type LaneProgramDefinition, type StepContext, type InstructionView, type NextStepTarget, type StepInputs } from './program.js';
2
2
  import type { LaneProgram } from '../scheduler/runtime.js';
3
3
  import type { Outcome, JsonValue } from '../core/types.js';
4
4
  import type { ZodTypeAny } from 'zod';
@@ -24,10 +24,7 @@ export declare function defineReActLane(config: {
24
24
  prompt: string | ((calls: JsonValue, ctx: StepContext<JsonValue>) => string);
25
25
  onDenied?: (reason: string, ctx: StepContext<JsonValue>) => NextStepTarget<JsonValue>;
26
26
  };
27
- historyCompaction?: {
28
- summarizeTask: string;
29
- keepRecentRounds: number;
30
- };
27
+ historyCompaction?: HistoryCompactionOptions;
31
28
  }): LaneProgramDefinition;
32
29
  export declare function defineSeriesLane(config: {
33
30
  id: string;
@@ -35,11 +35,19 @@ export function ruleHumanArbitration(value, agentId, inputId, modelId = 'rules')
35
35
  const object = value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
36
36
  const text = typeof value === 'string' ? value.trim() : typeof object?.text === 'string' ? object.text.trim() : undefined;
37
37
  const command = typeof object?.command === 'string' ? object.command : text?.startsWith('/') ? text.slice(1).split(/\s+/, 1)[0] : undefined;
38
+ const targetLaneId = typeof object?.laneId === 'string' ? object.laneId : undefined;
39
+ const targetEffectId = typeof object?.effectId === 'string' ? object.effectId : undefined;
40
+ if (!command && text) {
41
+ const normalizedText = text.toLowerCase().replace(/[\s,。!?!?、,.]+/g, '');
42
+ const isContinuation = /^(继续|继续处理|接着做|往下做|恢复任务|resume|continue|goon|keepgoing)$/.test(normalizedText);
43
+ const isStatusCheck = /^(你还活着吗|还在吗|有进展吗|进展呢|现在怎么样|areyoualive|areyoustillthere|anyupdate|status)$/.test(normalizedText);
44
+ if (isContinuation || isStatusCheck) {
45
+ return { schemaVersion: 1, decisionId: `rule:${inputId}`, inputId, agentId, action: 'steer', ...(targetLaneId === undefined ? {} : { targetLaneId }), reason: isContinuation ? 'Human asked the current task to continue.' : 'Human asked for the current task status.', modelId };
46
+ }
47
+ }
38
48
  if (!command)
39
49
  return undefined;
40
50
  const normalized = command.toLowerCase();
41
- const targetLaneId = typeof object?.laneId === 'string' ? object.laneId : undefined;
42
- const targetEffectId = typeof object?.effectId === 'string' ? object.effectId : undefined;
43
51
  if (normalized === 'cancel' || normalized === 'stop' || normalized === 'abort')
44
52
  return { schemaVersion: 1, decisionId: `rule:${inputId}`, inputId, agentId, action: 'cancel', ...(targetLaneId === undefined ? {} : { targetLaneId }), ...(targetEffectId === undefined ? {} : { targetEffectId }), reason: 'Human requested cancellation.', modelId };
45
53
  if (normalized === 'steer' || normalized === 'redirect')
@@ -278,6 +278,13 @@ export declare class PulseRuntime {
278
278
  private readonly auditLogPrivacy;
279
279
  private readonly persistenceBackend;
280
280
  private readonly sessionStore;
281
+ /**
282
+ * The file session store is synchronous by design, so avoid rewriting an
283
+ * unchanged warm-start snapshot on every scheduler tick. Without this
284
+ * guard an idle run can spend its whole event loop serializing and fsyncing
285
+ * the same snapshot, starving effect dispatch and human input handling.
286
+ */
287
+ private readonly sessionStoreDigests;
281
288
  private readonly enforcingRecoveryPrograms;
282
289
  private readonly toolVersions;
283
290
  private readonly recoveryCompatibility;
@@ -351,6 +358,7 @@ export declare class PulseRuntime {
351
358
  private readonly sessionId;
352
359
  private hostCommandSeq;
353
360
  private factWaiters;
361
+ private activityWaiters;
354
362
  private wakeScheduled;
355
363
  /**
356
364
  * A lock grant can happen synchronously while an effect completion is being
@@ -371,7 +379,7 @@ export declare class PulseRuntime {
371
379
  createAgent(goal: string, program: LaneProgram, agentId?: string): AgentHandle;
372
380
  start(agentId: string): PulseSession;
373
381
  /** Accept external human input without waiting for the current Effect to settle. */
374
- submitHumanInput(agentId: string, inputId: string, value: JsonValue, targetEffectId?: string): void;
382
+ submitHumanInput(agentId: string, inputId: string, value: JsonValue, targetEffectId?: string): boolean;
375
383
  humanInputsFor(agentId: string): HumanInputRecord[];
376
384
  private humanArbitrationRequest;
377
385
  private ownsAgentForRuntime;
@@ -406,7 +414,7 @@ export declare class PulseRuntime {
406
414
  private executeRegisteredEffect;
407
415
  private journalEffect;
408
416
  private enqueueFact;
409
- enqueueHostCommand(command: HostCommand): void;
417
+ enqueueHostCommand(command: HostCommand): boolean;
410
418
  private enqueueEffectCompletion;
411
419
  private enqueueLLMPreparation;
412
420
  private enqueueEffectReconcile;
@@ -454,6 +462,9 @@ export declare class PulseRuntime {
454
462
  private hasPendingTickCleanup;
455
463
  private hasPendingHostInteraction;
456
464
  private waitForFact;
465
+ /** Wait for a state or observation change without polling the event loop. */
466
+ waitForActivity(timeoutMs?: number): Promise<void>;
467
+ private notifyActivity;
457
468
  private completeFinishedChildAgents;
458
469
  completeEffect(effectId: string, execution: EffectExecution, status?: 'succeeded' | 'failed' | 'cancelled', error?: RuntimeError, additionalMutations?: Mutation[]): boolean;
459
470
  markRemoteUnknown(effectId: string, sideEffectState: 'none' | 'applied' | 'known' | 'unknown'): void;
@@ -453,6 +453,13 @@ export class PulseRuntime {
453
453
  auditLogPrivacy;
454
454
  persistenceBackend;
455
455
  sessionStore;
456
+ /**
457
+ * The file session store is synchronous by design, so avoid rewriting an
458
+ * unchanged warm-start snapshot on every scheduler tick. Without this
459
+ * guard an idle run can spend its whole event loop serializing and fsyncing
460
+ * the same snapshot, starving effect dispatch and human input handling.
461
+ */
462
+ sessionStoreDigests = new Map();
456
463
  enforcingRecoveryPrograms;
457
464
  toolVersions;
458
465
  recoveryCompatibility;
@@ -526,6 +533,7 @@ export class PulseRuntime {
526
533
  sessionId;
527
534
  hostCommandSeq = 1;
528
535
  factWaiters = [];
536
+ activityWaiters = new Set();
529
537
  wakeScheduled = false;
530
538
  /**
531
539
  * A lock grant can happen synchronously while an effect completion is being
@@ -815,7 +823,7 @@ export class PulseRuntime {
815
823
  if (!inputId)
816
824
  throw new Error('INVALID_HUMAN_INPUT_ID');
817
825
  strictJsonValue(value);
818
- this.enqueueHostCommand({ type: 'human_input', agentId, inputId, value, ...(targetEffectId === undefined ? {} : { targetEffectId }) });
826
+ return this.enqueueHostCommand({ type: 'human_input', agentId, inputId, value, ...(targetEffectId === undefined ? {} : { targetEffectId }) });
819
827
  }
820
828
  humanInputsFor(agentId) {
821
829
  return [...this.state.humanInputs.values()].filter((input) => input.agentId === agentId).map((input) => structuredClone(input));
@@ -918,7 +926,10 @@ export class PulseRuntime {
918
926
  return this.applyHumanArbitrationDecision(deferred);
919
927
  }
920
928
  try {
921
- const child = this.createAgent({ goal: `Human input: ${typeof record.value === 'string' ? record.value : JSON.stringify(record.value)}`, program: this.humanInputProgram, priority: 'urgent', parentAgentId: record.agentId, warmStart: { agentId: record.agentId, globalVersion: 'latest', include: 'facts_and_findings' } });
929
+ const parentAgent = this.state.agents.get(record.agentId);
930
+ const parentLane = parentAgent === undefined ? undefined : this.state.lanes.get(parentAgent.rootLaneId);
931
+ const inheritedResults = parentLane?.visibleResultRefs === undefined ? [] : [...parentLane.visibleResultRefs].slice(-64);
932
+ const child = this.createAgent({ goal: `Human input: ${typeof record.value === 'string' ? record.value : JSON.stringify(record.value)}`, program: this.humanInputProgram, priority: 'urgent', parentAgentId: record.agentId, warmStart: { agentId: record.agentId, globalVersion: 'latest', include: 'facts_and_findings', ...(inheritedResults.length ? { relevanceRefs: inheritedResults } : {}) } });
922
933
  const next = structuredClone(record);
923
934
  next.status = 'consumed';
924
935
  next.handledByLaneId = child.laneId;
@@ -1073,14 +1084,25 @@ export class PulseRuntime {
1073
1084
  async persist(backend) {
1074
1085
  const persistedPolicy = this.storagePolicy.clone();
1075
1086
  persistedPolicy.markPersisted();
1076
- const exported = exportRuntimePersistence(this.persistenceState(), this.mutationLog, this.outbox, this.quarantine, persistedPolicy, this.factInbox.snapshot(), this.persistenceCompatibility());
1087
+ // The saved state is already authoritative: restore does not replay the
1088
+ // journal unless the snapshot is a checkpoint. Rewriting every historical
1089
+ // mutation on each flush copies full Effect records into a file that grows
1090
+ // without bound and blocks the next dispatch. For the attached backend,
1091
+ // persist the watermark only and drop those entries after the save succeeds.
1092
+ const attached = backend === this.persistenceBackend;
1093
+ const journalWatermark = attached ? this.mutationLog.lastSequence : undefined;
1094
+ const exportedLog = journalWatermark === undefined ? this.mutationLog : new MutationLog([], journalWatermark);
1095
+ const exported = exportRuntimePersistence(this.persistenceState(), exportedLog, this.outbox, this.quarantine, persistedPolicy, this.factInbox.snapshot(), this.persistenceCompatibility());
1077
1096
  // Never write a snapshot that the constructor would refuse to load; failing here is recoverable, a poisoned store is not.
1078
1097
  validateRuntimePersistenceSnapshot(exported);
1079
1098
  const withResults = backend.resultStore === undefined ? exported : await externalizeRuntimeResultBodies(exported, backend.resultStore);
1080
1099
  const snapshot = backend.snapshotStore === undefined ? withResults : await externalizeRuntimeSnapshotBodies(withResults, backend.snapshotStore);
1081
- await backend.save(snapshot, backend === this.persistenceBackend ? this.persistenceDigest : undefined);
1082
- if (backend === this.persistenceBackend)
1100
+ await backend.save(snapshot, attached ? this.persistenceDigest : undefined);
1101
+ if (attached) {
1083
1102
  this.persistenceDigest = snapshot.integrity?.digest;
1103
+ if (journalWatermark !== undefined && journalWatermark > this.mutationLog.watermark && journalWatermark <= this.mutationLog.lastSequence)
1104
+ this.mutationLog.truncateThrough(journalWatermark);
1105
+ }
1084
1106
  this.storagePolicy.markPersisted();
1085
1107
  this.markArtifactsPersisted();
1086
1108
  this.syncStoragePolicy();
@@ -1088,9 +1110,15 @@ export class PulseRuntime {
1088
1110
  async flushPersistence() {
1089
1111
  if (!this.persistenceBackend)
1090
1112
  return;
1091
- // An explicit flush is a request to try now, even while background retries are backing off.
1113
+ // An explicit flush retries a pending/dirty snapshot, but must not mark an
1114
+ // already clean runtime dirty on every scheduler tick. The run loop calls
1115
+ // this after each tick; forcing a new write there can keep persistence
1116
+ // permanently dirty and starve the durable-dispatch gate.
1092
1117
  this.persistenceBackoff = false;
1093
- this.schedulePersistence();
1118
+ if (!this.persistenceDirty && !this.persistenceScheduled)
1119
+ return;
1120
+ if (this.persistenceDirty)
1121
+ this.schedulePersistence();
1094
1122
  while (true) {
1095
1123
  await this.persistencePending;
1096
1124
  if (!this.persistenceDirty)
@@ -1312,9 +1340,9 @@ export class PulseRuntime {
1312
1340
  }
1313
1341
  catch (cause) {
1314
1342
  if (signal.aborted && isSideEffectful(definition.manifest.sideEffectPolicy))
1315
- return { value: null, executionState: 'remote_unknown', sideEffectState: 'unknown', ...(executionRef === undefined ? {} : { executionRef }), metadata: { toolVersion: definition.manifest.version, reconcileRequired: true }, ...(cause instanceof Error ? { error: { code: 'TOOL_CANCELLED_UNKNOWN', message: cause.message } } : {}) };
1343
+ return { value: null, executionState: 'remote_unknown', sideEffectState: 'unknown', ...(executionRef === undefined ? {} : { executionRef }), metadata: { toolVersion: definition.manifest.version, reconcileRequired: true }, error: runtimeErrorFromCause(cause, 'TOOL_CANCELLED_UNKNOWN') };
1316
1344
  const error = runtimeErrorFromCause(cause, 'TOOL_EXECUTION_FAILED');
1317
- return { value: null, status: signal.aborted ? 'cancelled' : 'failed', executionState: 'failed', sideEffectState: 'none', ...(executionRef === undefined ? {} : { executionRef }), ...(cause instanceof Error ? { error } : {}), ...(observations.length ? { observations } : {}) };
1345
+ return { value: null, status: signal.aborted ? 'cancelled' : 'failed', executionState: 'failed', sideEffectState: 'none', ...(executionRef === undefined ? {} : { executionRef }), error, ...(observations.length ? { observations } : {}) };
1318
1346
  }
1319
1347
  }
1320
1348
  if (effect.kind !== 'llm')
@@ -1407,6 +1435,7 @@ export class PulseRuntime {
1407
1435
  this.syncStoragePolicy();
1408
1436
  this.schedulePersistence();
1409
1437
  this.scheduleWake();
1438
+ this.notifyActivity();
1410
1439
  for (const resolve of this.factWaiters.splice(0))
1411
1440
  resolve();
1412
1441
  return true;
@@ -1416,8 +1445,9 @@ export class PulseRuntime {
1416
1445
  const eventId = `host-command-${this.hostCommandSeq}`;
1417
1446
  const urgent = command.type === 'human_input' || command.type === 'reply' || command.type === 'cancel' || command.type === 'cancel_effect';
1418
1447
  if (!this.enqueueFact(command, eventId, urgent))
1419
- return;
1448
+ return false;
1420
1449
  this.hostCommandSeq++;
1450
+ return true;
1421
1451
  }
1422
1452
  enqueueEffectCompletion(effectId, attemptId, execution, status = 'succeeded', error, dispatchError) {
1423
1453
  const fact = { type: 'effect_completion', effectId, attemptId, execution: encodeEffectExecution(execution), status, ...(error === undefined ? {} : { error: error }), ...(dispatchError === undefined ? {} : { dispatchError: dispatchError }) };
@@ -1617,6 +1647,7 @@ export class PulseRuntime {
1617
1647
  this.wakeError = undefined;
1618
1648
  this.assertRecoveryPrograms();
1619
1649
  this.state.now = this.clock.now();
1650
+ const mutationSequenceBeforeTick = this.mutationLog.lastSequence;
1620
1651
  const tickStartedAt = performance.now();
1621
1652
  let tickOperations = 0;
1622
1653
  const canStartTickOperation = () => tickOperations === 0 || performance.now() - tickStartedAt < this.maxTickMs;
@@ -1805,6 +1836,12 @@ export class PulseRuntime {
1805
1836
  timer.callback();
1806
1837
  tickOperations++;
1807
1838
  }
1839
+ // A restored snapshot may contain a settled Effect and its still-pending
1840
+ // Wait when the process stopped between the two persistence writes. Re-run
1841
+ // wait resolution after recovery timers have fired so the Lane receives its
1842
+ // ResumeInput before the next program step is evaluated.
1843
+ if ([...this.state.waits.values()].some((wait) => wait.state === 'pending'))
1844
+ this.refreshWaits();
1808
1845
  let progressed = 0;
1809
1846
  while (progressed < this.maxSteps && canStartTickOperation()) {
1810
1847
  const laneId = this.selectReadyLane(this.state.now, this.maxSteps - progressed);
@@ -1826,7 +1863,7 @@ export class PulseRuntime {
1826
1863
  continue;
1827
1864
  }
1828
1865
  let output;
1829
- 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 }); } };
1866
+ 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(); } };
1830
1867
  try {
1831
1868
  output = withPureStepGuard(() => lane.series || program.seriesMember ? this.seriesStep(program, stepContext, lane.series) : program.step(stepContext));
1832
1869
  }
@@ -1941,12 +1978,19 @@ export class PulseRuntime {
1941
1978
  this.dispatchQueuedEffects();
1942
1979
  this.completeFinishedChildAgents();
1943
1980
  this.finalizeCancellations();
1944
- this.syncStoragePolicy();
1945
- this.schedulePersistence();
1981
+ const tickMutated = this.mutationLog.lastSequence !== mutationSequenceBeforeTick;
1982
+ if (tickMutated)
1983
+ this.syncStoragePolicy();
1984
+ // A wake can be scheduled solely to retry dispatch or drain an empty
1985
+ // queue. Persist only when this tick committed a mutation; otherwise a
1986
+ // waiting run rewrites the full runtime snapshot on every wake.
1987
+ if (tickMutated)
1988
+ this.schedulePersistence();
1946
1989
  const pendingTickCleanup = this.hasPendingTickCleanup();
1947
1990
  this.tickBudget = undefined;
1948
1991
  if (pendingTickCleanup)
1949
1992
  this.scheduleWake(true);
1993
+ this.notifyActivity();
1950
1994
  return progressed;
1951
1995
  }
1952
1996
  async run(agentOrMaxTicks = 10_000, requestedMaxTicks = 10_000) {
@@ -1961,7 +2005,7 @@ export class PulseRuntime {
1961
2005
  }
1962
2006
  if (this.ready.size === 0 && this.executions.size === 0 && !this.hasQueuedEffects() && !this.hasPendingTickCleanup()) {
1963
2007
  if (this.preparingLLMs.size) {
1964
- await Promise.resolve();
2008
+ await this.waitForFact();
1965
2009
  continue;
1966
2010
  }
1967
2011
  if (this.factInbox.size > 0)
@@ -2030,7 +2074,7 @@ export class PulseRuntime {
2030
2074
  }
2031
2075
  if (this.ready.size === 0 && this.executions.size === 0 && !this.hasQueuedEffects() && !this.hasPendingTickCleanup()) {
2032
2076
  if (this.preparingLLMs.size) {
2033
- await Promise.resolve();
2077
+ await this.waitForFact();
2034
2078
  continue;
2035
2079
  }
2036
2080
  if (this.factInbox.size > 0)
@@ -2083,7 +2127,7 @@ export class PulseRuntime {
2083
2127
  if (this.executions.size)
2084
2128
  await Promise.race([...this.executions.values()].map((execution) => execution.promise));
2085
2129
  else if (this.preparingLLMs.size)
2086
- await Promise.resolve();
2130
+ await this.waitForFact();
2087
2131
  else if (this.hasQueuedEffects() || this.factInbox.size || this.hasPendingTickCleanup() || this.hasDueTimer())
2088
2132
  await new Promise((resolve) => setImmediate(resolve));
2089
2133
  } await this.flushPersistence(); }
@@ -2285,9 +2329,16 @@ export class PulseRuntime {
2285
2329
  if (result)
2286
2330
  Object.assign(result, value);
2287
2331
  }
2288
- if (this.sessionStore)
2289
- for (const agent of state.agents.values())
2290
- this.sessionStore.put(exportWarmStartSession(state, agent.id));
2332
+ if (this.sessionStore) {
2333
+ for (const agent of state.agents.values()) {
2334
+ const snapshot = exportWarmStartSession(state, agent.id);
2335
+ const digest = contentHash(snapshot);
2336
+ if (this.sessionStoreDigests.get(agent.id) === digest)
2337
+ continue;
2338
+ this.sessionStore.put(snapshot);
2339
+ this.sessionStoreDigests.set(agent.id, digest);
2340
+ }
2341
+ }
2291
2342
  }
2292
2343
  }
2293
2344
  hasQueuedEffects() { return [...this.state.effects.values()].some((effect) => effect.state === 'queued' && !this.executions.has(effect.id)); }
@@ -2326,6 +2377,26 @@ export class PulseRuntime {
2326
2377
  }
2327
2378
  hasPendingHostInteraction(agentId) { return [...this.state.effects.values()].some((effect) => effect.kind === 'human' && !effect.outcome && (agentId === undefined || effect.agentId === agentId)); }
2328
2379
  waitForFact() { return new Promise((resolve) => this.factWaiters.push(resolve)); }
2380
+ /** Wait for a state or observation change without polling the event loop. */
2381
+ waitForActivity(timeoutMs = 250) {
2382
+ return new Promise((resolve) => {
2383
+ let settled = false;
2384
+ const finish = () => {
2385
+ if (settled)
2386
+ return;
2387
+ settled = true;
2388
+ this.activityWaiters.delete(finish);
2389
+ clearTimeout(timer);
2390
+ resolve();
2391
+ };
2392
+ const timer = setTimeout(finish, timeoutMs);
2393
+ this.activityWaiters.add(finish);
2394
+ });
2395
+ }
2396
+ notifyActivity() {
2397
+ for (const resolve of [...this.activityWaiters])
2398
+ resolve();
2399
+ }
2329
2400
  completeFinishedChildAgents() {
2330
2401
  // Human interaction Agents are detached from the main Lane rather than
2331
2402
  // represented by an agent Effect. Settle their Agent record when the root
@@ -2542,8 +2613,10 @@ export class PulseRuntime {
2542
2613
  }
2543
2614
  this.releaseEffectLocks(effectId);
2544
2615
  this.outbox.ack(`${effect.id}:${effect.attemptId}`);
2545
- for (const observation of effectiveExecution.observations ?? [])
2616
+ for (const observation of effectiveExecution.observations ?? []) {
2546
2617
  this.observationInbox.enqueue({ ...observation, agentId: effect.agentId, laneId: effect.ownerLaneId, timestamp: this.state.now });
2618
+ this.notifyActivity();
2619
+ }
2547
2620
  const settlementTransactionId = `effect:${effect.id}:${settledAttemptId}:settled`;
2548
2621
  const settlementMutations = [...publicationMutations];
2549
2622
  commitMutationTransaction(this.state, this.mutationLog, settlementTransactionId, settlementMutations, this.state.now, this.sessionId);
@@ -2777,8 +2850,9 @@ export class PulseRuntime {
2777
2850
  ...cancellableEffects.map((effect) => ({ type: 'effect.cancel_requested', effectId: effect.id, data: { reason } })),
2778
2851
  ...cancellableEffects.flatMap((effect) => {
2779
2852
  if (this.executions.has(effect.id) && (effect.cancelGraceMs ?? 0) === 0) {
2780
- const state = isSideEffectful(effect.sideEffectPolicy) ? 'reconcile_required' : 'cancelled';
2781
- return [{ type: 'effect.quarantined', effectId: effect.id, data: { reason, state } }];
2853
+ if (isSideEffectful(effect.sideEffectPolicy))
2854
+ return [{ type: 'effect.quarantined', effectId: effect.id, data: { reason, state: 'reconcile_required' } }];
2855
+ return [{ type: 'effect.settled', effectId: effect.id, data: { status: 'cancelled', error: { code: 'CANCELLED', message: reason } } }];
2782
2856
  }
2783
2857
  if (!this.executions.has(effect.id))
2784
2858
  return [{ type: 'effect.settled', effectId: effect.id, data: { status: 'cancelled', error: { code: 'CANCELLED', message: reason } } }];
@@ -2812,9 +2886,10 @@ export class PulseRuntime {
2812
2886
  const candidate = structuredClone(effect);
2813
2887
  candidate.cancelRequested = { reason, at: this.state.now };
2814
2888
  if (this.executions.has(effect.id) && (effect.cancelGraceMs ?? 0) === 0) {
2815
- candidate.executionState = 'remote_unknown';
2816
- candidate.sideEffectState = isSideEffectful(candidate.sideEffectPolicy) ? 'unknown' : 'none';
2817
- candidate.state = candidate.sideEffectState === 'unknown' ? 'reconcile_required' : 'cancelled';
2889
+ const shouldQuarantine = isSideEffectful(candidate.sideEffectPolicy);
2890
+ candidate.executionState = shouldQuarantine ? 'remote_unknown' : 'local_closed';
2891
+ candidate.sideEffectState = shouldQuarantine ? 'unknown' : 'none';
2892
+ candidate.state = shouldQuarantine ? 'reconcile_required' : 'cancelled';
2818
2893
  if (candidate.state === 'cancelled')
2819
2894
  candidate.outcome = { status: 'cancelled', reason, error: { code: reason, message: reason } };
2820
2895
  }
@@ -3001,9 +3076,13 @@ export class PulseRuntime {
3001
3076
  this.dispatchQueuedEffectsNow();
3002
3077
  return;
3003
3078
  }
3004
- this.schedulePersistence();
3005
3079
  if (this.dispatchPersistencePending)
3006
3080
  return;
3081
+ // Do not mark persistence dirty again while the durability gate is
3082
+ // already flushing. The scheduler calls this method on every tick; a
3083
+ // write scheduled before this guard would keep the flush promise alive
3084
+ // forever and starve the queued effects behind it.
3085
+ this.schedulePersistence();
3007
3086
  this.dispatchPersistencePending = true;
3008
3087
  void this.flushPersistence().then(() => {
3009
3088
  this.dispatchPersistencePending = false;
@@ -3144,6 +3223,7 @@ export class PulseRuntime {
3144
3223
  return;
3145
3224
  }
3146
3225
  this.observationInbox.enqueue({ ...observation, agentId: effect.agentId, laneId: effect.ownerLaneId, timestamp: this.state.now });
3226
+ this.notifyActivity();
3147
3227
  };
3148
3228
  const attemptId = effect.attemptId;
3149
3229
  const promise = this.executor(effect, controller.signal, emitObservation).then((execution) => {
@@ -3230,25 +3310,28 @@ export class PulseRuntime {
3230
3310
  if (!effect || effect.outcome)
3231
3311
  return false;
3232
3312
  const candidate = structuredClone(effect);
3313
+ const shouldQuarantine = isSideEffectful(candidate.sideEffectPolicy);
3233
3314
  if (precedingEvent?.type === 'effect.cancel_requested' || precedingEvent?.type === 'limit.rejected')
3234
3315
  candidate.cancelRequested = { reason, at: this.state.now };
3235
- candidate.executionState = 'remote_unknown';
3236
- candidate.sideEffectState = isSideEffectful(candidate.sideEffectPolicy) ? 'unknown' : 'none';
3237
- candidate.state = candidate.sideEffectState === 'unknown' ? 'reconcile_required' : 'cancelled';
3316
+ candidate.executionState = shouldQuarantine ? 'remote_unknown' : 'local_closed';
3317
+ candidate.sideEffectState = shouldQuarantine ? 'unknown' : 'none';
3318
+ candidate.state = shouldQuarantine ? 'reconcile_required' : 'cancelled';
3238
3319
  if (candidate.state === 'cancelled')
3239
3320
  candidate.outcome = { status: 'cancelled', reason, error: { code: reason, message: reason } };
3240
3321
  const lane = this.state.lanes.get(effect.ownerLaneId);
3241
3322
  const candidateLane = lane === undefined ? undefined : structuredClone(lane);
3242
- if (candidateLane)
3323
+ if (shouldQuarantine && candidateLane)
3243
3324
  candidateLane.unresolvedEffectIds = [...new Set([...(candidateLane.unresolvedEffectIds ?? []), effectId])];
3244
- const quarantineEvent = { type: 'effect.quarantined', effectId, data: { reason, state: candidate.state } };
3325
+ const terminalEvent = shouldQuarantine
3326
+ ? { type: 'effect.quarantined', effectId, data: { reason, state: candidate.state } }
3327
+ : { type: 'effect.settled', effectId, data: candidate.outcome };
3245
3328
  const admission = [{ op: 'setEffect', effectId, record: candidate }];
3246
3329
  if (candidateLane)
3247
3330
  admission.push({ op: 'setLane', laneId: candidateLane.id, record: candidateLane });
3248
3331
  if (precedingEvent)
3249
3332
  admission.push({ op: 'appendEvent', event: precedingEvent });
3250
3333
  admission.push(...additionalMutations.map((mutation) => structuredClone(mutation)));
3251
- admission.push({ op: 'appendEvent', event: quarantineEvent });
3334
+ admission.push({ op: 'appendEvent', event: terminalEvent });
3252
3335
  this.assertStorageAdmission(admission);
3253
3336
  if (execution) {
3254
3337
  execution.controller.abort();
@@ -3262,7 +3345,8 @@ export class PulseRuntime {
3262
3345
  Object.assign(lane, candidateLane);
3263
3346
  this.state.lanes.set(candidateLane.id, lane);
3264
3347
  }
3265
- this.quarantine.add(effectId, this.state.now, reason);
3348
+ if (shouldQuarantine)
3349
+ this.quarantine.add(effectId, this.state.now, reason);
3266
3350
  this.refreshWaits();
3267
3351
  this.schedulePersistence();
3268
3352
  return true;
@@ -85,6 +85,8 @@ export declare class WorkerCoordinator implements WorkerCoordinatorContract {
85
85
  private readonly persistenceBackend;
86
86
  private persistenceDigest;
87
87
  private persistencePending;
88
+ private persistenceDirty;
89
+ private persistenceScheduled;
88
90
  private sequence;
89
91
  constructor(options?: WorkerCoordinatorOptions);
90
92
  static restore(snapshot: WorkerCoordinatorSnapshot, options?: WorkerCoordinatorOptions): WorkerCoordinator;
@@ -173,6 +173,8 @@ export class WorkerCoordinator {
173
173
  persistenceBackend;
174
174
  persistenceDigest;
175
175
  persistencePending = Promise.resolve();
176
+ persistenceDirty = false;
177
+ persistenceScheduled = false;
176
178
  sequence = 1;
177
179
  constructor(options = {}) { this.persistenceBackend = options.persistenceBackend; }
178
180
  static restore(snapshot, options = {}) {
@@ -349,10 +351,28 @@ export class WorkerCoordinator {
349
351
  schedulePersistence() {
350
352
  if (!this.persistenceBackend)
351
353
  return;
354
+ this.persistenceDirty = true;
355
+ if (this.persistenceScheduled)
356
+ return;
357
+ this.persistenceScheduled = true;
352
358
  const operation = this.persistencePending.catch(() => undefined).then(async () => {
353
- const snapshot = this.snapshot();
354
- await this.persistenceBackend.save(snapshot, this.persistenceDigest);
355
- this.persistenceDigest = snapshot.integrity?.digest;
359
+ while (this.persistenceDirty) {
360
+ this.persistenceDirty = false;
361
+ const snapshot = this.snapshot();
362
+ const digest = snapshot.integrity?.digest;
363
+ if (digest === this.persistenceDigest)
364
+ continue;
365
+ try {
366
+ await this.persistenceBackend.save(snapshot, this.persistenceDigest);
367
+ this.persistenceDigest = digest;
368
+ }
369
+ catch (cause) {
370
+ this.persistenceDirty = true;
371
+ throw cause;
372
+ }
373
+ }
374
+ }).finally(() => {
375
+ this.persistenceScheduled = false;
356
376
  });
357
377
  this.persistencePending = operation;
358
378
  }
@@ -668,7 +688,7 @@ export class SqliteDistributedWorkerCoordinator {
668
688
  result.reject(task.error ?? { code: 'WORKER_FAILED', message: 'WORKER_FAILED' });
669
689
  else
670
690
  result.reject(new Error('WORKER_CANCELLED'));
671
- }, 10);
691
+ }, 50);
672
692
  watcher.unref();
673
693
  this.watchers.set(taskId, watcher);
674
694
  }
@@ -614,7 +614,12 @@ function prepareLLMInput(state, lane, submission) {
614
614
  const rejectedInputs = readRefs('rejectedOutputRefs');
615
615
  const artifactInputs = readRefs('artifacts');
616
616
  const eventInputs = readRefs('events');
617
- const inputError = resultInputs.error ?? findingInputs.error ?? rejectedInputs.error ?? artifactInputs.error ?? eventInputs.error;
617
+ const rawConversation = rawInputs.conversation;
618
+ const conversation = rawConversation === undefined ? [] : Array.isArray(rawConversation) && rawConversation.every((item) => item && typeof item === 'object' && !Array.isArray(item) && item.role !== undefined && ['system', 'user', 'assistant'].includes(String(item.role)) && typeof item.content === 'string')
619
+ ? rawConversation
620
+ : undefined;
621
+ const conversationError = rawConversation !== undefined && conversation === undefined ? 'INVALID_LLM_CONVERSATION' : undefined;
622
+ const inputError = resultInputs.error ?? findingInputs.error ?? rejectedInputs.error ?? artifactInputs.error ?? eventInputs.error ?? conversationError;
618
623
  if (inputError)
619
624
  return { error: inputError };
620
625
  const resultRefs = [...new Set([...(resultInputs.refs ?? []), ...(findingInputs.refs ?? []), ...(rejectedInputs.refs ?? [])])];
@@ -623,7 +628,7 @@ function prepareLLMInput(state, lane, submission) {
623
628
  const agent = state.agents.get(lane.agentId);
624
629
  if (!agent)
625
630
  return { error: 'UNKNOWN_AGENT' };
626
- const projection = new ContextBuilder(state).build({ agent, lane, resultRefs, ...(artifactRefs.length ? { artifactRefs } : {}), eventIds: eventInputs.refs ?? [], instruction, ...(typeof input.system === 'string' ? { system: input.system } : {}), ...(input.policy === undefined ? {} : { policy: input.policy }), ...(input.tools === undefined ? {} : { tools: input.tools }), toolSetId: typeof input.toolSetId === 'string' ? input.toolSetId : 'default' });
631
+ const projection = new ContextBuilder(state).build({ agent, lane, resultRefs, ...(artifactRefs.length ? { artifactRefs } : {}), eventIds: eventInputs.refs ?? [], ...(conversation === undefined || conversation.length === 0 ? {} : { conversation }), instruction, ...(typeof input.system === 'string' ? { system: input.system } : {}), ...(input.policy === undefined ? {} : { policy: input.policy }), ...(input.tools === undefined ? {} : { tools: input.tools }), toolSetId: typeof input.toolSetId === 'string' ? input.toolSetId : 'default' });
627
632
  return { input: { ...input, request: projection } };
628
633
  }
629
634
  catch (cause) {
@@ -708,8 +713,13 @@ export function validateStep(state, laneId, output) {
708
713
  else if (output.adoptCommittedContext)
709
714
  return { rejection: error('INVALID_ADOPT_COMMITTED_CONTEXT', 'adoptCommittedContext requires a ContextDelta') };
710
715
  const compactRequested = output.contextDelta?.target === 'lane' && output.contextDelta.ops.some((op) => op.op === 'compact_history');
716
+ // ReAct can discover pressure only after consuming a model result. Permit
717
+ // the two-step compaction handoff and its summary request to cross the hard
718
+ // threshold; the following compact_history delta removes the old records.
719
+ const compactionHandoff = output.next.step === '$compact:summarize' && actions.length === 0;
720
+ const compactionSummarySubmission = actions.some((action) => action.type === 'submit_effects' && action.effects.some((effect) => effect.key === '$compact-summary'));
711
721
  const nextHistoryTokens = estimateHistoryTokens(workingLane.context.history);
712
- if (nextHistoryTokens > state.historyHardTokens && !compactRequested)
722
+ if (nextHistoryTokens > state.historyHardTokens && !compactRequested && !compactionHandoff && !compactionSummarySubmission)
713
723
  return { rejection: error('CONTEXT_TOO_LARGE', 'Lane history exceeded hardTokens and must be compacted before another Step can commit.', { historyTokens: nextHistoryTokens, softTokens: state.historySoftTokens, hardTokens: state.historyHardTokens }) };
714
724
  const pressure = historyPressure(workingLane.context.history, state.historySoftTokens, state.historyHardTokens);
715
725
  if (pressure)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hunterzhu/pulse-runtime",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/zhuhengtan/Pulse"