@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.
- package/dist/dsl/program.d.ts +3 -0
- package/dist/dsl/program.js +112 -58
- package/dist/dsl/templates.d.ts +3 -0
- package/dist/dsl/templates.js +1 -1
- package/dist/scheduler/runtime.js +6 -0
- package/dist/tools/registry.d.ts +1 -0
- package/dist/tools/registry.js +4 -1
- package/package.json +1 -1
package/dist/dsl/program.d.ts
CHANGED
|
@@ -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;
|
package/dist/dsl/program.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)) {
|
package/dist/dsl/templates.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/dsl/templates.js
CHANGED
|
@@ -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 : {};
|
package/dist/tools/registry.d.ts
CHANGED
|
@@ -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;
|
package/dist/tools/registry.js
CHANGED
|
@@ -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
|
-
|
|
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))
|