ai-workflow-logic 0.2.1 → 0.2.3

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/src/core.ts CHANGED
@@ -47,13 +47,13 @@ export {
47
47
  export {
48
48
  AIAgentInvocationError,
49
49
  bindAIAgentProcessors,
50
+ bindWorkflowNodeTasks,
50
51
  normalizeAIAgentEffectConfig,
51
52
  normalizeAIAgentInvocation,
52
53
  normalizeAIAgentSelector,
53
54
  runAgent,
54
55
  runTargetedAgent,
55
56
  } from './agent-invocation.js';
56
- export * from './holon-task.js';
57
57
  export { assertAIAgentNodeTaskBinding, upgradeLegacyAIWorkflowProfileDurableState } from './agent-state.js';
58
58
  export type { AIAgentNodeBindingIdentity } from './agent-state.js';
59
59
  export {
@@ -122,27 +122,7 @@ export function assertAIWorkflowSubstrate(
122
122
  }
123
123
  }
124
124
 
125
- export function defineAICtrlWorkflow(
126
- definition: AICtrlWorkflowDefinitionBinding['definition'],
127
- ): AICtrlWorkflowDefinitionBinding {
128
- return Object.freeze({
129
- kind: 'AICtrlWorkflow',
130
- substrate: 'WorkCtrlFlow',
131
- descriptor: AI_CTRL_WORKFLOW_DESCRIPTOR,
132
- definition,
133
- });
134
- }
135
-
136
- export function defineAIDataWorkflow(
137
- definition: AIDataWorkflowDefinitionBinding['definition'],
138
- ): AIDataWorkflowDefinitionBinding {
139
- return Object.freeze({
140
- kind: 'AIDataWorkflow',
141
- substrate: 'EagerDataFlow',
142
- descriptor: AI_DATA_WORKFLOW_DESCRIPTOR,
143
- definition,
144
- });
145
- }
125
+ export { defineAICtrlWorkflow, defineAIDataWorkflow } from './definitions.js';
146
126
 
147
127
  export function projectAIWorkflowAppBundles(
148
128
  registry: AIWorkflowResourceRegistryView,
@@ -0,0 +1,30 @@
1
+ import type {
2
+ AICtrlWorkflowDefinitionBinding,
3
+ AIDataWorkflowDefinitionBinding,
4
+ } from "ai-workflow-contract";
5
+ import {
6
+ AI_CTRL_WORKFLOW_DESCRIPTOR,
7
+ AI_DATA_WORKFLOW_DESCRIPTOR,
8
+ } from "ai-workflow-contract/profiles";
9
+
10
+ export function defineAICtrlWorkflow(
11
+ definition: AICtrlWorkflowDefinitionBinding["definition"],
12
+ ): AICtrlWorkflowDefinitionBinding {
13
+ return Object.freeze({
14
+ kind: "AICtrlWorkflow",
15
+ substrate: "WorkCtrlFlow",
16
+ descriptor: AI_CTRL_WORKFLOW_DESCRIPTOR,
17
+ definition,
18
+ });
19
+ }
20
+
21
+ export function defineAIDataWorkflow(
22
+ definition: AIDataWorkflowDefinitionBinding["definition"],
23
+ ): AIDataWorkflowDefinitionBinding {
24
+ return Object.freeze({
25
+ kind: "AIDataWorkflow",
26
+ substrate: "EagerDataFlow",
27
+ descriptor: AI_DATA_WORKFLOW_DESCRIPTOR,
28
+ definition,
29
+ });
30
+ }
@@ -0,0 +1,387 @@
1
+ import type {
2
+ AIAgentProcessorRuntime, AIWorkflowFlowRunCheckpoint, FlowClosedValue,
3
+ WorkflowTaskDelegation, WorkflowDelegationRequest, WorkflowDelegationObservation,
4
+ WorkflowDelegationRecord, WorkflowDelegationEffectPort, WorkflowDelegationOwnerTarget,
5
+ } from 'ai-workflow-contract';
6
+ import { normalizeFlowClosedValue } from './run-checkpoint.js';
7
+ import { assertAIAgentNodeTaskBinding } from './agent-state.js';
8
+
9
+ export class WorkflowDelegationError extends Error {
10
+ constructor(readonly code: string) { super(code); this.name = 'WorkflowDelegationError'; }
11
+ }
12
+
13
+ export class WorkflowDelegationSuspendedError extends Error {
14
+ constructor(readonly invocationKey: string, readonly status: string, readonly cause?: unknown) {
15
+ super(`Workflow delegation ${invocationKey} is ${status}; observe the original invocation.`);
16
+ this.name = 'WorkflowDelegationSuspendedError';
17
+ }
18
+ }
19
+
20
+ function assert(condition: unknown, code: string): asserts condition {
21
+ if (!condition) throw new WorkflowDelegationError(code);
22
+ }
23
+ function dataRecord(value: unknown): Record<string, FlowClosedValue> {
24
+ // Normalize before accessing properties: getters, symbols and non-data values are rejected.
25
+ const data = normalizeFlowClosedValue(value, 'delegation');
26
+ assert(data !== null && typeof data === 'object' && !Array.isArray(data), 'DELEGATION_OBJECT_REQUIRED');
27
+ return data as Record<string, FlowClosedValue>;
28
+ }
29
+ function object(value: unknown, required: string[], optional: string[] = []): Record<string, FlowClosedValue> {
30
+ const row = dataRecord(value);
31
+ assert(required.every(key => Object.hasOwn(row, key)) && Object.keys(row).every(key => [...required, ...optional].includes(key)), 'DELEGATION_FIELDS_INVALID');
32
+ return row;
33
+ }
34
+ function identity(value: unknown): asserts value is string {
35
+ assert(typeof value === 'string' && value.length > 0 && value.trim() === value && value.normalize('NFC') === value, 'DELEGATION_IDENTITY_INVALID');
36
+ }
37
+ function identityValue(value: unknown): string { identity(value); return value; }
38
+ function integer(value: unknown): asserts value is number {
39
+ assert(Number.isSafeInteger(value) && (value as number) >= 0, 'DELEGATION_INTEGER_INVALID');
40
+ }
41
+ function same(a: unknown, b: unknown): boolean {
42
+ function sorted(value: unknown): unknown {
43
+ if (Array.isArray(value)) return value.map(sorted);
44
+ if (!value || typeof value !== 'object') return value;
45
+ const row = value as Record<string, unknown>;
46
+ return Object.fromEntries(Object.keys(row).sort().map(key => [key, sorted(row[key])]));
47
+ }
48
+ return JSON.stringify(sorted(a)) === JSON.stringify(sorted(b));
49
+ }
50
+ function target(value: unknown): WorkflowDelegationOwnerTarget {
51
+ const row = object(value, ['owner', 'providerScope', 'targetId']);
52
+ return Object.freeze({ owner: identityValue(row.owner), providerScope: identityValue(row.providerScope), targetId: identityValue(row.targetId) });
53
+ }
54
+
55
+ export function normalizeWorkflowDelegation(value: unknown): WorkflowTaskDelegation {
56
+ const row = object(value, ['kind', 'task', 'context', 'sessionPolicy', 'materials'], ['execution']);
57
+ assert(row.kind === 'workflow.task-delegation/v1', 'DELEGATION_KIND_INVALID');
58
+ const task = object(row.task, ['nodeId', 'agentDefinitionRef', 'input']);
59
+ identity(task.nodeId); identity(task.agentDefinitionRef);
60
+ assert(task.agentDefinitionRef.startsWith('resource://') && task.agentDefinitionRef.length > 11, 'DELEGATION_TASK_REF_INVALID');
61
+ const contextInput = object(row.context, ['mode'], ['owner', 'providerScope', 'targetId', 'nodeId']);
62
+ let context: WorkflowTaskDelegation['context'];
63
+ if (contextInput.mode === 'fresh') {
64
+ object(contextInput, ['mode', 'owner', 'providerScope']);
65
+ context = { mode: 'fresh', owner: identityValue(contextInput.owner), providerScope: identityValue(contextInput.providerScope) };
66
+ } else if (contextInput.mode === 'reuse') {
67
+ object(contextInput, ['mode', 'owner', 'providerScope', 'targetId']);
68
+ context = { mode: 'reuse', ...target({ owner: contextInput.owner, providerScope: contextInput.providerScope, targetId: contextInput.targetId }) };
69
+ } else {
70
+ object(contextInput, ['mode', 'nodeId']); assert(contextInput.mode === 'from-node', 'DELEGATION_CONTEXT_INVALID');
71
+ context = { mode: 'from-node', nodeId: identityValue(contextInput.nodeId) };
72
+ }
73
+ assert(row.sessionPolicy === 'shared' || row.sessionPolicy === 'independent-per-generation', 'DELEGATION_SESSION_POLICY_INVALID');
74
+ assert(row.sessionPolicy !== 'independent-per-generation' || context.mode === 'fresh', 'DELEGATION_INDEPENDENCE_REQUIRED');
75
+ assert(Array.isArray(row.materials) && row.materials.length <= 17, 'DELEGATION_MATERIALS_INVALID');
76
+ let total = 0;
77
+ const ids = new Set<string>();
78
+ let instructions = 0;
79
+ const materials = row.materials.map((item: unknown) => {
80
+ const material = object(item, ['id', 'role', 'ref', 'sha256', 'bytes']);
81
+ identity(material.id); identity(material.ref); identity(material.sha256); integer(material.bytes);
82
+ assert((material.role === 'instruction' || material.role === 'auxiliary') && /^[a-f0-9]{64}$/.test(material.sha256), 'DELEGATION_MATERIAL_INVALID');
83
+ assert(!ids.has(material.id) && material.bytes <= 1048576, 'DELEGATION_MATERIAL_LIMIT');
84
+ ids.add(material.id); total += material.bytes;
85
+ if (material.role === 'instruction') instructions++;
86
+ return Object.freeze({ id: material.id, ref: material.ref, sha256: material.sha256, bytes: material.bytes, role: material.role });
87
+ });
88
+ assert(instructions <= 1 && materials.length - instructions <= 16 && total <= 8388608, 'DELEGATION_MATERIAL_LIMIT');
89
+ const execution=Object.hasOwn(row,'execution')?normalizeFlowClosedValue(row.execution,'delegation.execution'):undefined;
90
+ if(execution!==undefined)assert(new TextEncoder().encode(JSON.stringify(execution)).length<=16384,'DELEGATION_EXECUTION_LIMIT');
91
+ return Object.freeze({ kind: row.kind, task: Object.freeze({ nodeId: task.nodeId, agentDefinitionRef: task.agentDefinitionRef as `resource://${string}`, input: task.input }), context: Object.freeze(context), sessionPolicy: row.sessionPolicy, materials: Object.freeze(materials),...(execution===undefined?{}:{execution}) });
92
+ }
93
+
94
+ function normalizeRequest(value: unknown): WorkflowDelegationRequest {
95
+ const row = object(value, ['delegation', 'lineage', 'attempt'], ['target']);
96
+ const lineage = object(row.lineage, ['instanceId', 'runId', 'nodeId', 'invocationKey', 'visit', 'generation'], ['sourceInvocationKey']);
97
+ for (const [key, value] of Object.entries(lineage)) key === 'generation' ? integer(value) : identity(value);
98
+ integer(lineage.generation);
99
+ integer(row.attempt);
100
+ const delegation = normalizeWorkflowDelegation(row.delegation);
101
+ assert(delegation.task.nodeId === lineage.nodeId, 'DELEGATION_LINEAGE_MISMATCH');
102
+ assert((delegation.context.mode === 'from-node') === Object.hasOwn(lineage, 'sourceInvocationKey'), 'DELEGATION_SOURCE_MISMATCH');
103
+ assert((delegation.context.mode !== 'fresh') === Object.hasOwn(row, 'target'), 'DELEGATION_TARGET_MISMATCH');
104
+ return { delegation, lineage: Object.freeze({
105
+ instanceId: identityValue(lineage.instanceId), runId: identityValue(lineage.runId), nodeId: identityValue(lineage.nodeId),
106
+ invocationKey: identityValue(lineage.invocationKey), visit: identityValue(lineage.visit), generation: lineage.generation,
107
+ ...(lineage.sourceInvocationKey === undefined ? {} : { sourceInvocationKey: identityValue(lineage.sourceInvocationKey) }),
108
+ }), attempt: row.attempt, ...(row.target === undefined ? {} : { target: target(row.target) }) };
109
+ }
110
+
111
+ export function normalizeWorkflowDelegationObservation(value: unknown, request: WorkflowDelegationRequest): WorkflowDelegationObservation {
112
+ const row = object(value, ['invocationKey', 'attempt', 'receiptId', 'status'], ['target', 'output', 'settled', 'reason']);
113
+ identity(row.receiptId);
114
+ assert(row.invocationKey === request.lineage.invocationKey && row.attempt === request.attempt, 'DELEGATION_RECEIPT_IDENTITY_MISMATCH');
115
+ if (row.status === 'completed') {
116
+ object(row, ['invocationKey', 'attempt', 'receiptId', 'status', 'target', 'output']);
117
+ const actual = target(row.target);
118
+ assertObservationTarget(actual, request);
119
+ } else if (row.status === 'failed') {
120
+ object(row, ['invocationKey', 'attempt', 'receiptId', 'status', 'target', 'settled', 'reason']);
121
+ assert(row.settled === true, 'DELEGATION_FAILURE_UNSETTLED'); identity(row.reason);
122
+ assertObservationTarget(target(row.target), request);
123
+ } else if (row.status === 'rejected' || row.status === 'not-dispatched') {
124
+ object(row, ['invocationKey', 'attempt', 'receiptId', 'status', 'settled', 'reason']);
125
+ assert(row.settled === true, 'DELEGATION_REJECTION_UNSETTLED'); identity(row.reason);
126
+ } else {
127
+ object(row, ['invocationKey', 'attempt', 'receiptId', 'status'], ['target']);
128
+ assert(['pending', 'busy', 'awaiting-user', 'unknown'].includes(identityValue(row.status)), 'DELEGATION_STATUS_INVALID');
129
+ if (row.target !== undefined) assertObservationTarget(target(row.target), request);
130
+ }
131
+ return Object.freeze(row) as WorkflowDelegationObservation;
132
+ }
133
+
134
+ function assertObservationTarget(actual: WorkflowDelegationOwnerTarget, request: WorkflowDelegationRequest): void {
135
+ if (request.target) assert(same(actual, request.target), 'DELEGATION_OWNER_TARGET_MISMATCH');
136
+ const context = request.delegation.context;
137
+ if (context.mode === 'fresh') assert(actual.owner === context.owner && actual.providerScope === context.providerScope, 'DELEGATION_OWNER_TARGET_MISMATCH');
138
+ }
139
+
140
+ export function normalizeWorkflowDelegations(value: unknown): Readonly<Record<string, WorkflowDelegationRecord>> {
141
+ const normalized = normalizeFlowClosedValue(value, 'delegationsByKey');
142
+ assert(normalized && typeof normalized === 'object' && !Array.isArray(normalized), 'DELEGATION_INDEX_INVALID');
143
+ const result: Record<string, WorkflowDelegationRecord> = {};
144
+ for (const key of Object.keys(normalized).sort()) {
145
+ identity(key);
146
+ const row = object(dataRecord(normalized)[key], ['attempts']);
147
+ assert(Array.isArray(row.attempts) && row.attempts.length > 0, 'DELEGATION_ATTEMPTS_REQUIRED');
148
+ const attempts = row.attempts.map((item: unknown, index: number) => {
149
+ const attempt = object(item, ['request', 'dispatchClaimed'], ['observation']);
150
+ assert(typeof attempt.dispatchClaimed === 'boolean' && (attempt.observation === undefined || attempt.dispatchClaimed), 'DELEGATION_DISPATCH_CLAIM_INVALID');
151
+ const request = normalizeRequest(attempt.request);
152
+ assert(request.attempt === index && request.lineage.invocationKey === key, 'DELEGATION_ATTEMPT_IDENTITY_MISMATCH');
153
+ return Object.freeze({ request, dispatchClaimed: attempt.dispatchClaimed, ...(attempt.observation === undefined ? {} : { observation: normalizeWorkflowDelegationObservation(attempt.observation, request) }) });
154
+ });
155
+ for (let index = 1; index < attempts.length; index++) {
156
+ const previous = attempts[index - 1];
157
+ assert(previous.observation?.status === 'rejected' || previous.observation?.status === 'not-dispatched', 'DELEGATION_RETRY_FORBIDDEN');
158
+ assert(same({ ...previous.request, attempt: index }, attempts[index].request), 'DELEGATION_RETRY_INTENT_CHANGED');
159
+ }
160
+ result[key] = Object.freeze({ attempts: Object.freeze(attempts) });
161
+ }
162
+ return Object.freeze(result);
163
+ }
164
+
165
+ export function validateWorkflowDelegationTransition(before: Readonly<Record<string, WorkflowDelegationRecord>>, after: Readonly<Record<string, WorkflowDelegationRecord>>): void {
166
+ for (const [key, record] of Object.entries(before)) {
167
+ const next = after[key];
168
+ assert(next && next.attempts.length >= record.attempts.length && next.attempts.length <= record.attempts.length + 1, 'DELEGATION_HISTORY_IMMUTABLE');
169
+ record.attempts.forEach((attempt, index) => {
170
+ const candidate = next.attempts[index]!;
171
+ assert(same(attempt.request, candidate.request), 'DELEGATION_RESERVATION_IMMUTABLE');
172
+ assert(!attempt.dispatchClaimed || candidate.dispatchClaimed, 'DELEGATION_DISPATCH_CLAIM_IMMUTABLE');
173
+ if (attempt.observation && ['completed', 'failed', 'rejected', 'not-dispatched'].includes(attempt.observation.status)) {
174
+ assert(same(attempt, candidate), 'DELEGATION_TERMINAL_IMMUTABLE');
175
+ }
176
+ });
177
+ if (next.attempts.length > record.attempts.length) assert(!next.attempts.at(-1)!.observation, 'DELEGATION_RESERVATION_REQUIRED');
178
+ }
179
+ for (const [key, record] of Object.entries(after)) if (!before[key]) {
180
+ assert(record.attempts.length === 1 && !record.attempts[0]!.observation, 'DELEGATION_RESERVATION_REQUIRED');
181
+ }
182
+ }
183
+
184
+ export interface WorkflowDelegationRuntime extends Omit<AIAgentProcessorRuntime, 'effects'> {
185
+ readonly effects: WorkflowDelegationEffectPort;
186
+ readonly visit: string;
187
+ readonly generation: number;
188
+ /** Native scheduler/admission projection, never supplied by authored task input. */
189
+ readonly dependencyInvocations: Readonly<Record<string, string>>;
190
+ }
191
+
192
+ async function load(runtime: WorkflowDelegationRuntime): Promise<AIWorkflowFlowRunCheckpoint> {
193
+ const checkpoint = await runtime.checkpointRuntime.checkpointStore.load(runtime.checkpointKey);
194
+ assert(checkpoint && checkpoint.instanceId === runtime.checkpointKey.instanceId && checkpoint.runId === runtime.checkpointKey.runId, 'DELEGATION_CHECKPOINT_MISSING');
195
+ assert(checkpoint.profile.kind === runtime.workflowKind && ['AICtrlWorkflow', 'AIDataWorkflow'].includes(checkpoint.profile.kind), 'DELEGATION_PROFILE_MISMATCH');
196
+ return checkpoint as AIWorkflowFlowRunCheckpoint;
197
+ }
198
+ function records(checkpoint: AIWorkflowFlowRunCheckpoint): Readonly<Record<string, WorkflowDelegationRecord>> {
199
+ assert(checkpoint.profile.kind === 'AICtrlWorkflow' || checkpoint.profile.kind === 'AIDataWorkflow', 'DELEGATION_PROFILE_MISMATCH');
200
+ return checkpoint.profile.ai.delegationsByKey ?? {};
201
+ }
202
+
203
+ /** Native loop frames and accepted data revisions establish serial order across visits.
204
+ * These identities come from the scheduler/checkpoint, never the authored task input.
205
+ * Same-visit sharing still requires the ordinary explicit predecessor relation.
206
+ */
207
+ function isNativeSerialPredecessor(runtime: WorkflowDelegationRuntime, checkpoint: AIWorkflowFlowRunCheckpoint, previous: WorkflowDelegationRequest): boolean {
208
+ const before = previous.lineage;
209
+ if (before.instanceId !== checkpoint.instanceId || before.runId !== checkpoint.runId) return false;
210
+ if (checkpoint.profile.kind === 'AICtrlWorkflow') {
211
+ const frames = (visit: string, generation: number): { scope: string; loops: { key: string; iteration: number }[] } | undefined => {
212
+ let value: unknown;
213
+ try { value = JSON.parse(visit); } catch { return; }
214
+ if (!Array.isArray(value) || value.length !== 2 || typeof value[0] !== 'string' || !Array.isArray(value[1]) || !value[1].length) return;
215
+ const loops: { key: string; iteration: number }[] = [];
216
+ for (const item of value[1]) {
217
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) return;
218
+ const row = dataRecord(item);
219
+ if (Object.keys(row).length !== 2 || typeof row.key !== 'string' || !row.key || typeof row.iteration !== 'number' || !Number.isSafeInteger(row.iteration) || row.iteration < 0) return;
220
+ loops.push({ key: row.key, iteration: row.iteration });
221
+ }
222
+ if (loops.at(-1)!.iteration !== generation) return;
223
+ return { scope: value[0], loops };
224
+ };
225
+ const oldVisit = frames(before.visit, before.generation), currentVisit = frames(runtime.visit, runtime.generation);
226
+ if (!oldVisit || !currentVisit || oldVisit.scope !== currentVisit.scope || oldVisit.loops.length !== currentVisit.loops.length) return false;
227
+ if (oldVisit.loops.some((frame, index) => frame.key !== currentVisit.loops[index]!.key)) return false;
228
+ // An outer iteration also orders all of its inner iterations, even after an inner reset.
229
+ const changed = oldVisit.loops.findIndex((frame, index) => frame.iteration !== currentVisit.loops[index]!.iteration);
230
+ return changed >= 0 && oldVisit.loops[changed]!.iteration < currentVisit.loops[changed]!.iteration;
231
+ }
232
+ if (checkpoint.profile.kind === 'AIDataWorkflow') {
233
+ if (before.nodeId !== runtime.nodeId || before.generation >= runtime.generation || before.visit !== `generation:${before.generation}` || runtime.visit !== `generation:${runtime.generation}`) return false;
234
+ const graph = dataRecord(checkpoint.profile.runGraph), nodes = dataRecord(graph.nodes), node = dataRecord(nodes[runtime.nodeId]);
235
+ if (graph.currentGeneration !== runtime.generation || node.generation !== runtime.generation) return false;
236
+ // A number supplied by a caller is insufficient: require the persisted native revision
237
+ // which actually reopened this node, or its still-active reciprocal invalidation fact.
238
+ const invalidations = Array.isArray(graph.invalidations) ? graph.invalidations : [];
239
+ if (invalidations.some(value => { const fact = dataRecord(value); return fact.nodeId === runtime.nodeId && fact.generation === runtime.generation; })) return true;
240
+ const patches = Array.isArray(graph.patchHistory) ? graph.patchHistory : [];
241
+ return patches.some(value => {
242
+ const patch = dataRecord(value);
243
+ return patch.generation === runtime.generation && Array.isArray(patch.operations) && patch.operations.some(value => {
244
+ const operation = dataRecord(value);
245
+ return operation.op === 'update-node' && operation.nodeId === runtime.nodeId;
246
+ });
247
+ });
248
+ }
249
+ return false;
250
+ }
251
+ async function commit(runtime: WorkflowDelegationRuntime, checkpoint: AIWorkflowFlowRunCheckpoint, key: string, record: WorkflowDelegationRecord): Promise<void> {
252
+ assert(checkpoint.profile.kind === 'AICtrlWorkflow' || checkpoint.profile.kind === 'AIDataWorkflow', 'DELEGATION_PROFILE_MISMATCH');
253
+ await runtime.checkpointRuntime.checkpointStore.commit({ expectedVersion: checkpoint.version, checkpoint: {
254
+ ...checkpoint, version: checkpoint.version + 1,
255
+ profile: { ...checkpoint.profile, ai: { ...checkpoint.profile.ai, delegationsByKey: { ...records(checkpoint), [key]: record } } },
256
+ } });
257
+ }
258
+
259
+ /** A typed result is returned while pending; native adapters suspend rather than inventing output. */
260
+ export async function delegateWorkflowTask(runtime: WorkflowDelegationRuntime, input: WorkflowTaskDelegation): Promise<WorkflowDelegationObservation> {
261
+ const delegation = normalizeWorkflowDelegation(input);
262
+ assert(delegation.task.nodeId === runtime.nodeId, 'DELEGATION_NODE_MISMATCH');
263
+ assertAIAgentNodeTaskBinding(runtime.taskBinding, { workflowKind: runtime.workflowKind, workflowRef: runtime.workflowRef, nodeId: runtime.nodeId }, { agentDefinitionRef: delegation.task.agentDefinitionRef });
264
+ identity(runtime.visit); integer(runtime.generation);
265
+ let checkpoint = await load(runtime);
266
+ // Target, policy, and materials are admitted with the immutable run config, not task input.
267
+ const binding = { ...delegation, task: { ...delegation.task, input: null } };
268
+ const bindings = dataRecord(dataRecord(checkpoint.config).workflowDelegations);
269
+ assert(same(bindings[runtime.nodeId], binding), 'DELEGATION_FROZEN_BINDING_MISMATCH');
270
+ const key = runtime.invocationKey;
271
+ let record = records(checkpoint)[key];
272
+ let request: WorkflowDelegationRequest;
273
+ let winner = false;
274
+ if (record) {
275
+ request = record.attempts.at(-1)!.request;
276
+ assert(same(request.delegation, delegation) && request.lineage.visit === runtime.visit && request.lineage.generation === runtime.generation && request.lineage.instanceId === checkpoint.instanceId && request.lineage.runId === checkpoint.runId && request.lineage.nodeId === runtime.nodeId, 'DELEGATION_INVOCATION_CONFLICT');
277
+ } else {
278
+ let sourceInvocationKey: string | undefined;
279
+ let selected: WorkflowDelegationOwnerTarget | undefined;
280
+ const context = delegation.context;
281
+ if (context.mode === 'reuse') selected = target({ owner: context.owner, providerScope: context.providerScope, targetId: context.targetId });
282
+ if (context.mode === 'from-node') {
283
+ sourceInvocationKey = runtime.dependencyInvocations[context.nodeId];
284
+ assert(sourceInvocationKey && context.nodeId !== runtime.nodeId, 'DELEGATION_DEPENDENCY_REQUIRED');
285
+ const source = records(checkpoint)[sourceInvocationKey]?.attempts.at(-1);
286
+ assert(source?.observation?.status === 'completed' && source.request.lineage.nodeId === context.nodeId, 'DELEGATION_SOURCE_UNFINISHED');
287
+ assert(source.request.lineage.instanceId === checkpoint.instanceId && source.request.lineage.runId === checkpoint.runId, 'DELEGATION_SOURCE_SCOPE_MISMATCH');
288
+ assert(source.request.lineage.generation === runtime.generation && source.request.lineage.visit === runtime.visit, 'DELEGATION_SOURCE_STALE');
289
+ assert(source.request.delegation.sessionPolicy !== 'independent-per-generation', 'DELEGATION_INDEPENDENCE_REQUIRED');
290
+ selected = source.observation.target;
291
+ }
292
+ for (const existing of Object.values(records(checkpoint))) {
293
+ const last = existing.attempts.at(-1)!;
294
+ const terminal = last.observation && ['completed', 'failed', 'rejected', 'not-dispatched'].includes(last.observation.status);
295
+ const actual = last.observation && 'target' in last.observation ? last.observation.target ?? last.request.target : last.request.target;
296
+ if (selected && actual && same(selected, actual)) {
297
+ assert(terminal && (Object.values(runtime.dependencyInvocations).includes(last.request.lineage.invocationKey) || isNativeSerialPredecessor(runtime, checkpoint, last.request)), 'DELEGATION_SHARED_TARGET_CONCURRENT');
298
+ }
299
+ }
300
+ request = normalizeRequest({ delegation, lineage: { ...runtime.checkpointKey, nodeId: runtime.nodeId, invocationKey: key, visit: runtime.visit, generation: runtime.generation, ...(sourceInvocationKey ? { sourceInvocationKey } : {}) }, attempt: 0, ...(selected ? { target: selected } : {}) });
301
+ record = { attempts: [{ request, dispatchClaimed: false }] };
302
+ await runtime.effects.verifyMaterials(delegation.materials);
303
+ try { await commit(runtime, checkpoint, key, record); }
304
+ catch (error) {
305
+ const latest = await load(runtime);
306
+ const accepted = records(latest)[key];
307
+ if (!accepted || !same(accepted.attempts.at(-1)!.request, request)) throw error;
308
+ record = accepted;
309
+ }
310
+ }
311
+ const last = record.attempts.at(-1)!;
312
+ if (last.observation && ['completed', 'failed', 'rejected', 'not-dispatched'].includes(last.observation.status)) return last.observation;
313
+ await runtime.effects.verifyMaterials(delegation.materials);
314
+ if (!last.dispatchClaimed) {
315
+ checkpoint = await load(runtime);
316
+ record = records(checkpoint)[key]!;
317
+ if (!record.attempts.at(-1)!.dispatchClaimed) {
318
+ const claimed = { attempts: [...record.attempts.slice(0, -1), { ...record.attempts.at(-1)!, dispatchClaimed: true }] };
319
+ try { await commit(runtime, checkpoint, key, claimed); winner = true; }
320
+ catch (error) {
321
+ const accepted = records(await load(runtime))[key]?.attempts.at(-1);
322
+ if (!accepted?.dispatchClaimed || !same(accepted.request, request)) throw error;
323
+ }
324
+ }
325
+ }
326
+ // A lost submit receipt is uncertain. Never call submit again on recovery.
327
+ let response: WorkflowDelegationObservation;
328
+ try { response = await (winner ? runtime.effects.submit(request) : runtime.effects.observe(request)); }
329
+ catch (cause) { throw new WorkflowDelegationSuspendedError(key, 'unknown', cause); }
330
+ const observation = normalizeWorkflowDelegationObservation(response, request);
331
+ checkpoint = await load(runtime);
332
+ record = records(checkpoint)[key]!;
333
+ const latest = record.attempts.at(-1)!;
334
+ if (latest.observation && ['completed', 'failed', 'rejected', 'not-dispatched'].includes(latest.observation.status)) return latest.observation;
335
+ assert(same(latest.request, request), 'DELEGATION_ATTEMPT_CHANGED');
336
+ if (observation.status === 'completed' && delegation.context.mode === 'fresh') {
337
+ for (const [otherKey, other] of Object.entries(records(checkpoint))) {
338
+ if (otherKey === key) continue;
339
+ for (const attempt of other.attempts) {
340
+ assert(attempt.observation?.status !== 'completed' || !same(attempt.observation.target, observation.target), 'DELEGATION_FRESH_CONTEXT_REUSED');
341
+ }
342
+ }
343
+ }
344
+ const updated = { attempts: [...record.attempts.slice(0, -1), { request, dispatchClaimed: true, observation }] };
345
+ try { await commit(runtime, checkpoint, key, updated); }
346
+ catch (error) {
347
+ const accepted = records(await load(runtime))[key]?.attempts.at(-1)?.observation;
348
+ if (!accepted || !same(accepted, observation)) throw error;
349
+ }
350
+ return observation;
351
+ }
352
+
353
+ /** Explicit audited retry reservation. It never dispatches and never rewrites the failed attempt. */
354
+ export async function prepareWorkflowDelegationRetry(runtime: WorkflowDelegationRuntime, input: { expectedVersion: number; invocationKey: string; receiptId: string }): Promise<void> {
355
+ const checkpoint = await load(runtime);
356
+ assert(checkpoint.version === input.expectedVersion && input.invocationKey === runtime.invocationKey, 'DELEGATION_RETRY_CAS_MISMATCH');
357
+ const record = records(checkpoint)[input.invocationKey];
358
+ const last = record?.attempts.at(-1);
359
+ assert(last && last.observation && ['rejected', 'not-dispatched'].includes(last.observation.status) && last.observation.receiptId === input.receiptId, 'DELEGATION_RETRY_FORBIDDEN');
360
+ await commit(runtime, checkpoint, input.invocationKey, { attempts: [...record!.attempts, { request: { ...last.request, attempt: last.request.attempt + 1 }, dispatchClaimed: false }] });
361
+ }
362
+
363
+ /** Bound by native node adapters. Authored code supplies only this task's business input. */
364
+ export function bindWorkflowTaskDelegation(runtime: Omit<WorkflowDelegationRuntime, 'dependencyInvocations'>): { delegateTask(input: FlowClosedValue): Promise<FlowClosedValue> } {
365
+ return Object.freeze({ async delegateTask(input: FlowClosedValue): Promise<FlowClosedValue> {
366
+ const checkpoint = await load({ ...runtime, dependencyInvocations: {} });
367
+ const config = dataRecord(checkpoint.config);
368
+ const binding = dataRecord(config.workflowDelegations)[runtime.nodeId];
369
+ assert(binding, 'DELEGATION_FROZEN_BINDING_MISSING');
370
+ const dependencies = (config.workflowDelegationDependencies === undefined ? {} : dataRecord(config.workflowDelegationDependencies))[runtime.nodeId] ?? [];
371
+ assert(Array.isArray(dependencies) && dependencies.every((id: unknown) => typeof id === 'string'), 'DELEGATION_DEPENDENCIES_INVALID');
372
+ const dependencyInvocations: Record<string, string> = {};
373
+ for (const nodeId of dependencies.map((id: unknown) => identityValue(id))) {
374
+ const matches = Object.values(records(checkpoint)).map(record => record.attempts.at(-1)!).filter(attempt => attempt.request.lineage.nodeId === nodeId && attempt.request.lineage.generation === runtime.generation && attempt.request.lineage.visit === runtime.visit);
375
+ assert(matches.length <= 1, 'DELEGATION_SOURCE_AMBIGUOUS');
376
+ if (matches.length === 1) dependencyInvocations[nodeId] = matches[0]!.request.lineage.invocationKey;
377
+ }
378
+ const normalizedBinding = normalizeWorkflowDelegation(binding);
379
+ // 已存在的调用沿保存的原lineage观察;新的循环寻址不能改写旧恢复身份。
380
+ const original = records(checkpoint)[runtime.invocationKey]?.attempts.at(-1)?.request;
381
+ const admittedRuntime = original ? { ...runtime, visit: original.lineage.visit, generation: original.lineage.generation } : runtime;
382
+ const observation = await delegateWorkflowTask({ ...admittedRuntime, dependencyInvocations }, { ...normalizedBinding, task: { ...normalizedBinding.task, input } });
383
+ if(observation.status==='failed')throw new WorkflowDelegationError(`DELEGATION_OWNER_FAILED:${observation.reason}`);
384
+ if (observation.status !== 'completed') throw new WorkflowDelegationSuspendedError(runtime.invocationKey, observation.status);
385
+ return observation.output;
386
+ } });
387
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './core.js';
2
+ export * from './delegation.js';
2
3
  export { resolveAIAgentExecutionResources } from './agent-execution-resources.js';
3
4
  export type { AIAgentExecutionResourceObservation, ResolveAIAgentExecutionResourcesRuntime, ResolveAIAgentExecutionResourcesInput } from './agent-execution-resources.js';
4
5
  export {
@@ -92,6 +92,15 @@ export function normalizeFlowRunCheckpoint(value: unknown): AIWorkflowFlowRunChe
92
92
  ? normalizeRunStepExtensions(dataField(record, 'stepExtensions', 'checkpoint.stepExtensions'))
93
93
  : undefined;
94
94
  const profile = normalizeProfile(dataField(record, 'profile', 'checkpoint.profile'), runId);
95
+ if (profile.kind !== 'WorkCtrlFlow') {
96
+ for (const [key, record] of Object.entries(profile.ai.delegationsByKey ?? {})) {
97
+ for (const attempt of record.attempts) {
98
+ if (attempt.request.lineage.instanceId !== instanceId || attempt.request.lineage.runId !== runId) {
99
+ invalid('DELEGATION_CHECKPOINT_SCOPE_MISMATCH', `checkpoint.profile.ai.delegationsByKey.${key}`, 'Delegation lineage must belong to the canonical instance and run.');
100
+ }
101
+ }
102
+ }
103
+ }
95
104
  validateProfileProjection(profile, { input, state, output, controllerSidecars, nodeSidecars });
96
105
  return Object.freeze({
97
106
  schemaVersion,
@@ -471,7 +480,7 @@ function normalizeWorkCtrlNestedFacts(snapshot: Record<string, unknown>, locatio
471
480
  if (kind === 'Branch' && !hasBranchKey) invalid('WORK_CTRL_BRANCH_KEY_MISSING', `${factLocation}.branchKey`, 'Branch decisions require a branch key.');
472
481
  if (hasBranchKey) {
473
482
  canonicalIdentity(dataField(fact, 'branchKey', `${factLocation}.branchKey`), `${factLocation}.branchKey`);
474
- if (kind !== 'Branch') invalid('WORK_CTRL_BRANCH_KEY_INVALID', `${factLocation}.branchKey`, 'Only Branch decisions can retain a branch key.');
483
+ if (kind === 'NoMatch') invalid('WORK_CTRL_BRANCH_KEY_INVALID', `${factLocation}.branchKey`, 'NoMatch decisions cannot retain a branch key.');
475
484
  }
476
485
  }
477
486
 
@@ -501,7 +510,7 @@ function normalizeWorkCtrlNestedFacts(snapshot: Record<string, unknown>, locatio
501
510
  normalizeWorkCtrlAttempts(dataField(orchestration, 'attempts', `${orchestrationLocation}.attempts`), `${orchestrationLocation}.attempts`);
502
511
  normalizeWorkCtrlDeadlines(dataField(orchestration, 'deadlines', `${orchestrationLocation}.deadlines`), `${orchestrationLocation}.deadlines`);
503
512
  const lanes = normalizeWorkCtrlLanes(dataField(orchestration, 'lanes', `${orchestrationLocation}.lanes`), `${orchestrationLocation}.lanes`);
504
- normalizeWorkCtrlComposites(dataField(orchestration, 'composites', `${orchestrationLocation}.composites`), `${orchestrationLocation}.composites`);
513
+ normalizeWorkCtrlComposites(dataField(orchestration, 'composites', `${orchestrationLocation}.composites`), `${orchestrationLocation}.composites`, lanes);
505
514
  normalizeWorkCtrlWinners(dataField(orchestration, 'winners', `${orchestrationLocation}.winners`), lanes, `${orchestrationLocation}.winners`);
506
515
  normalizeWorkCtrlCancellations(dataField(orchestration, 'cancellations', `${orchestrationLocation}.cancellations`), `${orchestrationLocation}.cancellations`);
507
516
  }
@@ -542,7 +551,8 @@ function normalizeWorkCtrlDeadlines(value: unknown, location: string): void {
542
551
  function normalizeWorkCtrlLanes(value: unknown, location: string): Map<string, { invocationKey: string; laneId: string; status: string }> {
543
552
  const output = new Map<string, { invocationKey: string; laneId: string; status: string }>();
544
553
  for (const [key, item, itemLocation] of closedRecordEntries(value, location)) {
545
- const fact = exactRecord(item, ['invocationKey', 'laneId', 'status', 'bodyId'], ['item', 'flowState', 'output'], itemLocation);
554
+ const fact = exactRecord(item, ['invocationKey', 'laneId', 'status', 'bodyId'], ['item', 'flowState', 'output', 'started'], itemLocation);
555
+ if (Object.hasOwn(fact, 'started') && typeof dataField(fact, 'started', `${itemLocation}.started`) !== 'boolean') invalid('WORK_CTRL_LANE_STARTED_INVALID', `${itemLocation}.started`, 'Lane slot claim must be boolean.');
546
556
  const invocationKey = canonicalIdentity(dataField(fact, 'invocationKey', `${itemLocation}.invocationKey`), `${itemLocation}.invocationKey`);
547
557
  const laneId = canonicalIdentity(dataField(fact, 'laneId', `${itemLocation}.laneId`), `${itemLocation}.laneId`);
548
558
  if (key !== `${invocationKey}/${laneId}`) invalid('WORK_CTRL_LANE_IDENTITY_MISMATCH', itemLocation, 'Lane map key must equal invocationKey/laneId.');
@@ -583,12 +593,22 @@ function normalizeWorkCtrlCancellations(value: unknown, location: string): void
583
593
  }
584
594
  }
585
595
 
586
- function normalizeWorkCtrlComposites(value: unknown, location: string): void {
596
+ function normalizeWorkCtrlComposites(value: unknown, location: string, lanes: Map<string, { invocationKey: string; laneId: string; status: string }>): void {
587
597
  for (const [key, item, itemLocation] of closedRecordEntries(value, location)) {
588
- const fact = exactRecord(item, ['invocationKey', 'primitive', 'bodyId', 'status', 'updatedAtMs'], ['input', 'cursor', 'childTreeId'], itemLocation);
598
+ const fact = exactRecord(item, ['invocationKey', 'primitive', 'bodyId', 'status', 'updatedAtMs'], ['input', 'cursor', 'childTreeId', 'expansion'], itemLocation);
589
599
  assertFactKey(fact, key, itemLocation);
590
600
  const primitive = dataField(fact, 'primitive', `${itemLocation}.primitive`);
591
601
  if (!['Retry', 'Timeout', 'ForEach', 'Parallel', 'Race', 'CallFlow'].includes(String(primitive))) invalid('WORK_CTRL_COMPOSITE_PRIMITIVE_INVALID', `${itemLocation}.primitive`, 'Composite primitive is invalid.');
602
+ if (Object.hasOwn(fact, 'expansion')) {
603
+ const location = `${itemLocation}.expansion`;
604
+ const expansion = exactRecord(dataField(fact, 'expansion', location), ['itemCount', 'concurrency'], ['maxItems'], location);
605
+ const itemCount = nonNegativeSafeInteger(dataField(expansion, 'itemCount', `${location}.itemCount`), `${location}.itemCount`);
606
+ const concurrency = nonNegativeSafeInteger(dataField(expansion, 'concurrency', `${location}.concurrency`), `${location}.concurrency`);
607
+ const maxItems = Object.hasOwn(expansion, 'maxItems') ? nonNegativeSafeInteger(dataField(expansion, 'maxItems', `${location}.maxItems`), `${location}.maxItems`) : undefined;
608
+ if (primitive !== 'ForEach' || concurrency < 1 || concurrency > Math.min(32, maxItems ?? 1000) || (maxItems !== undefined && (maxItems < 1 || maxItems > 1000 || itemCount > maxItems))) invalid('WORK_CTRL_EXPANSION_INVALID', location, 'Frozen ForEach expansion exceeds its budget.');
609
+ const expanded = [...lanes.values()].filter(lane => lane.invocationKey === key);
610
+ if (expanded.length !== itemCount || Array.from({ length: itemCount }, (_, index) => `${key}/${index}`).some(id => !lanes.has(id))) invalid('WORK_CTRL_EXPANSION_INCOMPLETE', location, 'Frozen ForEach lanes must cover the exact input order.');
611
+ }
592
612
  canonicalIdentity(dataField(fact, 'bodyId', `${itemLocation}.bodyId`), `${itemLocation}.bodyId`);
593
613
  if (!['Open', 'Waiting', 'Completed', 'Failed', 'Cancelled'].includes(String(dataField(fact, 'status', `${itemLocation}.status`)))) invalid('WORK_CTRL_COMPOSITE_STATUS_INVALID', `${itemLocation}.status`, 'Composite status is invalid.');
594
614
  nonNegativeSafeInteger(dataField(fact, 'updatedAtMs', `${itemLocation}.updatedAtMs`), `${itemLocation}.updatedAtMs`);
@@ -1412,7 +1432,7 @@ function normalizeAIDataNodeResult(
1412
1432
  const result = exactRecord(
1413
1433
  value,
1414
1434
  ['nodeId', 'generation', 'status'],
1415
- ['output', 'semanticFingerprint', 'reusedFrom'],
1435
+ ['output', 'semanticFingerprint', 'reusedFrom', 'waitContext'],
1416
1436
  location,
1417
1437
  );
1418
1438
  if (canonicalIdentity(dataField(result, 'nodeId', `${location}.nodeId`), `${location}.nodeId`) !== nodeId) {
@@ -1422,6 +1442,10 @@ function normalizeAIDataNodeResult(
1422
1442
  invalid('AI_DATA_RESULT_GENERATION_INVALID', `${location}.generation`, 'Result generation must equal its run node generation.');
1423
1443
  }
1424
1444
  const resultStatus = String(dataField(result, 'status', `${location}.status`));
1445
+ if(Object.hasOwn(result,'waitContext')){
1446
+ const context=normalizeFlowClosedObject(dataField(result,'waitContext',`${location}.waitContext`),`${location}.waitContext`);
1447
+ if(new TextEncoder().encode(JSON.stringify(context)).length>16384)invalid('AI_DATA_WAIT_CONTEXT_BUDGET',`${location}.waitContext`,'Native wait context exceeds 16KiB.');
1448
+ }
1425
1449
  if (!['Pending', 'Running', 'Waiting', 'Succeeded', 'Failed', 'Cancelled', 'Invalidated', 'Reused'].includes(resultStatus)) {
1426
1450
  invalid('AI_DATA_RESULT_STATUS_INVALID', `${location}.status`, 'Result status is not part of the public contract.');
1427
1451
  }