@memberjunction/ai-agent-manager 6.1.0-edge.0 → 6.1.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.
@@ -0,0 +1,102 @@
1
+ /** True when this step repeats a body rather than being one. */
2
+ export function IsLoopStep(step) {
3
+ return step.StepType === 'ForEach' || step.StepType === 'While';
4
+ }
5
+ /**
6
+ * Which field carries the body's id, per body type.
7
+ *
8
+ * The body's id lives in the SAME field a plain step of that type would use — a `ForEach` whose body
9
+ * is an Action still puts it in `ActionID`. Introducing a parallel `LoopBodyActionID` would have been
10
+ * a second place an action id can live, and the two would drift.
11
+ */
12
+ const BODY_ID_FIELD = {
13
+ Action: 'ActionID',
14
+ Prompt: 'PromptID',
15
+ 'Sub-Agent': 'SubAgentID',
16
+ };
17
+ /**
18
+ * Checks one `ForEach` / `While` step, returning every problem rather than the first.
19
+ *
20
+ * Returns messages rather than throwing, matching how the Architect reports the rest of its
21
+ * validation — one pass gives the model everything it has to fix, instead of a fix-and-retry loop
22
+ * that surfaces one error per round trip.
23
+ */
24
+ export function ValidateLoopStep(step, index) {
25
+ const errors = [];
26
+ if (!IsLoopStep(step))
27
+ return errors;
28
+ const where = `${step.StepType} step "${step.Name}" (index ${index})`;
29
+ validateLoopBody(step, where, errors);
30
+ const config = parseLoopConfiguration(step, where, errors);
31
+ if (config)
32
+ validateLoopBounds(step, config, where, errors);
33
+ return errors;
34
+ }
35
+ /** The loop must name what it repeats, and that thing must exist. */
36
+ function validateLoopBody(step, where, errors) {
37
+ if (!step.LoopBodyType) {
38
+ errors.push(`❌ ${where} must have LoopBodyType — one of "Action", "Prompt" or "Sub-Agent" — naming what runs on each pass`);
39
+ return;
40
+ }
41
+ const field = BODY_ID_FIELD[step.LoopBodyType];
42
+ if (step[field])
43
+ return;
44
+ // Two legitimate reasons the id is still empty:
45
+ // - a Sub-Agent body that AgentSpecSync will link by name once the sub-agent is created, exactly
46
+ // as it already does for a plain Sub-Agent step;
47
+ // - a Prompt body supplied inline as PromptText, which becomes an AIPrompt on save.
48
+ const linkedLater = step.LoopBodyType === 'Sub-Agent';
49
+ const inlinePrompt = step.LoopBodyType === 'Prompt' && !!step.PromptText?.trim();
50
+ if (linkedLater || inlinePrompt)
51
+ return;
52
+ errors.push(`❌ ${where} has LoopBodyType "${step.LoopBodyType}" but no ${field} to run`);
53
+ }
54
+ /** ForEach needs something to iterate; While needs something to test; both need a name for the item. */
55
+ function validateLoopBounds(step, config, where, errors) {
56
+ if (step.StepType === 'ForEach' && !config['collectionPath']) {
57
+ errors.push(`❌ ${where} must set Configuration.collectionPath — the payload path holding the items to iterate`);
58
+ }
59
+ if (step.StepType === 'While' && !config['condition']) {
60
+ errors.push(`❌ ${where} must set Configuration.condition — the expression checked before each pass`);
61
+ }
62
+ if (!config['itemVariable']) {
63
+ errors.push(`❌ ${where} must set Configuration.itemVariable — the name the body refers to the current item by`);
64
+ }
65
+ }
66
+ /**
67
+ * Reads a loop's `Configuration`, reporting rather than throwing. Null when unusable.
68
+ *
69
+ * Accepts an object as well as a JSON string — the same latitude the action mappings already get,
70
+ * because a model that has just been shown an object literal in the prompt will send one.
71
+ */
72
+ function parseLoopConfiguration(step, where, errors) {
73
+ const raw = step.Configuration;
74
+ if (raw == null || raw === '') {
75
+ errors.push(`❌ ${where} must have a Configuration object describing the loop's bounds`);
76
+ return null;
77
+ }
78
+ if (typeof raw === 'object') {
79
+ if (Array.isArray(raw)) {
80
+ errors.push(`❌ ${where} has a Configuration that is an array rather than an object`);
81
+ return null;
82
+ }
83
+ return raw;
84
+ }
85
+ if (typeof raw !== 'string') {
86
+ errors.push(`❌ ${where} has a Configuration that is neither an object nor JSON text`);
87
+ return null;
88
+ }
89
+ try {
90
+ const parsed = JSON.parse(raw);
91
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
92
+ errors.push(`❌ ${where} has a Configuration that is not a JSON object`);
93
+ return null;
94
+ }
95
+ return parsed;
96
+ }
97
+ catch (e) {
98
+ errors.push(`❌ ${where} has invalid Configuration JSON: ${e instanceof Error ? e.message : String(e)}`);
99
+ return null;
100
+ }
101
+ }
102
+ //# sourceMappingURL=flow-step-validation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flow-step-validation.js","sourceRoot":"","sources":["../src/flow-step-validation.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -5,7 +5,9 @@
5
5
  */
6
6
  export * from './old/agent-definition.interface.js';
7
7
  export * from './agent-spec-sync.js';
8
+ export * from './flow-step-validation.js';
8
9
  export * from './agents/architect-agent.js';
9
10
  export * from './agents/builder-agent.js';
10
11
  export * from './agents/planning-designer-agent.js';
12
+ export * from './workflow-agent-writer.js';
11
13
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,cAAc,kCAAkC,CAAC;AAEjD,cAAc,mBAAmB,CAAC;AAGlC,cAAc,0BAA0B,CAAC;AACzC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kCAAkC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,cAAc,kCAAkC,CAAC;AAEjD,cAAc,mBAAmB,CAAC;AAClC,cAAc,wBAAwB,CAAC;AAGvC,cAAc,0BAA0B,CAAC;AACzC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kCAAkC,CAAC;AACjD,cAAc,yBAAyB,CAAC"}
package/dist/index.js CHANGED
@@ -6,8 +6,10 @@
6
6
  // Export all interfaces
7
7
  export * from './old/agent-definition.interface.js';
8
8
  export * from './agent-spec-sync.js';
9
+ export * from './flow-step-validation.js';
9
10
  // Export agent implementations
10
11
  export * from './agents/architect-agent.js';
11
12
  export * from './agents/builder-agent.js';
12
13
  export * from './agents/planning-designer-agent.js';
14
+ export * from './workflow-agent-writer.js';
13
15
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,wBAAwB;AACxB,cAAc,kCAAkC,CAAC;AAEjD,cAAc,mBAAmB,CAAC;AAElC,+BAA+B;AAC/B,cAAc,0BAA0B,CAAC;AACzC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kCAAkC,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,wBAAwB;AACxB,cAAc,kCAAkC,CAAC;AAEjD,cAAc,mBAAmB,CAAC;AAClC,cAAc,wBAAwB,CAAC;AAEvC,+BAA+B;AAC/B,cAAc,0BAA0B,CAAC;AACzC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kCAAkC,CAAC;AACjD,cAAc,yBAAyB,CAAC"}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @fileoverview Persists a workflow's graph as a Flow agent, via `AgentSpecSync`.
3
+ *
4
+ * This is the host side of the seam `WorkflowSpecSync` declares. It lives here, in the agent-manager,
5
+ * because `AgentSpecSync` is the **one place that writes an agent** — it already owns atomic
6
+ * multi-entity writes and the mutation audit, and a second writer would be a second set of rules
7
+ * about what a valid agent record is.
8
+ *
9
+ * The conversion itself is `ConvertTaskGraphToAgentSpec`, shipped in Phase 4. That it is reusable
10
+ * here without modification is the practical payoff of the convergence: "save a runtime graph as a
11
+ * workflow" and "persist a workflow's graph" turn out to be the same operation, because after Phase 4
12
+ * they are the same model.
13
+ *
14
+ * @module @memberjunction/ai-agent-manager
15
+ */
16
+ import { UserInfo, IMetadataProvider } from '@memberjunction/core';
17
+ import { type WorkflowSpec } from '@memberjunction/ai-core-plus';
18
+ /** ClassFactory key under which hosts resolve the writer. */
19
+ export declare const WORKFLOW_AGENT_WRITER_KEY = "WorkflowAgentWriter";
20
+ /** Base so hosts can resolve an implementation without importing this package directly. */
21
+ export declare abstract class WorkflowAgentWriterBase {
22
+ abstract PersistFlowAgent(spec: WorkflowSpec, context: {
23
+ ContextUser: UserInfo;
24
+ Provider: IMetadataProvider;
25
+ }): Promise<string>;
26
+ }
27
+ export declare class WorkflowAgentWriter extends WorkflowAgentWriterBase {
28
+ /**
29
+ * Converts the workflow's graph to a Flow `AgentSpec` and persists it.
30
+ *
31
+ * **Losses are logged, not swallowed.** The converter reports what it could not carry across —
32
+ * human steps, unresolvable agents, run-specific inputs. Dropping that silently would hand
33
+ * someone a workflow missing an approval they believed they had saved, and they would only find
34
+ * out by running it. The save still proceeds: a workflow that is 90% right and says so is more
35
+ * useful than a refusal, and the losses are surfaced to the caller by the operation above.
36
+ */
37
+ PersistFlowAgent(spec: WorkflowSpec, context: {
38
+ ContextUser: UserInfo;
39
+ Provider: IMetadataProvider;
40
+ }): Promise<string>;
41
+ /** Resolves the Flow agent type, so the persisted agent is a Flow rather than a Loop. */
42
+ private resolveFlowAgentTypeID;
43
+ /** Name → ID for every agent, lowercased so a spec's human-entered name still resolves. */
44
+ private buildAgentNameIndex;
45
+ }
46
+ /** Prevents tree-shaking of the registered writer. */
47
+ export declare function LoadWorkflowAgentWriter(): void;
48
+ //# sourceMappingURL=workflow-agent-writer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workflow-agent-writer.d.ts","sourceRoot":"","sources":["../src/workflow-agent-writer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAqB,QAAQ,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAEtF,OAAO,EAGH,KAAK,YAAY,EACpB,MAAM,8BAA8B,CAAC;AAGtC,6DAA6D;AAC7D,eAAO,MAAM,yBAAyB,wBAAwB,CAAC;AAE/D,2FAA2F;AAC3F,8BAAsB,uBAAuB;aACzB,gBAAgB,CAC5B,IAAI,EAAE,YAAY,EAClB,OAAO,EAAE;QAAE,WAAW,EAAE,QAAQ,CAAC;QAAC,QAAQ,EAAE,iBAAiB,CAAA;KAAE,GAChE,OAAO,CAAC,MAAM,CAAC;CACrB;AAED,qBACa,mBAAoB,SAAQ,uBAAuB;IAC5D;;;;;;;;OAQG;IACU,gBAAgB,CACzB,IAAI,EAAE,YAAY,EAClB,OAAO,EAAE;QAAE,WAAW,EAAE,QAAQ,CAAC;QAAC,QAAQ,EAAE,iBAAiB,CAAA;KAAE,GAChE,OAAO,CAAC,MAAM,CAAC;IAyClB,yFAAyF;YAC3E,sBAAsB;IAUpC,2FAA2F;YAC7E,mBAAmB;CASpC;AAED,sDAAsD;AACtD,wBAAgB,uBAAuB,IAAI,IAAI,CAE9C"}
@@ -0,0 +1,97 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ /**
8
+ * @fileoverview Persists a workflow's graph as a Flow agent, via `AgentSpecSync`.
9
+ *
10
+ * This is the host side of the seam `WorkflowSpecSync` declares. It lives here, in the agent-manager,
11
+ * because `AgentSpecSync` is the **one place that writes an agent** — it already owns atomic
12
+ * multi-entity writes and the mutation audit, and a second writer would be a second set of rules
13
+ * about what a valid agent record is.
14
+ *
15
+ * The conversion itself is `ConvertTaskGraphToAgentSpec`, shipped in Phase 4. That it is reusable
16
+ * here without modification is the practical payoff of the convergence: "save a runtime graph as a
17
+ * workflow" and "persist a workflow's graph" turn out to be the same operation, because after Phase 4
18
+ * they are the same model.
19
+ *
20
+ * @module @memberjunction/ai-agent-manager
21
+ */
22
+ import { LogError, RunView } from '@memberjunction/core';
23
+ import { RegisterClass } from '@memberjunction/global';
24
+ import { ConvertTaskGraphToAgentSpec, FormatSaveAsWorkflowLosses, } from '@memberjunction/ai-core-plus';
25
+ import { AgentSpecSync } from './agent-spec-sync.js';
26
+ /** ClassFactory key under which hosts resolve the writer. */
27
+ export const WORKFLOW_AGENT_WRITER_KEY = 'WorkflowAgentWriter';
28
+ /** Base so hosts can resolve an implementation without importing this package directly. */
29
+ export class WorkflowAgentWriterBase {
30
+ }
31
+ let WorkflowAgentWriter = class WorkflowAgentWriter extends WorkflowAgentWriterBase {
32
+ /**
33
+ * Converts the workflow's graph to a Flow `AgentSpec` and persists it.
34
+ *
35
+ * **Losses are logged, not swallowed.** The converter reports what it could not carry across —
36
+ * human steps, unresolvable agents, run-specific inputs. Dropping that silently would hand
37
+ * someone a workflow missing an approval they believed they had saved, and they would only find
38
+ * out by running it. The save still proceeds: a workflow that is 90% right and says so is more
39
+ * useful than a refusal, and the losses are surfaced to the caller by the operation above.
40
+ */
41
+ async PersistFlowAgent(spec, context) {
42
+ const flowTypeID = await this.resolveFlowAgentTypeID(context);
43
+ const agentIDsByName = await this.buildAgentNameIndex(context);
44
+ let counter = 0;
45
+ const result = ConvertTaskGraphToAgentSpec(spec.graph, {
46
+ // Deterministic within one call, and unique because AgentSpecSync assigns real keys on
47
+ // insert — these only have to correlate steps to paths inside this payload.
48
+ AgentID: `workflow-${Date.now()}-${counter}`,
49
+ NextID: () => `wf-node-${++counter}`,
50
+ ResolveAgentID: (name) => agentIDsByName.get(name.trim().toLowerCase()) ?? null,
51
+ FlowAgentTypeID: flowTypeID,
52
+ Name: spec.name,
53
+ });
54
+ if (!result.Success || !result.Spec) {
55
+ throw new Error(result.ErrorMessage ?? 'The workflow graph could not be converted to an agent.');
56
+ }
57
+ if (result.Losses.length > 0) {
58
+ LogError(`[WorkflowAgentWriter] "${spec.name}" saved with losses:\n${FormatSaveAsWorkflowLosses(result.Losses)}`);
59
+ }
60
+ result.Spec.Description = spec.description ?? result.Spec.Description;
61
+ // A Draft or Paused workflow persists as a Disabled agent, so nothing can invoke it before
62
+ // its author has turned it on — the schedule side makes the same choice for the same reason.
63
+ // 'Disabled' and not 'Inactive': the latter is not a value `AIAgent.Status` accepts, so this
64
+ // line used to make every non-Active save fail its CHECK constraint.
65
+ result.Spec.Status = spec.status === 'Active' ? 'Active' : 'Disabled';
66
+ const sync = AgentSpecSync.FromRawSpec(result.Spec, context.ContextUser, context.Provider);
67
+ // AgentSpecSyncResult uses camelCase — it predates the PascalCase-public convention and is
68
+ // consumed by MCP tools, so it is left alone rather than renamed under this change.
69
+ const saved = await sync.SaveToDatabase();
70
+ if (!saved?.success) {
71
+ throw new Error(`Could not save the workflow's agent "${spec.name}".`);
72
+ }
73
+ return saved.agentId;
74
+ }
75
+ /** Resolves the Flow agent type, so the persisted agent is a Flow rather than a Loop. */
76
+ async resolveFlowAgentTypeID(context) {
77
+ const result = await RunView.FromMetadataProvider(context.Provider).RunView({ EntityName: 'MJ: AI Agent Types', ExtraFilter: `Name='Flow'`, Fields: ['ID'], ResultType: 'simple' }, context.ContextUser);
78
+ const id = result.Results?.[0]?.ID;
79
+ if (!id)
80
+ throw new Error("The 'Flow' agent type was not found — has the metadata seed been pushed?");
81
+ return id;
82
+ }
83
+ /** Name → ID for every agent, lowercased so a spec's human-entered name still resolves. */
84
+ async buildAgentNameIndex(context) {
85
+ const result = await RunView.FromMetadataProvider(context.Provider).RunView({ EntityName: 'MJ: AI Agents', Fields: ['ID', 'Name'], ResultType: 'simple' }, context.ContextUser);
86
+ return new Map((result.Results ?? []).map((a) => [a.Name.trim().toLowerCase(), a.ID]));
87
+ }
88
+ };
89
+ WorkflowAgentWriter = __decorate([
90
+ RegisterClass(WorkflowAgentWriterBase, WORKFLOW_AGENT_WRITER_KEY)
91
+ ], WorkflowAgentWriter);
92
+ export { WorkflowAgentWriter };
93
+ /** Prevents tree-shaking of the registered writer. */
94
+ export function LoadWorkflowAgentWriter() {
95
+ void WorkflowAgentWriter;
96
+ }
97
+ //# sourceMappingURL=workflow-agent-writer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workflow-agent-writer.js","sourceRoot":"","sources":["../src/workflow-agent-writer.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@memberjunction/ai-agent-manager",
3
3
  "type": "module",
4
- "version": "6.1.0-edge.0",
4
+ "version": "6.1.0-edge.2",
5
5
  "description": "Core interfaces and types for the MemberJunction Agent Manager system",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -14,14 +14,14 @@
14
14
  "author": "MemberJunction",
15
15
  "license": "ISC",
16
16
  "dependencies": {
17
- "@memberjunction/ai-agents": "6.1.0-edge.0",
18
- "@memberjunction/ai-core-plus": "6.1.0-edge.0",
19
- "@memberjunction/ai-engine-base": "6.1.0-edge.0",
20
- "@memberjunction/aiengine": "6.1.0-edge.0",
21
- "@memberjunction/core": "6.1.0-edge.0",
22
- "@memberjunction/core-entities": "6.1.0-edge.0",
23
- "@memberjunction/global": "6.1.0-edge.0",
24
- "@memberjunction/templates": "6.1.0-edge.0"
17
+ "@memberjunction/ai-agents": "6.1.0-edge.2",
18
+ "@memberjunction/ai-core-plus": "6.1.0-edge.2",
19
+ "@memberjunction/ai-engine-base": "6.1.0-edge.2",
20
+ "@memberjunction/aiengine": "6.1.0-edge.2",
21
+ "@memberjunction/core": "6.1.0-edge.2",
22
+ "@memberjunction/core-entities": "6.1.0-edge.2",
23
+ "@memberjunction/global": "6.1.0-edge.2",
24
+ "@memberjunction/templates": "6.1.0-edge.2"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^24.10.11",
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Tests for loop-step validation.
3
+ *
4
+ * The assertion that matters most here is the one about a loop that saves cleanly and then does
5
+ * nothing: a `ForEach` with no `collectionPath` is structurally valid SQL, passes every other check,
6
+ * and at runtime iterates zero times — which reads as the agent declining to do the work rather than
7
+ * as a malformed step. That is precisely the class of error the Architect has to catch before save.
8
+ */
9
+ import { describe, it, expect } from 'vitest';
10
+ import type { AgentStep } from '@memberjunction/ai-core-plus';
11
+ import { IsLoopStep, ValidateLoopStep } from '../flow-step-validation';
12
+
13
+ const step = (over: Partial<AgentStep> = {}): AgentStep => ({
14
+ ID: '',
15
+ Name: 'Score each lead',
16
+ StepType: 'ForEach',
17
+ StartingStep: false,
18
+ LoopBodyType: 'Action',
19
+ ActionID: 'AC71E1DA-1111-2222-3333-444455556666',
20
+ Configuration: JSON.stringify({ type: 'ForEach', collectionPath: 'leads', itemVariable: 'lead' }),
21
+ ...over,
22
+ });
23
+
24
+ describe('IsLoopStep', () => {
25
+ it('recognises exactly the two wrapping step types', () => {
26
+ expect(IsLoopStep({ StepType: 'ForEach' })).toBe(true);
27
+ expect(IsLoopStep({ StepType: 'While' })).toBe(true);
28
+ expect(IsLoopStep({ StepType: 'Action' })).toBe(false);
29
+ expect(IsLoopStep({ StepType: 'Prompt' })).toBe(false);
30
+ expect(IsLoopStep({ StepType: 'Sub-Agent' })).toBe(false);
31
+ });
32
+ });
33
+
34
+ describe('ValidateLoopStep — the happy paths', () => {
35
+ it('accepts a well-formed ForEach', () => {
36
+ expect(ValidateLoopStep(step(), 0)).toEqual([]);
37
+ });
38
+
39
+ it('accepts a well-formed While', () => {
40
+ const s = step({
41
+ StepType: 'While',
42
+ Name: 'Poll until done',
43
+ Configuration: JSON.stringify({ type: 'While', condition: "payload.status !== 'Complete'", itemVariable: 'attempt' }),
44
+ });
45
+ expect(ValidateLoopStep(s, 0)).toEqual([]);
46
+ });
47
+
48
+ it('accepts a Configuration supplied as an object, not only as JSON text', () => {
49
+ // A model that was just shown an object literal in the prompt will send one. Rejecting that
50
+ // would make the prompt and the validator disagree about the same example.
51
+ const s = step({ Configuration: { type: 'ForEach', collectionPath: 'leads', itemVariable: 'lead' } as unknown as string });
52
+ expect(ValidateLoopStep(s, 0)).toEqual([]);
53
+ });
54
+
55
+ it('ignores non-loop steps entirely', () => {
56
+ expect(ValidateLoopStep(step({ StepType: 'Action', LoopBodyType: undefined, Configuration: undefined }), 0)).toEqual([]);
57
+ });
58
+ });
59
+
60
+ describe('ValidateLoopStep — a loop that would silently do nothing', () => {
61
+ it('rejects a ForEach with nothing to iterate over', () => {
62
+ const s = step({ Configuration: JSON.stringify({ type: 'ForEach', itemVariable: 'lead' }) });
63
+ const errors = ValidateLoopStep(s, 2);
64
+ expect(errors).toHaveLength(1);
65
+ expect(errors[0]).toContain('collectionPath');
66
+ expect(errors[0]).toContain('index 2');
67
+ });
68
+
69
+ it('rejects a While with nothing to test', () => {
70
+ const s = step({ StepType: 'While', Configuration: JSON.stringify({ type: 'While', itemVariable: 'attempt' }) });
71
+ const errors = ValidateLoopStep(s, 0);
72
+ expect(errors).toHaveLength(1);
73
+ expect(errors[0]).toContain('condition');
74
+ });
75
+
76
+ it('rejects either kind with no name for the current item', () => {
77
+ const s = step({ Configuration: JSON.stringify({ type: 'ForEach', collectionPath: 'leads' }) });
78
+ expect(ValidateLoopStep(s, 0).join(' ')).toContain('itemVariable');
79
+ });
80
+
81
+ it('rejects a loop with no Configuration at all', () => {
82
+ const s = step({ Configuration: undefined });
83
+ const errors = ValidateLoopStep(s, 0);
84
+ expect(errors).toHaveLength(1);
85
+ expect(errors[0]).toContain('Configuration');
86
+ });
87
+
88
+ it('does not cascade bound errors when the Configuration could not be read', () => {
89
+ // One clear "this is not parseable" beats three downstream complaints about fields that
90
+ // could never have been found.
91
+ const s = step({ Configuration: '{not json' });
92
+ const errors = ValidateLoopStep(s, 0);
93
+ expect(errors).toHaveLength(1);
94
+ expect(errors[0]).toContain('invalid Configuration JSON');
95
+ });
96
+
97
+ it('rejects a Configuration that parses to something other than an object', () => {
98
+ expect(ValidateLoopStep(step({ Configuration: '"just a string"' }), 0)[0]).toContain('not a JSON object');
99
+ expect(ValidateLoopStep(step({ Configuration: '[1,2,3]' }), 0)[0]).toContain('not a JSON object');
100
+ expect(
101
+ ValidateLoopStep(step({ Configuration: [1, 2, 3] as unknown as string }), 0)[0],
102
+ ).toContain('array rather than an object');
103
+ });
104
+ });
105
+
106
+ describe('ValidateLoopStep — the body', () => {
107
+ it('rejects a loop that does not say what it repeats', () => {
108
+ const errors = ValidateLoopStep(step({ LoopBodyType: undefined }), 0);
109
+ expect(errors).toHaveLength(1);
110
+ expect(errors[0]).toContain('LoopBodyType');
111
+ });
112
+
113
+ it('looks for the body id in the field that body type would normally use', () => {
114
+ // No parallel LoopBodyActionID: an action id lives in ActionID whether or not a loop wraps it.
115
+ const errors = ValidateLoopStep(step({ ActionID: undefined }), 0);
116
+ expect(errors).toHaveLength(1);
117
+ expect(errors[0]).toContain('ActionID');
118
+ });
119
+
120
+ it('rejects a Prompt body with neither a prompt to use nor one to create', () => {
121
+ const s = step({ LoopBodyType: 'Prompt', ActionID: undefined });
122
+ expect(ValidateLoopStep(s, 0)[0]).toContain('PromptID');
123
+ });
124
+
125
+ it('accepts a Prompt body supplied inline, which becomes a prompt on save', () => {
126
+ const s = step({ LoopBodyType: 'Prompt', ActionID: undefined, PromptText: 'Summarise this lead.' });
127
+ expect(ValidateLoopStep(s, 0)).toEqual([]);
128
+ });
129
+
130
+ it('treats whitespace-only inline prompt text as absent', () => {
131
+ const s = step({ LoopBodyType: 'Prompt', ActionID: undefined, PromptText: ' ' });
132
+ expect(ValidateLoopStep(s, 0)[0]).toContain('PromptID');
133
+ });
134
+
135
+ it('accepts a Sub-Agent body with no id, because it is linked by name after the sub-agent is created', () => {
136
+ // Same latitude a plain Sub-Agent step already gets — requiring an id here would make it
137
+ // impossible to author a loop over a sub-agent being created in the same spec.
138
+ const s = step({ LoopBodyType: 'Sub-Agent', ActionID: undefined, SubAgentID: '' });
139
+ expect(ValidateLoopStep(s, 0)).toEqual([]);
140
+ });
141
+ });
142
+
143
+ describe('ValidateLoopStep — reporting', () => {
144
+ it('returns every problem at once rather than only the first', () => {
145
+ // One pass gives the model everything it must fix. Reporting one error per round trip turns
146
+ // a single correction into a retry loop.
147
+ const s = step({ LoopBodyType: undefined, ActionID: undefined, Configuration: JSON.stringify({}) });
148
+ const errors = ValidateLoopStep(s, 0);
149
+ expect(errors.length).toBeGreaterThanOrEqual(3);
150
+ expect(errors.join(' ')).toContain('LoopBodyType');
151
+ expect(errors.join(' ')).toContain('collectionPath');
152
+ expect(errors.join(' ')).toContain('itemVariable');
153
+ });
154
+
155
+ it('names the step and its index so the model can find it', () => {
156
+ const errors = ValidateLoopStep(step({ Name: 'Retry the import', Configuration: undefined }), 7);
157
+ expect(errors[0]).toContain('"Retry the import"');
158
+ expect(errors[0]).toContain('index 7');
159
+ expect(errors[0]).toContain('ForEach');
160
+ });
161
+ });
@@ -15,6 +15,7 @@ import {
15
15
  import {
16
16
  AgentSpec,
17
17
  AgentActionSpec,
18
+ AgentStep,
18
19
  SubAgentSpec
19
20
  } from '@memberjunction/ai-core-plus';
20
21
  import { UUIDsEqual } from '@memberjunction/global';
@@ -553,7 +554,7 @@ export class AgentSpecSync {
553
554
  Name: agent.Name || '',
554
555
  Description: agent.Description || undefined,
555
556
  TypeID: agent.TypeID || undefined,
556
- Status: (agent.Status as 'Active' | 'Inactive' | 'Pending') || undefined,
557
+ Status: agent.Status || undefined,
557
558
  IconClass: agent.IconClass || undefined,
558
559
  LogoURL: agent.LogoURL || undefined,
559
560
  ParentID: agent.ParentID || undefined,
@@ -718,7 +719,7 @@ export class AgentSpecSync {
718
719
  * @param step - The agent step entity
719
720
  * @returns Mapped step spec
720
721
  */
721
- private mapStepEntityToSpec(step: MJAIAgentStepEntity): any {
722
+ private mapStepEntityToSpec(step: MJAIAgentStepEntity): AgentStep {
722
723
  return {
723
724
  ID: step.ID,
724
725
  Name: step.Name,
@@ -728,8 +729,13 @@ export class AgentSpecSync {
728
729
  ActionID: step.ActionID || undefined,
729
730
  SubAgentID: step.SubAgentID || undefined,
730
731
  PromptID: step.PromptID || undefined,
731
- ActionInputMapping: this.parseJsonField<any>(step.ActionInputMapping),
732
- ActionOutputMapping: this.parseJsonField<any>(step.ActionOutputMapping)
732
+ // Loop steps round-trip their body type and bounds; without these, reading a flow agent
733
+ // that contains a loop and writing it back would silently turn the loop into an
734
+ // unconfigured step that iterates over nothing.
735
+ LoopBodyType: step.LoopBodyType || undefined,
736
+ Configuration: step.Configuration || undefined,
737
+ ActionInputMapping: this.parseJsonField<Record<string, unknown>>(step.ActionInputMapping),
738
+ ActionOutputMapping: this.parseJsonField<Record<string, unknown>>(step.ActionOutputMapping)
733
739
  };
734
740
  }
735
741
 
@@ -882,12 +888,9 @@ export class AgentSpecSync {
882
888
  agentEntity.TypeID = (this.spec as any).TypeID;
883
889
  }
884
890
 
885
- // Handle Status - defaults to Active if not specified
886
- if ((this.spec as any).Status) {
887
- agentEntity.Status = (this.spec as any).Status;
888
- } else {
889
- agentEntity.Status = 'Active';
890
- }
891
+ // Handle Status - defaults to Active if not specified. Typed rather than cast: the cast is
892
+ // what let `'Inactive'` — a value the CHECK constraint rejects — reach the entity unchallenged.
893
+ agentEntity.Status = this.spec.Status ?? 'Active';
891
894
 
892
895
  // Serialize JSON fields
893
896
  agentEntity.PayloadDownstreamPaths = JSON.stringify(
@@ -1362,6 +1365,11 @@ export class AgentSpecSync {
1362
1365
  stepEntity.ActionID = stepSpec.ActionID || null;
1363
1366
  stepEntity.SubAgentID = stepSpec.SubAgentID || null;
1364
1367
  stepEntity.Status = 'Active'; // Default to Active
1368
+ // Loop steps: the body type says which of Action/Prompt/Sub-Agent runs each pass, and
1369
+ // Configuration carries the bounds. Written for every step because a step that STOPS
1370
+ // being a loop must have these cleared, not left behind from its previous shape.
1371
+ stepEntity.LoopBodyType = stepSpec.LoopBodyType || null;
1372
+ stepEntity.Configuration = stepSpec.Configuration || null;
1365
1373
 
1366
1374
  // Handle inline prompt creation for Prompt-type steps
1367
1375
  // If StepType is Prompt and PromptID is empty, create a new AIPrompt record
@@ -3,6 +3,7 @@ import { ExecuteAgentParams, BaseAgentNextStep, AgentSpec, MJAIAgentRunEntityExt
3
3
  import { MJActionEntity } from "@memberjunction/core-entities";
4
4
  import { RunView } from '@memberjunction/core';
5
5
  import { RegisterClass, NormalizeUUID } from '@memberjunction/global';
6
+ import { IsLoopStep, ValidateLoopStep } from '../flow-step-validation';
6
7
 
7
8
  /**
8
9
  * Architect Agent - Transforms technical design into validated AgentSpec JSON
@@ -153,8 +154,10 @@ export class AgentArchitectAgent extends BaseAgent {
153
154
  specWithType.Status = 'Active';
154
155
  corrected = true;
155
156
  console.log('✓ Auto-corrected: Added default Status = "Active"');
156
- } else if (!['Active', 'Inactive', 'Pending'].includes(specWithType.Status)) {
157
- errors.push(`❌ Status must be "Active", "Inactive", or "Pending", got: "${specWithType.Status}"`);
157
+ } else if (!['Active', 'Disabled', 'Pending'].includes(specWithType.Status)) {
158
+ // 'Disabled', not 'Inactive' — this validator used to accept a value the AIAgent CHECK
159
+ // constraint rejects, so a spec it passed could still fail at save with no explanation.
160
+ errors.push(`❌ Status must be "Active", "Disabled", or "Pending", got: "${specWithType.Status}"`);
158
161
  }
159
162
 
160
163
  // 5. Validate agent type-specific requirements
@@ -247,6 +250,17 @@ export class AgentArchitectAgent extends BaseAgent {
247
250
  // SubAgentID can be empty "" for new sub-agents (will be linked by name matching)
248
251
  // No validation needed here - linking happens in AgentSpecSync
249
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
+ }
250
264
  }
251
265
  }
252
266
  }