@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.
- package/dist/context/builder.d.ts +6 -1
- package/dist/context/builder.js +37 -4
- package/dist/core/types.d.ts +6 -1
- package/dist/dsl/program.d.ts +4 -1
- package/dist/dsl/program.js +147 -24
- package/dist/dsl/session.js +10 -4
- package/dist/dsl/templates.d.ts +2 -5
- package/dist/scheduler/human-arbitration.js +10 -2
- package/dist/scheduler/runtime.d.ts +13 -2
- package/dist/scheduler/runtime.js +117 -33
- package/dist/scheduler/worker.d.ts +2 -0
- package/dist/scheduler/worker.js +24 -4
- package/dist/transitions/validate.js +13 -3
- package/package.json +1 -1
|
@@ -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";
|
package/dist/context/builder.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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) =>
|
|
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
|
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/dsl/program.d.ts
CHANGED
|
@@ -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;
|
package/dist/dsl/program.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
378
|
-
const
|
|
379
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
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;
|
package/dist/dsl/session.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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
|
}
|
package/dist/dsl/templates.d.ts
CHANGED
|
@@ -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):
|
|
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):
|
|
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
|
|
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
|
-
|
|
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,
|
|
1082
|
-
if (
|
|
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
|
|
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.
|
|
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 },
|
|
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 }),
|
|
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.
|
|
1945
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2781
|
-
|
|
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
|
-
|
|
2816
|
-
candidate.
|
|
2817
|
-
candidate.
|
|
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 =
|
|
3237
|
-
candidate.state =
|
|
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
|
|
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:
|
|
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
|
-
|
|
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;
|
package/dist/scheduler/worker.js
CHANGED
|
@@ -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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
},
|
|
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
|
|
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)
|