ai-workflow-logic 0.1.2 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-workflow-logic",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "private": false,
5
5
  "description": "AI workflow domain projections and pure runtime helpers with an explicit Halfcode filesystem adapter",
6
6
  "type": "module",
@@ -8,18 +8,20 @@
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
10
  "./browser": "./src/browser.ts",
11
+ "./checkpoint-filesystem": "./src/checkpoint-filesystem.ts",
11
12
  "./filesystem": "./src/filesystem.ts",
12
13
  "./run-freeze": "./src/run-freeze.ts"
13
14
  },
14
15
  "dependencies": {
15
- "ai-workflow-contract": "0.1.2",
16
- "halfcode-compiler.xnl": "0.2.2"
16
+ "ai-workflow-contract": "0.1.4",
17
+ "halfcode-compiler.xnl": "0.2.2",
18
+ "work-ctrl-flow-contract": "0.1.1",
19
+ "work-ctrl-flow-logic": "0.1.1"
17
20
  },
18
21
  "files": [
19
22
  "src"
20
23
  ],
21
24
  "publishConfig": {
22
- "access": "public",
23
- "registry": "https://registry.npmjs.com"
25
+ "access": "public"
24
26
  }
25
27
  }
@@ -0,0 +1,282 @@
1
+ import type {
2
+ AIAgentEffectConfig,
3
+ AIAgentHostRunResult,
4
+ AIAgentInstanceReference,
5
+ AIAgentInvocation,
6
+ AIAgentInvocationReceipt,
7
+ AIAgentProcessorRuntime,
8
+ AIAgentRunResult,
9
+ AIAgentSelector,
10
+ AIWorkflowAuthoredEffects,
11
+ AIWorkflowFlowRunCheckpoint,
12
+ AIWorkflowProfileDurableState,
13
+ FlowClosedValue,
14
+ } from 'ai-workflow-contract';
15
+ import {
16
+ AIAgentInvocationError,
17
+ normalizeAIAgentEffectConfig,
18
+ normalizeAIAgentInvocation,
19
+ normalizeAIAgentSelector,
20
+ normalizeCanonicalAIAgentState,
21
+ assertAIAgentNodeTaskBinding,
22
+ } from './agent-state.js';
23
+ import { normalizeFlowClosedObject, normalizeFlowClosedValue } from './run-checkpoint.js';
24
+
25
+ export { AIAgentInvocationError, normalizeAIAgentEffectConfig, normalizeAIAgentInvocation, normalizeAIAgentSelector } from './agent-state.js';
26
+
27
+ function fail(code: string, location: string, message: string): never {
28
+ throw new AIAgentInvocationError(code, location, message);
29
+ }
30
+
31
+ function profileAI(checkpoint: AIWorkflowFlowRunCheckpoint): AIWorkflowProfileDurableState {
32
+ if (checkpoint.profile.kind !== 'AICtrlWorkflow' && checkpoint.profile.kind !== 'AIDataWorkflow') {
33
+ return fail('AI_AGENT_PROFILE_REQUIRED', 'checkpoint.profile.kind', 'Agent invocation requires an AI workflow profile.');
34
+ }
35
+ return normalizeCanonicalAIAgentState(checkpoint.profile.ai, 'checkpoint.profile.ai');
36
+ }
37
+
38
+ async function loadCheckpoint(runtime: AIAgentProcessorRuntime): Promise<AIWorkflowFlowRunCheckpoint> {
39
+ const checkpoint = await runtime.checkpointRuntime.checkpointStore.load(runtime.checkpointKey) as AIWorkflowFlowRunCheckpoint | undefined;
40
+ if (!checkpoint) fail('AI_AGENT_CHECKPOINT_NOT_FOUND', 'runtime.checkpointKey', 'Canonical Flow checkpoint does not exist.');
41
+ if (checkpoint.instanceId !== runtime.checkpointKey.instanceId || checkpoint.runId !== runtime.checkpointKey.runId) {
42
+ fail('AI_AGENT_CHECKPOINT_IDENTITY_MISMATCH', 'runtime.checkpointKey', 'Loaded checkpoint identity differs from the requested run.');
43
+ }
44
+ return checkpoint;
45
+ }
46
+
47
+ function withAI(checkpoint: AIWorkflowFlowRunCheckpoint, ai: AIWorkflowProfileDurableState): AIWorkflowFlowRunCheckpoint {
48
+ if (checkpoint.profile.kind === 'AICtrlWorkflow') return { ...checkpoint, version: checkpoint.version + 1, profile: { ...checkpoint.profile, ai } };
49
+ if (checkpoint.profile.kind === 'AIDataWorkflow') return { ...checkpoint, version: checkpoint.version + 1, profile: { ...checkpoint.profile, ai } };
50
+ return fail('AI_AGENT_PROFILE_REQUIRED', 'checkpoint.profile.kind', 'Agent invocation requires an AI workflow profile.');
51
+ }
52
+
53
+ async function commitAI(runtime: AIAgentProcessorRuntime, checkpoint: AIWorkflowFlowRunCheckpoint, ai: AIWorkflowProfileDurableState): Promise<AIWorkflowFlowRunCheckpoint> {
54
+ return runtime.checkpointRuntime.checkpointStore.commit({ expectedVersion: checkpoint.version, checkpoint: withAI(checkpoint, normalizeCanonicalAIAgentState(ai)) }) as Promise<AIWorkflowFlowRunCheckpoint>;
55
+ }
56
+
57
+ function same(left: unknown, right: unknown): boolean {
58
+ return JSON.stringify(left) === JSON.stringify(right);
59
+ }
60
+
61
+ function semanticFacts(config: AIAgentEffectConfig, invocationMetadata?: Readonly<Record<string, FlowClosedValue>>): Readonly<Record<string, FlowClosedValue>> {
62
+ return normalizeFlowClosedObject({
63
+ materialRefs: config.materialRefs ?? [],
64
+ effectPolicy: config.effectPolicy ?? null,
65
+ invocationMetadata: invocationMetadata ?? null,
66
+ }, 'semanticFacts');
67
+ }
68
+
69
+ function assertTaskAuthority(runtime: AIAgentProcessorRuntime, config: AIAgentEffectConfig): void {
70
+ if (!runtime.taskBinding) fail('AI_AGENT_TASK_BINDING_REQUIRED', 'runtime.taskBinding', 'A frozen Agent task binding is required.');
71
+ assertAIAgentNodeTaskBinding(runtime.taskBinding, {
72
+ workflowKind: runtime.workflowKind,
73
+ workflowRef: runtime.workflowRef,
74
+ nodeId: runtime.nodeId,
75
+ }, config);
76
+ }
77
+
78
+ function assertSameInvocation(receipt: AIAgentInvocationReceipt, runtime: AIAgentProcessorRuntime, mode: 'new' | 'targeted', input: FlowClosedValue, config: AIAgentEffectConfig, invocationMetadata?: Readonly<Record<string, FlowClosedValue>>): void {
79
+ if (receipt.nodeId !== runtime.nodeId || receipt.mode !== mode || receipt.agentDefinitionRef !== config.agentDefinitionRef || !same(receipt.input, input) || receipt.generation !== runtime.generation || !same(receipt.semanticFacts, semanticFacts(config, invocationMetadata))) {
80
+ fail('AI_AGENT_INVOCATION_KEY_CONFLICT', `checkpoint.profile.ai.invocationsByKey.${runtime.invocationKey}`, 'Invocation key is already bound to different facts.');
81
+ }
82
+ if (mode === 'new' && receipt.instanceName !== config.instanceName) fail('AI_AGENT_INVOCATION_KEY_CONFLICT', `checkpoint.profile.ai.invocationsByKey.${runtime.invocationKey}.instanceName`, 'Invocation key has another name intent.');
83
+ }
84
+
85
+ function resultFromReceipt(ai: AIWorkflowProfileDurableState, receipt: AIAgentInvocationReceipt): AIAgentRunResult {
86
+ if (receipt.status !== 'completed' || receipt.instanceId === undefined || receipt.output === undefined) fail('AI_AGENT_COMPLETED_INVOCATION_INCOMPLETE', 'receipt', 'Completed receipt is incomplete.');
87
+ const instance = ai.instancesById[receipt.instanceId];
88
+ if (!instance) fail('AI_AGENT_INSTANCE_ID_NOT_FOUND', 'receipt.instanceId', 'Completed receipt instance is missing.');
89
+ return Object.freeze({
90
+ output: receipt.output,
91
+ instance,
92
+ receipt: Object.freeze({ invocationKey: receipt.invocationKey, nodeId: receipt.nodeId, status: 'completed' as const }),
93
+ });
94
+ }
95
+
96
+ function pendingReceipt(runtime: AIAgentProcessorRuntime, mode: 'new' | 'targeted', input: FlowClosedValue, config: AIAgentEffectConfig, instance?: AIAgentInstanceReference, invocationMetadata?: Readonly<Record<string, FlowClosedValue>>): AIAgentInvocationReceipt {
97
+ const instanceName = mode === 'targeted' ? instance?.instanceName : config.instanceName;
98
+ return Object.freeze({
99
+ invocationKey: runtime.invocationKey,
100
+ nodeId: runtime.nodeId,
101
+ status: 'pending',
102
+ mode,
103
+ agentDefinitionRef: config.agentDefinitionRef,
104
+ input,
105
+ semanticFacts: semanticFacts(config, invocationMetadata),
106
+ ...(instanceName === undefined ? {} : { instanceName }),
107
+ ...(instance === undefined ? {} : { instanceId: instance.instanceId }),
108
+ ...(runtime.generation === undefined ? {} : { generation: runtime.generation }),
109
+ ...(runtime.metadata === undefined ? {} : { metadata: normalizeFlowClosedObject(runtime.metadata, 'runtime.metadata') }),
110
+ });
111
+ }
112
+
113
+ function reserveName(ai: AIWorkflowProfileDurableState, receipt: AIAgentInvocationReceipt): void {
114
+ if (receipt.instanceName === undefined) return;
115
+ if (ai.instanceIdByName[receipt.instanceName] !== undefined) fail('AI_AGENT_INSTANCE_NAME_ALREADY_BOUND', 'config.instanceName', 'Instance name is already bound.');
116
+ for (const existing of Object.values(ai.invocationsByKey)) {
117
+ if (existing.invocationKey !== receipt.invocationKey && existing.status === 'pending' && existing.instanceName === receipt.instanceName) {
118
+ fail('AI_AGENT_INSTANCE_NAME_RESERVED', 'config.instanceName', 'Instance name is already reserved by another invocation.');
119
+ }
120
+ }
121
+ }
122
+
123
+ function resolveSelector(ai: AIWorkflowProfileDurableState, selector: AIAgentSelector, config: AIAgentEffectConfig): AIAgentInstanceReference {
124
+ const instanceId = 'byInstanceId' in selector ? selector.byInstanceId : ai.instanceIdByName[selector.byInstanceName];
125
+ if (instanceId === undefined) fail('AI_AGENT_INSTANCE_NAME_NOT_FOUND', 'selector.byInstanceName', 'Named Agent instance does not exist.');
126
+ const instance = ai.instancesById[instanceId];
127
+ if (!instance) fail('AI_AGENT_INSTANCE_ID_NOT_FOUND', 'selector', 'Agent instance does not exist.');
128
+ if (instance.instanceName !== undefined && ai.instanceIdByName[instance.instanceName] !== instance.instanceId) fail('AI_AGENT_INSTANCE_INDEX_CONFLICT', 'checkpoint.profile.ai', 'Agent instance indexes are not reciprocal.');
129
+ if (instance.agentDefinitionRef !== config.agentDefinitionRef) fail('AI_AGENT_DEFINITION_CONFLICT', 'config.agentDefinitionRef', 'Target instance is bound to another Agent definition.');
130
+ return instance;
131
+ }
132
+
133
+ function normalizeHostResult(result: AIAgentHostRunResult, config: AIAgentEffectConfig, expected?: AIAgentInstanceReference): { output: FlowClosedValue; instance: AIAgentInstanceReference; hostReceipt?: Record<string, FlowClosedValue> } {
134
+ const output = normalizeFlowClosedValue(result.output, 'hostResult.output');
135
+ const source = result.instance;
136
+ if (!source || typeof source !== 'object') fail('AI_AGENT_HOST_INSTANCE_INVALID', 'hostResult.instance', 'Host result must contain an instance reference.');
137
+ const candidate = normalizeCanonicalAIAgentState({
138
+ schemaVersion: 'depa.ai-agent-state/v1',
139
+ instancesById: {
140
+ [source.instanceId]: {
141
+ authority: source.authority,
142
+ instanceId: source.instanceId,
143
+ ...(source.instanceName === undefined ? {} : { instanceName: source.instanceName }),
144
+ ...(source.sessionId === undefined ? {} : { sessionId: source.sessionId }),
145
+ ...(source.metadata === undefined ? {} : { metadata: source.metadata }),
146
+ agentDefinitionRef: source.agentDefinitionRef ?? config.agentDefinitionRef,
147
+ },
148
+ },
149
+ instanceIdByName: source.instanceName === undefined ? {} : { [source.instanceName]: source.instanceId },
150
+ invocationsByKey: {},
151
+ }, 'hostResult.state');
152
+ const instance = candidate.instancesById[source.instanceId];
153
+ if (!instance || instance.agentDefinitionRef !== config.agentDefinitionRef) fail('AI_AGENT_HOST_DEFINITION_CONFLICT', 'hostResult.instance.agentDefinitionRef', 'Host instance uses another Agent definition.');
154
+ if (config.instanceName !== undefined && instance.instanceName !== config.instanceName) fail('AI_AGENT_HOST_NAME_CONFLICT', 'hostResult.instance.instanceName', 'Host instance did not preserve the reserved name.');
155
+ if (expected && (instance.instanceId !== expected.instanceId || instance.authority !== expected.authority)) fail('AI_AGENT_HOST_TARGET_CONFLICT', 'hostResult.instance', 'Host targeted result differs from the selected instance.');
156
+ return { output, instance, ...(result.hostReceipt === undefined ? {} : { hostReceipt: normalizeFlowClosedObject(result.hostReceipt, 'hostResult.hostReceipt') }) };
157
+ }
158
+
159
+ async function complete(runtime: AIAgentProcessorRuntime, checkpoint: AIWorkflowFlowRunCheckpoint, pending: AIAgentInvocationReceipt, host: AIAgentHostRunResult, config: AIAgentEffectConfig, expected?: AIAgentInstanceReference): Promise<AIAgentRunResult> {
160
+ const normalized = normalizeHostResult(host, config, expected);
161
+ const latest = await loadCheckpoint(runtime);
162
+ const ai = profileAI(latest);
163
+ const accepted = ai.invocationsByKey[runtime.invocationKey];
164
+ if (accepted?.status === 'completed') return resultFromReceipt(ai, accepted);
165
+ if (!accepted || !same(accepted, pending)) fail('AI_AGENT_INVOCATION_RESERVATION_CHANGED', 'checkpoint.profile.ai', 'Invocation reservation changed before completion.');
166
+ const prior = ai.instancesById[normalized.instance.instanceId];
167
+ if (prior && !same(prior, normalized.instance)) fail('AI_AGENT_INSTANCE_ID_CONFLICT', 'hostResult.instance.instanceId', 'Instance id is already bound to different facts.');
168
+ if (normalized.instance.instanceName !== undefined) {
169
+ const priorId = ai.instanceIdByName[normalized.instance.instanceName];
170
+ if (priorId !== undefined && priorId !== normalized.instance.instanceId) fail('AI_AGENT_INSTANCE_NAME_ALREADY_BOUND', 'hostResult.instance.instanceName', 'Instance name is already bound to another id.');
171
+ }
172
+ const completed: AIAgentInvocationReceipt = Object.freeze({ ...pending, status: 'completed', instanceId: normalized.instance.instanceId, output: normalized.output, ...(normalized.hostReceipt === undefined ? {} : { hostReceipt: normalized.hostReceipt }) });
173
+ const nextAI = normalizeCanonicalAIAgentState({
174
+ ...ai,
175
+ instancesById: { ...ai.instancesById, [normalized.instance.instanceId]: normalized.instance },
176
+ instanceIdByName: normalized.instance.instanceName === undefined ? ai.instanceIdByName : { ...ai.instanceIdByName, [normalized.instance.instanceName]: normalized.instance.instanceId },
177
+ invocationsByKey: { ...ai.invocationsByKey, [runtime.invocationKey]: completed },
178
+ });
179
+ try {
180
+ const committed = await commitAI(runtime, latest, nextAI);
181
+ return resultFromReceipt(profileAI(committed), completed);
182
+ } catch (error) {
183
+ const converged = await loadCheckpoint(runtime);
184
+ if (converged.version <= latest.version) throw error;
185
+ const convergedAI = profileAI(converged);
186
+ const acceptedCompletion = convergedAI.invocationsByKey[pending.invocationKey];
187
+ if (!acceptedCompletion || acceptedCompletion.status !== 'completed'
188
+ || acceptedCompletion.nodeId !== pending.nodeId
189
+ || acceptedCompletion.mode !== pending.mode
190
+ || acceptedCompletion.agentDefinitionRef !== pending.agentDefinitionRef
191
+ || acceptedCompletion.instanceId !== normalized.instance.instanceId
192
+ || acceptedCompletion.instanceName !== normalized.instance.instanceName
193
+ || acceptedCompletion.generation !== pending.generation
194
+ || !same(acceptedCompletion.input, pending.input)
195
+ || !same(acceptedCompletion.semanticFacts, pending.semanticFacts)) throw error;
196
+ const acceptedInstance = convergedAI.instancesById[normalized.instance.instanceId];
197
+ if (!acceptedInstance || !same(acceptedInstance, normalized.instance)) throw error;
198
+ return resultFromReceipt(convergedAI, acceptedCompletion);
199
+ }
200
+ }
201
+
202
+ export async function runAgent<Input extends FlowClosedValue = FlowClosedValue, Output extends FlowClosedValue = FlowClosedValue>(runtime: AIAgentProcessorRuntime, input: Input, configInput: AIAgentEffectConfig): Promise<AIAgentRunResult<Output>> {
203
+ const inputValue = normalizeFlowClosedValue(input, 'input') as Input;
204
+ const config = normalizeAIAgentEffectConfig(configInput);
205
+ assertTaskAuthority(runtime, config);
206
+ let checkpoint = await loadCheckpoint(runtime);
207
+ let ai = profileAI(checkpoint);
208
+ let receipt = ai.invocationsByKey[runtime.invocationKey];
209
+ if (receipt) {
210
+ assertSameInvocation(receipt, runtime, 'new', inputValue, config);
211
+ if (receipt.status === 'completed') return resultFromReceipt(ai, receipt) as AIAgentRunResult<Output>;
212
+ } else {
213
+ receipt = pendingReceipt(runtime, 'new', inputValue, config);
214
+ reserveName(ai, receipt);
215
+ try {
216
+ checkpoint = await commitAI(runtime, checkpoint, { ...ai, invocationsByKey: { ...ai.invocationsByKey, [runtime.invocationKey]: receipt } });
217
+ } catch (error) {
218
+ const latest = await loadCheckpoint(runtime);
219
+ if (latest.version <= checkpoint.version) throw error;
220
+ const latestAI = profileAI(latest);
221
+ const accepted = latestAI.invocationsByKey[runtime.invocationKey];
222
+ if (accepted) {
223
+ assertSameInvocation(accepted, runtime, 'new', inputValue, config);
224
+ checkpoint = latest;
225
+ } else {
226
+ reserveName(latestAI, receipt);
227
+ checkpoint = await commitAI(runtime, latest, { ...latestAI, invocationsByKey: { ...latestAI.invocationsByKey, [runtime.invocationKey]: receipt } });
228
+ }
229
+ }
230
+ ai = profileAI(checkpoint);
231
+ receipt = ai.invocationsByKey[runtime.invocationKey]!;
232
+ }
233
+ if (receipt.status === 'completed') return resultFromReceipt(ai, receipt) as AIAgentRunResult<Output>;
234
+ const host = await runtime.effects.runAgent({ invocationKey: runtime.invocationKey, flowInstanceId: runtime.checkpointKey.instanceId, runId: runtime.checkpointKey.runId, nodeId: runtime.nodeId, ...(runtime.generation === undefined ? {} : { generation: runtime.generation }), input: inputValue, agentDefinitionRef: config.agentDefinitionRef, ...(config.instanceName === undefined ? {} : { instanceName: config.instanceName }), ...(config.materialRefs === undefined ? {} : { materialRefs: config.materialRefs }), ...(config.effectPolicy === undefined ? {} : { effectPolicy: config.effectPolicy }), ...(runtime.signal === undefined ? {} : { signal: runtime.signal }), ...(runtime.metadata === undefined ? {} : { metadata: runtime.metadata }) });
235
+ return complete(runtime, checkpoint, receipt, host, config) as Promise<AIAgentRunResult<Output>>;
236
+ }
237
+
238
+ export async function runTargetedAgent<Input extends FlowClosedValue = FlowClosedValue, Output extends FlowClosedValue = FlowClosedValue>(runtime: AIAgentProcessorRuntime, selectorInput: AIAgentSelector, invocationInput: AIAgentInvocation<Input>, configInput: AIAgentEffectConfig): Promise<AIAgentRunResult<Output>> {
239
+ const selector = normalizeAIAgentSelector(selectorInput);
240
+ const invocation = normalizeAIAgentInvocation<Input>(invocationInput);
241
+ const config = normalizeAIAgentEffectConfig(configInput);
242
+ assertTaskAuthority(runtime, config);
243
+ let checkpoint = await loadCheckpoint(runtime);
244
+ let ai = profileAI(checkpoint);
245
+ const instance = resolveSelector(ai, selector, config);
246
+ let receipt = ai.invocationsByKey[runtime.invocationKey];
247
+ if (receipt) {
248
+ assertSameInvocation(receipt, runtime, 'targeted', invocation.payload, config, invocation.metadata);
249
+ if (receipt.instanceId !== instance.instanceId) fail('AI_AGENT_INVOCATION_TARGET_CONFLICT', 'selector', 'Invocation key is reserved for another instance.');
250
+ if (receipt.status === 'completed') return resultFromReceipt(ai, receipt) as AIAgentRunResult<Output>;
251
+ } else {
252
+ receipt = pendingReceipt(runtime, 'targeted', invocation.payload, config, instance, invocation.metadata);
253
+ try {
254
+ checkpoint = await commitAI(runtime, checkpoint, { ...ai, invocationsByKey: { ...ai.invocationsByKey, [runtime.invocationKey]: receipt } });
255
+ } catch (error) {
256
+ const latest = await loadCheckpoint(runtime);
257
+ if (latest.version <= checkpoint.version) throw error;
258
+ const latestAI = profileAI(latest);
259
+ const resolved = resolveSelector(latestAI, selector, config);
260
+ if (resolved.instanceId !== instance.instanceId) fail('AI_AGENT_SELECTOR_CHANGED', 'selector', 'Selector resolved to another instance after checkpoint conflict.');
261
+ const accepted = latestAI.invocationsByKey[runtime.invocationKey];
262
+ if (accepted) {
263
+ assertSameInvocation(accepted, runtime, 'targeted', invocation.payload, config, invocation.metadata);
264
+ checkpoint = latest;
265
+ } else {
266
+ checkpoint = await commitAI(runtime, latest, { ...latestAI, invocationsByKey: { ...latestAI.invocationsByKey, [runtime.invocationKey]: receipt } });
267
+ }
268
+ }
269
+ ai = profileAI(checkpoint);
270
+ receipt = ai.invocationsByKey[runtime.invocationKey]!;
271
+ }
272
+ if (receipt.status === 'completed') return resultFromReceipt(ai, receipt) as AIAgentRunResult<Output>;
273
+ const host = await runtime.effects.runTargetedAgent({ invocationKey: runtime.invocationKey, flowInstanceId: runtime.checkpointKey.instanceId, runId: runtime.checkpointKey.runId, nodeId: runtime.nodeId, ...(runtime.generation === undefined ? {} : { generation: runtime.generation }), input: invocation.payload, instance, agentDefinitionRef: config.agentDefinitionRef, ...(config.materialRefs === undefined ? {} : { materialRefs: config.materialRefs }), ...(config.effectPolicy === undefined ? {} : { effectPolicy: config.effectPolicy }), ...(runtime.signal === undefined ? {} : { signal: runtime.signal }), metadata: { ...(runtime.metadata ?? {}), ...(invocation.metadata ?? {}) } });
274
+ return complete(runtime, checkpoint, receipt, host, config, instance) as Promise<AIAgentRunResult<Output>>;
275
+ }
276
+
277
+ export function bindAIAgentProcessors(runtime: AIAgentProcessorRuntime): AIWorkflowAuthoredEffects {
278
+ return Object.freeze({
279
+ runAgent: <Input extends FlowClosedValue, Output extends FlowClosedValue>(input: Input, config: AIAgentEffectConfig) => runAgent<Input, Output>(runtime, input, config),
280
+ runTargetedAgent: <Input extends FlowClosedValue, Output extends FlowClosedValue>(selector: AIAgentSelector, invocation: AIAgentInvocation<Input>, config: AIAgentEffectConfig) => runTargetedAgent<Input, Output>(runtime, selector, invocation, config),
281
+ });
282
+ }
@@ -2,10 +2,13 @@ import type {
2
2
  AIAgentDefinitionProjection,
3
3
  AIAgentMessageProjection,
4
4
  AIAgentMessageRole,
5
+ AIWorkflowEffectPolicyProjection,
5
6
  AIWorkflowAgentResourceProjection,
6
7
  AIWorkflowAgentResourceRelation,
7
8
  AIWorkflowAgentTaskRef,
8
9
  AIWorkflowDefinitionResourceKind,
10
+ AIWorkflowMaterialValueProjection,
11
+ AIWorkflowMessageSchemaProjection,
9
12
  AIWorkflowResourceProjectionDiagnostic,
10
13
  AIWorkflowResourceRegistryView,
11
14
  AIWorkflowTypedResourceRef,
@@ -114,7 +117,7 @@ function projectMaterialPort(
114
117
  diagnostics.push(diagnostic('AI_MATERIAL_PORT_CARDINALITY_INVALID', resource.documentUri, 'MaterialPort cardinality must be one or many.'));
115
118
  valid = false;
116
119
  }
117
- const schema = projectOptionalTypedSubdomain(
120
+ const schema = projectOptionalSchemaSubdomain(
118
121
  resource,
119
122
  'SchemaRef',
120
123
  resources,
@@ -147,12 +150,12 @@ function projectAgentDefinition(
147
150
  const messages = messagesNode
148
151
  ? projectMessages(resource, messagesNode, resources, edges, diagnostics)
149
152
  : [];
150
- const inputSchema = projectOptionalTypedSubdomain(resource, 'InputSchemaRef', resources, 'agent-input-schema', edges, diagnostics);
151
- const outputSchema = projectOptionalTypedSubdomain(resource, 'OutputSchemaRef', resources, 'agent-output-schema', edges, diagnostics);
153
+ const inputSchema = projectOptionalSchemaSubdomain(resource, 'InputSchemaRef', resources, 'agent-input-schema', edges, diagnostics);
154
+ const outputSchema = projectOptionalSchemaSubdomain(resource, 'OutputSchemaRef', resources, 'agent-output-schema', edges, diagnostics);
152
155
  const tools = toolRefsNode
153
156
  ? projectTypedRefList(resource, toolRefsNode, 'ToolRef', resources, 'agent-tool', edges, diagnostics)
154
157
  : [];
155
- const effectPolicy = projectOptionalTypedSubdomain(resource, 'EffectPolicyRef', resources, 'agent-effect-policy', edges, diagnostics);
158
+ const effectPolicy = projectOptionalEffectPolicySubdomain(resource, 'EffectPolicyRef', resources, 'agent-effect-policy', edges, diagnostics);
156
159
  const materialPorts = materialPortRefsNode
157
160
  ? projectTypedRefList(resource, materialPortRefsNode, 'MaterialPortRef', resources, 'agent-material-port', edges, diagnostics, 'MaterialPort')
158
161
  : [];
@@ -209,7 +212,7 @@ function projectMessages(
209
212
  if (prompt) edges.push(edge(resource.resourceId, prompt.resource.resourceId, 'agent-message-prompt'));
210
213
  const schemaNode = value.subdomains.SchemaRef;
211
214
  const schema = schemaNode?.tag === 'SchemaRef'
212
- ? projectTypedRefNode(schemaNode, resources, `${location}#SchemaRef`, diagnostics)
215
+ ? projectMessageSchemaRefNode(schemaNode, resources, `${location}#SchemaRef`, diagnostics)
213
216
  : undefined;
214
217
  if (schemaNode && schemaNode.tag !== 'SchemaRef') diagnostics.push(diagnostic('AI_RESOURCE_SUBDOMAIN_TAG_MISMATCH', `${location}#SchemaRef`, `Subdomain SchemaRef must contain a SchemaRef node, got '${schemaNode.tag}'.`));
215
218
  if (schema) edges.push(edge(resource.resourceId, schema.resource.resourceId, 'agent-message-schema'));
@@ -274,6 +277,69 @@ function projectOptionalTypedSubdomain(
274
277
  return typedRef;
275
278
  }
276
279
 
280
+ function projectOptionalSchemaSubdomain(
281
+ owner: ResourceRecord,
282
+ name: string,
283
+ resources: ReadonlyMap<string, ResourceRecord>,
284
+ relation: AIWorkflowAgentResourceRelation,
285
+ edges: ResourceDependencyEdge[],
286
+ diagnostics: AIWorkflowResourceProjectionDiagnostic[],
287
+ ): AIWorkflowMessageSchemaProjection | undefined {
288
+ const node = owner.node.subdomains[name];
289
+ if (!node) return undefined;
290
+ if (node.tag !== name) {
291
+ diagnostics.push(diagnostic('AI_RESOURCE_SUBDOMAIN_TAG_MISMATCH', `${owner.documentUri}#${name}`, `Subdomain ${name} must contain a ${name} node, got '${node.tag}'.`));
292
+ return undefined;
293
+ }
294
+ const schema = projectMessageSchemaRefNode(node, resources, `${owner.documentUri}#${name}`, diagnostics);
295
+ if (schema) edges.push(edge(owner.resourceId, schema.resource.resourceId, relation));
296
+ return schema;
297
+ }
298
+
299
+ function projectOptionalEffectPolicySubdomain(
300
+ owner: ResourceRecord,
301
+ name: string,
302
+ resources: ReadonlyMap<string, ResourceRecord>,
303
+ relation: AIWorkflowAgentResourceRelation,
304
+ edges: ResourceDependencyEdge[],
305
+ diagnostics: AIWorkflowResourceProjectionDiagnostic[],
306
+ ): AIWorkflowEffectPolicyProjection | undefined {
307
+ const node = owner.node.subdomains[name];
308
+ if (!node) return undefined;
309
+ if (node.tag !== name) {
310
+ diagnostics.push(diagnostic('AI_RESOURCE_SUBDOMAIN_TAG_MISMATCH', `${owner.documentUri}#${name}`, `Subdomain ${name} must contain a ${name} node, got '${node.tag}'.`));
311
+ return undefined;
312
+ }
313
+ const typedRef = projectTypedRefNode(node, resources, `${owner.documentUri}#${name}`, diagnostics, 'EffectPolicy');
314
+ if (!typedRef) return undefined;
315
+ const supportedProperties = new Set(['lifecycle', 'description', 'toolMode']);
316
+ const unsupportedProperties = Reflect.ownKeys(typedRef.resource.node.properties)
317
+ .filter((key) => typeof key !== 'string' || !supportedProperties.has(key))
318
+ .map((key) => typeof key === 'string' ? key : key.toString())
319
+ .sort(compareCodeUnits);
320
+ if (unsupportedProperties.length > 0) {
321
+ for (const property of unsupportedProperties) {
322
+ diagnostics.push(diagnostic(
323
+ 'AI_EFFECT_POLICY_PROPERTY_UNSUPPORTED',
324
+ `${typedRef.resource.documentUri}#${property}`,
325
+ `EffectPolicy property '${property}' is not part of the closed executable policy contract.`,
326
+ ));
327
+ }
328
+ return undefined;
329
+ }
330
+ const toolMode = ownResourceValue(typedRef.resource.node.properties, 'toolMode');
331
+ if (toolMode !== 'declared-only' && toolMode !== 'none') {
332
+ diagnostics.push(diagnostic(
333
+ 'AI_EFFECT_POLICY_TOOL_MODE_INVALID',
334
+ typedRef.resource.documentUri,
335
+ "EffectPolicy toolMode must be 'declared-only' or 'none'.",
336
+ ));
337
+ return undefined;
338
+ }
339
+ edges.push(edge(owner.resourceId, typedRef.resource.resourceId, relation));
340
+ return Object.freeze({ ...typedRef, kind: 'EffectPolicy', toolMode });
341
+ }
342
+
277
343
  function projectMaterialBinding(
278
344
  resource: ResourceRecord,
279
345
  resources: ReadonlyMap<string, ResourceRecord>,
@@ -287,7 +353,10 @@ function projectMaterialBinding(
287
353
  const materialNode = requiredSubdomain(resource, 'MaterialRef', diagnostics);
288
354
  const task = taskNode ? projectTaskRef(taskNode, resources, `${resource.documentUri}#AgentTaskRef`, diagnostics) : undefined;
289
355
  const portRef = portNode ? projectTypedRefNode(portNode, resources, `${resource.documentUri}#PortRef`, diagnostics, 'MaterialPort') : undefined;
290
- const material = materialNode ? projectTypedRefNode(materialNode, resources, `${resource.documentUri}#MaterialRef`, diagnostics) : undefined;
356
+ const materialRef = materialNode ? projectTypedRefNode(materialNode, resources, `${resource.documentUri}#MaterialRef`, diagnostics) : undefined;
357
+ const material = materialRef
358
+ ? projectMaterialValue(materialRef, diagnostics)
359
+ : undefined;
291
360
  if (portRef) edges.push(edge(resource.resourceId, portRef.resource.resourceId, 'material-binding-port'));
292
361
  if (material) edges.push(edge(resource.resourceId, material.resource.resourceId, 'material-binding-material'));
293
362
  const port = portRef ? portsById.get(portRef.resource.resourceId) : undefined;
@@ -303,6 +372,134 @@ function projectMaterialBinding(
303
372
  });
304
373
  }
305
374
 
375
+ function projectMessageSchemaRefNode(
376
+ node: ResourceNode,
377
+ resources: ReadonlyMap<string, ResourceRecord>,
378
+ location: string,
379
+ diagnostics: AIWorkflowResourceProjectionDiagnostic[],
380
+ ): AIWorkflowMessageSchemaProjection | undefined {
381
+ const typedRef = projectTypedRefNode(node, resources, location, diagnostics, 'MessageSchema');
382
+ if (!typedRef) return undefined;
383
+ const schemaValue = ownResourceValue(typedRef.resource.node.properties, 'schema');
384
+ if (!isResourceValueMap(schemaValue)) {
385
+ diagnostics.push(diagnostic(
386
+ 'AI_MESSAGE_SCHEMA_VALUE_INVALID',
387
+ typedRef.resource.documentUri,
388
+ 'MessageSchema must declare one explicit schema map.',
389
+ ));
390
+ return undefined;
391
+ }
392
+ const schema = cloneResourceValue(schemaValue, typedRef.resource.documentUri, diagnostics, 'AI_MESSAGE_SCHEMA_VALUE_INVALID');
393
+ if (!isResourceValueMap(schema)) return undefined;
394
+ return Object.freeze({ ...typedRef, kind: 'MessageSchema', schema });
395
+ }
396
+
397
+ function projectMaterialValue(
398
+ typedRef: AIWorkflowTypedResourceRef,
399
+ diagnostics: AIWorkflowResourceProjectionDiagnostic[],
400
+ ): AIWorkflowMaterialValueProjection | undefined {
401
+ const value = ownResourceValue(typedRef.resource.node.properties, 'value');
402
+ if (value === undefined) {
403
+ diagnostics.push(diagnostic(
404
+ 'AI_MATERIAL_VALUE_MISSING',
405
+ typedRef.resource.documentUri,
406
+ `Material '${typedRef.resource.resourceId}' must declare one explicit value.`,
407
+ ));
408
+ return undefined;
409
+ }
410
+ const cloned = cloneResourceValue(value, typedRef.resource.documentUri, diagnostics, 'AI_MATERIAL_VALUE_INVALID');
411
+ return cloned === undefined ? undefined : Object.freeze({ ...typedRef, value: cloned });
412
+ }
413
+
414
+ function ownResourceValue(
415
+ record: Readonly<Record<string, ResourceValue>>,
416
+ field: string,
417
+ ): ResourceValue | undefined {
418
+ const descriptor = Object.getOwnPropertyDescriptor(record, field);
419
+ return descriptor && descriptor.enumerable && 'value' in descriptor
420
+ ? descriptor.value
421
+ : undefined;
422
+ }
423
+
424
+ function isResourceValueMap(value: ResourceValue | undefined): value is Readonly<Record<string, ResourceValue>> {
425
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && !isResourceNode(value);
426
+ }
427
+
428
+ function cloneResourceValue(
429
+ value: ResourceValue,
430
+ location: string,
431
+ diagnostics: AIWorkflowResourceProjectionDiagnostic[],
432
+ code: string,
433
+ ancestors: WeakSet<object> = new WeakSet(),
434
+ ): ResourceValue | undefined {
435
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
436
+ if (typeof value === 'number') {
437
+ if (Number.isFinite(value)) return value;
438
+ diagnostics.push(diagnostic(code, location, 'Executable resource values may contain only finite numbers.'));
439
+ return undefined;
440
+ }
441
+ if (ancestors.has(value)) {
442
+ diagnostics.push(diagnostic(code, location, 'Executable resource values must not contain cycles.'));
443
+ return undefined;
444
+ }
445
+ ancestors.add(value);
446
+ if (Array.isArray(value)) {
447
+ const keys = Reflect.ownKeys(value);
448
+ const expected = new Set([...value.keys()].map(String).concat('length'));
449
+ if (keys.some((key) => typeof key !== 'string' || !expected.has(key))) {
450
+ diagnostics.push(diagnostic(code, location, 'Executable resource arrays must be dense plain-data arrays.'));
451
+ ancestors.delete(value);
452
+ return undefined;
453
+ }
454
+ const output: ResourceValue[] = [];
455
+ for (let index = 0; index < value.length; index += 1) {
456
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
457
+ if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
458
+ diagnostics.push(diagnostic(code, location, 'Executable resource arrays must be dense plain-data arrays.'));
459
+ ancestors.delete(value);
460
+ return undefined;
461
+ }
462
+ const item = cloneResourceValue(descriptor.value as ResourceValue, location, diagnostics, code, ancestors);
463
+ if (item === undefined) {
464
+ ancestors.delete(value);
465
+ return undefined;
466
+ }
467
+ output.push(item);
468
+ }
469
+ ancestors.delete(value);
470
+ return Object.freeze(output);
471
+ }
472
+ const prototype = Object.getPrototypeOf(value);
473
+ if (prototype !== Object.prototype && prototype !== null) {
474
+ diagnostics.push(diagnostic(code, location, 'Executable resource maps must be plain-data objects.'));
475
+ ancestors.delete(value);
476
+ return undefined;
477
+ }
478
+ const output: Record<string, ResourceValue> = Object.create(null) as Record<string, ResourceValue>;
479
+ const keys = Reflect.ownKeys(value);
480
+ if (keys.some((key) => typeof key !== 'string')) {
481
+ diagnostics.push(diagnostic(code, location, 'Executable resource maps may contain only string keys.'));
482
+ ancestors.delete(value);
483
+ return undefined;
484
+ }
485
+ for (const key of (keys as string[]).sort(compareCodeUnits)) {
486
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
487
+ if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
488
+ diagnostics.push(diagnostic(code, location, 'Executable resource maps must contain only enumerable data properties.'));
489
+ ancestors.delete(value);
490
+ return undefined;
491
+ }
492
+ const item = cloneResourceValue(descriptor.value as ResourceValue, location, diagnostics, code, ancestors);
493
+ if (item === undefined) {
494
+ ancestors.delete(value);
495
+ return undefined;
496
+ }
497
+ output[key] = item;
498
+ }
499
+ ancestors.delete(value);
500
+ return Object.freeze(output);
501
+ }
502
+
306
503
  function projectTaskRef(
307
504
  node: ResourceNode,
308
505
  resources: ReadonlyMap<string, ResourceRecord>,