@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.
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Round-trip coverage for Decision steps in AgentSpecSync.
3
+ *
4
+ * Verifies that a Decision step's PromptID (whether custom or omitted) and Configuration
5
+ * (whether supplied as JSON string or parsed object) survive save and load round trips.
6
+ */
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+
9
+ const { capturedStepEntities, mockGetEntityObject, mockRunView, mockRunViews } = vi.hoisted(() => {
10
+ const capturedStepEntities: Record<string, unknown>[] = [];
11
+
12
+ const makeEntity = (entityName: string) => {
13
+ const entity: Record<string, unknown> = {
14
+ Load: vi.fn().mockResolvedValue(true),
15
+ Save: vi.fn().mockResolvedValue(true),
16
+ Delete: vi.fn().mockResolvedValue(true),
17
+ NewRecord: vi.fn(),
18
+ Validate: vi.fn().mockReturnValue({ Success: true, Errors: [] }),
19
+ ID: 'saved-id-1',
20
+ Name: 'Test Agent',
21
+ TypeID: '4F6A189B-C068-4736-9F23-3FF540B40FDD',
22
+ Status: 'Active',
23
+ StartingPayloadValidationMode: 'Fail',
24
+ };
25
+
26
+ if (entityName === 'MJ: AI Agent Steps') {
27
+ capturedStepEntities.push(entity);
28
+ }
29
+
30
+ return entity;
31
+ };
32
+
33
+ const empty = { Success: true, Results: [], RowCount: 0 };
34
+ return {
35
+ capturedStepEntities,
36
+ mockGetEntityObject: vi.fn((name: string) => Promise.resolve(makeEntity(name))),
37
+ mockRunView: vi.fn(() => Promise.resolve(empty)),
38
+ mockRunViews: vi.fn(() => Promise.resolve([empty, empty, empty, empty, empty])),
39
+ };
40
+ });
41
+
42
+ vi.mock('@memberjunction/core', () => {
43
+ const rvInstance = { RunView: mockRunView, RunViews: mockRunViews };
44
+ return {
45
+ Metadata: Object.assign(
46
+ vi.fn().mockImplementation(() => ({ GetEntityObject: mockGetEntityObject })),
47
+ { Provider: { GetEntityObject: mockGetEntityObject } }
48
+ ),
49
+ RunView: Object.assign(
50
+ vi.fn().mockImplementation(() => rvInstance),
51
+ { FromMetadataProvider: vi.fn(() => rvInstance) }
52
+ ),
53
+ UserInfo: vi.fn(),
54
+ LogError: vi.fn(),
55
+ };
56
+ });
57
+
58
+ vi.mock('@memberjunction/core-entities', () => ({
59
+ MJAIAgentEntity: vi.fn(),
60
+ MJAIAgentActionEntity: vi.fn(),
61
+ MJAIAgentRelationshipEntity: vi.fn(),
62
+ MJAIAgentStepEntity: vi.fn(),
63
+ MJAIAgentStepPathEntity: vi.fn(),
64
+ }));
65
+
66
+ vi.mock('@memberjunction/ai-core-plus', () => ({
67
+ AgentSpec: vi.fn(),
68
+ AgentActionSpec: vi.fn(),
69
+ SubAgentSpec: vi.fn(),
70
+ }));
71
+
72
+ import { UserInfo } from '@memberjunction/core';
73
+ import { AgentSpecSync } from '../agent-spec-sync';
74
+
75
+ describe('AgentSpecSync Decision step round-trip', () => {
76
+ // UserInfo is mocked above, so this is the mock's instance.
77
+ const mockUser = new UserInfo();
78
+
79
+ beforeEach(() => {
80
+ vi.clearAllMocks();
81
+ capturedStepEntities.length = 0;
82
+ });
83
+
84
+ it('persists a Decision step with custom PromptID and object Configuration', async () => {
85
+ const decisionConfig = {
86
+ key: 'triage',
87
+ questions: {
88
+ category: {
89
+ instructions: 'What category?',
90
+ kind: 'Choice',
91
+ options: [
92
+ { value: 'billing', description: 'Billing' },
93
+ { value: 'tech', description: 'Technical' },
94
+ ],
95
+ },
96
+ },
97
+ };
98
+
99
+ const sync = new AgentSpecSync(
100
+ {
101
+ ID: 'agent-1',
102
+ Name: 'Decision Flow Agent',
103
+ TypeID: '4F6A189B-C068-4736-9F23-3FF540B40FDD',
104
+ Steps: [
105
+ {
106
+ ID: 'step-1',
107
+ Name: 'Triage Issue',
108
+ StepType: 'Decision',
109
+ StartingStep: true,
110
+ PromptID: 'custom-prompt-uuid-999',
111
+ Configuration: decisionConfig,
112
+ },
113
+ ],
114
+ },
115
+ mockUser
116
+ );
117
+
118
+ const result = await sync.SaveToDatabase();
119
+ expect(result.success).toBe(true);
120
+
121
+ const savedStep = capturedStepEntities.find(s => s.Name === 'Triage Issue');
122
+ expect(savedStep).toBeDefined();
123
+ expect(savedStep?.StepType).toBe('Decision');
124
+ expect(savedStep?.PromptID).toBe('custom-prompt-uuid-999');
125
+ expect(savedStep?.Configuration).toBe(JSON.stringify(decisionConfig));
126
+ });
127
+
128
+ it('persists a Decision step with null PromptID when none is provided', async () => {
129
+ const sync = new AgentSpecSync(
130
+ {
131
+ ID: 'agent-2',
132
+ Name: 'Decision Agent Without Prompt',
133
+ TypeID: '4F6A189B-C068-4736-9F23-3FF540B40FDD',
134
+ Steps: [
135
+ {
136
+ ID: 'step-2',
137
+ Name: 'Default Decision Step',
138
+ StepType: 'Decision',
139
+ StartingStep: true,
140
+ Configuration: JSON.stringify({ key: 'route', questions: {} }),
141
+ },
142
+ ],
143
+ },
144
+ mockUser
145
+ );
146
+
147
+ const result = await sync.SaveToDatabase();
148
+ expect(result.success).toBe(true);
149
+
150
+ const savedStep = capturedStepEntities.find(s => s.Name === 'Default Decision Step');
151
+ expect(savedStep).toBeDefined();
152
+ expect(savedStep?.PromptID).toBeNull();
153
+ expect(savedStep?.Configuration).toBe(JSON.stringify({ key: 'route', questions: {} }));
154
+ });
155
+
156
+ it('loads a Decision step from database preserving PromptID and Configuration', async () => {
157
+ const decisionConfigJson = JSON.stringify({
158
+ key: 'triage',
159
+ questions: {
160
+ priority: {
161
+ text: 'What priority?',
162
+ kind: 'Rating',
163
+ minRating: 1,
164
+ maxRating: 5,
165
+ },
166
+ },
167
+ });
168
+
169
+ const mockAgentEntity = {
170
+ Load: vi.fn().mockResolvedValue(true),
171
+ ID: 'agent-3',
172
+ Name: 'Loaded Decision Agent',
173
+ TypeID: '4F6A189B-C068-4736-9F23-3FF540B40FDD',
174
+ Status: 'Active',
175
+ StartingPayloadValidationMode: 'Fail',
176
+ };
177
+
178
+ const mockStepEntity = {
179
+ ID: 'step-3',
180
+ AgentID: 'agent-3',
181
+ Name: 'Rate Priority',
182
+ StepType: 'Decision',
183
+ StartingStep: true,
184
+ PromptID: 'decision-prompt-777',
185
+ Configuration: decisionConfigJson,
186
+ Status: 'Active',
187
+ };
188
+
189
+ mockGetEntityObject.mockImplementation((name: string) => {
190
+ if (name === 'MJ: AI Agents') {
191
+ return Promise.resolve(mockAgentEntity);
192
+ }
193
+ return Promise.resolve({
194
+ Load: vi.fn().mockResolvedValue(true),
195
+ Save: vi.fn().mockResolvedValue(true),
196
+ Validate: vi.fn().mockReturnValue({ Success: true, Errors: [] }),
197
+ });
198
+ });
199
+
200
+ // Set up RunViews return: [actions, childAgents, relatedAgents, prompts, steps]
201
+ const empty = { Success: true, Results: [], RowCount: 0 };
202
+ const stepsResult = { Success: true, Results: [mockStepEntity], RowCount: 1 };
203
+ mockRunViews.mockResolvedValueOnce([empty, empty, empty, empty, stepsResult]);
204
+ // mockRunView for paths
205
+ mockRunView.mockResolvedValueOnce(empty);
206
+
207
+ const loadedSync = await AgentSpecSync.LoadFromDatabase('agent-3', mockUser);
208
+
209
+ expect(loadedSync.Spec.Steps).toBeDefined();
210
+ expect(loadedSync.Spec.Steps).toHaveLength(1);
211
+
212
+ const loadedStep = loadedSync.Spec.Steps![0];
213
+ expect(loadedStep.Name).toBe('Rate Priority');
214
+ expect(loadedStep.StepType).toBe('Decision');
215
+ expect(loadedStep.PromptID).toBe('decision-prompt-777');
216
+ expect(loadedStep.Configuration).toBe(decisionConfigJson);
217
+ });
218
+ });
@@ -0,0 +1,218 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import type { AgentSpec, AgentStep, AgentStepPath, ExecuteAgentParams, MJAIPromptEntityExtended } from '@memberjunction/ai-core-plus';
5
+ import type { MJAIAgentTypeEntity, MJAIModelTypeEntity } from '@memberjunction/core-entities';
6
+ import { AIEngine } from '@memberjunction/aiengine';
7
+ import { AgentArchitectAgent } from '../agents/architect-agent';
8
+ import { ValidateFlowGraph } from '../flow-step-validation';
9
+
10
+ /** The seeded agent types the Architect's template names by ID. */
11
+ const FLOW_TYPE_ID = '4F6A189B-C068-4736-9F23-3FF540B40FDD';
12
+ const LOOP_TYPE_ID = 'F7926101-5099-4FA5-836A-479D9707C818';
13
+
14
+ const LLM_MODEL_TYPE_ID = 'E8A5CCEC-6A37-EF11-86D4-000D3A4E707E';
15
+ const DECISION_MODEL_TYPE_ID = 'D3C15E6A-0000-4000-8000-00000000DEC1';
16
+
17
+ const DECISION_PROMPT_ID = 'D3C15E6A-0000-4000-8000-000000000001';
18
+ const CHAT_PROMPT_ID = 'D3C15E6A-0000-4000-8000-000000000002';
19
+
20
+ function agentType(id: string, name: string): MJAIAgentTypeEntity {
21
+ return { ID: id, Name: name } as MJAIAgentTypeEntity;
22
+ }
23
+
24
+ function modelType(id: string, name: string): MJAIModelTypeEntity {
25
+ return { ID: id, Name: name } as MJAIModelTypeEntity;
26
+ }
27
+
28
+ function prompt(id: string, name: string, modelTypeID: string | null): MJAIPromptEntityExtended {
29
+ return { ID: id, Name: name, AIModelTypeID: modelTypeID } as MJAIPromptEntityExtended;
30
+ }
31
+
32
+ class TestArchitectAgent extends AgentArchitectAgent {
33
+ public async testValidateAgentSpec(
34
+ spec: AgentSpec,
35
+ params: ExecuteAgentParams = {} as ExecuteAgentParams
36
+ ): Promise<{ errors: string[]; correctedSpec?: AgentSpec }> {
37
+ return this.validateAgentSpec(spec, params);
38
+ }
39
+
40
+ protected override async validateActions(): Promise<{ errors: string[] }> {
41
+ return { errors: [] };
42
+ }
43
+ }
44
+
45
+ /** The Architect's example outputs, keyed by example. */
46
+ type ArchitectExamples = Record<string, { output: AgentSpec }>;
47
+
48
+ const examplePath = path.resolve(
49
+ __dirname,
50
+ '../../../../../../metadata/prompts/output/agent-manager/architect-agent.example.json'
51
+ );
52
+
53
+ /** A fresh copy of example_4's spec, so a test can change it. */
54
+ function example4(): AgentSpec {
55
+ const examples: ArchitectExamples = JSON.parse(fs.readFileSync(examplePath, 'utf8'));
56
+ return examples.example_4_flow_agent_with_decision_step.output;
57
+ }
58
+
59
+ async function validateSpec(spec: AgentSpec): Promise<string[]> {
60
+ return (await new TestArchitectAgent().testValidateAgentSpec(spec)).errors;
61
+ }
62
+
63
+ describe('Architect Agent Example Output and Decision Step Validation', () => {
64
+ beforeEach(() => {
65
+ vi.restoreAllMocks();
66
+ // validateAgentSpec resolves a spec's TypeID (a GUID) to its type's name through AIEngine, and
67
+ // a Decision step's PromptID to its prompt and model type.
68
+ vi.spyOn(AIEngine.Instance, 'AgentTypes', 'get').mockReturnValue([agentType(FLOW_TYPE_ID, 'Flow'), agentType(LOOP_TYPE_ID, 'Loop')]);
69
+ vi.spyOn(AIEngine.Instance, 'ModelTypes', 'get').mockReturnValue([modelType(LLM_MODEL_TYPE_ID, 'LLM'), modelType(DECISION_MODEL_TYPE_ID, 'Decision')]);
70
+ vi.spyOn(AIEngine.Instance, 'Prompts', 'get').mockReturnValue([
71
+ prompt(DECISION_PROMPT_ID, 'Fast Triage Decision', DECISION_MODEL_TYPE_ID),
72
+ prompt(CHAT_PROMPT_ID, 'Summarize Ticket', LLM_MODEL_TYPE_ID),
73
+ ]);
74
+ });
75
+
76
+ it('parses architect-agent.example.json as valid JSON', () => {
77
+ const rawContent = fs.readFileSync(examplePath, 'utf8');
78
+ expect(() => JSON.parse(rawContent)).not.toThrow();
79
+ });
80
+
81
+ it('compiles and validates example_4_flow_agent_with_decision_step as the runtime does', () => {
82
+ const spec = example4();
83
+ expect(spec.Steps?.find(s => s.StepType === 'Decision')?.Name).toBe('Triage Issue');
84
+ expect(ValidateFlowGraph(spec.Steps ?? [], spec.Paths ?? [], spec.Name)).toEqual([]);
85
+ });
86
+
87
+ it('passes full AgentArchitectAgent.validateAgentSpec for example_4', async () => {
88
+ expect(await validateSpec(example4())).toEqual([]);
89
+ });
90
+
91
+ it('catches an incomplete Choice fork inside validateAgentSpec', async () => {
92
+ const spec = example4();
93
+ spec.Paths = spec.Paths?.filter(p => !p.Condition?.includes('general'));
94
+
95
+ const errors = (await validateSpec(spec)).join(' ');
96
+ expect(errors).toContain('[IncompleteFork]');
97
+ expect(errors).toContain('"general"');
98
+ });
99
+
100
+ it('accepts a single conditional path from a Decision step, which the runtime runs', async () => {
101
+ const spec = example4();
102
+ spec.Steps = spec.Steps?.filter(s => s.Name === 'Triage Issue' || s.Name === 'Handle Billing');
103
+ spec.Paths = spec.Paths?.filter(p => p.DestinationStepID === 'Handle Billing');
104
+
105
+ expect(await validateSpec(spec)).toEqual([]);
106
+ });
107
+
108
+ // The three specs a review probed: the Architect passed each one, and the runtime refused it.
109
+ it.each([
110
+ {
111
+ what: 'a path from a later step reading an unknown Decision key',
112
+ code: '[UnknownDecisionKey]',
113
+ change: (spec: AgentSpec): void => {
114
+ const summarize: AgentStep = { ID: '', Name: 'Summarize', StepType: 'Prompt', StartingStep: false, PromptText: 'Summarize the issue.', PromptName: 'Summarize Issue' };
115
+ spec.Steps = [...(spec.Steps ?? []), summarize];
116
+ const fromTriage: AgentStepPath = { ID: '', OriginStepID: 'Handle General', DestinationStepID: 'Summarize', Priority: 0 };
117
+ const fromSummarize: AgentStepPath = { ID: '', OriginStepID: 'Summarize', DestinationStepID: 'Handle Billing', Condition: "decisions.triag.category.value === 'billing'", Priority: 0 };
118
+ spec.Paths = [...(spec.Paths ?? []), fromTriage, fromSummarize];
119
+ },
120
+ },
121
+ {
122
+ what: 'a Likelihood read through .value',
123
+ code: '[InvalidCondition]',
124
+ change: (spec: AgentSpec): void => addUrgentGate(spec, 'decisions.triage.urgent.value >= 0.8'),
125
+ },
126
+ {
127
+ what: 'a question the Decision step does not ask',
128
+ code: '[InvalidCondition]',
129
+ change: (spec: AgentSpec): void => addUrgentGate(spec, 'decisions.triage.urgency.probability >= 0.8'),
130
+ },
131
+ ])('refuses $what inside validateAgentSpec', async ({ code, change }) => {
132
+ const spec = example4();
133
+ change(spec);
134
+ expect((await validateSpec(spec)).join('\n')).toContain(code);
135
+ });
136
+
137
+ it('validates the Decision steps of a Flow child sub-agent', async () => {
138
+ const child: AgentSpec = {
139
+ ID: '',
140
+ Name: 'Ticket Router',
141
+ TypeID: FLOW_TYPE_ID,
142
+ StartingPayloadValidationMode: 'Fail',
143
+ Prompts: [],
144
+ Steps: [
145
+ { ID: '', Name: 'Route', StepType: 'Decision', StartingStep: true, Configuration: { key: '1bad' } },
146
+ { ID: '', Name: 'Escalate', StepType: 'Action', StartingStep: false, ActionID: 'escalate-action-guid' },
147
+ ],
148
+ Paths: [{ ID: '', OriginStepID: 'Route', DestinationStepID: 'Escalate', Condition: 'decisions.nope.q', Priority: 0 }],
149
+ };
150
+ const parent: AgentSpec = {
151
+ ID: '',
152
+ Name: 'Support Desk',
153
+ TypeID: LOOP_TYPE_ID,
154
+ StartingPayloadValidationMode: 'Fail',
155
+ Prompts: [{ ID: '', PromptID: '', PromptName: 'Support Desk', PromptDescription: '', PromptText: 'Help the customer.', PromptTypeID: '' }],
156
+ SubAgents: [{ Type: 'child', SubAgent: child }],
157
+ };
158
+
159
+ const errors = await validateSpec(parent);
160
+ const childErrors = errors.filter(e => e.startsWith('Flow SubAgent[0] "Ticket Router" -> '));
161
+ expect(childErrors.join('\n')).toContain('[InvalidDecisionStep]');
162
+ expect(childErrors.join('\n')).toContain('it asks no questions');
163
+ expect(childErrors.join('\n')).toContain('[UnknownDecisionKey]');
164
+ expect(childErrors).toHaveLength(errors.length);
165
+ });
166
+
167
+ it('accepts a Decision step whose PromptID is a Decision prompt', async () => {
168
+ const spec = example4();
169
+ setTriagePrompt(spec, DECISION_PROMPT_ID.toLowerCase());
170
+ expect(await validateSpec(spec)).toEqual([]);
171
+ });
172
+
173
+ it('refuses a Decision step whose PromptID is not a prompt', async () => {
174
+ const spec = example4();
175
+ setTriagePrompt(spec, 'D3C15E6A-0000-4000-8000-0000000000FF');
176
+ expect((await validateSpec(spec)).join('\n')).toContain('which is not a prompt');
177
+ });
178
+
179
+ it('refuses a Decision step whose prompt runs on another model type', async () => {
180
+ const spec = example4();
181
+ setTriagePrompt(spec, CHAT_PROMPT_ID);
182
+ const errors = (await validateSpec(spec)).join('\n');
183
+ expect(errors).toContain('uses the prompt "Summarize Ticket", whose model type is "LLM"');
184
+ expect(errors).toContain('must run on Decision models');
185
+ });
186
+ });
187
+
188
+ /** example_4's triage, asking a Likelihood as well as the Choice. */
189
+ const TRIAGE_WITH_URGENT = {
190
+ key: 'triage',
191
+ questions: {
192
+ category: {
193
+ instructions: 'What category best describes this issue?',
194
+ kind: 'Choice',
195
+ options: [
196
+ { value: 'billing', description: 'Billing inquiry or invoice problem' },
197
+ { value: 'technical', description: 'Technical defect or system error' },
198
+ { value: 'general', description: 'General question or account update' },
199
+ ],
200
+ },
201
+ urgent: { instructions: 'The customer cannot work until this is resolved.', kind: 'Likelihood' },
202
+ },
203
+ };
204
+
205
+ /** Has the triage ask whether the issue is urgent, and gates a new step on the answer. */
206
+ function addUrgentGate(spec: AgentSpec, condition: string): void {
207
+ const triage = spec.Steps?.find(s => s.Name === 'Triage Issue');
208
+ if (triage) triage.Configuration = TRIAGE_WITH_URGENT;
209
+ const escalate: AgentStep = { ID: '', Name: 'Escalate', StepType: 'Action', StartingStep: false, ActionID: 'escalate-action-guid' };
210
+ spec.Steps = [...(spec.Steps ?? []), escalate];
211
+ const gate: AgentStepPath = { ID: '', OriginStepID: 'Handle Technical', DestinationStepID: 'Escalate', Condition: condition, Priority: 0 };
212
+ spec.Paths = [...(spec.Paths ?? []), gate];
213
+ }
214
+
215
+ function setTriagePrompt(spec: AgentSpec, promptID: string): void {
216
+ const triage = spec.Steps?.find(s => s.Name === 'Triage Issue');
217
+ if (triage) triage.PromptID = promptID;
218
+ }