@memberjunction/ai-agent-manager 6.2.0-edge.0 → 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.
@@ -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 { IsLoopStep, ValidateLoopStep } from '../flow-step-validation';
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 isLoopAgent = specWithType.TypeID?.includes('Loop');
165
- const isFlowAgent = specWithType.TypeID?.includes('Flow');
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
- // Validate that at least one step is marked as StartingStep
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 isLoopSubAgent = subAgent.SubAgent.TypeID.includes('Loop');
394
- const isFlowSubAgent = subAgent.SubAgent.TypeID.includes('Flow');
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 — specifically loops.
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
- * Pure and dependency-free so the rules are unit-testable without an agent run.
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 { AgentStep } from '@memberjunction/ai-core-plus';
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 agentIDsByName = await this.buildAgentNameIndex(context);
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) => agentIDsByName.get(name.trim().toLowerCase()) ?? null,
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
- /** Name → ID for every agent, lowercased so a spec's human-entered name still resolves. */
103
- private async buildAgentNameIndex(
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 result = await RunView.FromMetadataProvider(context.Provider).RunView<{ ID: string; Name: string }>(
107
- { EntityName: 'MJ: AI Agents', Fields: ['ID', 'Name'], ResultType: 'simple' },
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
- return new Map((result.Results ?? []).map((a) => [a.Name.trim().toLowerCase(), a.ID]));
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