@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,137 @@
1
+ /**
2
+ * @fileoverview Validation for the flow step types the Architect authors — specifically loops.
3
+ *
4
+ * **Why loops needed their own module.** The Architect's spec taught three step types
5
+ * (`Action`, `Prompt`, `Sub-Agent`) while `AIAgentStep.StepType` has accepted five for releases —
6
+ * `ForEach` and `While` were executable but unauthorable, so anything built through the Agent Manager
7
+ * could not repeat itself. Closing that gap means teaching the prompt *and* checking what comes back,
8
+ * because a loop is the one step type that saves perfectly well while doing nothing at all: a
9
+ * `ForEach` with no `collectionPath` iterates over nothing, and at runtime that reads as the agent
10
+ * declining to work rather than as a malformed step.
11
+ *
12
+ * Pure and dependency-free so the rules are unit-testable without an agent run.
13
+ *
14
+ * @module @memberjunction/ai-agent-manager
15
+ */
16
+ import type { AgentStep } from '@memberjunction/ai-core-plus';
17
+
18
+ /** The step types that wrap a body and repeat it. */
19
+ export type LoopStepType = Extract<AgentStep['StepType'], 'ForEach' | 'While'>;
20
+
21
+ /** True when this step repeats a body rather than being one. */
22
+ export function IsLoopStep(step: Pick<AgentStep, 'StepType'>): boolean {
23
+ return step.StepType === 'ForEach' || step.StepType === 'While';
24
+ }
25
+
26
+ /**
27
+ * Which field carries the body's id, per body type.
28
+ *
29
+ * The body's id lives in the SAME field a plain step of that type would use — a `ForEach` whose body
30
+ * is an Action still puts it in `ActionID`. Introducing a parallel `LoopBodyActionID` would have been
31
+ * a second place an action id can live, and the two would drift.
32
+ */
33
+ const BODY_ID_FIELD: Record<NonNullable<AgentStep['LoopBodyType']>, 'ActionID' | 'PromptID' | 'SubAgentID'> = {
34
+ Action: 'ActionID',
35
+ Prompt: 'PromptID',
36
+ 'Sub-Agent': 'SubAgentID',
37
+ };
38
+
39
+ /**
40
+ * Checks one `ForEach` / `While` step, returning every problem rather than the first.
41
+ *
42
+ * Returns messages rather than throwing, matching how the Architect reports the rest of its
43
+ * validation — one pass gives the model everything it has to fix, instead of a fix-and-retry loop
44
+ * that surfaces one error per round trip.
45
+ */
46
+ export function ValidateLoopStep(step: AgentStep, index: number): string[] {
47
+ const errors: string[] = [];
48
+ if (!IsLoopStep(step)) return errors;
49
+
50
+ const where = `${step.StepType} step "${step.Name}" (index ${index})`;
51
+
52
+ validateLoopBody(step, where, errors);
53
+ const config = parseLoopConfiguration(step, where, errors);
54
+ if (config) validateLoopBounds(step, config, where, errors);
55
+
56
+ return errors;
57
+ }
58
+
59
+ /** The loop must name what it repeats, and that thing must exist. */
60
+ function validateLoopBody(step: AgentStep, where: string, errors: string[]): void {
61
+ if (!step.LoopBodyType) {
62
+ errors.push(
63
+ `❌ ${where} must have LoopBodyType — one of "Action", "Prompt" or "Sub-Agent" — naming what runs on each pass`,
64
+ );
65
+ return;
66
+ }
67
+
68
+ const field = BODY_ID_FIELD[step.LoopBodyType];
69
+ if (step[field]) return;
70
+
71
+ // Two legitimate reasons the id is still empty:
72
+ // - a Sub-Agent body that AgentSpecSync will link by name once the sub-agent is created, exactly
73
+ // as it already does for a plain Sub-Agent step;
74
+ // - a Prompt body supplied inline as PromptText, which becomes an AIPrompt on save.
75
+ const linkedLater = step.LoopBodyType === 'Sub-Agent';
76
+ const inlinePrompt = step.LoopBodyType === 'Prompt' && !!step.PromptText?.trim();
77
+ if (linkedLater || inlinePrompt) return;
78
+
79
+ errors.push(`❌ ${where} has LoopBodyType "${step.LoopBodyType}" but no ${field} to run`);
80
+ }
81
+
82
+ /** ForEach needs something to iterate; While needs something to test; both need a name for the item. */
83
+ function validateLoopBounds(
84
+ step: AgentStep,
85
+ config: Record<string, unknown>,
86
+ where: string,
87
+ errors: string[],
88
+ ): void {
89
+ if (step.StepType === 'ForEach' && !config['collectionPath']) {
90
+ errors.push(`❌ ${where} must set Configuration.collectionPath — the payload path holding the items to iterate`);
91
+ }
92
+ if (step.StepType === 'While' && !config['condition']) {
93
+ errors.push(`❌ ${where} must set Configuration.condition — the expression checked before each pass`);
94
+ }
95
+ if (!config['itemVariable']) {
96
+ errors.push(`❌ ${where} must set Configuration.itemVariable — the name the body refers to the current item by`);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Reads a loop's `Configuration`, reporting rather than throwing. Null when unusable.
102
+ *
103
+ * Accepts an object as well as a JSON string — the same latitude the action mappings already get,
104
+ * because a model that has just been shown an object literal in the prompt will send one.
105
+ */
106
+ function parseLoopConfiguration(step: AgentStep, where: string, errors: string[]): Record<string, unknown> | null {
107
+ const raw: unknown = step.Configuration;
108
+ if (raw == null || raw === '') {
109
+ errors.push(`❌ ${where} must have a Configuration object describing the loop's bounds`);
110
+ return null;
111
+ }
112
+
113
+ if (typeof raw === 'object') {
114
+ if (Array.isArray(raw)) {
115
+ errors.push(`❌ ${where} has a Configuration that is an array rather than an object`);
116
+ return null;
117
+ }
118
+ return raw as Record<string, unknown>;
119
+ }
120
+
121
+ if (typeof raw !== 'string') {
122
+ errors.push(`❌ ${where} has a Configuration that is neither an object nor JSON text`);
123
+ return null;
124
+ }
125
+
126
+ try {
127
+ const parsed: unknown = JSON.parse(raw);
128
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
129
+ errors.push(`❌ ${where} has a Configuration that is not a JSON object`);
130
+ return null;
131
+ }
132
+ return parsed as Record<string, unknown>;
133
+ } catch (e) {
134
+ errors.push(`❌ ${where} has invalid Configuration JSON: ${e instanceof Error ? e.message : String(e)}`);
135
+ return null;
136
+ }
137
+ }
package/src/index.ts CHANGED
@@ -8,8 +8,10 @@
8
8
  export * from './old/agent-definition.interface';
9
9
 
10
10
  export * from './agent-spec-sync';
11
+ export * from './flow-step-validation';
11
12
 
12
13
  // Export agent implementations
13
14
  export * from './agents/architect-agent';
14
15
  export * from './agents/builder-agent';
15
16
  export * from './agents/planning-designer-agent';
17
+ export * from './workflow-agent-writer';
@@ -0,0 +1,117 @@
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 { LogError, RunView, UserInfo, IMetadataProvider } from '@memberjunction/core';
17
+ import { RegisterClass } from '@memberjunction/global';
18
+ import {
19
+ ConvertTaskGraphToAgentSpec,
20
+ FormatSaveAsWorkflowLosses,
21
+ type WorkflowSpec,
22
+ } from '@memberjunction/ai-core-plus';
23
+ import { AgentSpecSync } from './agent-spec-sync';
24
+
25
+ /** ClassFactory key under which hosts resolve the writer. */
26
+ export const WORKFLOW_AGENT_WRITER_KEY = 'WorkflowAgentWriter';
27
+
28
+ /** Base so hosts can resolve an implementation without importing this package directly. */
29
+ export abstract class WorkflowAgentWriterBase {
30
+ public abstract PersistFlowAgent(
31
+ spec: WorkflowSpec,
32
+ context: { ContextUser: UserInfo; Provider: IMetadataProvider },
33
+ ): Promise<string>;
34
+ }
35
+
36
+ @RegisterClass(WorkflowAgentWriterBase, WORKFLOW_AGENT_WRITER_KEY)
37
+ export class WorkflowAgentWriter extends WorkflowAgentWriterBase {
38
+ /**
39
+ * Converts the workflow's graph to a Flow `AgentSpec` and persists it.
40
+ *
41
+ * **Losses are logged, not swallowed.** The converter reports what it could not carry across —
42
+ * human steps, unresolvable agents, run-specific inputs. Dropping that silently would hand
43
+ * someone a workflow missing an approval they believed they had saved, and they would only find
44
+ * out by running it. The save still proceeds: a workflow that is 90% right and says so is more
45
+ * useful than a refusal, and the losses are surfaced to the caller by the operation above.
46
+ */
47
+ public async PersistFlowAgent(
48
+ spec: WorkflowSpec,
49
+ context: { ContextUser: UserInfo; Provider: IMetadataProvider },
50
+ ): Promise<string> {
51
+ const flowTypeID = await this.resolveFlowAgentTypeID(context);
52
+ const agentIDsByName = await this.buildAgentNameIndex(context);
53
+
54
+ let counter = 0;
55
+ const result = ConvertTaskGraphToAgentSpec(spec.graph, {
56
+ // Deterministic within one call, and unique because AgentSpecSync assigns real keys on
57
+ // insert — these only have to correlate steps to paths inside this payload.
58
+ AgentID: `workflow-${Date.now()}-${counter}`,
59
+ NextID: () => `wf-node-${++counter}`,
60
+ ResolveAgentID: (name) => agentIDsByName.get(name.trim().toLowerCase()) ?? null,
61
+ FlowAgentTypeID: flowTypeID,
62
+ Name: spec.name,
63
+ });
64
+
65
+ if (!result.Success || !result.Spec) {
66
+ throw new Error(result.ErrorMessage ?? 'The workflow graph could not be converted to an agent.');
67
+ }
68
+ if (result.Losses.length > 0) {
69
+ LogError(
70
+ `[WorkflowAgentWriter] "${spec.name}" saved with losses:\n${FormatSaveAsWorkflowLosses(result.Losses)}`,
71
+ );
72
+ }
73
+
74
+ result.Spec.Description = spec.description ?? result.Spec.Description;
75
+ // A Draft or Paused workflow persists as a Disabled agent, so nothing can invoke it before
76
+ // its author has turned it on — the schedule side makes the same choice for the same reason.
77
+ // 'Disabled' and not 'Inactive': the latter is not a value `AIAgent.Status` accepts, so this
78
+ // line used to make every non-Active save fail its CHECK constraint.
79
+ result.Spec.Status = spec.status === 'Active' ? 'Active' : 'Disabled';
80
+
81
+ const sync = AgentSpecSync.FromRawSpec(result.Spec, context.ContextUser, context.Provider);
82
+ // AgentSpecSyncResult uses camelCase — it predates the PascalCase-public convention and is
83
+ // consumed by MCP tools, so it is left alone rather than renamed under this change.
84
+ const saved = await sync.SaveToDatabase();
85
+ if (!saved?.success) {
86
+ throw new Error(`Could not save the workflow's agent "${spec.name}".`);
87
+ }
88
+ return saved.agentId;
89
+ }
90
+
91
+ /** Resolves the Flow agent type, so the persisted agent is a Flow rather than a Loop. */
92
+ private async resolveFlowAgentTypeID(context: { ContextUser: UserInfo; Provider: IMetadataProvider }): Promise<string> {
93
+ const result = await RunView.FromMetadataProvider(context.Provider).RunView<{ ID: string }>(
94
+ { EntityName: 'MJ: AI Agent Types', ExtraFilter: `Name='Flow'`, Fields: ['ID'], ResultType: 'simple' },
95
+ context.ContextUser,
96
+ );
97
+ const id = result.Results?.[0]?.ID;
98
+ if (!id) throw new Error("The 'Flow' agent type was not found — has the metadata seed been pushed?");
99
+ return id;
100
+ }
101
+
102
+ /** Name → ID for every agent, lowercased so a spec's human-entered name still resolves. */
103
+ private async buildAgentNameIndex(
104
+ 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' },
108
+ context.ContextUser,
109
+ );
110
+ return new Map((result.Results ?? []).map((a) => [a.Name.trim().toLowerCase(), a.ID]));
111
+ }
112
+ }
113
+
114
+ /** Prevents tree-shaking of the registered writer. */
115
+ export function LoadWorkflowAgentWriter(): void {
116
+ void WorkflowAgentWriter;
117
+ }