@memberjunction/ai-agent-manager 2.107.0 → 2.109.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,1000 @@
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
+ SubAgent: {
417
+ ID: childAgent.ID,
418
+ Name: childAgent.Name || '',
419
+ StartingPayloadValidationMode: 'Fail'
420
+ }
421
+ };
422
+ }
423
+
424
+ /**
425
+ * Map related agent (relationship-based) to SubAgentSpec format.
426
+ *
427
+ * @private
428
+ * @param relationship - The agent relationship entity
429
+ * @returns Mapped sub-agent spec
430
+ */
431
+ private mapRelatedAgentToSpec(relationship: AIAgentRelationshipEntity): SubAgentSpec {
432
+ return {
433
+ Type: 'related',
434
+ SubAgent: {
435
+ ID: relationship.SubAgentID,
436
+ Name: relationship.SubAgent || '',
437
+ StartingPayloadValidationMode: 'Fail'
438
+ },
439
+ AgentRelationshipID: relationship.ID,
440
+ SubAgentInputMapping: this.parseJsonField<Record<string, string>>(relationship.SubAgentInputMapping),
441
+ SubAgentOutputMapping: this.parseJsonField<Record<string, string>>(relationship.SubAgentOutputMapping),
442
+ SubAgentContextPaths: this.parseJsonField<Record<string, string>>(relationship.SubAgentContextPaths)
443
+ };
444
+ }
445
+
446
+ // ===== SAVING METHODS =====
447
+
448
+ /**
449
+ * Save the current spec to the database.
450
+ *
451
+ * This will create new records or update existing ones based on whether IDs exist.
452
+ * The save operation is performed atomically - if any part fails, no changes are committed.
453
+ *
454
+ * Validation is performed before saving. All entity-level validations defined in the
455
+ * database schema are executed, and any failures will prevent the save.
456
+ *
457
+ * @param validate - Whether to validate before saving (default: true)
458
+ * @returns Promise resolving to the saved agent ID
459
+ * @throws {Error} If validation fails or save operation fails
460
+ *
461
+ * @example
462
+ * ```typescript
463
+ * const spec = new AgentSpecSync({
464
+ * Name: 'New Agent',
465
+ * InvocationMode: 'Any'
466
+ * }, contextUser);
467
+ *
468
+ * const agentId = await spec.SaveToDatabase();
469
+ * console.log('Saved with ID:', agentId);
470
+ * ```
471
+ */
472
+ async SaveToDatabase(validate: boolean = true): Promise<string> {
473
+ if (!this._isDirty && this._isLoaded) {
474
+ // No changes to save
475
+ return this.spec.ID;
476
+ }
477
+
478
+ // Step 1: Save main agent entity
479
+ const agentId = await this.saveAgentEntity(validate);
480
+
481
+ // Step 2: Save actions
482
+ await this.saveActions(agentId);
483
+
484
+ // Step 3: Save sub-agents (both child and related)
485
+ await this.saveSubAgents(agentId);
486
+
487
+ // Step 4: Save prompts
488
+ await this.savePrompts(agentId);
489
+
490
+ this._isDirty = false;
491
+ this._isLoaded = true;
492
+
493
+ return agentId;
494
+ }
495
+
496
+ /**
497
+ * Save the main AIAgent entity.
498
+ *
499
+ * @private
500
+ * @param validate - Whether to perform validation before saving
501
+ * @returns Promise resolving to the saved agent ID
502
+ * @throws {Error} If validation fails or save fails
503
+ */
504
+ private async saveAgentEntity(validate: boolean): Promise<string> {
505
+ const md = new Metadata();
506
+ const agentEntity = await md.GetEntityObject<AIAgentEntity>(
507
+ 'AI Agents',
508
+ this._contextUser
509
+ );
510
+
511
+ // If ID exists, load existing record
512
+ if (this.spec.ID) {
513
+ const loaded = await agentEntity.Load(this.spec.ID);
514
+ if (!loaded) {
515
+ throw new Error(`Cannot update non-existent agent with ID ${this.spec.ID}`);
516
+ }
517
+ }
518
+
519
+ // Map spec to entity fields
520
+ agentEntity.Name = this.spec.Name;
521
+ agentEntity.Description = this.spec.Description || null;
522
+ agentEntity.IconClass = this.spec.IconClass || null;
523
+ agentEntity.LogoURL = this.spec.LogoURL || null;
524
+ agentEntity.ParentID = this.spec.ParentID || null;
525
+ agentEntity.DriverClass = this.spec.DriverClass || null;
526
+ agentEntity.ModelSelectionMode = this.spec.ModelSelectionMode || 'Agent Type';
527
+
528
+ // Serialize JSON fields
529
+ agentEntity.PayloadDownstreamPaths = JSON.stringify(
530
+ this.spec.PayloadDownstreamPaths || ['*']
531
+ );
532
+ agentEntity.PayloadUpstreamPaths = JSON.stringify(
533
+ this.spec.PayloadUpstreamPaths || ['*']
534
+ );
535
+ agentEntity.PayloadSelfReadPaths = this.spec.PayloadSelfReadPaths
536
+ ? JSON.stringify(this.spec.PayloadSelfReadPaths)
537
+ : null;
538
+ agentEntity.PayloadSelfWritePaths = this.spec.PayloadSelfWritePaths
539
+ ? JSON.stringify(this.spec.PayloadSelfWritePaths)
540
+ : null;
541
+ agentEntity.PayloadScope = this.spec.PayloadScope || null;
542
+
543
+ // Validation fields
544
+ agentEntity.FinalPayloadValidation = this.spec.FinalPayloadValidation || null;
545
+ agentEntity.FinalPayloadValidationMode = this.spec.FinalPayloadValidationMode || 'Retry';
546
+ agentEntity.FinalPayloadValidationMaxRetries = this.spec.FinalPayloadValidationMaxRetries || 3;
547
+
548
+ agentEntity.StartingPayloadValidation = this.spec.StartingPayloadValidation || null;
549
+ agentEntity.StartingPayloadValidationMode = this.spec.StartingPayloadValidationMode || 'Fail';
550
+
551
+ // Resource limits
552
+ agentEntity.MaxCostPerRun = this.spec.MaxCostPerRun || null;
553
+ agentEntity.MaxTokensPerRun = this.spec.MaxTokensPerRun || null;
554
+ agentEntity.MaxIterationsPerRun = this.spec.MaxIterationsPerRun || null;
555
+ agentEntity.MaxTimePerRun = this.spec.MaxTimePerRun || null;
556
+
557
+ // Execution frequency
558
+ agentEntity.MinExecutionsPerRun = this.spec.MinExecutionsPerRun || null;
559
+ agentEntity.MaxExecutionsPerRun = this.spec.MaxExecutionsPerRun || null;
560
+
561
+ // Other config
562
+ agentEntity.DefaultPromptEffortLevel = this.spec.DefaultPromptEffortLevel || null;
563
+ agentEntity.ChatHandlingOption = this.spec.ChatHandlingOption || null;
564
+ agentEntity.DefaultArtifactTypeID = this.spec.DefaultArtifactTypeID || null;
565
+ if (this.spec.OwnerUserID) {
566
+ agentEntity.OwnerUserID = this.spec.OwnerUserID;
567
+ }
568
+ agentEntity.InvocationMode = this.spec.InvocationMode || 'Any';
569
+
570
+ // Validate if requested
571
+ if (validate) {
572
+ const validation = agentEntity.Validate();
573
+ if (!validation.Success) {
574
+ const errors = validation.Errors.map(e => e.Message).join(', ');
575
+ throw new Error(`Agent validation failed: ${errors}`);
576
+ }
577
+ }
578
+
579
+ // Save
580
+ const saved = await agentEntity.Save();
581
+ if (!saved) {
582
+ throw new Error('Failed to save agent entity');
583
+ }
584
+
585
+ // Update spec with saved ID
586
+ this.spec.ID = agentEntity.ID;
587
+ return agentEntity.ID;
588
+ }
589
+
590
+ /**
591
+ * Save all actions for this agent.
592
+ *
593
+ * Creates or updates agent action records. Existing actions are updated, new actions
594
+ * are created. This method does not delete actions that are no longer in the spec -
595
+ * use a separate delete method for that.
596
+ *
597
+ * @private
598
+ * @param agentId - The parent agent ID
599
+ * @throws {Error} If any action save fails
600
+ */
601
+ private async saveActions(agentId: string): Promise<void> {
602
+ if (!this.spec.Actions || this.spec.Actions.length === 0) {
603
+ return;
604
+ }
605
+
606
+ const md = new Metadata();
607
+
608
+ for (const actionSpec of this.spec.Actions) {
609
+ const actionEntity = await md.GetEntityObject<AIAgentActionEntity>(
610
+ 'AI Agent Actions',
611
+ this._contextUser
612
+ );
613
+
614
+ // Load existing if ID provided
615
+ if (actionSpec.AgentActionID) {
616
+ await actionEntity.Load(actionSpec.AgentActionID);
617
+ }
618
+
619
+ // Map fields
620
+ actionEntity.AgentID = agentId;
621
+ actionEntity.ActionID = actionSpec.ActionID;
622
+ actionEntity.Status = actionSpec.Status;
623
+ actionEntity.MaxExecutionsPerRun = actionSpec.MaxExecutionsPerRun || null;
624
+ actionEntity.ResultExpirationTurns = actionSpec.ResultExpirationTurns || null;
625
+ actionEntity.ResultExpirationMode = actionSpec.ResultExpirationMode || 'None';
626
+ actionEntity.CompactMode = actionSpec.CompactMode || null;
627
+ actionEntity.CompactLength = actionSpec.CompactLength || null;
628
+ actionEntity.CompactPromptID = actionSpec.CompactPromptID || null;
629
+
630
+ const saved = await actionEntity.Save();
631
+ if (!saved) {
632
+ throw new Error(`Failed to save action ${actionSpec.ActionID}`);
633
+ }
634
+
635
+ // Update spec with saved ID
636
+ actionSpec.AgentActionID = actionEntity.ID;
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Save all sub-agents (both child and related types).
642
+ *
643
+ * Handles both ParentID-based child agents and relationship-based related agents.
644
+ * For child agents, updates the ParentID on the sub-agent entity. For related agents,
645
+ * creates or updates the relationship record.
646
+ *
647
+ * @private
648
+ * @param agentId - The parent agent ID
649
+ * @throws {Error} If any sub-agent save fails
650
+ */
651
+ private async saveSubAgents(agentId: string): Promise<void> {
652
+ if (!this.spec.SubAgents || this.spec.SubAgents.length === 0) {
653
+ return;
654
+ }
655
+
656
+ for (const SubAgentSpec of this.spec.SubAgents) {
657
+ if (SubAgentSpec.Type === 'child') {
658
+ await this.saveChildSubAgent(agentId, SubAgentSpec);
659
+ } else {
660
+ await this.saveRelatedSubAgent(agentId, SubAgentSpec);
661
+ }
662
+ }
663
+ }
664
+
665
+ /**
666
+ * Save a child sub-agent (ParentID-based relationship).
667
+ *
668
+ * For child agents, the relationship is established by setting the ParentID field
669
+ * on the child agent entity. If the SubAgent.ID is empty, this creates a new
670
+ * child agent recursively using AgentSpecSync.
671
+ *
672
+ * This enables creating complete agent hierarchies in one call - sub-agents are
673
+ * created first (depth-first), then parent references them via ParentID.
674
+ *
675
+ * @private
676
+ * @param parentId - The parent agent ID
677
+ * @param SubAgentSpec - The sub-agent specification
678
+ * @throws {Error} If child agent save fails
679
+ */
680
+ private async saveChildSubAgent(parentId: string, SubAgentSpec: SubAgentSpec): Promise<void> {
681
+ console.log(`🔗 saveChildSubAgent: Processing child "${SubAgentSpec.SubAgent?.Name}", ID="${SubAgentSpec.SubAgent?.ID}"`);
682
+
683
+ // If SubAgent.ID is empty or missing, create the sub-agent recursively
684
+ if (!SubAgentSpec.SubAgent?.ID || SubAgentSpec.SubAgent.ID === '') {
685
+ console.log(`🔨 saveChildSubAgent: Creating new child sub-agent "${SubAgentSpec.SubAgent.Name}"...`);
686
+
687
+ // Set ParentID in the sub-agent spec before creating it
688
+ const childSpec: AgentSpec = {
689
+ ...SubAgentSpec.SubAgent,
690
+ ParentID: parentId
691
+ };
692
+
693
+ // Recursively create the sub-agent using AgentSpecSync
694
+ const childSync = new AgentSpecSync(childSpec, this._contextUser);
695
+ childSync.markDirty();
696
+ const childId = await childSync.SaveToDatabase();
697
+
698
+ // Update the SubAgentSpec with the created ID so parent can reference it
699
+ SubAgentSpec.SubAgent.ID = childId;
700
+
701
+ console.log(`✅ saveChildSubAgent: Created child sub-agent with ID: ${childId}`);
702
+ } else {
703
+ // SubAgent already exists - just ensure ParentID is set correctly
704
+ console.log(`🔗 saveChildSubAgent: Updating existing child sub-agent "${SubAgentSpec.SubAgent.ID}"...`);
705
+
706
+ const md = new Metadata();
707
+ const childEntity = await md.GetEntityObject<AIAgentEntity>(
708
+ 'AI Agents',
709
+ this._contextUser
710
+ );
711
+
712
+ const loaded = await childEntity.Load(SubAgentSpec.SubAgent.ID);
713
+ if (!loaded) {
714
+ throw new Error(`Child agent ${SubAgentSpec.SubAgent.ID} not found in database`);
715
+ }
716
+
717
+ // Update parent ID if needed
718
+ if (childEntity.ParentID !== parentId) {
719
+ console.log(`🔗 saveChildSubAgent: Setting ParentID to ${parentId}`);
720
+ childEntity.ParentID = parentId;
721
+ const saved = await childEntity.Save();
722
+ if (!saved) {
723
+ throw new Error(`Failed to update ParentID for child agent ${SubAgentSpec.SubAgent.ID}`);
724
+ }
725
+ } else {
726
+ console.log(`✅ saveChildSubAgent: ParentID already correct`);
727
+ }
728
+ }
729
+ }
730
+
731
+ /**
732
+ * Save a related sub-agent (relationship-based).
733
+ *
734
+ * Creates or updates an AIAgentRelationship record that links the parent and
735
+ * sub-agent. This includes the input/output mapping and context path configurations.
736
+ *
737
+ * @private
738
+ * @param agentId - The parent agent ID
739
+ * @param SubAgentSpec - The sub-agent specification
740
+ * @throws {Error} If relationship save fails
741
+ */
742
+ private async saveRelatedSubAgent(agentId: string, SubAgentSpec: SubAgentSpec): Promise<void> {
743
+ const md = new Metadata();
744
+ const relationshipEntity = await md.GetEntityObject<AIAgentRelationshipEntity>(
745
+ 'MJ: AI Agent Relationships',
746
+ this._contextUser
747
+ );
748
+
749
+ // Load existing if ID provided
750
+ if (SubAgentSpec.AgentRelationshipID) {
751
+ await relationshipEntity.Load(SubAgentSpec.AgentRelationshipID);
752
+ }
753
+
754
+ // Map fields
755
+ relationshipEntity.AgentID = agentId;
756
+ relationshipEntity.SubAgentID = SubAgentSpec.SubAgent.ID;
757
+ relationshipEntity.Status = 'Active';
758
+
759
+ // Serialize mapping fields
760
+ if (SubAgentSpec.SubAgentInputMapping) {
761
+ relationshipEntity.SubAgentInputMapping = JSON.stringify(SubAgentSpec.SubAgentInputMapping);
762
+ } else {
763
+ relationshipEntity.SubAgentInputMapping = null;
764
+ }
765
+
766
+ if (SubAgentSpec.SubAgentOutputMapping) {
767
+ relationshipEntity.SubAgentOutputMapping = JSON.stringify(SubAgentSpec.SubAgentOutputMapping);
768
+ } else {
769
+ relationshipEntity.SubAgentOutputMapping = null;
770
+ }
771
+
772
+ if (SubAgentSpec.SubAgentContextPaths) {
773
+ relationshipEntity.SubAgentContextPaths = JSON.stringify(SubAgentSpec.SubAgentContextPaths);
774
+ } else {
775
+ relationshipEntity.SubAgentContextPaths = null;
776
+ }
777
+
778
+ const saved = await relationshipEntity.Save();
779
+ if (!saved) {
780
+ throw new Error(`Failed to save relationship for sub-agent ${SubAgentSpec.SubAgent.ID}`);
781
+ }
782
+
783
+ // Update spec with saved ID
784
+ SubAgentSpec.AgentRelationshipID = relationshipEntity.ID;
785
+ }
786
+
787
+ /**
788
+ * Save all prompts for this agent.
789
+ *
790
+ * Creates AIPrompt records (with template) and AIAgentPrompt junction records.
791
+ * Supports simplified prompt format from Architect Agent with just PromptText,
792
+ * PromptRole, and PromptPosition.
793
+ *
794
+ * @private
795
+ * @param agentId - The parent agent ID
796
+ * @throws {Error} If any prompt save fails
797
+ */
798
+ private async savePrompts(agentId: string): Promise<void> {
799
+ console.log(`💬 savePrompts: Called with agentId=${agentId}, Prompts=${this.spec.Prompts ? this.spec.Prompts.length : 'undefined'}`);
800
+
801
+ if (!this.spec.Prompts || this.spec.Prompts.length === 0) {
802
+ console.log('💬 savePrompts: No prompts to save, returning early');
803
+ return;
804
+ }
805
+
806
+ console.log(`💬 savePrompts: Processing ${this.spec.Prompts.length} prompt(s)...`);
807
+ const md = new Metadata();
808
+
809
+ for (let i = 0; i < this.spec.Prompts.length; i++) {
810
+ const promptSpec = this.spec.Prompts[i];
811
+
812
+ // Create AIPrompt entity
813
+ const promptEntity = await md.GetEntityObject<any>(
814
+ 'AI Prompts',
815
+ this._contextUser
816
+ );
817
+
818
+ // Set required fields
819
+ promptEntity.Name = `${this.spec.Name} - Prompt ${i + 1}`;
820
+ promptEntity.Description = `Agent prompt ${i + 1} for ${this.spec.Name}`;
821
+ promptEntity.TypeID = 'a6da423e-f36b-1410-8dac-00021f8b792e'; // Chat type
822
+ promptEntity.Status = 'Active';
823
+ promptEntity.ResponseFormat = 'JSON';
824
+
825
+ // Handle prompt text - supports both string and object formats
826
+ if (typeof (promptSpec as any).PromptText === 'string') {
827
+ promptEntity.TemplateText = (promptSpec as any).PromptText;
828
+ } else if (typeof (promptSpec as any).PromptText === 'object') {
829
+ // Architect may send PromptText as {text: "...", json: {...}}
830
+ const promptTextObj = (promptSpec as any).PromptText as any;
831
+ let combinedText = promptTextObj.text || '';
832
+ if (promptTextObj.json) {
833
+ combinedText += '\n\n```json\n' + JSON.stringify(promptTextObj.json, null, 2) + '\n```';
834
+ }
835
+ promptEntity.TemplateText = combinedText;
836
+ }
837
+
838
+ // Set prompt role and position if provided
839
+ if ((promptSpec as any).PromptRole) {
840
+ promptEntity.PromptRole = (promptSpec as any).PromptRole;
841
+ }
842
+ if ((promptSpec as any).PromptPosition) {
843
+ promptEntity.PromptPosition = (promptSpec as any).PromptPosition;
844
+ }
845
+
846
+ const saved = await promptEntity.Save();
847
+ if (!saved) {
848
+ throw new Error(`Failed to save prompt ${i + 1} for agent ${this.spec.Name}`);
849
+ }
850
+
851
+ console.log(`✅ savePrompts: Created AIPrompt with ID: ${promptEntity.ID}`);
852
+
853
+ // Create AIAgentPrompt junction
854
+ const agentPromptEntity = await md.GetEntityObject<any>(
855
+ 'MJ: AI Agent Prompts',
856
+ this._contextUser
857
+ );
858
+
859
+ agentPromptEntity.AgentID = agentId;
860
+ agentPromptEntity.PromptID = promptEntity.ID;
861
+ agentPromptEntity.ExecutionOrder = i;
862
+ agentPromptEntity.Status = 'Active';
863
+
864
+ const junctionSaved = await agentPromptEntity.Save();
865
+ if (!junctionSaved) {
866
+ throw new Error(`Failed to save agent-prompt junction for prompt ${i + 1}`);
867
+ }
868
+
869
+ console.log(`✅ savePrompts: Created AIAgentPrompt junction with ID: ${agentPromptEntity.ID}`);
870
+ }
871
+
872
+ console.log(`✅ savePrompts: Successfully saved all ${this.spec.Prompts.length} prompt(s)`);
873
+ }
874
+
875
+ // ===== UTILITY METHODS =====
876
+
877
+ /**
878
+ * Parse a JSON string field, returning undefined if null/empty.
879
+ *
880
+ * Safely parses JSON fields from the database, handling null/undefined values
881
+ * and logging errors if parsing fails.
882
+ *
883
+ * @private
884
+ * @param jsonString - The JSON string to parse
885
+ * @returns Parsed object or undefined if null/empty/invalid
886
+ */
887
+ private parseJsonField<T>(jsonString: string | null | undefined): T | undefined {
888
+ if (!jsonString) return undefined;
889
+ try {
890
+ return JSON.parse(jsonString) as T;
891
+ } catch (error) {
892
+ LogError(`Failed to parse JSON field: ${error}`);
893
+ return undefined;
894
+ }
895
+ }
896
+
897
+ /**
898
+ * Initialize a spec with defaults for any missing required fields.
899
+ *
900
+ * Takes a partial spec and fills in defaults for any missing fields to ensure
901
+ * a valid AgentSpec structure.
902
+ *
903
+ * @private
904
+ * @param partial - Partial agent specification
905
+ * @returns Complete AgentSpec with defaults
906
+ */
907
+ private initializeSpec(partial: Partial<AgentSpec>): AgentSpec {
908
+ return {
909
+ ID: partial.ID || '',
910
+ Name: partial.Name || '',
911
+ Description: partial.Description,
912
+ IconClass: partial.IconClass,
913
+ LogoURL: partial.LogoURL,
914
+ ParentID: partial.ParentID,
915
+ DriverClass: partial.DriverClass,
916
+ ModelSelectionMode: partial.ModelSelectionMode || 'Agent Type',
917
+ PayloadDownstreamPaths: partial.PayloadDownstreamPaths,
918
+ PayloadUpstreamPaths: partial.PayloadUpstreamPaths,
919
+ PayloadSelfReadPaths: partial.PayloadSelfReadPaths,
920
+ PayloadSelfWritePaths: partial.PayloadSelfWritePaths,
921
+ PayloadScope: partial.PayloadScope,
922
+ FinalPayloadValidation: partial.FinalPayloadValidation,
923
+ FinalPayloadValidationMode: partial.FinalPayloadValidationMode || 'Retry',
924
+ FinalPayloadValidationMaxRetries: partial.FinalPayloadValidationMaxRetries,
925
+ MaxCostPerRun: partial.MaxCostPerRun,
926
+ MaxTokensPerRun: partial.MaxTokensPerRun,
927
+ MaxIterationsPerRun: partial.MaxIterationsPerRun,
928
+ MaxTimePerRun: partial.MaxTimePerRun,
929
+ MinExecutionsPerRun: partial.MinExecutionsPerRun,
930
+ MaxExecutionsPerRun: partial.MaxExecutionsPerRun,
931
+ StartingPayloadValidation: partial.StartingPayloadValidation,
932
+ StartingPayloadValidationMode: partial.StartingPayloadValidationMode || 'Fail',
933
+ DefaultPromptEffortLevel: partial.DefaultPromptEffortLevel,
934
+ ChatHandlingOption: partial.ChatHandlingOption,
935
+ DefaultArtifactTypeID: partial.DefaultArtifactTypeID,
936
+ OwnerUserID: partial.OwnerUserID,
937
+ InvocationMode: partial.InvocationMode || 'Any',
938
+ Actions: partial.Actions || [],
939
+ SubAgents: partial.SubAgents || [],
940
+ Prompts: partial.Prompts || []
941
+ };
942
+ }
943
+
944
+ /**
945
+ * Get a clean serializable version of the spec.
946
+ *
947
+ * Returns a plain JavaScript object suitable for JSON serialization,
948
+ * API responses, or storage. This is useful when you need to send the
949
+ * agent spec over the wire or store it in a file.
950
+ *
951
+ * @returns Clean copy of the agent specification
952
+ *
953
+ * @example
954
+ * ```typescript
955
+ * const spec = await AgentSpecSync.LoadFromDatabase('agent-uuid', contextUser);
956
+ * const json = spec.toJSON();
957
+ * res.json(json); // Send as API response
958
+ * ```
959
+ */
960
+ public toJSON(): AgentSpec {
961
+ return { ...this.spec };
962
+ }
963
+
964
+ /**
965
+ * Check if this spec has unsaved changes.
966
+ *
967
+ * @returns True if there are unsaved changes, false otherwise
968
+ */
969
+ public get isDirty(): boolean {
970
+ return this._isDirty;
971
+ }
972
+
973
+ /**
974
+ * Check if this spec has been loaded from the database.
975
+ *
976
+ * @returns True if loaded from database, false if created in memory
977
+ */
978
+ public get isLoaded(): boolean {
979
+ return this._isLoaded;
980
+ }
981
+
982
+ /**
983
+ * Mark the spec as having changes.
984
+ *
985
+ * Call this method after modifying the spec to indicate that changes need to be saved.
986
+ * The spec is automatically marked dirty when created with the constructor, but if you
987
+ * load a spec and then modify it, you should call this method.
988
+ *
989
+ * @example
990
+ * ```typescript
991
+ * const spec = await AgentSpecSync.LoadFromDatabase('agent-uuid', contextUser);
992
+ * spec.spec.Description = 'New description';
993
+ * spec.markDirty();
994
+ * await spec.SaveToDatabase();
995
+ * ```
996
+ */
997
+ public markDirty(): void {
998
+ this._isDirty = true;
999
+ }
1000
+ }