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 +7 -5
- package/src/agent-invocation.ts +282 -0
- package/src/agent-resources.ts +203 -6
- package/src/agent-state.ts +293 -0
- package/src/checkpoint-filesystem.ts +1223 -0
- package/src/checkpoint-store.ts +601 -0
- package/src/index.ts +34 -4
- package/src/run-checkpoint.ts +1666 -0
- package/src/run-freeze.ts +82 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-workflow-logic",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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
|
+
}
|
package/src/agent-resources.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
151
|
-
const outputSchema =
|
|
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 =
|
|
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
|
-
?
|
|
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
|
|
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>,
|