@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.
@@ -1,14 +1,17 @@
1
1
  /**
2
- * Tests for loop-step validation.
2
+ * Tests for loop-step validation, and for validating a flow as the runtime does.
3
3
  *
4
- * The assertion that matters most here is the one about a loop that saves cleanly and then does
4
+ * The assertion that matters most for loops is the one about a loop that saves cleanly and then does
5
5
  * nothing: a `ForEach` with no `collectionPath` is structurally valid SQL, passes every other check,
6
6
  * and at runtime iterates zero times — which reads as the agent declining to do the work rather than
7
7
  * as a malformed step. That is precisely the class of error the Architect has to catch before save.
8
+ *
9
+ * For a flow as a whole, the property is agreement with the runtime: `ValidateFlowGraph` refuses
10
+ * exactly what `CompileFlowToTaskGraph` + `ValidateTaskGraphSpec` refuse, and passes what they run.
8
11
  */
9
12
  import { describe, it, expect } from 'vitest';
10
- import type { AgentStep } from '@memberjunction/ai-core-plus';
11
- import { IsLoopStep, ValidateLoopStep } from '../flow-step-validation';
13
+ import type { AgentStep, AgentStepPath } from '@memberjunction/ai-core-plus';
14
+ import { IsLoopStep, ValidateLoopStep, IsDecisionStep, ValidateFlowGraph, StepConfigurationText } from '../flow-step-validation';
12
15
 
13
16
  const step = (over: Partial<AgentStep> = {}): AgentStep => ({
14
17
  ID: '',
@@ -48,7 +51,7 @@ describe('ValidateLoopStep — the happy paths', () => {
48
51
  it('accepts a Configuration supplied as an object, not only as JSON text', () => {
49
52
  // A model that was just shown an object literal in the prompt will send one. Rejecting that
50
53
  // 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 });
54
+ const s = step({ Configuration: { type: 'ForEach', collectionPath: 'leads', itemVariable: 'lead' } });
52
55
  expect(ValidateLoopStep(s, 0)).toEqual([]);
53
56
  });
54
57
 
@@ -97,9 +100,9 @@ describe('ValidateLoopStep — a loop that would silently do nothing', () => {
97
100
  it('rejects a Configuration that parses to something other than an object', () => {
98
101
  expect(ValidateLoopStep(step({ Configuration: '"just a string"' }), 0)[0]).toContain('not a JSON object');
99
102
  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
+ // A model's JSON can put anything here, so this step is read from JSON, as a spec is.
104
+ const fromModel: AgentStep = JSON.parse(JSON.stringify({ ...step(), Configuration: [1, 2, 3] }));
105
+ expect(ValidateLoopStep(fromModel, 0)[0]).toContain('array rather than an object');
103
106
  });
104
107
  });
105
108
 
@@ -159,3 +162,320 @@ describe('ValidateLoopStep — reporting', () => {
159
162
  expect(errors[0]).toContain('ForEach');
160
163
  });
161
164
  });
165
+
166
+
167
+ /** A Choice and a Likelihood about one ticket, written the way the Architect's template teaches. */
168
+ const TRIAGE_CONFIGURATION = {
169
+ key: 'triage',
170
+ questions: {
171
+ category: {
172
+ instructions: 'What category best describes this issue?',
173
+ kind: 'Choice',
174
+ options: [
175
+ { value: 'billing', description: 'Billing inquiry or invoice problem' },
176
+ { value: 'technical', description: 'Technical defect or system error' },
177
+ { value: 'general', description: 'General question or account update' },
178
+ ],
179
+ },
180
+ urgent: { instructions: 'The customer cannot work until this is resolved.', kind: 'Likelihood' },
181
+ },
182
+ };
183
+
184
+ const actionStep = (name: string, over: Partial<AgentStep> = {}): AgentStep => ({
185
+ ID: '',
186
+ Name: name,
187
+ StepType: 'Action',
188
+ StartingStep: false,
189
+ ActionID: 'AC71E1DA-1111-2222-3333-444455556666',
190
+ ...over,
191
+ });
192
+
193
+ const decisionStep = (over: Partial<AgentStep> = {}): AgentStep => ({
194
+ ID: '',
195
+ Name: 'Triage Issue',
196
+ StepType: 'Decision',
197
+ StartingStep: true,
198
+ Configuration: TRIAGE_CONFIGURATION,
199
+ ...over,
200
+ });
201
+
202
+ const path = (from: string, to: string, condition?: string, over: Partial<AgentStepPath> = {}): AgentStepPath => ({
203
+ ID: '',
204
+ OriginStepID: from,
205
+ DestinationStepID: to,
206
+ Condition: condition,
207
+ Priority: 10,
208
+ ...over,
209
+ });
210
+
211
+ type Flow = { Steps: AgentStep[]; Paths: AgentStepPath[] };
212
+
213
+ /** The Decision step, a handler per category, and a path to each: a Choice fork covering every option. */
214
+ const triageFlow = (): Flow => ({
215
+ Steps: [decisionStep(), actionStep('Handle Billing'), actionStep('Handle Technical'), actionStep('Handle General')],
216
+ Paths: [
217
+ path('Triage Issue', 'Handle Billing', "decisions.triage.category.value === 'billing'"),
218
+ path('Triage Issue', 'Handle Technical', "decisions.triage.category.value === 'technical'"),
219
+ path('Triage Issue', 'Handle General', "decisions.triage.category.value === 'general'"),
220
+ ],
221
+ });
222
+
223
+ /** The Decision step and one path from it to a single handler. */
224
+ const gateFlow = (condition: string): Flow => ({
225
+ Steps: [decisionStep(), actionStep('Handle Billing')],
226
+ Paths: [path('Triage Issue', 'Handle Billing', condition)],
227
+ });
228
+
229
+ const validate = (flow: Flow): string[] => ValidateFlowGraph(flow.Steps, flow.Paths, 'Customer Issue Triager');
230
+
231
+ describe('IsDecisionStep', () => {
232
+ it('recognises Decision steps', () => {
233
+ expect(IsDecisionStep({ StepType: 'Decision' })).toBe(true);
234
+ expect(IsDecisionStep({ StepType: 'ForEach' })).toBe(false);
235
+ expect(IsDecisionStep({ StepType: 'While' })).toBe(false);
236
+ expect(IsDecisionStep({ StepType: 'Action' })).toBe(false);
237
+ expect(IsDecisionStep({ StepType: 'Prompt' })).toBe(false);
238
+ expect(IsDecisionStep({ StepType: 'Sub-Agent' })).toBe(false);
239
+ });
240
+ });
241
+
242
+ describe('ValidateFlowGraph — flows the runtime runs', () => {
243
+ it('accepts a Choice fork with a path for every option', () => {
244
+ expect(validate(triageFlow())).toEqual([]);
245
+ });
246
+
247
+ it('accepts the Configuration as JSON text as well as an object', () => {
248
+ const flow = triageFlow();
249
+ flow.Steps[0] = decisionStep({ Configuration: JSON.stringify(TRIAGE_CONFIGURATION) });
250
+ expect(validate(flow)).toEqual([]);
251
+ });
252
+
253
+ it('accepts the aliases a model writes: text for instructions and for an option description', () => {
254
+ const flow = triageFlow();
255
+ flow.Steps[0] = decisionStep({
256
+ Configuration: {
257
+ key: 'triage',
258
+ questions: {
259
+ category: {
260
+ text: 'What category best describes this issue?',
261
+ kind: 'Choice',
262
+ options: [
263
+ { value: 'billing', text: 'Billing' },
264
+ { value: 'technical', text: 'Technical' },
265
+ { value: 'general', text: 'General' },
266
+ ],
267
+ },
268
+ },
269
+ },
270
+ });
271
+ expect(validate(flow)).toEqual([]);
272
+ });
273
+
274
+ it('accepts a single conditional path from a Decision step: a gate on one option', () => {
275
+ // The compiler makes an exclusive group only when a step has two or more paths, so a gate is
276
+ // not a fork and the runtime runs it. The Agent Manager used to refuse it.
277
+ expect(validate(gateFlow("decisions.triage.category.value === 'billing'"))).toEqual([]);
278
+ });
279
+
280
+ it("accepts a Likelihood gate that reads the answer's probability", () => {
281
+ expect(validate(gateFlow('decisions.triage.urgent.probability >= 0.8'))).toEqual([]);
282
+ });
283
+
284
+ it('accepts every field the template documents for each kind of answer', () => {
285
+ const scored = decisionStep({
286
+ Configuration: {
287
+ ...TRIAGE_CONFIGURATION,
288
+ questions: {
289
+ ...TRIAGE_CONFIGURATION.questions,
290
+ severity: { instructions: 'How severe is the impact?', kind: 'Score', levels: ['low', 'medium', 'high'] },
291
+ },
292
+ },
293
+ });
294
+ for (const condition of [
295
+ "decisions.triage.category.confidence >= 0.7 && decisions.triage.category.probabilities.billing > 0.5",
296
+ 'decisions.triage.severity.value >= 1',
297
+ 'decisions.triage.severity.confidence >= 0.7',
298
+ 'decisions.triage.severity.probabilities.high > 0.5',
299
+ 'decisions.triage.urgent.probability >= 0.8',
300
+ ]) {
301
+ const flow = gateFlow(condition);
302
+ flow.Steps[0] = scored;
303
+ expect(validate(flow)).toEqual([]);
304
+ }
305
+ });
306
+
307
+ it('accepts a fork whose other options go to an unconditional default path', () => {
308
+ const flow = triageFlow();
309
+ flow.Paths = [
310
+ path('Triage Issue', 'Handle Billing', "decisions.triage.category.value === 'billing'"),
311
+ path('Triage Issue', 'Handle General', undefined, { Priority: 0 }),
312
+ ];
313
+ expect(validate(flow)).toEqual([]);
314
+ });
315
+
316
+ it('matches a path end to a step ID as a UUID, whatever its case', () => {
317
+ const triageID = 'A1B2C3D4-0000-4000-8000-000000000001';
318
+ const billingID = 'A1B2C3D4-0000-4000-8000-000000000002';
319
+ const flow: Flow = {
320
+ Steps: [decisionStep({ ID: triageID }), actionStep('Handle Billing', { ID: billingID })],
321
+ Paths: [path(triageID.toLowerCase(), billingID.toLowerCase(), "decisions.triage.category.value === 'billing'")],
322
+ };
323
+ expect(validate(flow)).toEqual([]);
324
+ });
325
+ });
326
+
327
+ describe('ValidateFlowGraph — what the runtime refuses is refused here', () => {
328
+ // The first three are the specs a review probed: the Agent Manager passed each one, and the runtime
329
+ // refused it.
330
+ it.each([
331
+ {
332
+ what: 'a path from a later step that reads an unknown Decision key',
333
+ flow: (): Flow => ({
334
+ Steps: [
335
+ decisionStep(),
336
+ { ID: '', Name: 'Summarize', StepType: 'Prompt', StartingStep: false, PromptText: 'Summarize the ticket.' },
337
+ actionStep('Handle Billing'),
338
+ ],
339
+ Paths: [
340
+ path('Triage Issue', 'Summarize'),
341
+ path('Summarize', 'Handle Billing', "decisions.triag.category.value === 'billing'"),
342
+ ],
343
+ }),
344
+ code: '[UnknownDecisionKey]',
345
+ mentions: 'decisions.triag',
346
+ },
347
+ {
348
+ what: 'a Likelihood read through .value, a field its answer does not have',
349
+ flow: (): Flow => gateFlow('decisions.triage.urgent.value >= 0.8'),
350
+ code: '[InvalidCondition]',
351
+ mentions: 'reads "value" from the Likelihood question "urgent"',
352
+ },
353
+ {
354
+ what: 'a question the Decision step does not ask',
355
+ flow: (): Flow => gateFlow('decisions.triage.urgency.probability >= 0.8'),
356
+ code: '[InvalidCondition]',
357
+ mentions: 'reads the question "urgency"',
358
+ },
359
+ {
360
+ what: 'a Score read through .probability, a field only a Likelihood has',
361
+ flow: (): Flow => {
362
+ const flow = gateFlow('decisions.triage.severity.probability >= 0.8');
363
+ flow.Steps[0] = decisionStep({
364
+ Configuration: {
365
+ key: 'triage',
366
+ questions: { severity: { instructions: 'How severe is the impact?', kind: 'Score', levels: ['low', 'medium', 'high'] } },
367
+ },
368
+ });
369
+ return flow;
370
+ },
371
+ code: '[InvalidCondition]',
372
+ mentions: 'reads "probability" from the Score question "severity"',
373
+ },
374
+ ])('refuses $what', ({ flow, code, mentions }) => {
375
+ const errors = validate(flow());
376
+ expect(errors).toHaveLength(1);
377
+ expect(errors[0]).toContain(code);
378
+ expect(errors[0]).toContain(mentions);
379
+ });
380
+
381
+ it('refuses a Choice fork with no path for one of its options, naming the step rather than its ID', () => {
382
+ const triageID = 'A1B2C3D4-0000-4000-8000-000000000001';
383
+ const flow = triageFlow();
384
+ flow.Steps[0] = decisionStep({ ID: triageID });
385
+ flow.Paths = flow.Paths.filter((p) => !p.Condition?.includes('general'));
386
+
387
+ const errors = validate(flow);
388
+ expect(errors).toHaveLength(1);
389
+ expect(errors[0]).toContain('[IncompleteFork]');
390
+ expect(errors[0]).toContain('no path for "general"');
391
+ expect(errors[0]).toContain('"Triage Issue"');
392
+ expect(errors[0]).not.toContain(triageID);
393
+ });
394
+
395
+ it('refuses a configuration a Decision step cannot run with', () => {
396
+ const flow = gateFlow('decisions.nope.q');
397
+ flow.Steps[0] = decisionStep({ Configuration: { key: '1bad' } });
398
+
399
+ const errors = validate(flow).join('\n');
400
+ expect(errors).toContain('[InvalidDecisionStep]');
401
+ expect(errors).toContain('its key "1bad" cannot be named in a path condition');
402
+ expect(errors).toContain('it asks no questions');
403
+ expect(errors).toContain('[UnknownDecisionKey]');
404
+ });
405
+
406
+ it('refuses a Decision step with no configuration at all', () => {
407
+ const flow = gateFlow("decisions.triage.category.value === 'billing'");
408
+ flow.Steps[0] = decisionStep({ Configuration: undefined });
409
+ expect(validate(flow).join('\n')).toContain('it has no configuration; it needs a key and at least one question');
410
+ });
411
+
412
+ it('refuses a key two Decision steps share, once', () => {
413
+ const flow: Flow = {
414
+ Steps: [decisionStep(), decisionStep({ Name: 'Triage Again', StartingStep: false }), actionStep('Handle Billing')],
415
+ Paths: [
416
+ path('Triage Issue', 'Triage Again'),
417
+ path('Triage Again', 'Handle Billing', "decisions.triage.category.value === 'billing'"),
418
+ ],
419
+ };
420
+ const errors = validate(flow);
421
+ expect(errors.filter((e) => e.includes('[DuplicateDecisionKey]'))).toHaveLength(1);
422
+ expect(errors.join('\n')).toContain('both use the key "triage"');
423
+ });
424
+
425
+ it('refuses a shared key even when the flow cannot reach one of the steps, as the in-run walker does', () => {
426
+ const flow = triageFlow();
427
+ flow.Steps.push(decisionStep({ Name: 'Unwired Triage', StartingStep: false }));
428
+ expect(validate(flow)).toEqual([expect.stringContaining('[DuplicateDecisionKey]')]);
429
+ });
430
+
431
+ it('refuses a path that names no step, which the compiler would drop without a word', () => {
432
+ const flow = triageFlow();
433
+ flow.Paths[2] = path('Triage Issue', 'Handle Genral', "decisions.triage.category.value === 'general'");
434
+
435
+ const errors = validate(flow).join('\n');
436
+ expect(errors).toContain('names no step as its destination');
437
+ // Dropping the path left the fork without "general", which is refused as well.
438
+ expect(errors).toContain('[IncompleteFork]');
439
+ });
440
+
441
+ it('matches a path end to a step name exactly, as AgentSpecSync does', () => {
442
+ const flow = gateFlow("decisions.triage.category.value === 'billing'");
443
+ flow.Paths = [path('triage issue', 'Handle Billing', "decisions.triage.category.value === 'billing'")];
444
+ expect(validate(flow).join('\n')).toContain('names no step as its origin');
445
+ });
446
+
447
+ it('returns every problem the compiler finds at once', () => {
448
+ const flow: Flow = {
449
+ Steps: [decisionStep({ Configuration: { key: 'triage' } }), actionStep('Handle Billing'), actionStep('Handle General')],
450
+ Paths: [
451
+ path('Triage Issue', 'Handle Billing', "decisions.unknown1.category.value === 'billing'"),
452
+ path('Triage Issue', 'Handle General', "decisions.unknown2.category.value === 'general'"),
453
+ ],
454
+ };
455
+ const errors = validate(flow).join('\n');
456
+ expect(errors).toContain('it asks no questions');
457
+ expect(errors).toContain('decisions.unknown1');
458
+ expect(errors).toContain('decisions.unknown2');
459
+ });
460
+ });
461
+
462
+ describe('StepConfigurationText', () => {
463
+ it('writes an object as JSON text and passes text through', () => {
464
+ const loop = { type: 'ForEach', collectionPath: 'leads', itemVariable: 'lead' };
465
+ expect(StepConfigurationText({ StepType: 'ForEach', Configuration: loop })).toBe(JSON.stringify(loop));
466
+ expect(StepConfigurationText({ StepType: 'ForEach', Configuration: '{"collectionPath":"leads"}' })).toBe('{"collectionPath":"leads"}');
467
+ });
468
+
469
+ it('normalizes the aliases in a Decision step', () => {
470
+ const text = StepConfigurationText({
471
+ StepType: 'Decision',
472
+ Configuration: { key: 'triage', questions: { urgent: { text: 'Is it urgent?', kind: 'Likelihood' } } },
473
+ });
474
+ expect(JSON.parse(text ?? '{}').questions.urgent.instructions).toBe('Is it urgent?');
475
+ });
476
+
477
+ it('stores nothing for an empty configuration', () => {
478
+ expect(StepConfigurationText({ StepType: 'Action', Configuration: undefined })).toBeNull();
479
+ expect(StepConfigurationText({ StepType: 'Action', Configuration: '' })).toBeNull();
480
+ });
481
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `WorkflowAgentWriter` resolves the names a workflow's steps refer to before it saves them.
3
+ *
4
+ * Pinned here: a failed load of those names stops the save. An empty index resolves nothing, so
5
+ * every agent and prompt would be reported lost and every action saved pointing at nothing.
6
+ */
7
+ import { describe, it, expect } from 'vitest';
8
+ import { UserInfo, type RunViewParams } from '@memberjunction/core';
9
+ import { WorkflowAgentWriter } from '../workflow-agent-writer';
10
+
11
+ type NameRow = { ID: string; Name: string };
12
+ type NameLoad = { Success: boolean; Results: NameRow[]; ErrorMessage?: string };
13
+
14
+ /** The one provider call the name indexes make. */
15
+ type NameProvider = { RunViews(params: RunViewParams[]): Promise<NameLoad[]> };
16
+
17
+ /** The private step these tests drive, typed by the provider call it makes. */
18
+ type WriterInternals = {
19
+ buildNameIndexes(context: { ContextUser: UserInfo; Provider: NameProvider }): Promise<{
20
+ Agents: Map<string, string>;
21
+ Actions: Map<string, string>;
22
+ Prompts: Map<string, string>;
23
+ }>;
24
+ };
25
+
26
+ /** True when the writer still has the step these tests drive — checked, so a rename fails here. */
27
+ function drivesNameIndexes(value: object): value is WriterInternals {
28
+ return typeof Reflect.get(value, 'buildNameIndexes') === 'function';
29
+ }
30
+
31
+ function writer(): WriterInternals {
32
+ const instance: object = new WorkflowAgentWriter();
33
+ if (!drivesNameIndexes(instance)) throw new Error('WorkflowAgentWriter no longer builds name indexes.');
34
+ return instance;
35
+ }
36
+
37
+ const provider = (loads: NameLoad[]): NameProvider => ({ RunViews: async () => loads });
38
+ const loaded = (rows: NameRow[]): NameLoad => ({ Success: true, Results: rows });
39
+
40
+ describe('WorkflowAgentWriter name indexes', () => {
41
+ it('indexes agents, actions and prompts by lowercased name', async () => {
42
+ const indexes = await writer().buildNameIndexes({
43
+ ContextUser: new UserInfo(),
44
+ Provider: provider([
45
+ loaded([{ ID: 'agent-1', Name: 'Sage' }]),
46
+ loaded([{ ID: 'action-1', Name: 'Send Email ' }]),
47
+ loaded([{ ID: 'prompt-1', Name: 'Triage Decision' }]),
48
+ ]),
49
+ });
50
+ expect(indexes.Agents.get('sage')).toBe('agent-1');
51
+ expect(indexes.Actions.get('send email')).toBe('action-1');
52
+ expect(indexes.Prompts.get('triage decision')).toBe('prompt-1');
53
+ });
54
+
55
+ it('refuses to go on when a load fails, naming what could not be read', async () => {
56
+ const failing = writer().buildNameIndexes({
57
+ ContextUser: new UserInfo(),
58
+ Provider: provider([loaded([]), { Success: false, Results: [], ErrorMessage: 'permission denied' }, loaded([])]),
59
+ });
60
+ await expect(failing).rejects.toThrow(/MJ: Actions: permission denied/);
61
+ });
62
+ });
@@ -19,6 +19,7 @@ import {
19
19
  SubAgentSpec
20
20
  } from '@memberjunction/ai-core-plus';
21
21
  import { UUIDsEqual } from '@memberjunction/global';
22
+ import { StepConfigurationText } from './flow-step-validation';
22
23
 
23
24
  /**
24
25
  * Represents a single database mutation performed by AgentSpecSync
@@ -748,9 +749,9 @@ export class AgentSpecSync {
748
749
  ActionID: step.ActionID || undefined,
749
750
  SubAgentID: step.SubAgentID || undefined,
750
751
  PromptID: step.PromptID || undefined,
751
- // Loop steps round-trip their body type and bounds; without these, reading a flow agent
752
- // that contains a loop and writing it back would silently turn the loop into an
753
- // unconfigured step that iterates over nothing.
752
+ // Loop & Decision steps round-trip their configuration and bounds; without these, reading
753
+ // a flow agent that contains a decision or loop and writing it back would silently strip
754
+ // the step's logic or bounds.
754
755
  LoopBodyType: step.LoopBodyType || undefined,
755
756
  Configuration: step.Configuration || undefined,
756
757
  ActionInputMapping: this.parseJsonField<Record<string, unknown>>(step.ActionInputMapping),
@@ -1388,7 +1389,9 @@ export class AgentSpecSync {
1388
1389
  // Configuration carries the bounds. Written for every step because a step that STOPS
1389
1390
  // being a loop must have these cleared, not left behind from its previous shape.
1390
1391
  stepEntity.LoopBodyType = stepSpec.LoopBodyType || null;
1391
- stepEntity.Configuration = stepSpec.Configuration || null;
1392
+ // Stored as JSON text even when the model wrote an object, with a Decision step's aliases
1393
+ // normalized: the text the Architect's validator compiled.
1394
+ stepEntity.Configuration = StepConfigurationText(stepSpec);
1392
1395
 
1393
1396
  // Handle inline prompt creation for Prompt-type steps
1394
1397
  // If StepType is Prompt and PromptID is empty, create a new AIPrompt record