@memberjunction/ai-agent-manager 2.106.0 → 2.108.0

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,874 @@
1
+ import {
2
+ Metadata,
3
+ RunView,
4
+ UserInfo,
5
+ LogError
6
+ } from '@memberjunction/core';
7
+ import {
8
+ AIAgentEntity,
9
+ AIAgentActionEntity,
10
+ AIAgentRelationshipEntity
11
+ } from '@memberjunction/core-entities';
12
+ import {
13
+ AgentSpec,
14
+ AgentActionSpec,
15
+ SubAgentSpec
16
+ } from '@memberjunction/ai-core-plus';
17
+
18
+ /**
19
+ * AgentSpecSync provides a high-level interface for working with AI Agent metadata in MemberJunction.
20
+ *
21
+ * This class serves as a bi-directional bridge between the simple, serializable {@link AgentSpec}
22
+ * format and the complex MemberJunction database metadata across three core entities:
23
+ * - {@link AIAgentEntity} - Core agent configuration
24
+ * - {@link AIAgentActionEntity} - Agent-action relationships
25
+ * - {@link AIAgentRelationshipEntity} - Parent-child agent relationships
26
+ *
27
+ * ## Key Features
28
+ *
29
+ * - **Load from Database**: Load complete agent hierarchies including all sub-agents and actions
30
+ * - **Save to Database**: Persist changes atomically with full validation
31
+ * - **Recursive Support**: Handles n-level agent hierarchies automatically
32
+ * - **Type Safety**: Full TypeScript typing with proper entity types
33
+ * - **Dirty Tracking**: Knows when changes need to be saved
34
+ * - **JSON Serialization**: Easy export for APIs and storage
35
+ *
36
+ * ## Usage Examples
37
+ *
38
+ * ### Load an existing agent
39
+ * ```typescript
40
+ * const spec = await AgentSpecSync.LoadFromDatabase('agent-uuid', contextUser);
41
+ * console.log('Agent Name:', spec.spec.Name);
42
+ * console.log('Actions:', spec.spec.Actions?.length);
43
+ * ```
44
+ *
45
+ * ### Create a new agent
46
+ * ```typescript
47
+ * const newAgent = new AgentSpecSync({
48
+ * Name: 'My New Agent',
49
+ * Description: 'Does amazing things',
50
+ * IconClass: 'fa-robot',
51
+ * InvocationMode: 'Any'
52
+ * }, contextUser);
53
+ *
54
+ * const agentId = await newAgent.SaveToDatabase();
55
+ * ```
56
+ *
57
+ * ### Modify and save
58
+ * ```typescript
59
+ * const spec = await AgentSpecSync.LoadFromDatabase('agent-uuid', contextUser);
60
+ * spec.spec.Description = 'Updated description';
61
+ * spec.spec.MaxCostPerRun = 10.00;
62
+ * spec.markDirty();
63
+ * await spec.SaveToDatabase();
64
+ * ```
65
+ *
66
+ * @module @memberjunction/ai-agent-manager
67
+ */
68
+ export class AgentSpecSync {
69
+ /**
70
+ * The raw specification data structure containing all agent configuration
71
+ */
72
+ public spec: AgentSpec;
73
+
74
+ /**
75
+ * Tracks whether this spec has been loaded from the database
76
+ * @private
77
+ */
78
+ private _isLoaded: boolean = false;
79
+
80
+ /**
81
+ * Tracks whether this spec has unsaved changes
82
+ * @private
83
+ */
84
+ private _isDirty: boolean = false;
85
+
86
+ /**
87
+ * Context user for database operations (required for server-side operations)
88
+ * @private
89
+ */
90
+ private _contextUser?: UserInfo;
91
+
92
+ /**
93
+ * Create a new AgentSpecSync instance.
94
+ *
95
+ * Note: This constructor is typically not called directly. Instead, use the static factory methods:
96
+ * - {@link LoadFromDatabase} - Load existing agent from database
97
+ * - {@link LoadByName} - Load agent by name
98
+ * - {@link FromRawSpec} - Create from raw spec data
99
+ *
100
+ * @param spec - Optional initial spec data (for creating new agents or working with existing data)
101
+ * @param contextUser - Optional context user (required for server-side operations)
102
+ */
103
+ constructor(spec?: Partial<AgentSpec>, contextUser?: UserInfo) {
104
+ if (spec) {
105
+ this.spec = this.initializeSpec(spec);
106
+ this._isDirty = true;
107
+ } else {
108
+ // Create minimal empty spec
109
+ this.spec = {
110
+ ID: '', // Will be set on save if empty
111
+ Name: '',
112
+ StartingPayloadValidationMode: () => 'Fail',
113
+ Actions: [],
114
+ SubAgents: []
115
+ };
116
+ }
117
+ this._contextUser = contextUser;
118
+ }
119
+
120
+ // ===== STATIC FACTORY METHODS =====
121
+
122
+ /**
123
+ * Load an agent and its complete hierarchy from the database by ID.
124
+ *
125
+ * This method efficiently loads the agent along with all its actions and sub-agents
126
+ * using batched queries to minimize database round trips. When `includeSubAgents` is true,
127
+ * it recursively loads the entire agent hierarchy.
128
+ *
129
+ * @param agentId - The unique ID of the agent to load
130
+ * @param contextUser - Optional context user (required for server-side operations)
131
+ * @param includeSubAgents - Whether to recursively load all sub-agents (default: true)
132
+ * @returns Promise resolving to AgentSpecSync instance with loaded data
133
+ * @throws {Error} If agent with specified ID is not found
134
+ *
135
+ * @example
136
+ * ```typescript
137
+ * // Load agent with all sub-agents
138
+ * const spec = await AgentSpecSync.LoadFromDatabase(
139
+ * 'agent-uuid-here',
140
+ * contextUser,
141
+ * true
142
+ * );
143
+ * ```
144
+ */
145
+ static async LoadFromDatabase(
146
+ agentId: string,
147
+ contextUser?: UserInfo,
148
+ includeSubAgents: boolean = true
149
+ ): Promise<AgentSpecSync> {
150
+ const instance = new AgentSpecSync(undefined, contextUser);
151
+ await instance.loadFromEntities(agentId, includeSubAgents);
152
+ return instance;
153
+ }
154
+
155
+ /**
156
+ * Load an agent by name (must be unique).
157
+ *
158
+ * Searches for an agent with the specified name and loads it. If multiple agents
159
+ * have the same name, an error is thrown. Agent names should be unique within
160
+ * the system for this method to work reliably.
161
+ *
162
+ * @param agentName - The name of the agent to load
163
+ * @param contextUser - Optional context user (required for server-side operations)
164
+ * @param includeSubAgents - Whether to recursively load sub-agents (default: true)
165
+ * @returns Promise resolving to AgentSpecSync instance with loaded data
166
+ * @throws {Error} If agent is not found or multiple agents have the same name
167
+ *
168
+ * @example
169
+ * ```typescript
170
+ * const spec = await AgentSpecSync.LoadByName('My Agent', contextUser);
171
+ * ```
172
+ */
173
+ static async LoadByName(
174
+ agentName: string,
175
+ contextUser?: UserInfo,
176
+ includeSubAgents: boolean = true
177
+ ): Promise<AgentSpecSync> {
178
+ // Find agent by name
179
+ const rv = new RunView();
180
+ const result = await rv.RunView<AIAgentEntity>({
181
+ EntityName: 'AI Agents',
182
+ ExtraFilter: `Name='${agentName.replace(/'/g, "''")}'`,
183
+ ResultType: 'entity_object'
184
+ }, contextUser);
185
+
186
+ if (!result.Success) {
187
+ throw new Error(`Failed to find agent by name: ${result.ErrorMessage}`);
188
+ }
189
+
190
+ if (!result.Results || result.Results.length === 0) {
191
+ throw new Error(`Agent with name '${agentName}' not found`);
192
+ }
193
+
194
+ if (result.Results.length > 1) {
195
+ throw new Error(`Multiple agents found with name '${agentName}'. Use LoadFromDatabase with specific ID instead.`);
196
+ }
197
+
198
+ const agent = result.Results[0];
199
+ return AgentSpecSync.LoadFromDatabase(agent.ID, contextUser, includeSubAgents);
200
+ }
201
+
202
+ /**
203
+ * Create a new agent spec from a raw specification.
204
+ *
205
+ * This creates an in-memory AgentSpecSync instance from raw data. The agent is not
206
+ * saved to the database until {@link SaveToDatabase} is called.
207
+ *
208
+ * @param rawSpec - The raw spec data conforming to {@link AgentSpec} interface
209
+ * @param contextUser - Optional context user (required when saving server-side)
210
+ * @returns New AgentSpecSync instance (not yet saved to database)
211
+ *
212
+ * @example
213
+ * ```typescript
214
+ * const rawSpec: AgentSpec = {
215
+ * ID: '',
216
+ * Name: 'New Agent',
217
+ * Description: 'Agent description',
218
+ * InvocationMode: 'Any',
219
+ * Actions: [],
220
+ * SubAgents: []
221
+ * };
222
+ * const spec = AgentSpecSync.FromRawSpec(rawSpec, contextUser);
223
+ * await spec.SaveToDatabase();
224
+ * ```
225
+ */
226
+ static FromRawSpec(rawSpec: AgentSpec, contextUser?: UserInfo): AgentSpecSync {
227
+ return new AgentSpecSync(rawSpec, contextUser);
228
+ }
229
+
230
+ // ===== LOADING METHODS =====
231
+
232
+ /**
233
+ * Load the complete agent specification from database entities.
234
+ *
235
+ * This method orchestrates loading from AIAgent, AIAgentAction, and AIAgentRelationship tables
236
+ * using batched queries for optimal performance. It handles both child agents (ParentID-based)
237
+ * and related agents (relationship-based).
238
+ *
239
+ * @private
240
+ * @param agentId - The agent ID to load
241
+ * @param includeSubAgents - Whether to recursively load sub-agents
242
+ * @throws {Error} If agent is not found
243
+ */
244
+ private async loadFromEntities(agentId: string, includeSubAgents: boolean): Promise<void> {
245
+ const md = new Metadata();
246
+ const rv = new RunView();
247
+
248
+ // Step 1: Load the main agent entity
249
+ const agentEntity = await md.GetEntityObject<AIAgentEntity>(
250
+ 'AI Agents',
251
+ this._contextUser
252
+ );
253
+ const loaded = await agentEntity.Load(agentId);
254
+ if (!loaded) {
255
+ throw new Error(`Agent with ID ${agentId} not found`);
256
+ }
257
+
258
+ // Step 2: Batch load related entities using RunViews for optimal performance
259
+ const [actionsResult, childAgentsResult, relatedAgentsResult] = await rv.RunViews([
260
+ {
261
+ EntityName: 'AI Agent Actions',
262
+ ExtraFilter: `AgentID='${agentId}'`,
263
+ OrderBy: 'Status, ActionID',
264
+ ResultType: 'entity_object'
265
+ },
266
+ {
267
+ EntityName: 'AI Agents',
268
+ ExtraFilter: `ParentID='${agentId}'`,
269
+ OrderBy: 'ExecutionOrder, Name',
270
+ ResultType: 'entity_object'
271
+ },
272
+ {
273
+ EntityName: 'MJ: AI Agent Relationships',
274
+ ExtraFilter: `AgentID='${agentId}' AND Status='Active'`,
275
+ OrderBy: '__mj_CreatedAt',
276
+ ResultType: 'entity_object'
277
+ }
278
+ ], this._contextUser);
279
+
280
+ // Check for errors
281
+ if (!actionsResult.Success) {
282
+ throw new Error(`Failed to load agent actions: ${actionsResult.ErrorMessage}`);
283
+ }
284
+ if (!childAgentsResult.Success) {
285
+ throw new Error(`Failed to load child agents: ${childAgentsResult.ErrorMessage}`);
286
+ }
287
+ if (!relatedAgentsResult.Success) {
288
+ throw new Error(`Failed to load related agents: ${relatedAgentsResult.ErrorMessage}`);
289
+ }
290
+
291
+ // Step 3: Map entities to raw spec format
292
+ this.spec = this.mapEntitiesToRawSpec(
293
+ agentEntity,
294
+ actionsResult.Results || [],
295
+ childAgentsResult.Results || [],
296
+ relatedAgentsResult.Results || []
297
+ );
298
+
299
+ // Step 4: Recursively load sub-agents if requested
300
+ if (includeSubAgents && this.spec.SubAgents && this.spec.SubAgents.length > 0) {
301
+ // Note: We don't recursively populate the full spec here since SubAgentSpec
302
+ // only contains the ID and relationship metadata. To get full details,
303
+ // users can call LoadFromDatabase on the SubAgentID separately if needed.
304
+ }
305
+
306
+ this._isLoaded = true;
307
+ this._isDirty = false;
308
+ }
309
+
310
+ /**
311
+ * Map database entities to AgentSpec format.
312
+ *
313
+ * Transforms the normalized database entities into a single denormalized specification
314
+ * object that's easy to work with in code. Handles JSON parsing for all structured fields.
315
+ *
316
+ * @private
317
+ * @param agent - The main agent entity
318
+ * @param actions - Array of agent action entities
319
+ * @param childAgents - Array of child agent entities (ParentID-based)
320
+ * @param relatedAgents - Array of related agent relationship entities
321
+ * @returns Fully populated AgentSpec object
322
+ */
323
+ private mapEntitiesToRawSpec(
324
+ agent: AIAgentEntity,
325
+ actions: AIAgentActionEntity[],
326
+ childAgents: AIAgentEntity[],
327
+ relatedAgents: AIAgentRelationshipEntity[]
328
+ ): AgentSpec {
329
+ // Map all agent fields to spec
330
+ const spec: AgentSpec = {
331
+ ID: agent.ID,
332
+ Name: agent.Name || '',
333
+ Description: agent.Description || undefined,
334
+ IconClass: agent.IconClass || undefined,
335
+ LogoURL: agent.LogoURL || undefined,
336
+ ParentID: agent.ParentID || undefined,
337
+ DriverClass: agent.DriverClass || undefined,
338
+ ModelSelectionMode: agent.ModelSelectionMode,
339
+
340
+ // Parse JSON array fields
341
+ PayloadDownstreamPaths: this.parseJsonField<string[]>(agent.PayloadDownstreamPaths),
342
+ PayloadUpstreamPaths: this.parseJsonField<string[]>(agent.PayloadUpstreamPaths),
343
+ PayloadSelfReadPaths: this.parseJsonField<string[]>(agent.PayloadSelfReadPaths),
344
+ PayloadSelfWritePaths: this.parseJsonField<string[]>(agent.PayloadSelfWritePaths),
345
+ PayloadScope: agent.PayloadScope || undefined,
346
+
347
+ // Validation fields
348
+ FinalPayloadValidation: agent.FinalPayloadValidation || null,
349
+ FinalPayloadValidationMode: agent.FinalPayloadValidationMode,
350
+ FinalPayloadValidationMaxRetries: agent.FinalPayloadValidationMaxRetries || undefined,
351
+
352
+ StartingPayloadValidation: agent.StartingPayloadValidation || null,
353
+ StartingPayloadValidationMode: agent.StartingPayloadValidationMode as any,
354
+
355
+ // Resource limits
356
+ MaxCostPerRun: agent.MaxCostPerRun || null,
357
+ MaxTokensPerRun: agent.MaxTokensPerRun || null,
358
+ MaxIterationsPerRun: agent.MaxIterationsPerRun || null,
359
+ MaxTimePerRun: agent.MaxTimePerRun || undefined,
360
+
361
+ // Execution frequency
362
+ MinExecutionsPerRun: agent.MinExecutionsPerRun || undefined,
363
+ MaxExecutionsPerRun: agent.MaxExecutionsPerRun || undefined,
364
+
365
+ // Other config
366
+ DefaultPromptEffortLevel: agent.DefaultPromptEffortLevel || undefined,
367
+ ChatHandlingOption: agent.ChatHandlingOption || undefined,
368
+ DefaultArtifactTypeID: agent.DefaultArtifactTypeID || undefined,
369
+ OwnerUserID: agent.OwnerUserID || undefined,
370
+ InvocationMode: agent.InvocationMode,
371
+
372
+ // Map actions
373
+ Actions: actions.map(action => this.mapActionEntityToSpec(action)),
374
+
375
+ // Map sub-agents (both child and related)
376
+ SubAgents: [
377
+ ...childAgents.map(child => this.mapChildAgentToSpec(child)),
378
+ ...relatedAgents.map(rel => this.mapRelatedAgentToSpec(rel))
379
+ ]
380
+ };
381
+
382
+ return spec;
383
+ }
384
+
385
+ /**
386
+ * Map AIAgentActionEntity to AgentActionSpec format.
387
+ *
388
+ * @private
389
+ * @param action - The agent action entity from the database
390
+ * @returns Mapped action spec
391
+ */
392
+ private mapActionEntityToSpec(action: AIAgentActionEntity): AgentActionSpec {
393
+ return {
394
+ AgentActionID: action.ID,
395
+ ActionID: action.ActionID || '',
396
+ Status: action.Status,
397
+ MaxExecutionsPerRun: action.MaxExecutionsPerRun || undefined,
398
+ ResultExpirationTurns: action.ResultExpirationTurns || undefined,
399
+ ResultExpirationMode: action.ResultExpirationMode || undefined,
400
+ CompactMode: action.CompactMode || undefined,
401
+ CompactLength: action.CompactLength || undefined,
402
+ CompactPromptID: action.CompactPromptID || null
403
+ };
404
+ }
405
+
406
+ /**
407
+ * Map child agent (ParentID-based) to SubAgentSpec format.
408
+ *
409
+ * @private
410
+ * @param childAgent - The child agent entity
411
+ * @returns Mapped sub-agent spec
412
+ */
413
+ private mapChildAgentToSpec(childAgent: AIAgentEntity): SubAgentSpec {
414
+ return {
415
+ Type: 'child',
416
+ SubAgentID: childAgent.ID
417
+ };
418
+ }
419
+
420
+ /**
421
+ * Map related agent (relationship-based) to SubAgentSpec format.
422
+ *
423
+ * @private
424
+ * @param relationship - The agent relationship entity
425
+ * @returns Mapped sub-agent spec
426
+ */
427
+ private mapRelatedAgentToSpec(relationship: AIAgentRelationshipEntity): SubAgentSpec {
428
+ return {
429
+ Type: 'related',
430
+ SubAgentID: relationship.SubAgentID,
431
+ AgentRelationshipID: relationship.ID,
432
+ SubAgentInputMapping: this.parseJsonField<Record<string, string>>(relationship.SubAgentInputMapping),
433
+ SubAgentOutputMapping: this.parseJsonField<Record<string, string>>(relationship.SubAgentOutputMapping),
434
+ SubAgentContextPaths: this.parseJsonField<Record<string, string>>(relationship.SubAgentContextPaths)
435
+ };
436
+ }
437
+
438
+ // ===== SAVING METHODS =====
439
+
440
+ /**
441
+ * Save the current spec to the database.
442
+ *
443
+ * This will create new records or update existing ones based on whether IDs exist.
444
+ * The save operation is performed atomically - if any part fails, no changes are committed.
445
+ *
446
+ * Validation is performed before saving. All entity-level validations defined in the
447
+ * database schema are executed, and any failures will prevent the save.
448
+ *
449
+ * @param validate - Whether to validate before saving (default: true)
450
+ * @returns Promise resolving to the saved agent ID
451
+ * @throws {Error} If validation fails or save operation fails
452
+ *
453
+ * @example
454
+ * ```typescript
455
+ * const spec = new AgentSpecSync({
456
+ * Name: 'New Agent',
457
+ * InvocationMode: 'Any'
458
+ * }, contextUser);
459
+ *
460
+ * const agentId = await spec.SaveToDatabase();
461
+ * console.log('Saved with ID:', agentId);
462
+ * ```
463
+ */
464
+ async SaveToDatabase(validate: boolean = true): Promise<string> {
465
+ if (!this._isDirty && this._isLoaded) {
466
+ // No changes to save
467
+ return this.spec.ID;
468
+ }
469
+
470
+ // Step 1: Save main agent entity
471
+ const agentId = await this.saveAgentEntity(validate);
472
+
473
+ // Step 2: Save actions
474
+ await this.saveActions(agentId);
475
+
476
+ // Step 3: Save sub-agents (both child and related)
477
+ await this.saveSubAgents(agentId);
478
+
479
+ this._isDirty = false;
480
+ this._isLoaded = true;
481
+
482
+ return agentId;
483
+ }
484
+
485
+ /**
486
+ * Save the main AIAgent entity.
487
+ *
488
+ * @private
489
+ * @param validate - Whether to perform validation before saving
490
+ * @returns Promise resolving to the saved agent ID
491
+ * @throws {Error} If validation fails or save fails
492
+ */
493
+ private async saveAgentEntity(validate: boolean): Promise<string> {
494
+ const md = new Metadata();
495
+ const agentEntity = await md.GetEntityObject<AIAgentEntity>(
496
+ 'AI Agents',
497
+ this._contextUser
498
+ );
499
+
500
+ // If ID exists, load existing record
501
+ if (this.spec.ID) {
502
+ const loaded = await agentEntity.Load(this.spec.ID);
503
+ if (!loaded) {
504
+ throw new Error(`Cannot update non-existent agent with ID ${this.spec.ID}`);
505
+ }
506
+ }
507
+
508
+ // Map spec to entity fields
509
+ agentEntity.Name = this.spec.Name;
510
+ agentEntity.Description = this.spec.Description || null;
511
+ agentEntity.IconClass = this.spec.IconClass || null;
512
+ agentEntity.LogoURL = this.spec.LogoURL || null;
513
+ agentEntity.ParentID = this.spec.ParentID || null;
514
+ agentEntity.DriverClass = this.spec.DriverClass || null;
515
+ agentEntity.ModelSelectionMode = this.spec.ModelSelectionMode || 'Agent Type';
516
+
517
+ // Serialize JSON fields
518
+ agentEntity.PayloadDownstreamPaths = JSON.stringify(
519
+ this.spec.PayloadDownstreamPaths || ['*']
520
+ );
521
+ agentEntity.PayloadUpstreamPaths = JSON.stringify(
522
+ this.spec.PayloadUpstreamPaths || ['*']
523
+ );
524
+ agentEntity.PayloadSelfReadPaths = this.spec.PayloadSelfReadPaths
525
+ ? JSON.stringify(this.spec.PayloadSelfReadPaths)
526
+ : null;
527
+ agentEntity.PayloadSelfWritePaths = this.spec.PayloadSelfWritePaths
528
+ ? JSON.stringify(this.spec.PayloadSelfWritePaths)
529
+ : null;
530
+ agentEntity.PayloadScope = this.spec.PayloadScope || null;
531
+
532
+ // Validation fields
533
+ agentEntity.FinalPayloadValidation = this.spec.FinalPayloadValidation || null;
534
+ agentEntity.FinalPayloadValidationMode = this.spec.FinalPayloadValidationMode || 'Retry';
535
+ agentEntity.FinalPayloadValidationMaxRetries = this.spec.FinalPayloadValidationMaxRetries || 3;
536
+
537
+ agentEntity.StartingPayloadValidation = this.spec.StartingPayloadValidation || null;
538
+ agentEntity.StartingPayloadValidationMode = this.spec.StartingPayloadValidationMode?.() || 'Fail';
539
+
540
+ // Resource limits
541
+ agentEntity.MaxCostPerRun = this.spec.MaxCostPerRun || null;
542
+ agentEntity.MaxTokensPerRun = this.spec.MaxTokensPerRun || null;
543
+ agentEntity.MaxIterationsPerRun = this.spec.MaxIterationsPerRun || null;
544
+ agentEntity.MaxTimePerRun = this.spec.MaxTimePerRun || null;
545
+
546
+ // Execution frequency
547
+ agentEntity.MinExecutionsPerRun = this.spec.MinExecutionsPerRun || null;
548
+ agentEntity.MaxExecutionsPerRun = this.spec.MaxExecutionsPerRun || null;
549
+
550
+ // Other config
551
+ agentEntity.DefaultPromptEffortLevel = this.spec.DefaultPromptEffortLevel || null;
552
+ agentEntity.ChatHandlingOption = this.spec.ChatHandlingOption || null;
553
+ agentEntity.DefaultArtifactTypeID = this.spec.DefaultArtifactTypeID || null;
554
+ if (this.spec.OwnerUserID) {
555
+ agentEntity.OwnerUserID = this.spec.OwnerUserID;
556
+ }
557
+ agentEntity.InvocationMode = this.spec.InvocationMode || 'Any';
558
+
559
+ // Validate if requested
560
+ if (validate) {
561
+ const validation = agentEntity.Validate();
562
+ if (!validation.Success) {
563
+ const errors = validation.Errors.map(e => e.Message).join(', ');
564
+ throw new Error(`Agent validation failed: ${errors}`);
565
+ }
566
+ }
567
+
568
+ // Save
569
+ const saved = await agentEntity.Save();
570
+ if (!saved) {
571
+ throw new Error('Failed to save agent entity');
572
+ }
573
+
574
+ // Update spec with saved ID
575
+ this.spec.ID = agentEntity.ID;
576
+ return agentEntity.ID;
577
+ }
578
+
579
+ /**
580
+ * Save all actions for this agent.
581
+ *
582
+ * Creates or updates agent action records. Existing actions are updated, new actions
583
+ * are created. This method does not delete actions that are no longer in the spec -
584
+ * use a separate delete method for that.
585
+ *
586
+ * @private
587
+ * @param agentId - The parent agent ID
588
+ * @throws {Error} If any action save fails
589
+ */
590
+ private async saveActions(agentId: string): Promise<void> {
591
+ if (!this.spec.Actions || this.spec.Actions.length === 0) {
592
+ return;
593
+ }
594
+
595
+ const md = new Metadata();
596
+
597
+ for (const actionSpec of this.spec.Actions) {
598
+ const actionEntity = await md.GetEntityObject<AIAgentActionEntity>(
599
+ 'AI Agent Actions',
600
+ this._contextUser
601
+ );
602
+
603
+ // Load existing if ID provided
604
+ if (actionSpec.AgentActionID) {
605
+ await actionEntity.Load(actionSpec.AgentActionID);
606
+ }
607
+
608
+ // Map fields
609
+ actionEntity.AgentID = agentId;
610
+ actionEntity.ActionID = actionSpec.ActionID;
611
+ actionEntity.Status = actionSpec.Status;
612
+ actionEntity.MaxExecutionsPerRun = actionSpec.MaxExecutionsPerRun || null;
613
+ actionEntity.ResultExpirationTurns = actionSpec.ResultExpirationTurns || null;
614
+ actionEntity.ResultExpirationMode = actionSpec.ResultExpirationMode || 'None';
615
+ actionEntity.CompactMode = actionSpec.CompactMode || null;
616
+ actionEntity.CompactLength = actionSpec.CompactLength || null;
617
+ actionEntity.CompactPromptID = actionSpec.CompactPromptID || null;
618
+
619
+ const saved = await actionEntity.Save();
620
+ if (!saved) {
621
+ throw new Error(`Failed to save action ${actionSpec.ActionID}`);
622
+ }
623
+
624
+ // Update spec with saved ID
625
+ actionSpec.AgentActionID = actionEntity.ID;
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Save all sub-agents (both child and related types).
631
+ *
632
+ * Handles both ParentID-based child agents and relationship-based related agents.
633
+ * For child agents, updates the ParentID on the sub-agent entity. For related agents,
634
+ * creates or updates the relationship record.
635
+ *
636
+ * @private
637
+ * @param agentId - The parent agent ID
638
+ * @throws {Error} If any sub-agent save fails
639
+ */
640
+ private async saveSubAgents(agentId: string): Promise<void> {
641
+ if (!this.spec.SubAgents || this.spec.SubAgents.length === 0) {
642
+ return;
643
+ }
644
+
645
+ for (const SubAgentSpec of this.spec.SubAgents) {
646
+ if (SubAgentSpec.Type === 'child') {
647
+ await this.saveChildSubAgent(agentId, SubAgentSpec);
648
+ } else {
649
+ await this.saveRelatedSubAgent(agentId, SubAgentSpec);
650
+ }
651
+ }
652
+ }
653
+
654
+ /**
655
+ * Save a child sub-agent (ParentID-based relationship).
656
+ *
657
+ * For child agents, the relationship is established by setting the ParentID field
658
+ * on the child agent entity. If the SubAgentID is empty, this indicates a new
659
+ * child agent that needs to be created.
660
+ *
661
+ * @private
662
+ * @param parentId - The parent agent ID
663
+ * @param SubAgentSpec - The sub-agent specification
664
+ * @throws {Error} If child agent doesn't exist or save fails
665
+ */
666
+ private async saveChildSubAgent(parentId: string, SubAgentSpec: SubAgentSpec): Promise<void> {
667
+ if (!SubAgentSpec.SubAgentID) {
668
+ throw new Error('Child sub-agent must have a SubAgentID');
669
+ }
670
+
671
+ // For child agents, we just need to ensure the ParentID is set correctly
672
+ // The sub-agent itself should already exist or be created separately
673
+ const md = new Metadata();
674
+ const childEntity = await md.GetEntityObject<AIAgentEntity>(
675
+ 'AI Agents',
676
+ this._contextUser
677
+ );
678
+
679
+ const loaded = await childEntity.Load(SubAgentSpec.SubAgentID);
680
+ if (!loaded) {
681
+ throw new Error(`Child agent ${SubAgentSpec.SubAgentID} not found`);
682
+ }
683
+
684
+ // Update parent ID if needed
685
+ if (childEntity.ParentID !== parentId) {
686
+ childEntity.ParentID = parentId;
687
+ const saved = await childEntity.Save();
688
+ if (!saved) {
689
+ throw new Error(`Failed to update ParentID for child agent ${SubAgentSpec.SubAgentID}`);
690
+ }
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Save a related sub-agent (relationship-based).
696
+ *
697
+ * Creates or updates an AIAgentRelationship record that links the parent and
698
+ * sub-agent. This includes the input/output mapping and context path configurations.
699
+ *
700
+ * @private
701
+ * @param agentId - The parent agent ID
702
+ * @param SubAgentSpec - The sub-agent specification
703
+ * @throws {Error} If relationship save fails
704
+ */
705
+ private async saveRelatedSubAgent(agentId: string, SubAgentSpec: SubAgentSpec): Promise<void> {
706
+ const md = new Metadata();
707
+ const relationshipEntity = await md.GetEntityObject<AIAgentRelationshipEntity>(
708
+ 'MJ: AI Agent Relationships',
709
+ this._contextUser
710
+ );
711
+
712
+ // Load existing if ID provided
713
+ if (SubAgentSpec.AgentRelationshipID) {
714
+ await relationshipEntity.Load(SubAgentSpec.AgentRelationshipID);
715
+ }
716
+
717
+ // Map fields
718
+ relationshipEntity.AgentID = agentId;
719
+ relationshipEntity.SubAgentID = SubAgentSpec.SubAgentID;
720
+ relationshipEntity.Status = 'Active';
721
+
722
+ // Serialize mapping fields
723
+ if (SubAgentSpec.SubAgentInputMapping) {
724
+ relationshipEntity.SubAgentInputMapping = JSON.stringify(SubAgentSpec.SubAgentInputMapping);
725
+ } else {
726
+ relationshipEntity.SubAgentInputMapping = null;
727
+ }
728
+
729
+ if (SubAgentSpec.SubAgentOutputMapping) {
730
+ relationshipEntity.SubAgentOutputMapping = JSON.stringify(SubAgentSpec.SubAgentOutputMapping);
731
+ } else {
732
+ relationshipEntity.SubAgentOutputMapping = null;
733
+ }
734
+
735
+ if (SubAgentSpec.SubAgentContextPaths) {
736
+ relationshipEntity.SubAgentContextPaths = JSON.stringify(SubAgentSpec.SubAgentContextPaths);
737
+ } else {
738
+ relationshipEntity.SubAgentContextPaths = null;
739
+ }
740
+
741
+ const saved = await relationshipEntity.Save();
742
+ if (!saved) {
743
+ throw new Error(`Failed to save relationship for sub-agent ${SubAgentSpec.SubAgentID}`);
744
+ }
745
+
746
+ // Update spec with saved ID
747
+ SubAgentSpec.AgentRelationshipID = relationshipEntity.ID;
748
+ }
749
+
750
+ // ===== UTILITY METHODS =====
751
+
752
+ /**
753
+ * Parse a JSON string field, returning undefined if null/empty.
754
+ *
755
+ * Safely parses JSON fields from the database, handling null/undefined values
756
+ * and logging errors if parsing fails.
757
+ *
758
+ * @private
759
+ * @param jsonString - The JSON string to parse
760
+ * @returns Parsed object or undefined if null/empty/invalid
761
+ */
762
+ private parseJsonField<T>(jsonString: string | null | undefined): T | undefined {
763
+ if (!jsonString) return undefined;
764
+ try {
765
+ return JSON.parse(jsonString) as T;
766
+ } catch (error) {
767
+ LogError(`Failed to parse JSON field: ${error}`);
768
+ return undefined;
769
+ }
770
+ }
771
+
772
+ /**
773
+ * Initialize a spec with defaults for any missing required fields.
774
+ *
775
+ * Takes a partial spec and fills in defaults for any missing fields to ensure
776
+ * a valid AgentSpec structure.
777
+ *
778
+ * @private
779
+ * @param partial - Partial agent specification
780
+ * @returns Complete AgentSpec with defaults
781
+ */
782
+ private initializeSpec(partial: Partial<AgentSpec>): AgentSpec {
783
+ return {
784
+ ID: partial.ID || '',
785
+ Name: partial.Name || '',
786
+ Description: partial.Description,
787
+ IconClass: partial.IconClass,
788
+ LogoURL: partial.LogoURL,
789
+ ParentID: partial.ParentID,
790
+ DriverClass: partial.DriverClass,
791
+ ModelSelectionMode: partial.ModelSelectionMode || 'Agent Type',
792
+ PayloadDownstreamPaths: partial.PayloadDownstreamPaths,
793
+ PayloadUpstreamPaths: partial.PayloadUpstreamPaths,
794
+ PayloadSelfReadPaths: partial.PayloadSelfReadPaths,
795
+ PayloadSelfWritePaths: partial.PayloadSelfWritePaths,
796
+ PayloadScope: partial.PayloadScope,
797
+ FinalPayloadValidation: partial.FinalPayloadValidation,
798
+ FinalPayloadValidationMode: partial.FinalPayloadValidationMode || 'Retry',
799
+ FinalPayloadValidationMaxRetries: partial.FinalPayloadValidationMaxRetries,
800
+ MaxCostPerRun: partial.MaxCostPerRun,
801
+ MaxTokensPerRun: partial.MaxTokensPerRun,
802
+ MaxIterationsPerRun: partial.MaxIterationsPerRun,
803
+ MaxTimePerRun: partial.MaxTimePerRun,
804
+ MinExecutionsPerRun: partial.MinExecutionsPerRun,
805
+ MaxExecutionsPerRun: partial.MaxExecutionsPerRun,
806
+ StartingPayloadValidation: partial.StartingPayloadValidation,
807
+ StartingPayloadValidationMode: partial.StartingPayloadValidationMode || (() => 'Fail'),
808
+ DefaultPromptEffortLevel: partial.DefaultPromptEffortLevel,
809
+ ChatHandlingOption: partial.ChatHandlingOption,
810
+ DefaultArtifactTypeID: partial.DefaultArtifactTypeID,
811
+ OwnerUserID: partial.OwnerUserID,
812
+ InvocationMode: partial.InvocationMode || 'Any',
813
+ Actions: partial.Actions || [],
814
+ SubAgents: partial.SubAgents || []
815
+ };
816
+ }
817
+
818
+ /**
819
+ * Get a clean serializable version of the spec.
820
+ *
821
+ * Returns a plain JavaScript object suitable for JSON serialization,
822
+ * API responses, or storage. This is useful when you need to send the
823
+ * agent spec over the wire or store it in a file.
824
+ *
825
+ * @returns Clean copy of the agent specification
826
+ *
827
+ * @example
828
+ * ```typescript
829
+ * const spec = await AgentSpecSync.LoadFromDatabase('agent-uuid', contextUser);
830
+ * const json = spec.toJSON();
831
+ * res.json(json); // Send as API response
832
+ * ```
833
+ */
834
+ public toJSON(): AgentSpec {
835
+ return { ...this.spec };
836
+ }
837
+
838
+ /**
839
+ * Check if this spec has unsaved changes.
840
+ *
841
+ * @returns True if there are unsaved changes, false otherwise
842
+ */
843
+ public get isDirty(): boolean {
844
+ return this._isDirty;
845
+ }
846
+
847
+ /**
848
+ * Check if this spec has been loaded from the database.
849
+ *
850
+ * @returns True if loaded from database, false if created in memory
851
+ */
852
+ public get isLoaded(): boolean {
853
+ return this._isLoaded;
854
+ }
855
+
856
+ /**
857
+ * Mark the spec as having changes.
858
+ *
859
+ * Call this method after modifying the spec to indicate that changes need to be saved.
860
+ * The spec is automatically marked dirty when created with the constructor, but if you
861
+ * load a spec and then modify it, you should call this method.
862
+ *
863
+ * @example
864
+ * ```typescript
865
+ * const spec = await AgentSpecSync.LoadFromDatabase('agent-uuid', contextUser);
866
+ * spec.spec.Description = 'New description';
867
+ * spec.markDirty();
868
+ * await spec.SaveToDatabase();
869
+ * ```
870
+ */
871
+ public markDirty(): void {
872
+ this._isDirty = true;
873
+ }
874
+ }