@hunterzhu/pulse-runtime 0.1.5 → 0.1.7

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,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;
@@ -26,6 +26,7 @@ export interface PulseSessionSnapshot {
26
26
  waits: unknown[];
27
27
  results: unknown[];
28
28
  mergeProposals: unknown[];
29
+ humanInputs: unknown[];
29
30
  quarantine: unknown[];
30
31
  observationsPending: number;
31
32
  }
@@ -36,10 +37,16 @@ export declare class PulseSession {
36
37
  /** Stable session handle used by the explicit warm-start API. */
37
38
  readonly sessionId: string;
38
39
  constructor(runtime: PulseRuntime, agentId: string);
40
+ private ownsAgent;
39
41
  private ownsEvent;
42
+ private hasActiveDescendant;
40
43
  stream(fromSeq?: number): AsyncIterable<SessionEvent>;
41
44
  snapshot(): Promise<PulseSessionSnapshot>;
42
45
  outcome(): Promise<Outcome>;
43
46
  reply(effectId: string, value: JsonValue): Promise<void>;
47
+ /** Submit a human message while the agent is still running. The scheduler
48
+ * records it immediately; targetEffectId is optional for direct replies to
49
+ * a waiting Human Effect. */
50
+ submitHumanInput(inputId: string, value: JsonValue, targetEffectId?: string): Promise<void>;
44
51
  cancel(reason: string): Promise<void>;
45
52
  }
@@ -19,15 +19,29 @@ export class PulseSession {
19
19
  };
20
20
  });
21
21
  }
22
+ ownsAgent(agentId) {
23
+ let current = this.runtime.state.agents.get(agentId);
24
+ const seen = new Set();
25
+ while (current && !seen.has(current.id)) {
26
+ if (current.id === this.agentId)
27
+ return true;
28
+ seen.add(current.id);
29
+ current = current.parentAgentId === undefined ? undefined : this.runtime.state.agents.get(current.parentAgentId);
30
+ }
31
+ return false;
32
+ }
22
33
  ownsEvent(event) {
23
- if (event.agentId === this.agentId)
34
+ if (event.agentId !== undefined && this.ownsAgent(event.agentId))
35
+ return true;
36
+ if (event.laneId !== undefined && this.ownsAgent(this.runtime.state.lanes.get(event.laneId)?.agentId ?? ''))
37
+ return true;
38
+ if (event.effectId !== undefined && this.ownsAgent(this.runtime.state.effects.get(event.effectId)?.agentId ?? ''))
24
39
  return true;
25
- if (event.laneId !== undefined)
26
- return this.runtime.state.lanes.get(event.laneId)?.agentId === this.agentId;
27
- if (event.effectId !== undefined)
28
- return this.runtime.state.effects.get(event.effectId)?.agentId === this.agentId;
29
40
  return false;
30
41
  }
42
+ hasActiveDescendant() {
43
+ return [...this.runtime.state.agents.values()].some((agent) => this.ownsAgent(agent.id) && agent.id !== this.agentId && agent.state !== undefined && !['succeeded', 'failed', 'cancelled'].includes(agent.state));
44
+ }
31
45
  async *stream(fromSeq = 0) {
32
46
  let cursor = fromSeq;
33
47
  let observationCursor = 0;
@@ -55,9 +69,11 @@ export class PulseSession {
55
69
  yield { kind: 'observation', type: 'observation', seq: observation.seq, observation: observation };
56
70
  }
57
71
  const root = [...this.runtime.state.lanes.values()].find((lane) => lane.agentId === this.agentId && lane.ownerLaneId === undefined);
58
- if (root && ['succeeded', 'failed', 'cancelled'].includes(root.status) && (this.runtime.state.events.at(-1)?.seq ?? compactedThrough) === cursor)
72
+ if (root && ['succeeded', 'failed', 'cancelled'].includes(root.status) && !this.hasActiveDescendant() && (this.runtime.state.events.at(-1)?.seq ?? compactedThrough) === cursor)
59
73
  return;
60
- 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();
61
77
  }
62
78
  }
63
79
  async snapshot() {
@@ -71,11 +87,12 @@ export class PulseSession {
71
87
  now: this.runtime.state.now,
72
88
  eventSeq: this.runtime.state.events.at(-1)?.seq ?? 0,
73
89
  agent: agent ? structuredClone({ id: agent.id, goal: agent.goal ?? null, state: agent.state ?? null, latestGlobalVersion: agent.latestGlobalVersion, globalVersions: [...agent.globalVersions.entries()].map(([version, value]) => ({ version, value, ...(agent.globalPrivacy?.get(version) === undefined ? {} : { privacy: agent.globalPrivacy.get(version) }) })) }) : null,
74
- lanes: [...this.runtime.state.lanes.values()].filter((lane) => laneIds.has(lane.id)).map((lane) => structuredClone({ id: lane.id, agentId: lane.agentId, ownerLaneId: lane.ownerLaneId ?? null, status: lane.status, cancelReason: lane.cancelReason ?? null, failure: lane.failure ?? null, version: lane.version, goal: lane.goal, priority: lane.priority, inheritedFloor: lane.inheritedFloor ?? null, readySince: lane.readySince, resume: lane.resume, pendingResumeInput: lane.pendingResumeInput ?? null, contextSnapshotVersion: lane.contextSnapshotVersion, context: lane.context, visibleResultRefs: lane.visibleResultRefs ? [...lane.visibleResultRefs] : [], historyPressure: lane.historyPressure ?? null, activeWaitId: lane.activeWaitId ?? null, children: [...lane.children], ownedEffectIds: [...lane.ownedEffectIds], resultRef: lane.resultRef ?? null, closingResult: lane.closingResult ?? null, consecutiveControlErrors: lane.consecutiveControlErrors ?? 0, unresolvedEffectIds: lane.unresolvedEffectIds ?? [], progressWatchdog: lane.progressWatchdog ?? null })),
90
+ lanes: [...this.runtime.state.lanes.values()].filter((lane) => laneIds.has(lane.id)).map((lane) => structuredClone({ id: lane.id, agentId: lane.agentId, ownerLaneId: lane.ownerLaneId ?? null, status: lane.status, cancelReason: lane.cancelReason ?? null, failure: lane.failure ?? null, version: lane.version, goal: lane.goal, priority: lane.priority, inheritedFloor: lane.inheritedFloor ?? null, readySince: lane.readySince, resume: lane.resume, pendingResumeInput: lane.pendingResumeInput ?? null, pendingHumanInputs: lane.pendingHumanInputs ?? [], contextSnapshotVersion: lane.contextSnapshotVersion, context: lane.context, visibleResultRefs: lane.visibleResultRefs ? [...lane.visibleResultRefs] : [], historyPressure: lane.historyPressure ?? null, activeWaitId: lane.activeWaitId ?? null, children: [...lane.children], ownedEffectIds: [...lane.ownedEffectIds], resultRef: lane.resultRef ?? null, closingResult: lane.closingResult ?? null, consecutiveControlErrors: lane.consecutiveControlErrors ?? 0, unresolvedEffectIds: lane.unresolvedEffectIds ?? [], progressWatchdog: lane.progressWatchdog ?? null })),
75
91
  effects: effects.map((effect) => structuredClone(effect)),
76
92
  waits: [...this.runtime.state.waits.values()].filter((wait) => laneIds.has(wait.laneId)).map((wait) => structuredClone(wait)),
77
93
  results: [...this.runtime.state.results.values()].filter((result) => (result.effectId !== undefined && effectIds.has(result.effectId)) || [...this.runtime.state.lanes.values()].some((lane) => laneIds.has(lane.id) && lane.resultRef === result.id)).map((result) => structuredClone(result)),
78
94
  mergeProposals: [...this.runtime.state.mergeProposals.values()].filter((proposal) => proposal.agentId === this.agentId).map((proposal) => structuredClone(proposal)),
95
+ humanInputs: [...this.runtime.state.humanInputs.values()].filter((input) => input.agentId === this.agentId).map((input) => structuredClone(input)),
79
96
  quarantine: structuredClone(this.runtime.quarantine.snapshot().filter((entry) => effectIds.has(entry.effectId))),
80
97
  observationsPending: this.runtime.observationInbox.snapshot().filter((observation) => observation.agentId === this.agentId).length,
81
98
  };
@@ -83,11 +100,21 @@ export class PulseSession {
83
100
  async outcome() { return this.execution; }
84
101
  async reply(effectId, value) {
85
102
  const effect = this.runtime.state.effects.get(effectId);
86
- if (!effect || effect.agentId !== this.agentId)
103
+ if (!effect || !this.ownsAgent(effect.agentId))
87
104
  throw new Error('EFFECT_NOT_OWNED');
88
105
  if (effect.kind !== 'human' || effect.outcome)
89
106
  throw new Error('EFFECT_NOT_REPLYABLE');
90
- 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
+ }
110
+ }
111
+ /** Submit a human message while the agent is still running. The scheduler
112
+ * records it immediately; targetEffectId is optional for direct replies to
113
+ * a waiting Human Effect. */
114
+ async submitHumanInput(inputId, value, targetEffectId) {
115
+ if (!this.runtime.submitHumanInput(this.agentId, inputId, value, targetEffectId)) {
116
+ throw new Error('HOST_COMMAND_NOT_ENQUEUED');
117
+ }
91
118
  }
92
119
  async cancel(reason) { this.runtime.requestCancel(this.agentId, reason); }
93
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;
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export * from './transitions/index.js';
7
7
  export * from './dependencies/index.js';
8
8
  export * from './scheduler/index.js';
9
9
  export * from './scheduler/runtime.js';
10
+ export * from './scheduler/human-arbitration.js';
10
11
  export * from './lifecycle/index.js';
11
12
  export * from './context/index.js';
12
13
  export * from './models/index.js';
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ export * from './transitions/index.js';
7
7
  export * from './dependencies/index.js';
8
8
  export * from './scheduler/index.js';
9
9
  export * from './scheduler/runtime.js';
10
+ export * from './scheduler/human-arbitration.js';
10
11
  export * from './lifecycle/index.js';
11
12
  export * from './context/index.js';
12
13
  export * from './models/index.js';
@@ -0,0 +1,59 @@
1
+ import type { EffectRecord, HumanInputRecord, JsonValue, LaneRecord } from '../core/types.js';
2
+ export type HumanArbitrationAction = 'respond' | 'steer' | 'spawn' | 'defer' | 'cancel';
3
+ export interface HumanArbitrationCandidate {
4
+ laneId: string;
5
+ agentId: string;
6
+ status: LaneRecord['status'];
7
+ priority: number;
8
+ goal?: string;
9
+ activeWaitId?: string;
10
+ }
11
+ export interface HumanArbitrationEffectCandidate {
12
+ effectId: string;
13
+ agentId: string;
14
+ laneId: string;
15
+ kind: EffectRecord['kind'];
16
+ state: EffectRecord['state'];
17
+ sideEffectPolicy?: EffectRecord['sideEffectPolicy'];
18
+ sideEffectState: EffectRecord['sideEffectState'];
19
+ }
20
+ export interface HumanArbitrationRequest {
21
+ schemaVersion: 1;
22
+ decisionId: string;
23
+ agentId: string;
24
+ input: HumanInputRecord;
25
+ lanes: readonly HumanArbitrationCandidate[];
26
+ effects: readonly HumanArbitrationEffectCandidate[];
27
+ availableLLMSlots: number;
28
+ }
29
+ export interface HumanArbitrationDecision {
30
+ schemaVersion?: 1;
31
+ decisionId: string;
32
+ inputId: string;
33
+ agentId: string;
34
+ action: HumanArbitrationAction;
35
+ targetLaneId?: string;
36
+ targetEffectId?: string;
37
+ reason?: string;
38
+ modelId: string;
39
+ }
40
+ export interface HumanArbitrationModel {
41
+ readonly id: string;
42
+ decide(request: HumanArbitrationRequest, signal: AbortSignal): Promise<HumanArbitrationDecision>;
43
+ }
44
+ export interface HumanArbitrationConfig {
45
+ model?: HumanArbitrationModel;
46
+ timeoutMs?: number;
47
+ }
48
+ export declare class HumanArbitrationCoordinator {
49
+ private readonly model;
50
+ private readonly timeoutMs;
51
+ private outstanding;
52
+ private readonly controllers;
53
+ constructor(model: HumanArbitrationModel, timeoutMs: number);
54
+ get pending(): number;
55
+ request(request: HumanArbitrationRequest, onDecision: (decision: HumanArbitrationDecision) => void, onFailure: () => void): boolean;
56
+ cancel(): void;
57
+ }
58
+ /** Deterministic, side-effect-safe control grammar used before model arbitration. */
59
+ export declare function ruleHumanArbitration(value: JsonValue, agentId: string, inputId: string, modelId?: string): HumanArbitrationDecision | undefined;
@@ -0,0 +1,62 @@
1
+ export class HumanArbitrationCoordinator {
2
+ model;
3
+ timeoutMs;
4
+ outstanding = 0;
5
+ controllers = new Set();
6
+ constructor(model, timeoutMs) {
7
+ this.model = model;
8
+ this.timeoutMs = timeoutMs;
9
+ }
10
+ get pending() { return this.outstanding; }
11
+ request(request, onDecision, onFailure) {
12
+ if (this.outstanding > 0)
13
+ return false;
14
+ const controller = new AbortController();
15
+ this.controllers.add(controller);
16
+ this.outstanding++;
17
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
18
+ void Promise.resolve().then(() => this.model.decide(structuredClone(request), controller.signal)).then((decision) => {
19
+ if (decision && typeof decision === 'object')
20
+ onDecision(structuredClone(decision));
21
+ else
22
+ onFailure();
23
+ }, () => onFailure()).finally(() => {
24
+ clearTimeout(timer);
25
+ this.controllers.delete(controller);
26
+ this.outstanding--;
27
+ });
28
+ return true;
29
+ }
30
+ cancel() { for (const controller of this.controllers)
31
+ controller.abort(); this.controllers.clear(); }
32
+ }
33
+ /** Deterministic, side-effect-safe control grammar used before model arbitration. */
34
+ export function ruleHumanArbitration(value, agentId, inputId, modelId = 'rules') {
35
+ const object = value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
36
+ const text = typeof value === 'string' ? value.trim() : typeof object?.text === 'string' ? object.text.trim() : undefined;
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
+ }
48
+ if (!command)
49
+ return undefined;
50
+ const normalized = command.toLowerCase();
51
+ if (normalized === 'cancel' || normalized === 'stop' || normalized === 'abort')
52
+ return { schemaVersion: 1, decisionId: `rule:${inputId}`, inputId, agentId, action: 'cancel', ...(targetLaneId === undefined ? {} : { targetLaneId }), ...(targetEffectId === undefined ? {} : { targetEffectId }), reason: 'Human requested cancellation.', modelId };
53
+ if (normalized === 'steer' || normalized === 'redirect')
54
+ return { schemaVersion: 1, decisionId: `rule:${inputId}`, inputId, agentId, action: 'steer', ...(targetLaneId === undefined ? {} : { targetLaneId }), reason: 'Human requested steering.', modelId };
55
+ if (normalized === 'spawn' || normalized === 'parallel')
56
+ return { schemaVersion: 1, decisionId: `rule:${inputId}`, inputId, agentId, action: 'spawn', reason: 'Human requested a concurrent interaction.', modelId };
57
+ if (normalized === 'defer' || normalized === 'later')
58
+ return { schemaVersion: 1, decisionId: `rule:${inputId}`, inputId, agentId, action: 'defer', reason: 'Human requested deferral.', modelId };
59
+ if (normalized === 'respond' || normalized === 'reply')
60
+ return { schemaVersion: 1, decisionId: `rule:${inputId}`, inputId, agentId, action: 'respond', ...(targetEffectId === undefined ? {} : { targetEffectId }), modelId };
61
+ return undefined;
62
+ }