@memberjunction/ai-agent-manager 6.2.0-edge.1 → 6.2.0-edge.2
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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +57 -0
- package/dist/agent-spec-sync.d.ts.map +1 -1
- package/dist/agent-spec-sync.js +7 -4
- package/dist/agent-spec-sync.js.map +1 -1
- package/dist/agents/architect-agent.d.ts +28 -0
- package/dist/agents/architect-agent.d.ts.map +1 -1
- package/dist/agents/architect-agent.js +123 -76
- package/dist/agents/architect-agent.js.map +1 -1
- package/dist/flow-step-validation.d.ts +56 -3
- package/dist/flow-step-validation.d.ts.map +1 -1
- package/dist/flow-step-validation.js +214 -0
- package/dist/flow-step-validation.js.map +1 -1
- package/dist/workflow-agent-writer.d.ts +9 -2
- package/dist/workflow-agent-writer.d.ts.map +1 -1
- package/dist/workflow-agent-writer.js +30 -6
- package/dist/workflow-agent-writer.js.map +1 -1
- package/package.json +9 -9
- package/src/__tests__/agent-spec-sync-decision-roundtrip.test.ts +218 -0
- package/src/__tests__/architect-agent-example.test.ts +218 -0
- package/src/__tests__/flow-step-validation.test.ts +328 -8
- package/src/__tests__/workflow-agent-writer.test.ts +62 -0
- package/src/agent-spec-sync.ts +7 -4
- package/src/agents/architect-agent.ts +131 -82
- package/src/flow-step-validation.ts +254 -3
- package/src/workflow-agent-writer.ts +33 -8
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { BaseAgent, PayloadManager } from '@memberjunction/ai-agents';
|
|
2
|
-
import { ExecuteAgentParams, BaseAgentNextStep, AgentSpec, MJAIAgentRunEntityExtended, MJAIAgentRunStepEntityExtended } from '@memberjunction/ai-core-plus';
|
|
2
|
+
import { ExecuteAgentParams, BaseAgentNextStep, AgentSpec, AgentStep, MJAIAgentRunEntityExtended, MJAIAgentRunStepEntityExtended } from '@memberjunction/ai-core-plus';
|
|
3
3
|
import { MJActionEntity } from "@memberjunction/core-entities";
|
|
4
4
|
import { RunView } from '@memberjunction/core';
|
|
5
|
-
import { RegisterClass, NormalizeUUID } from '@memberjunction/global';
|
|
6
|
-
import {
|
|
5
|
+
import { RegisterClass, NormalizeUUID, UUIDsEqual } from '@memberjunction/global';
|
|
6
|
+
import { AIEngine } from '@memberjunction/aiengine';
|
|
7
|
+
import { ValidateLoopStep, ValidateFlowGraph } from '../flow-step-validation';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The model type a Decision step's prompt runs on: the name of an `MJ: AI Model Types` row, and what
|
|
11
|
+
* the decision runner requires (`AIDecisionRunner.RequiredModelType`).
|
|
12
|
+
*/
|
|
13
|
+
const DECISION_MODEL_TYPE = 'Decision';
|
|
7
14
|
|
|
8
15
|
/**
|
|
9
16
|
* Architect Agent - Transforms technical design into validated AgentSpec JSON
|
|
@@ -161,8 +168,9 @@ export class AgentArchitectAgent extends BaseAgent {
|
|
|
161
168
|
}
|
|
162
169
|
|
|
163
170
|
// 5. Validate agent type-specific requirements
|
|
164
|
-
const
|
|
165
|
-
const
|
|
171
|
+
const typeName = this.agentTypeName(specWithType.TypeID);
|
|
172
|
+
const isLoopAgent = typeName.includes('Loop');
|
|
173
|
+
const isFlowAgent = typeName.includes('Flow');
|
|
166
174
|
|
|
167
175
|
if (isLoopAgent) {
|
|
168
176
|
// Loop agents require at least one prompt
|
|
@@ -188,81 +196,7 @@ export class AgentArchitectAgent extends BaseAgent {
|
|
|
188
196
|
}
|
|
189
197
|
}
|
|
190
198
|
|
|
191
|
-
|
|
192
|
-
if (correctedSpec.Steps && correctedSpec.Steps.length > 0) {
|
|
193
|
-
const hasStartingStep = correctedSpec.Steps.some(step => step.StartingStep === true);
|
|
194
|
-
if (!hasStartingStep) {
|
|
195
|
-
errors.push('❌ Flow agents require at least ONE step with StartingStep: true');
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Validate each step based on its type
|
|
199
|
-
for (let i = 0; i < correctedSpec.Steps.length; i++) {
|
|
200
|
-
const step = correctedSpec.Steps[i];
|
|
201
|
-
|
|
202
|
-
// Validate Action steps
|
|
203
|
-
if (step.StepType === 'Action') {
|
|
204
|
-
if (!step.ActionID) {
|
|
205
|
-
errors.push(`❌ Action step "${step.Name}" (index ${i}) must have ActionID field`);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Validate ActionInputMapping if provided (supports both string and object)
|
|
209
|
-
if (step.ActionInputMapping) {
|
|
210
|
-
try {
|
|
211
|
-
if (typeof step.ActionInputMapping === 'string') {
|
|
212
|
-
JSON.parse(step.ActionInputMapping);
|
|
213
|
-
}
|
|
214
|
-
// else it's already an object, which is valid and will be stringified later
|
|
215
|
-
} catch (e: any) {
|
|
216
|
-
errors.push(`❌ Step "${step.Name}" (index ${i}) has invalid ActionInputMapping JSON: ${e?.message || String(e)}`);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// Validate ActionOutputMapping if provided (supports both string and object)
|
|
221
|
-
if (step.ActionOutputMapping) {
|
|
222
|
-
try {
|
|
223
|
-
if (typeof step.ActionOutputMapping === 'string') {
|
|
224
|
-
JSON.parse(step.ActionOutputMapping);
|
|
225
|
-
}
|
|
226
|
-
// else it's already an object, which is valid and will be stringified later
|
|
227
|
-
} catch (e: any) {
|
|
228
|
-
errors.push(`❌ Step "${step.Name}" (index ${i}) has invalid ActionOutputMapping JSON: ${e?.message || String(e)}`);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Validate Prompt steps
|
|
234
|
-
if (step.StepType === 'Prompt') {
|
|
235
|
-
// If PromptID is empty or not provided, PromptText is required for inline creation
|
|
236
|
-
if (!step.PromptID || step.PromptID === '') {
|
|
237
|
-
if (!step.PromptText || step.PromptText.trim() === '') {
|
|
238
|
-
errors.push(`❌ Prompt step "${step.Name}" (index ${i}) has empty PromptID but missing PromptText. For inline prompt creation, PromptText is required.`);
|
|
239
|
-
}
|
|
240
|
-
// PromptName and PromptDescription are recommended but not required
|
|
241
|
-
if (!step.PromptName) {
|
|
242
|
-
console.log(`⚠️ Warning: Prompt step "${step.Name}" (index ${i}) is missing PromptName (will default to "[Step Name] Prompt")`);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
// If PromptID is provided (existing prompt), other fields are optional
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Validate Sub-Agent steps
|
|
249
|
-
if (step.StepType === 'Sub-Agent') {
|
|
250
|
-
// SubAgentID can be empty "" for new sub-agents (will be linked by name matching)
|
|
251
|
-
// No validation needed here - linking happens in AgentSpecSync
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Validate loop steps (ForEach / While)
|
|
255
|
-
//
|
|
256
|
-
// A loop is a wrapper, not a leaf: LoopBodyType names which of Action/Prompt/
|
|
257
|
-
// Sub-Agent runs each pass, and Configuration carries the bounds. A loop missing
|
|
258
|
-
// either is the failure mode worth catching here — it saves cleanly and then
|
|
259
|
-
// iterates over nothing at runtime, which looks like the agent doing no work
|
|
260
|
-
// rather than like a malformed step.
|
|
261
|
-
if (IsLoopStep(step)) {
|
|
262
|
-
errors.push(...ValidateLoopStep(step, i));
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
199
|
+
errors.push(...this.validateFlowSteps(correctedSpec));
|
|
266
200
|
}
|
|
267
201
|
|
|
268
202
|
// 6. Validate optional but important fields
|
|
@@ -390,8 +324,9 @@ export class AgentArchitectAgent extends BaseAgent {
|
|
|
390
324
|
errors.push(`❌ Child SubAgent[${i}] "${subAgent.SubAgent.Name}" is missing TypeID field`);
|
|
391
325
|
} else {
|
|
392
326
|
// Validate Loop/Flow specific requirements for child sub-agents
|
|
393
|
-
const
|
|
394
|
-
const
|
|
327
|
+
const subAgentTypeName = this.agentTypeName(subAgent.SubAgent.TypeID);
|
|
328
|
+
const isLoopSubAgent = subAgentTypeName.includes('Loop');
|
|
329
|
+
const isFlowSubAgent = subAgentTypeName.includes('Flow');
|
|
395
330
|
|
|
396
331
|
if (isLoopSubAgent) {
|
|
397
332
|
// Loop sub-agents require at least one prompt
|
|
@@ -410,6 +345,11 @@ export class AgentArchitectAgent extends BaseAgent {
|
|
|
410
345
|
if (!subAgent.SubAgent.Steps || subAgent.SubAgent.Steps.length === 0) {
|
|
411
346
|
errors.push(`❌ Flow SubAgent[${i}] "${subAgent.SubAgent.Name}" requires at least ONE step in Steps array`);
|
|
412
347
|
}
|
|
348
|
+
|
|
349
|
+
// A Flow child runs its steps exactly as a Flow agent does, so they get the same checks.
|
|
350
|
+
errors.push(...this.validateFlowSteps(subAgent.SubAgent).map(
|
|
351
|
+
err => `Flow SubAgent[${i}] "${subAgent.SubAgent.Name}" -> ${err}`
|
|
352
|
+
));
|
|
413
353
|
}
|
|
414
354
|
}
|
|
415
355
|
} else if (subAgent.Type === 'related') {
|
|
@@ -477,6 +417,103 @@ export class AgentArchitectAgent extends BaseAgent {
|
|
|
477
417
|
return { errors, corrected };
|
|
478
418
|
}
|
|
479
419
|
|
|
420
|
+
/**
|
|
421
|
+
* Every problem with a Flow agent's steps and paths: a starting step, each step on its own, and
|
|
422
|
+
* then the flow as the runtime compiles and validates it ({@link ValidateFlowGraph}). Used for the
|
|
423
|
+
* agent itself and for each Flow child sub-agent, which runs its steps the same way.
|
|
424
|
+
*/
|
|
425
|
+
private validateFlowSteps(spec: Pick<AgentSpec, 'Name' | 'Steps' | 'Paths'>): string[] {
|
|
426
|
+
const steps = spec.Steps ?? [];
|
|
427
|
+
if (steps.length === 0) {
|
|
428
|
+
return [];
|
|
429
|
+
}
|
|
430
|
+
const stepErrors = steps.flatMap((step, i) => this.validateFlowStep(step, i));
|
|
431
|
+
if (!steps.some(step => step.StartingStep === true)) {
|
|
432
|
+
// The compiler would refuse the flow for this alone, so there is nothing more to learn from it.
|
|
433
|
+
return ['❌ Flow agents require at least ONE step with StartingStep: true', ...stepErrors];
|
|
434
|
+
}
|
|
435
|
+
return [...stepErrors, ...ValidateFlowGraph(steps, spec.Paths ?? [], spec.Name)];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The checks one step gets on its own, by type. */
|
|
439
|
+
private validateFlowStep(step: AgentStep, index: number): string[] {
|
|
440
|
+
switch (step.StepType) {
|
|
441
|
+
case 'Action':
|
|
442
|
+
return this.validateActionStep(step, index);
|
|
443
|
+
case 'Prompt':
|
|
444
|
+
return this.validatePromptStep(step, index);
|
|
445
|
+
case 'ForEach':
|
|
446
|
+
case 'While':
|
|
447
|
+
// A loop is a wrapper, not a leaf: LoopBodyType names which of Action/Prompt/Sub-Agent
|
|
448
|
+
// runs each pass, and Configuration carries the bounds. A loop missing either saves
|
|
449
|
+
// cleanly and then iterates over nothing, which looks like the agent doing no work
|
|
450
|
+
// rather than like a malformed step.
|
|
451
|
+
return ValidateLoopStep(step, index);
|
|
452
|
+
case 'Decision':
|
|
453
|
+
return this.validateDecisionPrompt(step, index);
|
|
454
|
+
default:
|
|
455
|
+
// A Sub-Agent step's SubAgentID may be empty for a sub-agent this spec creates.
|
|
456
|
+
return [];
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** An Action step needs its action, and any mapping written as text must be JSON. */
|
|
461
|
+
private validateActionStep(step: AgentStep, index: number): string[] {
|
|
462
|
+
const errors: string[] = [];
|
|
463
|
+
if (!step.ActionID) {
|
|
464
|
+
errors.push(`❌ Action step "${step.Name}" (index ${index}) must have ActionID field`);
|
|
465
|
+
}
|
|
466
|
+
for (const [field, mapping] of [['ActionInputMapping', step.ActionInputMapping], ['ActionOutputMapping', step.ActionOutputMapping]] as const) {
|
|
467
|
+
// An object is valid as it is, and is stringified when saved.
|
|
468
|
+
if (typeof mapping !== 'string' || !mapping) continue;
|
|
469
|
+
try {
|
|
470
|
+
JSON.parse(mapping);
|
|
471
|
+
} catch (e) {
|
|
472
|
+
errors.push(`❌ Step "${step.Name}" (index ${index}) has invalid ${field} JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return errors;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** A Prompt step names an existing prompt, or writes one inline as PromptText for the Builder to create. */
|
|
479
|
+
private validatePromptStep(step: AgentStep, index: number): string[] {
|
|
480
|
+
if (step.PromptID) {
|
|
481
|
+
return [];
|
|
482
|
+
}
|
|
483
|
+
// PromptName and PromptDescription are recommended but not required
|
|
484
|
+
if (!step.PromptName) {
|
|
485
|
+
console.log(`⚠️ Warning: Prompt step "${step.Name}" (index ${index}) is missing PromptName (will default to "[Step Name] Prompt")`);
|
|
486
|
+
}
|
|
487
|
+
return step.PromptText?.trim()
|
|
488
|
+
? []
|
|
489
|
+
: [`❌ Prompt step "${step.Name}" (index ${index}) has empty PromptID but missing PromptText. For inline prompt creation, PromptText is required.`];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* A Decision step's `PromptID`, when it sets one, must be a prompt that can run a decision. Empty
|
|
494
|
+
* means the Default Decision prompt.
|
|
495
|
+
*
|
|
496
|
+
* The runtime runs the step's prompt on Decision-type models only, and refuses a prompt whose model
|
|
497
|
+
* type is set to anything else; it finds that out only when the step runs. A prompt with no model
|
|
498
|
+
* type takes the runner's, so it is not refused here.
|
|
499
|
+
*/
|
|
500
|
+
private validateDecisionPrompt(step: AgentStep, index: number): string[] {
|
|
501
|
+
if (!step.PromptID) {
|
|
502
|
+
return [];
|
|
503
|
+
}
|
|
504
|
+
const where = `Decision step "${step.Name}" (index ${index})`;
|
|
505
|
+
const prompt = AIEngine.Instance.Prompts.find(p => UUIDsEqual(p.ID, step.PromptID));
|
|
506
|
+
if (!prompt) {
|
|
507
|
+
return [`❌ ${where} has PromptID "${step.PromptID}", which is not a prompt. Leave PromptID empty to use the Default Decision prompt.`];
|
|
508
|
+
}
|
|
509
|
+
const decisionType = AIEngine.Instance.ModelTypes.find(t => t.Name.trim().toLowerCase() === DECISION_MODEL_TYPE.toLowerCase());
|
|
510
|
+
if (!prompt.AIModelTypeID || !decisionType || UUIDsEqual(prompt.AIModelTypeID, decisionType.ID)) {
|
|
511
|
+
return [];
|
|
512
|
+
}
|
|
513
|
+
const typeName = AIEngine.Instance.ModelTypes.find(t => UUIDsEqual(t.ID, prompt.AIModelTypeID))?.Name ?? prompt.AIModelTypeID;
|
|
514
|
+
return [`❌ ${where} uses the prompt "${prompt.Name}", whose model type is "${typeName}". A Decision step's prompt must run on ${DECISION_MODEL_TYPE} models. Leave PromptID empty to use the Default Decision prompt.`];
|
|
515
|
+
}
|
|
516
|
+
|
|
480
517
|
/**
|
|
481
518
|
* Deduplicates arrays in AgentSpec to fix common LLM retry issues
|
|
482
519
|
* Modifies spec in place and returns whether duplicates were found
|
|
@@ -599,6 +636,18 @@ export class AgentArchitectAgent extends BaseAgent {
|
|
|
599
636
|
return { hadDuplicates };
|
|
600
637
|
}
|
|
601
638
|
|
|
639
|
+
/**
|
|
640
|
+
* The agent type's name for a spec's `TypeID`. The Architect's template has the model write the
|
|
641
|
+
* type's ID (a GUID), which never contains 'Loop' or 'Flow', so the ID is resolved through
|
|
642
|
+
* AIEngine's agent types. Anything that is not a known type's ID (an older spec that wrote the
|
|
643
|
+
* name) is returned as written.
|
|
644
|
+
*/
|
|
645
|
+
private agentTypeName(typeID: string | undefined): string {
|
|
646
|
+
if (!typeID) return '';
|
|
647
|
+
const type = AIEngine.Instance.AgentTypes.find(t => UUIDsEqual(t.ID, typeID));
|
|
648
|
+
return type?.Name ?? typeID;
|
|
649
|
+
}
|
|
650
|
+
|
|
602
651
|
/**
|
|
603
652
|
* Simple hash function for string deduplication
|
|
604
653
|
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview Validation for the flow step types the Architect authors
|
|
2
|
+
* @fileoverview Validation for the flow step types the Architect authors, and for a flow as a whole.
|
|
3
3
|
*
|
|
4
4
|
* **Why loops needed their own module.** The Architect's spec taught three step types
|
|
5
5
|
* (`Action`, `Prompt`, `Sub-Agent`) while `AIAgentStep.StepType` has accepted five for releases —
|
|
@@ -9,11 +9,28 @@
|
|
|
9
9
|
* `ForEach` with no `collectionPath` iterates over nothing, and at runtime that reads as the agent
|
|
10
10
|
* declining to work rather than as a malformed step.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* **A flow as a whole is checked by the runtime's own code.** {@link ValidateFlowGraph} compiles the
|
|
13
|
+
* flow with `CompileFlowToTaskGraph` and validates the result with `ValidateTaskGraphSpec`, as a Flow
|
|
14
|
+
* agent is before it runs, rather than re-implementing any of their rules. A second copy of those
|
|
15
|
+
* rules is a copy that drifts: it passed conditions the runtime refused and refused forks the
|
|
16
|
+
* runtime ran.
|
|
17
|
+
*
|
|
18
|
+
* Pure: no database and no engine, so the rules are unit-testable without an agent run.
|
|
13
19
|
*
|
|
14
20
|
* @module @memberjunction/ai-agent-manager
|
|
15
21
|
*/
|
|
16
|
-
import type {
|
|
22
|
+
import type {
|
|
23
|
+
AgentStep,
|
|
24
|
+
AgentStepPath,
|
|
25
|
+
FlowCompileError,
|
|
26
|
+
FlowCompilerOptions,
|
|
27
|
+
FlowCompilerPath,
|
|
28
|
+
FlowCompilerStep,
|
|
29
|
+
TaskGraphSpec,
|
|
30
|
+
TaskGraphValidationError,
|
|
31
|
+
} from '@memberjunction/ai-core-plus';
|
|
32
|
+
import { CollectDecisionStepKeys, CompileFlowToTaskGraph, ValidateTaskGraphSpec } from '@memberjunction/ai-core-plus';
|
|
33
|
+
import { UUIDsEqual } from '@memberjunction/global';
|
|
17
34
|
|
|
18
35
|
/** The step types that wrap a body and repeat it. */
|
|
19
36
|
export type LoopStepType = Extract<AgentStep['StepType'], 'ForEach' | 'While'>;
|
|
@@ -23,6 +40,11 @@ export function IsLoopStep(step: Pick<AgentStep, 'StepType'>): boolean {
|
|
|
23
40
|
return step.StepType === 'ForEach' || step.StepType === 'While';
|
|
24
41
|
}
|
|
25
42
|
|
|
43
|
+
/** True when this step is a fast typed decision. */
|
|
44
|
+
export function IsDecisionStep(step: Pick<AgentStep, 'StepType'>): boolean {
|
|
45
|
+
return step.StepType === 'Decision';
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
/**
|
|
27
49
|
* Which field carries the body's id, per body type.
|
|
28
50
|
*
|
|
@@ -135,3 +157,232 @@ function parseLoopConfiguration(step: AgentStep, where: string, errors: string[]
|
|
|
135
157
|
return null;
|
|
136
158
|
}
|
|
137
159
|
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Normalizes a Decision step's configuration so the aliases a model tends to write (`text` for a
|
|
163
|
+
* question's `instructions`, and `text` for an option's `description`) read as the fields
|
|
164
|
+
* `ReadFlowDecisionStepConfiguration` expects. Returns a copy; the input is not changed.
|
|
165
|
+
*/
|
|
166
|
+
export function NormalizeDecisionConfiguration(raw: Record<string, unknown>): Record<string, unknown> {
|
|
167
|
+
const cloned = structuredClone(raw);
|
|
168
|
+
const questions = cloned.questions;
|
|
169
|
+
if (!isPlainObject(questions)) {
|
|
170
|
+
return cloned;
|
|
171
|
+
}
|
|
172
|
+
for (const question of Object.values(questions)) {
|
|
173
|
+
if (!isPlainObject(question)) continue;
|
|
174
|
+
copyAlias(question, 'text', 'instructions');
|
|
175
|
+
if (Array.isArray(question.options)) {
|
|
176
|
+
for (const option of question.options) {
|
|
177
|
+
if (isPlainObject(option)) copyAlias(option, 'text', 'description');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return cloned;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
185
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Sets `record[to]` from `record[from]` when only the alias is present. */
|
|
189
|
+
function copyAlias(record: Record<string, unknown>, from: string, to: string): void {
|
|
190
|
+
if (!record[to] && typeof record[from] === 'string') {
|
|
191
|
+
record[to] = record[from];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A Decision step's configuration as JSON text, normalized. A model may write the configuration as
|
|
197
|
+
* an object rather than as JSON text, as it may for a loop; text that is not a JSON object is
|
|
198
|
+
* returned unchanged, so `ReadFlowDecisionStepConfiguration` reports why it cannot be read.
|
|
199
|
+
*/
|
|
200
|
+
export function DecisionConfigurationText(raw: AgentStep['Configuration'] | null): string | null {
|
|
201
|
+
if (raw == null) return null;
|
|
202
|
+
if (typeof raw !== 'string') {
|
|
203
|
+
// Typed as an object, but it is a model's JSON: an array or a number can arrive here too, and
|
|
204
|
+
// is written as the JSON it is so the reader can say it is not an object.
|
|
205
|
+
return isPlainObject(raw) ? JSON.stringify(NormalizeDecisionConfiguration(raw)) : JSON.stringify(raw);
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const parsed: unknown = JSON.parse(raw);
|
|
209
|
+
return isPlainObject(parsed) ? JSON.stringify(NormalizeDecisionConfiguration(parsed)) : raw;
|
|
210
|
+
} catch {
|
|
211
|
+
return raw;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A step's `Configuration` as the JSON text `AIAgentStep.Configuration` stores, which is what the
|
|
217
|
+
* runtime reads. An object is written as text, and a Decision step's is normalized
|
|
218
|
+
* ({@link DecisionConfigurationText}). `AgentSpecSync` saves this and {@link ValidateFlowGraph}
|
|
219
|
+
* compiles it, so what is validated is what is saved.
|
|
220
|
+
*/
|
|
221
|
+
export function StepConfigurationText(step: Pick<AgentStep, 'StepType' | 'Configuration'>): string | null {
|
|
222
|
+
if (step.StepType === 'Decision') return DecisionConfigurationText(step.Configuration);
|
|
223
|
+
const raw = step.Configuration;
|
|
224
|
+
if (raw == null || raw === '') return null;
|
|
225
|
+
return typeof raw === 'string' ? raw : JSON.stringify(raw);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Every problem the runtime would refuse a Flow agent's steps and paths for, as messages that name
|
|
230
|
+
* the steps. Empty when the flow can run.
|
|
231
|
+
*
|
|
232
|
+
* The flow is compiled with `CompileFlowToTaskGraph` and the result validated with
|
|
233
|
+
* `ValidateTaskGraphSpec`, exactly as a Flow agent is before it runs, so a flow this passes is one
|
|
234
|
+
* the runtime runs and a flow it refuses is one the runtime refuses. That covers every Decision step
|
|
235
|
+
* the flow can reach: its configuration, its key, each condition that reads `decisions.<key>` (the
|
|
236
|
+
* key, the question, and whether that question's answer has the field read), and a fork on a Choice
|
|
237
|
+
* question that has no path for one of its options.
|
|
238
|
+
*
|
|
239
|
+
* Two checks come from outside the compiler, because it cannot see what they catch:
|
|
240
|
+
* - a path whose origin or destination names no step, which the compiler drops without a word and
|
|
241
|
+
* `AgentSpecSync` cannot save;
|
|
242
|
+
* - a key two Decision steps share when the flow cannot reach one of them. The compiler checks only
|
|
243
|
+
* the steps it compiles, but the in-run walker checks every step, since it can start at any.
|
|
244
|
+
*
|
|
245
|
+
* Names behind IDs are not resolved here: each ID stands in for its own name. The Architect checks
|
|
246
|
+
* a spec's actions, and a Decision step's prompt, separately. It also accepts a step whose sub-agent
|
|
247
|
+
* or inline prompt has no ID until the spec is saved, so a placeholder stands in for that ID.
|
|
248
|
+
*
|
|
249
|
+
* @param steps the flow's steps
|
|
250
|
+
* @param paths the flow's paths: `AgentSpec.Paths`, which is what `AgentSpecSync` saves
|
|
251
|
+
* @param workflowName the agent's name, which the compiled workflow carries
|
|
252
|
+
*/
|
|
253
|
+
export function ValidateFlowGraph(
|
|
254
|
+
steps: readonly AgentStep[],
|
|
255
|
+
paths: readonly AgentStepPath[],
|
|
256
|
+
workflowName: string | undefined,
|
|
257
|
+
): string[] {
|
|
258
|
+
const compilerSteps = steps.map(toCompilerStep);
|
|
259
|
+
const resolved = resolvePaths(paths, steps);
|
|
260
|
+
const compiled = CompileFlowToTaskGraph(compilerSteps, resolved.Paths, compilerOptions(workflowName));
|
|
261
|
+
return [
|
|
262
|
+
...resolved.Errors,
|
|
263
|
+
// Over every step, as the in-run walker checks them. The compiler's own check covers only the
|
|
264
|
+
// steps it compiles, so every step it flags is flagged here too.
|
|
265
|
+
...CollectDecisionStepKeys(compilerSteps).Errors.map(compileErrorMessage),
|
|
266
|
+
...compiled.Errors.filter((e) => e.Code !== 'DuplicateDecisionKey').map(compileErrorMessage),
|
|
267
|
+
...(compiled.Spec ? graphErrorMessages(compiled.Spec) : []),
|
|
268
|
+
];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** What stands in for an ID the Architect lets a spec leave empty until it is saved. */
|
|
272
|
+
const FILLED_ON_SAVE = '(set when the spec is saved)';
|
|
273
|
+
|
|
274
|
+
/** A step's identity in a spec: its ID, or its name for a step not yet saved. */
|
|
275
|
+
function stepRef(step: Pick<AgentStep, 'ID' | 'Name'>): string {
|
|
276
|
+
return step.ID || step.Name;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** A spec step as the compiler reads it: the row `AgentSpecSync` would save. */
|
|
280
|
+
function toCompilerStep(step: AgentStep): FlowCompilerStep {
|
|
281
|
+
return {
|
|
282
|
+
ID: stepRef(step),
|
|
283
|
+
Name: step.Name,
|
|
284
|
+
Description: step.Description ?? null,
|
|
285
|
+
StepType: step.StepType,
|
|
286
|
+
StartingStep: step.StartingStep === true,
|
|
287
|
+
// AgentSpecSync saves every step Active.
|
|
288
|
+
Status: 'Active',
|
|
289
|
+
ActionID: step.ActionID || null,
|
|
290
|
+
SubAgentID: step.SubAgentID || (runsSubAgent(step) ? FILLED_ON_SAVE : null),
|
|
291
|
+
PromptID: step.PromptID || (hasInlinePrompt(step) ? FILLED_ON_SAVE : null),
|
|
292
|
+
LoopBodyType: step.LoopBodyType,
|
|
293
|
+
Configuration: StepConfigurationText(step),
|
|
294
|
+
ActionInputMapping: mappingText(step.ActionInputMapping),
|
|
295
|
+
ActionOutputMapping: mappingText(step.ActionOutputMapping),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A Sub-Agent step, or a loop whose body is one. The Architect accepts either with no sub-agent ID yet. */
|
|
300
|
+
function runsSubAgent(step: AgentStep): boolean {
|
|
301
|
+
return step.StepType === 'Sub-Agent' || (IsLoopStep(step) && step.LoopBodyType === 'Sub-Agent');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** A Prompt step, or a loop whose body is one, with its prompt written inline as `PromptText`. */
|
|
305
|
+
function hasInlinePrompt(step: AgentStep): boolean {
|
|
306
|
+
const runsPrompt = step.StepType === 'Prompt' || (IsLoopStep(step) && step.LoopBodyType === 'Prompt');
|
|
307
|
+
return runsPrompt && !!step.PromptText?.trim();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** An action mapping as the JSON text the column stores. */
|
|
311
|
+
function mappingText(mapping: AgentStep['ActionInputMapping']): string | null {
|
|
312
|
+
if (mapping == null || mapping === '') return null;
|
|
313
|
+
return typeof mapping === 'string' ? mapping : JSON.stringify(mapping);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The spec's paths with both ends resolved to their steps, and a message for each path that names a
|
|
318
|
+
* step no spec step has. An end names a step by its ID, compared as a UUID, or by its name, exactly
|
|
319
|
+
* as written — the match `AgentSpecSync` makes when it saves the path.
|
|
320
|
+
*/
|
|
321
|
+
function resolvePaths(
|
|
322
|
+
paths: readonly AgentStepPath[],
|
|
323
|
+
steps: readonly AgentStep[],
|
|
324
|
+
): { Paths: FlowCompilerPath[]; Errors: string[] } {
|
|
325
|
+
const resolved: { Paths: FlowCompilerPath[]; Errors: string[] } = { Paths: [], Errors: [] };
|
|
326
|
+
paths.forEach((path, index) => {
|
|
327
|
+
const origin = findStep(steps, path.OriginStepID);
|
|
328
|
+
const destination = findStep(steps, path.DestinationStepID);
|
|
329
|
+
if (!origin || !destination) {
|
|
330
|
+
resolved.Errors.push(unknownEndMessage(path, !origin, !destination));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
resolved.Paths.push({
|
|
334
|
+
// A new path has no ID yet, and the compiler tells a fork's paths apart by ID.
|
|
335
|
+
ID: path.ID || `new-path-${index}`,
|
|
336
|
+
OriginStepID: stepRef(origin),
|
|
337
|
+
DestinationStepID: stepRef(destination),
|
|
338
|
+
Condition: path.Condition ?? null,
|
|
339
|
+
Priority: Number.isFinite(path.Priority) ? path.Priority : 0,
|
|
340
|
+
PathPoints: path.PathPoints ?? null,
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
return resolved;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The step a path end names, if any. */
|
|
347
|
+
function findStep(steps: readonly AgentStep[], ref: string | undefined): AgentStep | undefined {
|
|
348
|
+
if (!ref) return undefined;
|
|
349
|
+
return steps.find((s) => !!s.ID && UUIDsEqual(s.ID, ref)) ?? steps.find((s) => s.Name === ref);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function unknownEndMessage(path: AgentStepPath, originUnknown: boolean, destinationUnknown: boolean): string {
|
|
353
|
+
const ends = [originUnknown ? 'origin' : null, destinationUnknown ? 'destination' : null].filter((e) => e !== null);
|
|
354
|
+
return `❌ The path from "${path.OriginStepID}" to "${path.DestinationStepID}" names no step as its ${ends.join(' or ')}. `
|
|
355
|
+
+ 'Name each end by a step\'s exact Name, or by its ID for a step that already exists.';
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The compiler's settings: names are placeholders, and a fork is an exclusive choice, as when a Flow agent runs. */
|
|
359
|
+
function compilerOptions(workflowName: string | undefined): FlowCompilerOptions {
|
|
360
|
+
const placeholderName = (id: string): string => id;
|
|
361
|
+
return {
|
|
362
|
+
WorkflowName: workflowName?.trim() || 'Flow agent',
|
|
363
|
+
ResolveAgentName: placeholderName,
|
|
364
|
+
ResolveActionName: placeholderName,
|
|
365
|
+
ResolvePromptName: placeholderName,
|
|
366
|
+
TraversalMode: 'sequential',
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function compileErrorMessage(error: FlowCompileError): string {
|
|
371
|
+
return `❌ [${error.Code}] ${error.Message}`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* What `ValidateTaskGraphSpec` refuses in the compiled workflow. A compiled step is named by its
|
|
376
|
+
* spec step's ID where it has one, so each ID in a message becomes the step's name, as the Flow agent
|
|
377
|
+
* does when it reports these.
|
|
378
|
+
*/
|
|
379
|
+
function graphErrorMessages(spec: TaskGraphSpec): string[] {
|
|
380
|
+
return ValidateTaskGraphSpec(spec).Errors.map((e: TaskGraphValidationError) => `❌ [${e.Code}] ${namedBySteps(e.Message, spec)}`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function namedBySteps(message: string, spec: TaskGraphSpec): string {
|
|
384
|
+
return spec.tasks.reduce(
|
|
385
|
+
(text, task) => (task.tempId && task.name && task.tempId !== task.name ? text.split(task.tempId).join(task.name) : text),
|
|
386
|
+
message,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
@@ -49,7 +49,8 @@ export class WorkflowAgentWriter extends WorkflowAgentWriterBase {
|
|
|
49
49
|
context: { ContextUser: UserInfo; Provider: IMetadataProvider },
|
|
50
50
|
): Promise<string> {
|
|
51
51
|
const flowTypeID = await this.resolveFlowAgentTypeID(context);
|
|
52
|
-
const
|
|
52
|
+
const { Agents, Actions, Prompts } = await this.buildNameIndexes(context);
|
|
53
|
+
const byName = (index: Map<string, string>, name: string): string | null => index.get(name.trim().toLowerCase()) ?? null;
|
|
53
54
|
|
|
54
55
|
let counter = 0;
|
|
55
56
|
const result = ConvertTaskGraphToAgentSpec(spec.graph, {
|
|
@@ -57,7 +58,11 @@ export class WorkflowAgentWriter extends WorkflowAgentWriterBase {
|
|
|
57
58
|
// insert — these only have to correlate steps to paths inside this payload.
|
|
58
59
|
AgentID: `workflow-${Date.now()}-${counter}`,
|
|
59
60
|
NextID: () => `wf-node-${++counter}`,
|
|
60
|
-
ResolveAgentID: (name) =>
|
|
61
|
+
ResolveAgentID: (name) => byName(Agents, name),
|
|
62
|
+
// Without these, a saved Action step carries no action and a Decision step loses its
|
|
63
|
+
// named prompt: the spec addresses both by name, and a step stores the ID.
|
|
64
|
+
ResolveActionID: (name) => byName(Actions, name),
|
|
65
|
+
ResolvePromptID: (name) => byName(Prompts, name),
|
|
61
66
|
FlowAgentTypeID: flowTypeID,
|
|
62
67
|
Name: spec.name,
|
|
63
68
|
});
|
|
@@ -99,15 +104,35 @@ export class WorkflowAgentWriter extends WorkflowAgentWriterBase {
|
|
|
99
104
|
return id;
|
|
100
105
|
}
|
|
101
106
|
|
|
102
|
-
/**
|
|
103
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Name → ID for every agent, action and prompt, in one batch, lowercased so a spec's
|
|
109
|
+
* human-entered name still resolves.
|
|
110
|
+
*
|
|
111
|
+
* A failed load throws rather than leaving an index empty: an empty index resolves nothing, and
|
|
112
|
+
* every agent, action and prompt in the workflow would then be reported lost — or, for an action,
|
|
113
|
+
* saved pointing at nothing.
|
|
114
|
+
*/
|
|
115
|
+
private async buildNameIndexes(
|
|
104
116
|
context: { ContextUser: UserInfo; Provider: IMetadataProvider },
|
|
105
|
-
): Promise<Map<string, string
|
|
106
|
-
const
|
|
107
|
-
|
|
117
|
+
): Promise<{ Agents: Map<string, string>; Actions: Map<string, string>; Prompts: Map<string, string> }> {
|
|
118
|
+
const entityNames = ['MJ: AI Agents', 'MJ: Actions', 'MJ: AI Prompts'];
|
|
119
|
+
const results = await RunView.FromMetadataProvider(context.Provider).RunViews<{ ID: string; Name: string }>(
|
|
120
|
+
entityNames.map((EntityName) => ({
|
|
121
|
+
EntityName,
|
|
122
|
+
Fields: ['ID', 'Name'],
|
|
123
|
+
ResultType: 'simple' as const,
|
|
124
|
+
})),
|
|
108
125
|
context.ContextUser,
|
|
109
126
|
);
|
|
110
|
-
|
|
127
|
+
const failed = entityNames.filter((_, i) => !results[i]?.Success);
|
|
128
|
+
if (failed.length > 0) {
|
|
129
|
+
const reasons = failed.map((name) => `${name}: ${results[entityNames.indexOf(name)]?.ErrorMessage || 'no result'}`);
|
|
130
|
+
throw new Error(`Could not load the names a workflow's steps refer to (${reasons.join('; ')}).`);
|
|
131
|
+
}
|
|
132
|
+
const [agents, actions, prompts] = results;
|
|
133
|
+
const index = (rows: Array<{ ID: string; Name: string }>): Map<string, string> =>
|
|
134
|
+
new Map(rows.map((r) => [r.Name.trim().toLowerCase(), r.ID]));
|
|
135
|
+
return { Agents: index(agents.Results), Actions: index(actions.Results), Prompts: index(prompts.Results) };
|
|
111
136
|
}
|
|
112
137
|
}
|
|
113
138
|
|