@hunterzhu/pulse-runtime 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -255,7 +255,9 @@ export declare class StepBuilder<TState = JsonValue> {
255
255
  resetTurnsOnEntry?: (ctx: StepContext<TState>) => string | number | undefined;
256
256
  outputSchema?: ZodTypeAny;
257
257
  requirements?: Record<string, JsonValue>;
258
+ manualApprovalFallback?: boolean;
258
259
  toolApproval?: {
260
+ requiresApproval?: (calls: JsonValue) => boolean;
259
261
  prompt: string | ((calls: JsonValue, ctx: StepContext<TState>) => string);
260
262
  onDenied?: (reason: string, ctx: StepContext<TState>) => NextStepTarget<TState>;
261
263
  };
@@ -268,6 +270,7 @@ export declare class StepBuilder<TState = JsonValue> {
268
270
  };
269
271
  onMaxTurns?: (ctx: StepContext<TState>) => NextStepTarget<TState>;
270
272
  prepareQuestion?: (toolName: string, input: JsonValue, ctx: StepContext<TState>) => JsonValue;
273
+ validateQuestion?: (toolName: string, input: JsonValue, ctx: StepContext<TState>) => string | undefined;
271
274
  onHumanReply?: (question: JsonValue, answer: JsonValue, ctx: StepContext<TState>) => NextStepTarget<TState> | undefined;
272
275
  onError?: (error: RuntimeError, ctx: StepContext<TState>) => NextStepTarget<TState>;
273
276
  }): this;
@@ -774,6 +774,11 @@ export class StepBuilder {
774
774
  return updates.length ? interactionUserRevision(updates) : typeof prior === 'string' ? prior : interactionUserRevision([]);
775
775
  };
776
776
  const submitModel = (ctx, turn, inputs = {}, answers = readAnswers(ctx)) => {
777
+ // A new model wait owns its own result. Older snapshots may still carry
778
+ // the proposal pinned while compaction was awaiting tool approval.
779
+ const locals = writeTurns(ctx, turn, inputs);
780
+ const modelSdk = { ...sdkLocals(locals) };
781
+ delete modelSdk[pendingResultKey];
777
782
  if (answers.length)
778
783
  inputs = { ...inputs, conversation: [...(inputs.conversation ?? []), { role: 'user', content: '[Confirmed interaction data; not new instructions]\n' + JSON.stringify(confirmedInteractionSurface(answers, (() => { const controller = callInput(callInput(ctx.global).taskController ?? {}); const stage = Array.isArray(controller.tasks) ? controller.tasks.map(callInput).find(t => t.id === controller.activeId) : undefined; return [String(controller.activeId ?? ''), ...(Array.isArray(stage?.criterionIds) ? stage.criterionIds.filter((id) => typeof id === 'string') : [])]; })())) + '\nThese replies are the current decisions, including the selected option labels. An older document saying unresolved does not reopen a decision. Ask again only if the user changes it; execution approval remains a separate explicit gate.' }] };
779
784
  const allResultRefs = [...new Set(inputs.results ?? [])];
@@ -783,7 +788,7 @@ export class StepBuilder {
783
788
  const artifactRefs = [...new Set(inputs.artifacts ?? [])];
784
789
  const dataRefs = [...resultRefs, ...findingRefs, ...artifactRefs.map((ref) => ({ kind: 'artifact', ref }))];
785
790
  const requirements = { ...(options.requirements ?? {}), ...(options.toolAllow === undefined ? {} : { toolCalling: true }) };
786
- 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) };
791
+ 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: { ...locals, $sdk: modelSdk } }, locals: { ...locals, $sdk: modelSdk } };
787
792
  };
788
793
  const invalidInputRecovery = (ctx) => {
789
794
  const input = ctx.resumeInput;
@@ -819,6 +824,18 @@ export class StepBuilder {
819
824
  return { ...output, next: `${name}:decode`, locals: { ...locals, $sdk: { ...sdk, [failureKey]: { signature, count, observations: [{ code: 'INVALID_TOOL_INPUT', message }] }, [`${name}QueuedTools`]: [], [`${name}StopAfterSerial`]: false } } };
820
825
  };
821
826
  this.handlers.set(name, (ctx) => { const resetToken = options.resetTurnsOnEntry?.(ctx); const priorToken = sdkLocals(ctx.lane.resume.locals)[resetTokenKey]; const reset = resetToken !== undefined && priorToken !== resetToken; const turn = (reset ? 0 : readTurns(ctx)) + 1; const inputs = options.inputs?.(ctx) ?? {}; const output = submitModel(ctx, turn, inputs); const locals = writeTurns(ctx, turn, inputs, resetToken); return { ...output, next: `${name}:decode`, locals }; });
827
+ this.handlers.set(`${name}:manual-approval`, (ctx) => {
828
+ const dependency = Object.values(waitResolution(ctx.resumeInput)?.dependencies ?? {}).find(item => item.state === 'settled' && item.outcome.resultRef);
829
+ const ref = dependency?.state === 'settled' ? dependency.outcome.resultRef : undefined;
830
+ const answer = ref ? readResult(ctx, ref) : undefined;
831
+ if (!answer || typeof answer !== 'object' || Array.isArray(answer) || answer.approved !== true)
832
+ return { next: { fail: { code: 'APPROVAL_DENIED', message: 'Manual approval denied or cancelled. Operation not executed.', retryable: false } } };
833
+ const calls = sdkLocals(ctx.lane.resume.locals)[`${name}ManualCalls`];
834
+ const question = ref ? ctx.results.meta(ref)?.humanQuestion : undefined;
835
+ if (!Array.isArray(calls) || !calls.length || !question || typeof question !== 'object' || Array.isArray(question) || JSON.stringify(question.tools) !== JSON.stringify(calls))
836
+ return { next: { fail: { code: 'APPROVAL_DIGEST_MISMATCH', message: 'Approved operation does not match the persisted question.', retryable: false } } };
837
+ return { actions: [{ type: 'submit_effects', effects: calls.map((call, index) => { const c = call; return { key: `${name}-manual-approved-${index}`, kind: 'tool', concurrencyClass: 'tool', toolCallId: String(c.toolCallId), derivedFrom: ref ? [ref] : [], input: { name: String(c.toolName), arguments: c.input ?? {}, toolCallId: String(c.toolCallId) } }; }), wait: { onUnsatisfied: 'resume_with_error' } }], next: `${name}:tools`, locals: { ...ctx.lane.resume.locals, $sdk: { ...sdkLocals(ctx.lane.resume.locals), [`${name}ManualCalls`]: null } } };
838
+ });
822
839
  this.handlers.set(`${name}:tools`, (ctx) => {
823
840
  for (const ref of resultRefsFromWait(ctx)) {
824
841
  const meta = ctx.results.meta(ref);
@@ -828,6 +845,16 @@ export class StepBuilder {
828
845
  if (next)
829
846
  return { next };
830
847
  }
848
+ const manualCalls = Object.values(waitResolution(ctx.resumeInput)?.dependencies ?? {}).flatMap(dependency => {
849
+ const details = dependency.state === 'settled' ? dependency.outcome.error?.details : undefined;
850
+ if (!details || typeof details !== 'object' || Array.isArray(details) || details.manualApproval !== true || typeof details.toolName !== 'string' || typeof details.toolCallId !== 'string')
851
+ return [];
852
+ return [{ toolName: details.toolName, toolCallId: details.toolCallId, input: details.input ?? {} }];
853
+ });
854
+ if (options.manualApprovalFallback && manualCalls.length) {
855
+ const calls = [...new Map(manualCalls.map(call => [call.toolCallId, call])).values()];
856
+ return { actions: [{ type: 'submit_effects', effects: [{ key: `${name}-manual-approval`, kind: 'human', concurrencyClass: 'none', input: { prompt: '本地审批未通过或不可用。请确认下面的原始操作;未确认前不会执行。', tools: calls } }], wait: { onUnsatisfied: 'resume_with_error' } }], next: `${name}:manual-approval`, locals: { ...ctx.lane.resume.locals, $sdk: { ...sdkLocals(ctx.lane.resume.locals), [`${name}ManualCalls`]: calls } } };
857
+ }
831
858
  // Stop before another model turn or queued call can bypass a denied operation.
832
859
  const stopped = Object.values(waitResolution(ctx.resumeInput)?.dependencies ?? {}).find(dependency => dependency.state === 'settled' && (dependency.outcome.status === 'cancelled' || dependency.outcome.error?.code.startsWith('AI_APPROVAL_') || ['AI_APPROVAL_DENIED', 'APPROVAL_DENIED', 'APPROVAL_REQUIRED', 'TASK_MODEL_CALL_BUDGET_EXHAUSTED', 'TASK_MODEL_CALL_BUDGET_CORRUPT'].includes(dependency.outcome.error?.code ?? '')));
833
860
  if (stopped?.state === 'settled')
@@ -978,6 +1005,73 @@ export class StepBuilder {
978
1005
  delete nextSdk[skippedKey];
979
1006
  return { ...output, next: `${name}:decode`, locals: { ...locals, $sdk: nextSdk } };
980
1007
  });
1008
+ const dispatchTools = (action, locals, deferred = [], skippedNames = []) => {
1009
+ if (action.type !== 'submit_effects')
1010
+ throw new Error('INVALID_TOOL_ACTION');
1011
+ const serial = options.serialTools !== undefined && action.effects.some(effect => options.serialTools.includes(String(callInput(effect.input ?? {}).name)));
1012
+ const limit = serial ? 1 : Math.max(1, options.maxToolsPerTurn ?? action.effects.length);
1013
+ const base = locals;
1014
+ const priorSdk = sdkLocals(base);
1015
+ const usedTools = new Set(Array.isArray(priorSdk[`${name}UsedTools`]) ? priorSdk[`${name}UsedTools`].filter((item) => typeof item === 'string') : []);
1016
+ for (const effect of action.effects) {
1017
+ const input = effect.input && typeof effect.input === 'object' && !Array.isArray(effect.input) ? effect.input : {};
1018
+ if (typeof input.name === 'string')
1019
+ usedTools.add(input.name);
1020
+ }
1021
+ const sdk = { ...priorSdk, [`${name}ReadGateSkipped`]: deferred, [`${name}UsedTools`]: [...usedTools], [`${name}QueuedTools`]: action.effects.slice(limit), [`${name}StopAfterSerial`]: options.stopAfterFirstSerialTool === true && serial && action.effects.length > limit };
1022
+ // Approval must retain the proposal until it is checked, then consume
1023
+ // it once just like direct tool dispatch after compaction.
1024
+ delete sdk[pendingResultKey];
1025
+ if (skippedNames.length)
1026
+ sdk[`${name}SkippedTools`] = skippedNames;
1027
+ return { actions: [{ ...action, effects: action.effects.slice(0, limit) }], next: `${name}:tools`, locals: { ...base, $sdk: sdk } };
1028
+ };
1029
+ // Approval is a durable resume point, so register it when the program is built.
1030
+ // A handler installed only during decode disappears in a new process and would
1031
+ // fall back to the entry step, losing the approved operations.
1032
+ this.handlers.set(`${name}:approval`, ctx => {
1033
+ // A denied or mismatched approval is the user's decision for this lane.
1034
+ // Stage recovery would mark one stage blocked and keep dispatching the rest.
1035
+ const stopsLane = (code) => code === 'APPROVAL_DENIED' || code === 'APPROVAL_DIGEST_MISMATCH' || code === 'APPROVAL_CALLS_MISSING' || code === 'APPROVAL_RESPONSE_INVALID' || code === 'APPROVAL_RESPONSE_MISSING' || code.startsWith('TASK_MODEL_CALL_BUDGET_') || code.startsWith('AI_APPROVAL_');
1036
+ const fail = (error) => stopsLane(error.code) ? { next: { fail: error } } : options.onError ? { next: options.onError(error, ctx), locals: clearPendingResult(ctx) } : { next: { fail: error } };
1037
+ const resolution = waitResolution(ctx.resumeInput);
1038
+ const error = waitFailure(ctx);
1039
+ if (error)
1040
+ return fail(error);
1041
+ const dependency = resolution && Object.values(resolution.dependencies).find(item => item.state === 'settled' && item.outcome);
1042
+ if (!dependency || dependency.state !== 'settled' || !dependency.outcome?.resultRef)
1043
+ return fail({ code: 'APPROVAL_RESPONSE_INVALID', message: 'Approval wait has no settled reply.', retryable: false });
1044
+ const reply = z.object({ approved: z.boolean(), reason: z.string().optional() }).safeParse(readResult(ctx, dependency.outcome.resultRef));
1045
+ if (!reply.success)
1046
+ return fail({ code: 'APPROVAL_RESPONSE_INVALID', message: 'Approval response must contain approved=true or false.', retryable: false });
1047
+ if (!reply.data.approved)
1048
+ return options.toolApproval?.onDenied ? { next: options.toolApproval.onDenied(reply.data.reason ?? 'User denied the proposed tool call.', ctx) } : fail({ code: 'APPROVAL_DENIED', message: reply.data.reason ?? 'User denied the proposed tool call.', retryable: false });
1049
+ const sdk = sdkLocals(ctx.lane.resume.locals), approvalKey = `${name}PendingToolCalls`, digestKey = `${name}PendingToolDigest`;
1050
+ const pending = sdk[approvalKey];
1051
+ if (!Array.isArray(pending))
1052
+ return fail({ code: 'APPROVAL_CALLS_MISSING', message: 'Approved calls are missing from persisted lane state.', retryable: false });
1053
+ const evidenceKey = `${name}PendingToolEvidence`;
1054
+ const storedEvidence = sdk[evidenceKey];
1055
+ const legacyDigest = storedEvidence === undefined && sdk[digestKey] === contentHash(pending);
1056
+ if (!legacyDigest && sdk[digestKey] !== contentHash({ calls: pending, evidence: storedEvidence ?? null }))
1057
+ return fail({ code: 'APPROVAL_DIGEST_MISMATCH', message: 'Persisted calls differ from the approved digest.', retryable: false });
1058
+ const evidence = legacyDigest ? {} : callInput(storedEvidence ?? {});
1059
+ const sourceRef = typeof sdk[pendingResultKey] === 'string' ? sdk[pendingResultKey] : undefined;
1060
+ const sourceMeta = sourceRef ? ctx.results.meta(sourceRef) : undefined;
1061
+ const sourceEffectId = typeof evidence.sourceEffectId === 'string' ? evidence.sourceEffectId : sourceMeta?.producer.kind === 'effect' ? sourceMeta.producer.id : undefined;
1062
+ const privacy = ['public', 'cloud_allowed', 'local_only'].includes(String(evidence.sourcePrivacy)) ? evidence.sourcePrivacy : sourceMeta?.privacy;
1063
+ const refs = Array.isArray(evidence.derivedFrom) ? evidence.derivedFrom.filter((ref) => typeof ref === 'string') : sourceRef ? [sourceRef] : [];
1064
+ const turns = readTurns(ctx);
1065
+ const action = { type: 'submit_effects', effects: pending.map((raw, index) => {
1066
+ const call = callInput(raw), toolCallId = String(call.toolCallId ?? `${name}:${turns}:${String(call.originalId)}`);
1067
+ return { key: `${name}-tool-${turns}-${index + 1}`, toolCallId, kind: 'tool', concurrencyClass: 'tool', ...(sourceEffectId ? { llmEffectId: sourceEffectId } : {}), ...(privacy ? { privacy } : {}), ...(refs.length ? { derivedFrom: refs } : {}), input: { toolCallId, name: String(call.toolName), arguments: call.input ?? {}, ...(privacy ? { privacy } : {}), ...(refs.length ? { derivedFrom: refs } : {}) } };
1068
+ }), wait: { onUnsatisfied: 'resume_with_error' } };
1069
+ const locals = ordinaryLocals(ctx.lane.resume.locals);
1070
+ const cleared = { ...locals, $sdk: { ...sdk, [approvalKey]: null, [digestKey]: null, [`${name}PendingToolEvidence`]: null } };
1071
+ const deferred = Array.isArray(evidence.deferred) ? evidence.deferred.filter((name) => typeof name === 'string') : [];
1072
+ const skipped = Array.isArray(evidence.skippedNames) ? evidence.skippedNames.filter((name) => typeof name === 'string') : [];
1073
+ return dispatchTools(action, cleared, deferred, skipped);
1074
+ });
981
1075
  this.handlers.set(`${name}:decode`, (ctx) => {
982
1076
  const recovered = invalidInputRecovery(ctx);
983
1077
  if (recovered)
@@ -1081,6 +1175,18 @@ export class StepBuilder {
1081
1175
  const toolName = canonicalToolName(rawToolName);
1082
1176
  return { originalId, toolName, toolCallId: options.scopeToolCallsToEffect ? `${name}:${sourceEffectId ?? 'turn'}:${turns}:${originalId}` : `${name}:${turns}:${originalId}`, input: toolName.startsWith('ask.') && options.prepareQuestion ? options.prepareQuestion(toolName, normalizeModelToolInput(toolName, item.input ?? {}), ctx) : normalizeModelToolInput(toolName, item.input ?? {}) };
1083
1177
  });
1178
+ const questionNotice = decodedCalls.filter(call => call.toolName.startsWith('ask.')).map(call => options.validateQuestion?.(call.toolName, call.input, ctx)).find(Boolean);
1179
+ if (questionNotice) {
1180
+ const key = `${name}QuestionValidationFailures`, signature = contentHash(questionNotice);
1181
+ const previous = callInput(sdkLocals(ctx.lane.resume.locals)[key] ?? {});
1182
+ const count = previous.signature === signature ? Number(previous.count ?? 0) + 1 : 1;
1183
+ if (count >= 3 || turns >= maxTurns)
1184
+ return fail({ code: 'QUESTION_VALIDATION_FAILED', message: questionNotice, retryable: false });
1185
+ const current = options.inputs?.(ctx) ?? {};
1186
+ const output = submitModel(ctx, turns + 1, { ...readInputs(ctx), ...current, conversation: [...(current.conversation ?? []), { role: 'user', content: questionNotice }] });
1187
+ const locals = output.locals;
1188
+ return { ...output, next: `${name}:decode`, locals: { ...locals, $sdk: { ...sdkLocals(locals), [key]: { signature, count } } } };
1189
+ }
1084
1190
  const patchKey = `${name}PatchRecovery`;
1085
1191
  const patchRefs = [...new Set([...(readInputs(ctx).results ?? []), ...(options.inputs?.(ctx).results ?? []), ...ctx.history.flatMap(h => h.resultRefs)])];
1086
1192
  const patchRecoveries = refreshPatchRecoveries(ctx, readPatchRecoveries(sdkLocals(ctx.lane.resume.locals)[patchKey]), patchRefs);
@@ -1282,68 +1388,16 @@ export class StepBuilder {
1282
1388
  return { actions: [{ type: 'submit_effects', effects: askEffects, wait: { onUnsatisfied: 'resume_with_error' } }], next: `${name}:tools`, locals: clearPendingResult(ctx) };
1283
1389
  }
1284
1390
  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' } });
1285
- const dispatchTools = (action, locals) => {
1286
- if (action.type !== 'submit_effects')
1287
- throw new Error('INVALID_TOOL_ACTION');
1288
- const serial = options.serialTools !== undefined && calls.some((call) => options.serialTools.includes(String(call.toolName)));
1289
- const limit = serial ? 1 : Math.max(1, options.maxToolsPerTurn ?? action.effects.length);
1290
- const base = locals;
1291
- const priorSdk = sdkLocals(base);
1292
- const usedTools = new Set(Array.isArray(priorSdk[`${name}UsedTools`]) ? priorSdk[`${name}UsedTools`].filter((item) => typeof item === 'string') : []);
1293
- for (const effect of action.effects) {
1294
- const input = effect.input && typeof effect.input === 'object' && !Array.isArray(effect.input) ? effect.input : {};
1295
- if (typeof input.name === 'string')
1296
- usedTools.add(input.name);
1297
- }
1298
- const sdk = { ...priorSdk, [`${name}ReadGateSkipped`]: deferred, [`${name}UsedTools`]: [...usedTools], [`${name}QueuedTools`]: action.effects.slice(limit), [`${name}StopAfterSerial`]: options.stopAfterFirstSerialTool === true && serial && action.effects.length > limit };
1299
- if (skippedNames.length)
1300
- sdk[`${name}SkippedTools`] = skippedNames;
1301
- return { actions: [{ ...action, effects: action.effects.slice(0, limit) }], next: `${name}:tools`, locals: { ...base, $sdk: sdk } };
1302
- };
1303
- if (options.toolApproval) {
1391
+ if (options.toolApproval && (options.toolApproval.requiresApproval?.(calls) ?? true)) {
1304
1392
  const approvalKey = `${name}PendingToolCalls`;
1305
1393
  const digestKey = `${name}PendingToolDigest`;
1306
1394
  const locals = ctx.lane.resume.locals && typeof ctx.lane.resume.locals === 'object' && !Array.isArray(ctx.lane.resume.locals) ? ctx.lane.resume.locals : {};
1307
- const digest = contentHash(calls);
1395
+ const evidenceRecord = { sourceEffectId: sourceEffectId ?? null, sourcePrivacy: sourcePrivacy ?? null, derivedFrom: toolDerivedFrom, deferred, skippedNames };
1396
+ const digest = contentHash({ calls, evidence: evidenceRecord });
1308
1397
  const listing = calls.map((call, index) => `${index + 1}. ${call.toolName} ${call.toolCallId}`).join('\n');
1309
1398
  const extra = typeof options.toolApproval.prompt === 'string' ? options.toolApproval.prompt : options.toolApproval.prompt(calls.map((call) => ({ name: call.toolName, toolCallId: call.originalId })), ctx);
1310
1399
  const prompt = boundedInstruction(`Approve ${calls.length} tool call(s). Digest ${digest}.\n${listing}\n${extra}`);
1311
- this.handlers.set(`${name}:approval`, (approvalCtx) => {
1312
- const resolution = waitResolution(approvalCtx.resumeInput);
1313
- const dependency = resolution === undefined ? undefined : Object.values(resolution.dependencies).find((candidate) => candidate.state === 'settled');
1314
- const approvalError = waitFailure(approvalCtx);
1315
- if (approvalError)
1316
- return fail(approvalError);
1317
- const settledDependency = dependency;
1318
- if (settledDependency?.state !== 'settled') {
1319
- return fail({
1320
- code: 'APPROVAL_RESPONSE_MISSING',
1321
- message: 'Approval response was not received.',
1322
- retryable: false,
1323
- details: { dependencyState: settledDependency?.state ?? 'missing', waitId: resolution?.waitId ?? null },
1324
- });
1325
- }
1326
- const value = settledDependency.outcome.resultRef ? readResult(approvalCtx, settledDependency.outcome.resultRef) : undefined;
1327
- const parsed = z.object({ approved: z.boolean(), reason: z.string().optional() }).safeParse(value);
1328
- if (!parsed.success)
1329
- return fail({ code: 'APPROVAL_RESPONSE_INVALID', message: 'Approval response must contain approved=true or false.', retryable: false, details: parsed.error.message });
1330
- if (!parsed.data.approved) {
1331
- const reason = parsed.data.reason ?? 'User denied the proposed tool call.';
1332
- if (options.toolApproval?.onDenied)
1333
- return { next: options.toolApproval.onDenied(reason, approvalCtx) };
1334
- return { next: { fail: { code: 'APPROVAL_DENIED', message: reason, retryable: false } } };
1335
- }
1336
- const pendingLocals = sdkLocals(approvalCtx.lane.resume.locals);
1337
- const pending = pendingLocals[approvalKey];
1338
- if (!Array.isArray(pending))
1339
- return fail({ code: 'APPROVAL_CALLS_MISSING', message: 'Approved tool calls were not found in the persisted lane state.', retryable: false });
1340
- if (pendingLocals[digestKey] !== contentHash(pending))
1341
- return fail({ code: 'APPROVAL_DIGEST_MISMATCH', message: 'Persisted tool calls no longer match the approved digest.', retryable: false });
1342
- const approvedCalls = pending.flatMap((item) => item && typeof item === 'object' && !Array.isArray(item) ? [{ originalId: item.originalId ?? '', toolName: item.toolName ?? '', toolCallId: item.toolCallId ?? '', input: item.input ?? {} }] : []);
1343
- const cleared = { ...locals, $sdk: { ...pendingLocals, [approvalKey]: null, [digestKey]: null } };
1344
- return dispatchTools(makeToolEffects(approvedCalls), cleared);
1345
- });
1346
- 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 } } };
1400
+ 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, [`${name}PendingToolEvidence`]: evidenceRecord } } };
1347
1401
  }
1348
1402
  const gatedLocals = clearPendingResult(ctx);
1349
1403
  const gatedSdk = { ...sdkLocals(gatedLocals) };
@@ -1351,7 +1405,7 @@ export class StepBuilder {
1351
1405
  gatedSdk[editGateKey] = editGateReads + 1;
1352
1406
  else
1353
1407
  delete gatedSdk[editGateKey];
1354
- return dispatchTools(makeToolEffects(calls), { ...gatedLocals, $sdk: gatedSdk });
1408
+ return dispatchTools(makeToolEffects(calls), { ...gatedLocals, $sdk: gatedSdk }, deferred, skippedNames);
1355
1409
  }
1356
1410
  const answerText = typeof record?.text === 'string' ? record.text : '';
1357
1411
  if (toolCalls.length === 0 && looksLikeUnexecutedToolRequest(answerText)) {
@@ -21,6 +21,7 @@ export interface ReActLaneConfig {
21
21
  maxTruncationRetries?: number;
22
22
  maxToolsPerTurn?: number;
23
23
  prepareQuestion?: (toolName: string, input: JsonValue, ctx: StepContext<JsonValue>) => JsonValue;
24
+ validateQuestion?: (toolName: string, input: JsonValue, ctx: StepContext<JsonValue>) => string | undefined;
24
25
  onHumanReply?: (question: JsonValue, answer: JsonValue, ctx: StepContext<JsonValue>) => NextStepTarget<JsonValue> | undefined;
25
26
  /** Bound investigation-only rounds for an implementation run; pagination is exempt. */
26
27
  maxReadOnlyRounds?: number | ((ctx: StepContext<JsonValue>) => number | undefined);
@@ -30,7 +31,9 @@ export interface ReActLaneConfig {
30
31
  resetTurnsOnEntry?: (ctx: StepContext<JsonValue>) => string | number | undefined;
31
32
  outputSchema?: ZodTypeAny;
32
33
  requirements?: Record<string, JsonValue>;
34
+ manualApprovalFallback?: boolean;
33
35
  toolApproval?: {
36
+ requiresApproval?: (calls: JsonValue) => boolean;
34
37
  prompt: string | ((calls: JsonValue, ctx: StepContext<JsonValue>) => string);
35
38
  onDenied?: (reason: string, ctx: StepContext<JsonValue>) => NextStepTarget<JsonValue>;
36
39
  };
@@ -4,7 +4,7 @@ export function defineReActLane(config) {
4
4
  const onFinish = config.onFinish ?? (config.outputSchema === undefined
5
5
  ? { text: (resultRef) => ({ complete: { value: { textRef: resultRef } } }) }
6
6
  : { text: (resultRef) => ({ complete: { value: { textRef: resultRef } } }), structured: { schema: config.outputSchema, onParsed: (value) => ({ complete: { value: value } }) } });
7
- builder.addReActLoopStep('react', { ...(config.task === undefined ? {} : { task: config.task }), instruction: config.instruction, ...(config.prepareQuestion === undefined ? {} : { prepareQuestion: config.prepareQuestion }), ...(config.onHumanReply === undefined ? {} : { onHumanReply: config.onHumanReply }), ...(config.inputs === undefined ? {} : { inputs: config.inputs }), ...(config.toolAllow === undefined ? {} : { toolAllow: config.toolAllow }), ...(config.maxToolsPerTurn === undefined ? {} : { maxToolsPerTurn: config.maxToolsPerTurn }), ...(config.maxReadOnlyRounds === undefined ? {} : { maxReadOnlyRounds: config.maxReadOnlyRounds }), ...(config.serialTools === undefined ? {} : { serialTools: config.serialTools }), ...(config.stopAfterFirstSerialTool === undefined ? {} : { stopAfterFirstSerialTool: config.stopAfterFirstSerialTool }), ...(config.maxTruncationRetries === undefined ? {} : { maxTruncationRetries: config.maxTruncationRetries }), ...(config.maxTurns === undefined ? {} : { maxTurns: config.maxTurns }), ...(config.resetTurnsOnEntry === undefined ? {} : { resetTurnsOnEntry: config.resetTurnsOnEntry }), ...(config.outputSchema === undefined ? {} : { outputSchema: config.outputSchema }), ...(config.requirements === undefined ? {} : { requirements: config.requirements }), ...(config.toolApproval === undefined ? {} : { toolApproval: config.toolApproval }), onFinish });
7
+ builder.addReActLoopStep('react', { ...(config.task === undefined ? {} : { task: config.task }), instruction: config.instruction, ...(config.manualApprovalFallback ? { manualApprovalFallback: true } : {}), ...(config.prepareQuestion === undefined ? {} : { prepareQuestion: config.prepareQuestion }), ...(config.validateQuestion === undefined ? {} : { validateQuestion: config.validateQuestion }), ...(config.onHumanReply === undefined ? {} : { onHumanReply: config.onHumanReply }), ...(config.inputs === undefined ? {} : { inputs: config.inputs }), ...(config.toolAllow === undefined ? {} : { toolAllow: config.toolAllow }), ...(config.maxToolsPerTurn === undefined ? {} : { maxToolsPerTurn: config.maxToolsPerTurn }), ...(config.maxReadOnlyRounds === undefined ? {} : { maxReadOnlyRounds: config.maxReadOnlyRounds }), ...(config.serialTools === undefined ? {} : { serialTools: config.serialTools }), ...(config.stopAfterFirstSerialTool === undefined ? {} : { stopAfterFirstSerialTool: config.stopAfterFirstSerialTool }), ...(config.maxTruncationRetries === undefined ? {} : { maxTruncationRetries: config.maxTruncationRetries }), ...(config.maxTurns === undefined ? {} : { maxTurns: config.maxTurns }), ...(config.resetTurnsOnEntry === undefined ? {} : { resetTurnsOnEntry: config.resetTurnsOnEntry }), ...(config.outputSchema === undefined ? {} : { outputSchema: config.outputSchema }), ...(config.requirements === undefined ? {} : { requirements: config.requirements }), ...(config.toolApproval === undefined ? {} : { toolApproval: config.toolApproval }), onFinish });
8
8
  config.extend?.(builder);
9
9
  });
10
10
  }
@@ -2616,6 +2616,12 @@ export class PulseRuntime {
2616
2616
  }
2617
2617
  let effectiveExecution = execution;
2618
2618
  let outputError = error ?? execution.error;
2619
+ // A timeout remains a failure even when an aborted executor returns a generic cancellation.
2620
+ const timeoutReason = effect.cancelRequested?.reason;
2621
+ if (timeoutReason === 'ATTEMPT_TIMEOUT' || timeoutReason === 'TIMEOUT') {
2622
+ outputError = { code: timeoutReason, message: `Effect exceeded its execution time limit (${timeoutReason}).`, retryable: false };
2623
+ effectiveExecution = { ...execution, status: 'failed', executionState: 'failed', error: outputError };
2624
+ }
2619
2625
  let resultDerivedFrom = [...(effect.derivedFrom ?? [])];
2620
2626
  let publishedArtifact;
2621
2627
  const rawInput = effect.input && typeof effect.input === 'object' && !Array.isArray(effect.input) ? effect.input : {};
@@ -58,6 +58,7 @@ export interface RuntimeToolDefinition {
58
58
  execute(input: unknown, context: RuntimeToolContext): Promise<unknown> | unknown;
59
59
  executionRef?(input: unknown, context: RuntimeToolContext): JsonValue;
60
60
  resolveResources?(input: unknown): ResourceLockSpec[];
61
+ resolveTimeoutMs?(input: unknown): number;
61
62
  reconcile?(executionRef: JsonValue, context: RuntimeReconcileContext): Promise<RuntimeReconcileResult>;
62
63
  normalize?(output: unknown): JsonValue;
63
64
  summarize?(output: unknown): JsonValue;
@@ -299,7 +299,10 @@ export class RuntimeToolRegistry {
299
299
  admission(name, input) {
300
300
  const definition = this.require(name);
301
301
  const parsed = this.validateInput(name, input);
302
- return { locks: structuredClone(this.resolveResources(name, parsed)), sideEffectPolicy: definition.manifest.sideEffectPolicy, defaultTimeoutMs: definition.manifest.defaultTimeoutMs, retrySafety: definition.manifest.retrySafety, version: definition.manifest.version };
302
+ const timeout = definition.resolveTimeoutMs?.(parsed) ?? definition.manifest.defaultTimeoutMs;
303
+ if (definition.resolveTimeoutMs && (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > 600_000))
304
+ throw Object.assign(new Error('Resolved tool timeout is outside host limits.'), { code: 'INVALID_TOOL_TIMEOUT' });
305
+ return { locks: structuredClone(this.resolveResources(name, parsed)), sideEffectPolicy: definition.manifest.sideEffectPolicy, defaultTimeoutMs: timeout, retrySafety: definition.manifest.retrySafety, version: definition.manifest.version };
303
306
  }
304
307
  require(name) {
305
308
  if (!this.isAllowed(name))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hunterzhu/pulse-runtime",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "license": "PolyForm-Noncommercial-1.0.0",
5
5
  "repository": {
6
6
  "type": "git",