@memberjunction/ai-agent-manager 2.112.0 → 2.113.1

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,37 +1,46 @@
1
- import { Metadata, RunView, UserInfo, LogError } from '@memberjunction/global';
2
1
  import {
3
- AIAgentEntity,
4
- AIAgentActionEntity,
5
- AIAgentRelationshipEntity,
6
- AIAgentStepEntity,
7
- AIAgentStepPathEntity,
2
+ Metadata,
3
+ RunView,
4
+ UserInfo,
5
+ LogError
6
+ } from '@memberjunction/core';
7
+ import {
8
+ AIAgentEntity,
9
+ AIAgentActionEntity,
10
+ AIAgentRelationshipEntity,
11
+ AIAgentStepEntity,
12
+ AIAgentStepPathEntity
8
13
  } from '@memberjunction/core-entities';
9
- import { AgentSpec, AgentActionSpec, SubAgentSpec } from '@memberjunction/ai-core-plus';
14
+ import {
15
+ AgentSpec,
16
+ AgentActionSpec,
17
+ SubAgentSpec
18
+ } from '@memberjunction/ai-core-plus';
10
19
 
11
20
  /**
12
21
  * Represents a single database mutation performed by AgentSpecSync
13
22
  */
14
23
  export interface AgentSpecSyncMutation {
15
- /** Entity name (e.g., "AI Agents", "AI Prompts", "AIAgentAction") */
16
- Entity: string;
17
- /** Operation type */
18
- Operation: 'Create' | 'Update' | 'Delete';
19
- /** Record ID */
20
- ID: string;
21
- /** Human-readable description of what was done */
22
- Description: string;
24
+ /** Entity name (e.g., "AI Agents", "AI Prompts", "AIAgentAction") */
25
+ Entity: string;
26
+ /** Operation type */
27
+ Operation: 'Create' | 'Update' | 'Delete';
28
+ /** Record ID */
29
+ ID: string;
30
+ /** Human-readable description of what was done */
31
+ Description: string;
23
32
  }
24
33
 
25
34
  /**
26
35
  * Result of SaveToDatabase operation including all mutations performed
27
36
  */
28
37
  export interface AgentSpecSyncResult {
29
- /** ID of the saved agent */
30
- agentId: string;
31
- /** Whether the operation succeeded */
32
- success: boolean;
33
- /** Array of all database mutations performed */
34
- mutations: AgentSpecSyncMutation[];
38
+ /** ID of the saved agent */
39
+ agentId: string;
40
+ /** Whether the operation succeeded */
41
+ success: boolean;
42
+ /** Array of all database mutations performed */
43
+ mutations: AgentSpecSyncMutation[];
35
44
  }
36
45
 
37
46
  /**
@@ -40,12 +49,12 @@ export interface AgentSpecSyncResult {
40
49
  * @private
41
50
  */
42
51
  interface DatabaseState {
43
- actions: AIAgentActionEntity[];
44
- prompts: any[]; // AIAgentPromptEntity (junction records)
45
- relationships: AIAgentRelationshipEntity[];
46
- steps: AIAgentStepEntity[];
47
- paths: AIAgentStepPathEntity[];
48
- childAgents: AIAgentEntity[];
52
+ actions: AIAgentActionEntity[];
53
+ prompts: any[]; // AIAgentPromptEntity (junction records)
54
+ relationships: AIAgentRelationshipEntity[];
55
+ steps: AIAgentStepEntity[];
56
+ paths: AIAgentStepPathEntity[];
57
+ childAgents: AIAgentEntity[];
49
58
  }
50
59
 
51
60
  /**
@@ -54,12 +63,12 @@ interface DatabaseState {
54
63
  * @private
55
64
  */
56
65
  interface Orphans {
57
- actions: AIAgentActionEntity[];
58
- prompts: any[]; // AIAgentPromptEntity junctions (NOT the AIPrompt entities themselves)
59
- relationships: AIAgentRelationshipEntity[];
60
- steps: AIAgentStepEntity[];
61
- paths: AIAgentStepPathEntity[];
62
- childAgents: AIAgentEntity[]; // Will be orphaned (ParentID = NULL) not deleted
66
+ actions: AIAgentActionEntity[];
67
+ prompts: any[]; // AIAgentPromptEntity junctions (NOT the AIPrompt entities themselves)
68
+ relationships: AIAgentRelationshipEntity[];
69
+ steps: AIAgentStepEntity[];
70
+ paths: AIAgentStepPathEntity[];
71
+ childAgents: AIAgentEntity[]; // Will be orphaned (ParentID = NULL) not deleted
63
72
  }
64
73
 
65
74
  /**
@@ -113,1802 +122,1859 @@ interface Orphans {
113
122
  * @module @memberjunction/ai-agent-manager
114
123
  */
115
124
  export class AgentSpecSync {
116
- /**
117
- * The raw specification data structure containing all agent configuration
118
- */
119
- public spec: AgentSpec;
120
-
121
- /**
122
- * Tracks whether this spec has been loaded from the database
123
- * @private
124
- */
125
- private _isLoaded: boolean = false;
126
-
127
- /**
128
- * Tracks whether this spec has unsaved changes
129
- * @private
130
- */
131
- private _isDirty: boolean = false;
132
-
133
- /**
134
- * Context user for database operations (required for server-side operations)
135
- * @private
136
- */
137
- private _contextUser?: UserInfo;
138
-
139
- /**
140
- * Tracks all database mutations performed during save operations
141
- * @private
142
- */
143
- private _mutations: AgentSpecSyncMutation[] = [];
144
-
145
- /**
146
- * Create a new AgentSpecSync instance.
147
- *
148
- * Note: This constructor is typically not called directly. Instead, use the static factory methods:
149
- * - {@link LoadFromDatabase} - Load existing agent from database
150
- * - {@link LoadByName} - Load agent by name
151
- * - {@link FromRawSpec} - Create from raw spec data
152
- *
153
- * @param spec - Optional initial spec data (for creating new agents or working with existing data)
154
- * @param contextUser - Optional context user (required for server-side operations)
155
- */
156
- constructor(spec?: Partial<AgentSpec>, contextUser?: UserInfo) {
157
- if (spec) {
158
- this.spec = this.initializeSpec(spec);
159
- this._isDirty = true;
160
- } else {
161
- // Create minimal empty spec
162
- this.spec = {
163
- ID: '', // Will be set on save if empty
164
- Name: '',
165
- StartingPayloadValidationMode: 'Fail',
166
- Actions: [],
167
- SubAgents: [],
168
- };
169
- }
170
- this._contextUser = contextUser;
171
- }
172
-
173
- // ===== MUTATION TRACKING METHODS =====
174
-
175
- /**
176
- * Track a database mutation performed during save operation
177
- * @private
178
- */
179
- private trackMutation(entity: string, operation: 'Create' | 'Update' | 'Delete', id: string, description: string): void {
180
- this._mutations.push({
181
- Entity: entity,
182
- Operation: operation,
183
- ID: id,
184
- Description: description,
185
- });
186
- }
187
-
188
- /**
189
- * Get all mutations tracked during the last save operation
190
- * @returns Array of mutations
191
- */
192
- public getMutations(): AgentSpecSyncMutation[] {
193
- return [...this._mutations];
194
- }
195
-
196
- /**
197
- * Clear all tracked mutations
198
- */
199
- public clearMutations(): void {
200
- this._mutations = [];
201
- }
202
-
203
- // ===== STATIC FACTORY METHODS =====
204
-
205
- /**
206
- * Load an agent and its complete hierarchy from the database by ID.
207
- *
208
- * This method efficiently loads the agent along with all its actions and sub-agents
209
- * using batched queries to minimize database round trips. When `includeSubAgents` is true,
210
- * it recursively loads the entire agent hierarchy.
211
- *
212
- * @param agentId - The unique ID of the agent to load
213
- * @param contextUser - Optional context user (required for server-side operations)
214
- * @param includeSubAgents - Whether to recursively load all sub-agents (default: true)
215
- * @returns Promise resolving to AgentSpecSync instance with loaded data
216
- * @throws {Error} If agent with specified ID is not found
217
- *
218
- * @example
219
- * ```typescript
220
- * // Load agent with all sub-agents
221
- * const spec = await AgentSpecSync.LoadFromDatabase(
222
- * 'agent-uuid-here',
223
- * contextUser,
224
- * true
225
- * );
226
- * ```
227
- */
228
- static async LoadFromDatabase(agentId: string, contextUser?: UserInfo, includeSubAgents: boolean = true): Promise<AgentSpecSync> {
229
- const instance = new AgentSpecSync(undefined, contextUser);
230
- await instance.loadFromEntities(agentId, includeSubAgents);
231
- return instance;
232
- }
233
-
234
- /**
235
- * Load an agent by name (must be unique).
236
- *
237
- * Searches for an agent with the specified name and loads it. If multiple agents
238
- * have the same name, an error is thrown. Agent names should be unique within
239
- * the system for this method to work reliably.
240
- *
241
- * @param agentName - The name of the agent to load
242
- * @param contextUser - Optional context user (required for server-side operations)
243
- * @param includeSubAgents - Whether to recursively load sub-agents (default: true)
244
- * @returns Promise resolving to AgentSpecSync instance with loaded data
245
- * @throws {Error} If agent is not found or multiple agents have the same name
246
- *
247
- * @example
248
- * ```typescript
249
- * const spec = await AgentSpecSync.LoadByName('My Agent', contextUser);
250
- * ```
251
- */
252
- static async LoadByName(agentName: string, contextUser?: UserInfo, includeSubAgents: boolean = true): Promise<AgentSpecSync> {
253
- // Find agent by name
254
- const rv = new RunView();
255
- const result = await rv.RunView<AIAgentEntity>(
256
- {
257
- EntityName: 'AI Agents',
258
- ExtraFilter: `Name='${agentName.replace(/'/g, "''")}'`,
259
- ResultType: 'entity_object',
260
- },
261
- contextUser
262
- );
263
-
264
- if (!result.Success) {
265
- throw new Error(`Failed to find agent by name: ${result.ErrorMessage}`);
125
+ /**
126
+ * The raw specification data structure containing all agent configuration
127
+ */
128
+ public spec: AgentSpec;
129
+
130
+ /**
131
+ * Tracks whether this spec has been loaded from the database
132
+ * @private
133
+ */
134
+ private _isLoaded: boolean = false;
135
+
136
+ /**
137
+ * Tracks whether this spec has unsaved changes
138
+ * @private
139
+ */
140
+ private _isDirty: boolean = false;
141
+
142
+ /**
143
+ * Context user for database operations (required for server-side operations)
144
+ * @private
145
+ */
146
+ private _contextUser?: UserInfo;
147
+
148
+ /**
149
+ * Tracks all database mutations performed during save operations
150
+ * @private
151
+ */
152
+ private _mutations: AgentSpecSyncMutation[] = [];
153
+
154
+ /**
155
+ * Create a new AgentSpecSync instance.
156
+ *
157
+ * Note: This constructor is typically not called directly. Instead, use the static factory methods:
158
+ * - {@link LoadFromDatabase} - Load existing agent from database
159
+ * - {@link LoadByName} - Load agent by name
160
+ * - {@link FromRawSpec} - Create from raw spec data
161
+ *
162
+ * @param spec - Optional initial spec data (for creating new agents or working with existing data)
163
+ * @param contextUser - Optional context user (required for server-side operations)
164
+ */
165
+ constructor(spec?: Partial<AgentSpec>, contextUser?: UserInfo) {
166
+ if (spec) {
167
+ this.spec = this.initializeSpec(spec);
168
+ this._isDirty = true;
169
+ } else {
170
+ // Create minimal empty spec
171
+ this.spec = {
172
+ ID: '', // Will be set on save if empty
173
+ Name: '',
174
+ StartingPayloadValidationMode: 'Fail',
175
+ Actions: [],
176
+ SubAgents: []
177
+ };
178
+ }
179
+ this._contextUser = contextUser;
266
180
  }
267
181
 
268
- if (!result.Results || result.Results.length === 0) {
269
- throw new Error(`Agent with name '${agentName}' not found`);
182
+ // ===== MUTATION TRACKING METHODS =====
183
+
184
+ /**
185
+ * Track a database mutation performed during save operation
186
+ * @private
187
+ */
188
+ private trackMutation(
189
+ entity: string,
190
+ operation: 'Create' | 'Update' | 'Delete',
191
+ id: string,
192
+ description: string
193
+ ): void {
194
+ this._mutations.push({
195
+ Entity: entity,
196
+ Operation: operation,
197
+ ID: id,
198
+ Description: description
199
+ });
270
200
  }
271
201
 
272
- if (result.Results.length > 1) {
273
- throw new Error(`Multiple agents found with name '${agentName}'. Use LoadFromDatabase with specific ID instead.`);
202
+ /**
203
+ * Get all mutations tracked during the last save operation
204
+ * @returns Array of mutations
205
+ */
206
+ public getMutations(): AgentSpecSyncMutation[] {
207
+ return [...this._mutations];
274
208
  }
275
209
 
276
- const agent = result.Results[0];
277
- return AgentSpecSync.LoadFromDatabase(agent.ID, contextUser, includeSubAgents);
278
- }
279
-
280
- /**
281
- * Create a new agent spec from a raw specification.
282
- *
283
- * This creates an in-memory AgentSpecSync instance from raw data. The agent is not
284
- * saved to the database until {@link SaveToDatabase} is called.
285
- *
286
- * @param rawSpec - The raw spec data conforming to {@link AgentSpec} interface
287
- * @param contextUser - Optional context user (required when saving server-side)
288
- * @returns New AgentSpecSync instance (not yet saved to database)
289
- *
290
- * @example
291
- * ```typescript
292
- * const rawSpec: AgentSpec = {
293
- * ID: '',
294
- * Name: 'New Agent',
295
- * Description: 'Agent description',
296
- * InvocationMode: 'Any',
297
- * Actions: [],
298
- * SubAgents: []
299
- * };
300
- * const spec = AgentSpecSync.FromRawSpec(rawSpec, contextUser);
301
- * await spec.SaveToDatabase();
302
- * ```
303
- */
304
- static FromRawSpec(rawSpec: AgentSpec, contextUser?: UserInfo): AgentSpecSync {
305
- return new AgentSpecSync(rawSpec, contextUser);
306
- }
307
-
308
- // ===== LOADING METHODS =====
309
-
310
- /**
311
- * Load the complete agent specification from database entities.
312
- *
313
- * This method orchestrates loading from AIAgent, AIAgentAction, and AIAgentRelationship tables
314
- * using batched queries for optimal performance. It handles both child agents (ParentID-based)
315
- * and related agents (relationship-based).
316
- *
317
- * @private
318
- * @param agentId - The agent ID to load
319
- * @param includeSubAgents - Whether to recursively load sub-agents
320
- * @throws {Error} If agent is not found
321
- */
322
- private async loadFromEntities(agentId: string, includeSubAgents: boolean): Promise<void> {
323
- const md = new Metadata();
324
- const rv = new RunView();
325
-
326
- // Step 1: Load the main agent entity
327
- const agentEntity = await md.GetEntityObject<AIAgentEntity>('AI Agents', this._contextUser);
328
- const loaded = await agentEntity.Load(agentId);
329
- if (!loaded) {
330
- throw new Error(`Agent with ID ${agentId} not found`);
210
+ /**
211
+ * Clear all tracked mutations
212
+ */
213
+ public clearMutations(): void {
214
+ this._mutations = [];
331
215
  }
332
216
 
333
- // Step 2: Batch load related entities using RunViews for optimal performance
334
- // Note: Load paths separately after steps to avoid hardcoded view name in subquery
335
- const [actionsResult, childAgentsResult, relatedAgentsResult, promptsResult, stepsResult] = await rv.RunViews(
336
- [
337
- {
338
- EntityName: 'AI Agent Actions',
339
- ExtraFilter: `AgentID='${agentId}'`,
340
- OrderBy: 'Status, ActionID',
341
- ResultType: 'entity_object',
342
- },
343
- {
344
- EntityName: 'AI Agents',
345
- ExtraFilter: `ParentID='${agentId}'`,
346
- OrderBy: 'ExecutionOrder, Name',
347
- ResultType: 'entity_object',
348
- },
349
- {
350
- EntityName: 'MJ: AI Agent Relationships',
351
- ExtraFilter: `AgentID='${agentId}' AND Status='Active'`,
352
- OrderBy: '__mj_CreatedAt',
353
- ResultType: 'entity_object',
354
- },
355
- {
356
- EntityName: 'MJ: AI Agent Prompts',
357
- ExtraFilter: `AgentID='${agentId}'`,
358
- OrderBy: 'ExecutionOrder',
359
- ResultType: 'entity_object',
360
- },
361
- {
362
- EntityName: 'MJ: AI Agent Steps',
363
- ExtraFilter: `AgentID='${agentId}'`,
364
- OrderBy: 'Name',
365
- ResultType: 'entity_object',
366
- },
367
- ],
368
- this._contextUser
369
- );
370
-
371
- // Check for errors
372
- if (!actionsResult.Success) {
373
- throw new Error(`Failed to load agent actions: ${actionsResult.ErrorMessage}`);
217
+ // ===== STATIC FACTORY METHODS =====
218
+
219
+ /**
220
+ * Load an agent and its complete hierarchy from the database by ID.
221
+ *
222
+ * This method efficiently loads the agent along with all its actions and sub-agents
223
+ * using batched queries to minimize database round trips. When `includeSubAgents` is true,
224
+ * it recursively loads the entire agent hierarchy.
225
+ *
226
+ * @param agentId - The unique ID of the agent to load
227
+ * @param contextUser - Optional context user (required for server-side operations)
228
+ * @param includeSubAgents - Whether to recursively load all sub-agents (default: true)
229
+ * @returns Promise resolving to AgentSpecSync instance with loaded data
230
+ * @throws {Error} If agent with specified ID is not found
231
+ *
232
+ * @example
233
+ * ```typescript
234
+ * // Load agent with all sub-agents
235
+ * const spec = await AgentSpecSync.LoadFromDatabase(
236
+ * 'agent-uuid-here',
237
+ * contextUser,
238
+ * true
239
+ * );
240
+ * ```
241
+ */
242
+ static async LoadFromDatabase(
243
+ agentId: string,
244
+ contextUser?: UserInfo,
245
+ includeSubAgents: boolean = true
246
+ ): Promise<AgentSpecSync> {
247
+ const instance = new AgentSpecSync(undefined, contextUser);
248
+ await instance.loadFromEntities(agentId, includeSubAgents);
249
+ return instance;
374
250
  }
375
- if (!childAgentsResult.Success) {
376
- throw new Error(`Failed to load child agents: ${childAgentsResult.ErrorMessage}`);
377
- }
378
- if (!relatedAgentsResult.Success) {
379
- throw new Error(`Failed to load related agents: ${relatedAgentsResult.ErrorMessage}`);
251
+
252
+ /**
253
+ * Load an agent by name (must be unique).
254
+ *
255
+ * Searches for an agent with the specified name and loads it. If multiple agents
256
+ * have the same name, an error is thrown. Agent names should be unique within
257
+ * the system for this method to work reliably.
258
+ *
259
+ * @param agentName - The name of the agent to load
260
+ * @param contextUser - Optional context user (required for server-side operations)
261
+ * @param includeSubAgents - Whether to recursively load sub-agents (default: true)
262
+ * @returns Promise resolving to AgentSpecSync instance with loaded data
263
+ * @throws {Error} If agent is not found or multiple agents have the same name
264
+ *
265
+ * @example
266
+ * ```typescript
267
+ * const spec = await AgentSpecSync.LoadByName('My Agent', contextUser);
268
+ * ```
269
+ */
270
+ static async LoadByName(
271
+ agentName: string,
272
+ contextUser?: UserInfo,
273
+ includeSubAgents: boolean = true
274
+ ): Promise<AgentSpecSync> {
275
+ // Find agent by name
276
+ const rv = new RunView();
277
+ const result = await rv.RunView<AIAgentEntity>({
278
+ EntityName: 'AI Agents',
279
+ ExtraFilter: `Name='${agentName.replace(/'/g, "''")}'`,
280
+ ResultType: 'entity_object'
281
+ }, contextUser);
282
+
283
+ if (!result.Success) {
284
+ throw new Error(`Failed to find agent by name: ${result.ErrorMessage}`);
285
+ }
286
+
287
+ if (!result.Results || result.Results.length === 0) {
288
+ throw new Error(`Agent with name '${agentName}' not found`);
289
+ }
290
+
291
+ if (result.Results.length > 1) {
292
+ throw new Error(`Multiple agents found with name '${agentName}'. Use LoadFromDatabase with specific ID instead.`);
293
+ }
294
+
295
+ const agent = result.Results[0];
296
+ return AgentSpecSync.LoadFromDatabase(agent.ID, contextUser, includeSubAgents);
380
297
  }
381
- if (!promptsResult.Success) {
382
- throw new Error(`Failed to load agent prompts: ${promptsResult.ErrorMessage}`);
298
+
299
+ /**
300
+ * Create a new agent spec from a raw specification.
301
+ *
302
+ * This creates an in-memory AgentSpecSync instance from raw data. The agent is not
303
+ * saved to the database until {@link SaveToDatabase} is called.
304
+ *
305
+ * @param rawSpec - The raw spec data conforming to {@link AgentSpec} interface
306
+ * @param contextUser - Optional context user (required when saving server-side)
307
+ * @returns New AgentSpecSync instance (not yet saved to database)
308
+ *
309
+ * @example
310
+ * ```typescript
311
+ * const rawSpec: AgentSpec = {
312
+ * ID: '',
313
+ * Name: 'New Agent',
314
+ * Description: 'Agent description',
315
+ * InvocationMode: 'Any',
316
+ * Actions: [],
317
+ * SubAgents: []
318
+ * };
319
+ * const spec = AgentSpecSync.FromRawSpec(rawSpec, contextUser);
320
+ * await spec.SaveToDatabase();
321
+ * ```
322
+ */
323
+ static FromRawSpec(rawSpec: AgentSpec, contextUser?: UserInfo): AgentSpecSync {
324
+ return new AgentSpecSync(rawSpec, contextUser);
383
325
  }
384
- if (!stepsResult.Success) {
385
- throw new Error(`Failed to load agent steps: ${stepsResult.ErrorMessage}`);
326
+
327
+ // ===== LOADING METHODS =====
328
+
329
+ /**
330
+ * Load the complete agent specification from database entities.
331
+ *
332
+ * This method orchestrates loading from AIAgent, AIAgentAction, and AIAgentRelationship tables
333
+ * using batched queries for optimal performance. It handles both child agents (ParentID-based)
334
+ * and related agents (relationship-based).
335
+ *
336
+ * @private
337
+ * @param agentId - The agent ID to load
338
+ * @param includeSubAgents - Whether to recursively load sub-agents
339
+ * @throws {Error} If agent is not found
340
+ */
341
+ private async loadFromEntities(agentId: string, includeSubAgents: boolean): Promise<void> {
342
+ const md = new Metadata();
343
+ const rv = new RunView();
344
+
345
+ // Step 1: Load the main agent entity
346
+ const agentEntity = await md.GetEntityObject<AIAgentEntity>(
347
+ 'AI Agents',
348
+ this._contextUser
349
+ );
350
+ const loaded = await agentEntity.Load(agentId);
351
+ if (!loaded) {
352
+ throw new Error(`Agent with ID ${agentId} not found`);
353
+ }
354
+
355
+ // Step 2: Batch load related entities using RunViews for optimal performance
356
+ // Note: Load paths separately after steps to avoid hardcoded view name in subquery
357
+ const [actionsResult, childAgentsResult, relatedAgentsResult, promptsResult, stepsResult] = await rv.RunViews([
358
+ {
359
+ EntityName: 'AI Agent Actions',
360
+ ExtraFilter: `AgentID='${agentId}'`,
361
+ OrderBy: 'Status, ActionID',
362
+ ResultType: 'entity_object'
363
+ },
364
+ {
365
+ EntityName: 'AI Agents',
366
+ ExtraFilter: `ParentID='${agentId}'`,
367
+ OrderBy: 'ExecutionOrder, Name',
368
+ ResultType: 'entity_object'
369
+ },
370
+ {
371
+ EntityName: 'MJ: AI Agent Relationships',
372
+ ExtraFilter: `AgentID='${agentId}' AND Status='Active'`,
373
+ OrderBy: '__mj_CreatedAt',
374
+ ResultType: 'entity_object'
375
+ },
376
+ {
377
+ EntityName: 'MJ: AI Agent Prompts',
378
+ ExtraFilter: `AgentID='${agentId}'`,
379
+ OrderBy: 'ExecutionOrder',
380
+ ResultType: 'entity_object'
381
+ },
382
+ {
383
+ EntityName: 'MJ: AI Agent Steps',
384
+ ExtraFilter: `AgentID='${agentId}'`,
385
+ OrderBy: 'Name',
386
+ ResultType: 'entity_object'
387
+ }
388
+ ], this._contextUser);
389
+
390
+ // Check for errors
391
+ if (!actionsResult.Success) {
392
+ throw new Error(`Failed to load agent actions: ${actionsResult.ErrorMessage}`);
393
+ }
394
+ if (!childAgentsResult.Success) {
395
+ throw new Error(`Failed to load child agents: ${childAgentsResult.ErrorMessage}`);
396
+ }
397
+ if (!relatedAgentsResult.Success) {
398
+ throw new Error(`Failed to load related agents: ${relatedAgentsResult.ErrorMessage}`);
399
+ }
400
+ if (!promptsResult.Success) {
401
+ throw new Error(`Failed to load agent prompts: ${promptsResult.ErrorMessage}`);
402
+ }
403
+ if (!stepsResult.Success) {
404
+ throw new Error(`Failed to load agent steps: ${stepsResult.ErrorMessage}`);
405
+ }
406
+
407
+ // Step 2b: Load paths separately using step IDs to avoid hardcoded view name
408
+ let pathsResult;
409
+ const steps = stepsResult.Results || [];
410
+ if (steps.length > 0) {
411
+ const stepIds = steps.map((s: AIAgentStepEntity) => `'${s.ID}'`).join(',');
412
+ pathsResult = await rv.RunView<AIAgentStepPathEntity>({
413
+ EntityName: 'MJ: AI Agent Step Paths',
414
+ ExtraFilter: `OriginStepID IN (${stepIds})`,
415
+ OrderBy: 'Priority DESC',
416
+ ResultType: 'entity_object'
417
+ }, this._contextUser);
418
+
419
+ if (!pathsResult.Success) {
420
+ throw new Error(`Failed to load step paths: ${pathsResult.ErrorMessage}`);
421
+ }
422
+ } else {
423
+ // No steps, so no paths
424
+ pathsResult = {
425
+ Success: true,
426
+ Results: [],
427
+ RowCount: 0
428
+ };
429
+ }
430
+
431
+ // Step 2c: Load full AI Prompt records to get PromptText, PromptRole, PromptPosition
432
+ let fullPromptsResult;
433
+ const agentPrompts = promptsResult.Results || [];
434
+ if (agentPrompts.length > 0) {
435
+ const promptIds = agentPrompts.map((p: any) => `'${p.PromptID}'`).join(',');
436
+ fullPromptsResult = await rv.RunView({
437
+ EntityName: 'AI Prompts',
438
+ ExtraFilter: `ID IN (${promptIds})`,
439
+ OrderBy: 'Name',
440
+ ResultType: 'entity_object'
441
+ }, this._contextUser);
442
+
443
+ if (!fullPromptsResult.Success) {
444
+ throw new Error(`Failed to load AI prompts: ${fullPromptsResult.ErrorMessage}`);
445
+ }
446
+ } else {
447
+ // No prompts
448
+ fullPromptsResult = {
449
+ Success: true,
450
+ Results: [],
451
+ RowCount: 0
452
+ };
453
+ }
454
+
455
+ // Step 3: Map entities to raw spec format
456
+ this.spec = this.mapEntitiesToRawSpec(
457
+ agentEntity,
458
+ actionsResult.Results || [],
459
+ childAgentsResult.Results || [],
460
+ relatedAgentsResult.Results || [],
461
+ promptsResult.Results || [],
462
+ fullPromptsResult.Results || [],
463
+ stepsResult.Results || [],
464
+ pathsResult.Results || []
465
+ );
466
+
467
+ // Step 4: Recursively load sub-agents if requested
468
+ if (includeSubAgents && this.spec.SubAgents && this.spec.SubAgents.length > 0) {
469
+ // Recursively load complete specs for all sub-agents
470
+ for (const subAgentSpec of this.spec.SubAgents) {
471
+ if (subAgentSpec.SubAgent && subAgentSpec.SubAgent.ID) {
472
+ try {
473
+ // Load the complete sub-agent spec recursively
474
+ const subAgentSync = await AgentSpecSync.LoadFromDatabase(
475
+ subAgentSpec.SubAgent.ID,
476
+ this._contextUser,
477
+ true // Recursively load nested sub-agents too
478
+ );
479
+
480
+ // Replace the minimal SubAgent with the complete spec
481
+ const fullSubAgentSpec = subAgentSync.toJSON();
482
+
483
+ // Preserve the relationship metadata while adding full spec details
484
+ subAgentSpec.SubAgent = {
485
+ ...fullSubAgentSpec,
486
+ // Ensure we preserve any relationship-specific overrides
487
+ StartingPayloadValidationMode: subAgentSpec.SubAgent.StartingPayloadValidationMode || fullSubAgentSpec.StartingPayloadValidationMode
488
+ };
489
+ } catch (error) {
490
+ LogError(`Failed to load sub-agent ${subAgentSpec.SubAgent.ID}: ${error instanceof Error ? error.message : String(error)}`);
491
+ // Continue with partial data rather than failing completely
492
+ }
493
+ }
494
+ }
495
+ }
496
+
497
+ this._isLoaded = true;
498
+ this._isDirty = false;
386
499
  }
387
500
 
388
- // Step 2b: Load paths separately using step IDs to avoid hardcoded view name
389
- let pathsResult;
390
- const steps = stepsResult.Results || [];
391
- if (steps.length > 0) {
392
- const stepIds = steps.map((s: AIAgentStepEntity) => `'${s.ID}'`).join(',');
393
- pathsResult = await rv.RunView<AIAgentStepPathEntity>(
394
- {
395
- EntityName: 'MJ: AI Agent Step Paths',
396
- ExtraFilter: `OriginStepID IN (${stepIds})`,
397
- OrderBy: 'Priority DESC',
398
- ResultType: 'entity_object',
399
- },
400
- this._contextUser
401
- );
402
-
403
- if (!pathsResult.Success) {
404
- throw new Error(`Failed to load step paths: ${pathsResult.ErrorMessage}`);
405
- }
406
- } else {
407
- // No steps, so no paths
408
- pathsResult = {
409
- Success: true,
410
- Results: [],
411
- RowCount: 0,
412
- };
501
+ /**
502
+ * Map database entities to AgentSpec format.
503
+ *
504
+ * Transforms the normalized database entities into a single denormalized specification
505
+ * object that's easy to work with in code. Handles JSON parsing for all structured fields.
506
+ *
507
+ * @private
508
+ * @param agent - The main agent entity
509
+ * @param actions - Array of agent action entities
510
+ * @param childAgents - Array of child agent entities (ParentID-based)
511
+ * @param relatedAgents - Array of related agent relationship entities
512
+ * @param agentPrompts - Array of agent prompt junction entities
513
+ * @param fullPrompts - Array of full AI Prompt entities with PromptText, PromptRole, PromptPosition
514
+ * @param steps - Array of agent step entities (for Flow agents)
515
+ * @param paths - Array of step path entities (for Flow agents)
516
+ * @returns Fully populated AgentSpec object
517
+ */
518
+ private mapEntitiesToRawSpec(
519
+ agent: AIAgentEntity,
520
+ actions: AIAgentActionEntity[],
521
+ childAgents: AIAgentEntity[],
522
+ relatedAgents: AIAgentRelationshipEntity[],
523
+ agentPrompts: any[],
524
+ fullPrompts: any[],
525
+ steps: AIAgentStepEntity[],
526
+ paths: AIAgentStepPathEntity[]
527
+ ): AgentSpec {
528
+ // Map all agent fields to spec
529
+ const spec: AgentSpec = {
530
+ ID: agent.ID,
531
+ Name: agent.Name || '',
532
+ Description: agent.Description || undefined,
533
+ TypeID: agent.TypeID || undefined,
534
+ Status: (agent.Status as 'Active' | 'Inactive' | 'Pending') || undefined,
535
+ IconClass: agent.IconClass || undefined,
536
+ LogoURL: agent.LogoURL || undefined,
537
+ ParentID: agent.ParentID || undefined,
538
+ DriverClass: agent.DriverClass || undefined,
539
+ ModelSelectionMode: agent.ModelSelectionMode,
540
+
541
+ // Parse JSON array fields
542
+ PayloadDownstreamPaths: this.parseJsonField<string[]>(agent.PayloadDownstreamPaths),
543
+ PayloadUpstreamPaths: this.parseJsonField<string[]>(agent.PayloadUpstreamPaths),
544
+ PayloadSelfReadPaths: this.parseJsonField<string[]>(agent.PayloadSelfReadPaths),
545
+ PayloadSelfWritePaths: this.parseJsonField<string[]>(agent.PayloadSelfWritePaths),
546
+ PayloadScope: agent.PayloadScope || undefined,
547
+
548
+ // Validation fields
549
+ FinalPayloadValidation: agent.FinalPayloadValidation || null,
550
+ FinalPayloadValidationMode: agent.FinalPayloadValidationMode,
551
+ FinalPayloadValidationMaxRetries: agent.FinalPayloadValidationMaxRetries || undefined,
552
+
553
+ StartingPayloadValidation: agent.StartingPayloadValidation || null,
554
+ StartingPayloadValidationMode: agent.StartingPayloadValidationMode as any,
555
+
556
+ // Resource limits
557
+ MaxCostPerRun: agent.MaxCostPerRun || null,
558
+ MaxTokensPerRun: agent.MaxTokensPerRun || null,
559
+ MaxIterationsPerRun: agent.MaxIterationsPerRun || null,
560
+ MaxTimePerRun: agent.MaxTimePerRun || undefined,
561
+
562
+ // Execution frequency
563
+ MinExecutionsPerRun: agent.MinExecutionsPerRun || undefined,
564
+ MaxExecutionsPerRun: agent.MaxExecutionsPerRun || undefined,
565
+
566
+ // Other config
567
+ DefaultPromptEffortLevel: agent.DefaultPromptEffortLevel || undefined,
568
+ ChatHandlingOption: agent.ChatHandlingOption || undefined,
569
+ DefaultArtifactTypeID: agent.DefaultArtifactTypeID || undefined,
570
+ OwnerUserID: agent.OwnerUserID || undefined,
571
+ InvocationMode: agent.InvocationMode,
572
+
573
+ // Requirements and design documentation
574
+ FunctionalRequirements: (agent as any).FunctionalRequirements || null,
575
+ TechnicalDesign: (agent as any).TechnicalDesign || null,
576
+
577
+ // Map actions
578
+ Actions: actions.map(action => this.mapActionEntityToSpec(action)),
579
+
580
+ // Map sub-agents (both child and related)
581
+ SubAgents: [
582
+ ...childAgents.map(child => this.mapChildAgentToSpec(child)),
583
+ ...relatedAgents.map(rel => this.mapRelatedAgentToSpec(rel))
584
+ ],
585
+
586
+ // Map prompts (agent-level prompts for Loop agents)
587
+ Prompts: agentPrompts.map(agentPrompt => this.mapPromptEntityToSpec(agentPrompt, fullPrompts)),
588
+
589
+ // Map steps (for Flow agents)
590
+ Steps: steps.map(step => this.mapStepEntityToSpec(step)),
591
+
592
+ // Map paths (for Flow agents)
593
+ Paths: paths.map(path => this.mapPathEntityToSpec(path))
594
+ };
595
+
596
+ return spec;
413
597
  }
414
598
 
415
- // Step 2c: Load full AI Prompt records to get PromptText, PromptRole, PromptPosition
416
- let fullPromptsResult;
417
- const agentPrompts = promptsResult.Results || [];
418
- if (agentPrompts.length > 0) {
419
- const promptIds = agentPrompts.map((p: any) => `'${p.PromptID}'`).join(',');
420
- fullPromptsResult = await rv.RunView(
421
- {
422
- EntityName: 'AI Prompts',
423
- ExtraFilter: `ID IN (${promptIds})`,
424
- OrderBy: 'Name',
425
- ResultType: 'entity_object',
426
- },
427
- this._contextUser
428
- );
429
-
430
- if (!fullPromptsResult.Success) {
431
- throw new Error(`Failed to load AI prompts: ${fullPromptsResult.ErrorMessage}`);
432
- }
433
- } else {
434
- // No prompts
435
- fullPromptsResult = {
436
- Success: true,
437
- Results: [],
438
- RowCount: 0,
439
- };
599
+ /**
600
+ * Map AIAgentActionEntity to AgentActionSpec format.
601
+ *
602
+ * @private
603
+ * @param action - The agent action entity from the database
604
+ * @returns Mapped action spec
605
+ */
606
+ private mapActionEntityToSpec(action: AIAgentActionEntity): AgentActionSpec {
607
+ return {
608
+ AgentActionID: action.ID,
609
+ ActionID: action.ActionID || '',
610
+ Status: action.Status,
611
+ MaxExecutionsPerRun: action.MaxExecutionsPerRun || undefined,
612
+ ResultExpirationTurns: action.ResultExpirationTurns || undefined,
613
+ ResultExpirationMode: action.ResultExpirationMode || undefined,
614
+ CompactMode: action.CompactMode || undefined,
615
+ CompactLength: action.CompactLength || undefined,
616
+ CompactPromptID: action.CompactPromptID || null
617
+ };
440
618
  }
441
619
 
442
- // Step 3: Map entities to raw spec format
443
- this.spec = this.mapEntitiesToRawSpec(
444
- agentEntity,
445
- actionsResult.Results || [],
446
- childAgentsResult.Results || [],
447
- relatedAgentsResult.Results || [],
448
- promptsResult.Results || [],
449
- fullPromptsResult.Results || [],
450
- stepsResult.Results || [],
451
- pathsResult.Results || []
452
- );
453
-
454
- // Step 4: Recursively load sub-agents if requested
455
- if (includeSubAgents && this.spec.SubAgents && this.spec.SubAgents.length > 0) {
456
- // Recursively load complete specs for all sub-agents
457
- for (const subAgentSpec of this.spec.SubAgents) {
458
- if (subAgentSpec.SubAgent && subAgentSpec.SubAgent.ID) {
459
- try {
460
- // Load the complete sub-agent spec recursively
461
- const subAgentSync = await AgentSpecSync.LoadFromDatabase(
462
- subAgentSpec.SubAgent.ID,
463
- this._contextUser,
464
- true // Recursively load nested sub-agents too
465
- );
620
+ /**
621
+ * Map child agent (ParentID-based) to SubAgentSpec format.
622
+ *
623
+ * @private
624
+ * @param childAgent - The child agent entity
625
+ * @returns Mapped sub-agent spec
626
+ */
627
+ private mapChildAgentToSpec(childAgent: AIAgentEntity): SubAgentSpec {
628
+ return {
629
+ Type: 'child',
630
+ SubAgent: {
631
+ ID: childAgent.ID,
632
+ Name: childAgent.Name || '',
633
+ StartingPayloadValidationMode: 'Fail'
634
+ }
635
+ };
636
+ }
466
637
 
467
- // Replace the minimal SubAgent with the complete spec
468
- const fullSubAgentSpec = subAgentSync.toJSON();
638
+ /**
639
+ * Map related agent (relationship-based) to SubAgentSpec format.
640
+ *
641
+ * @private
642
+ * @param relationship - The agent relationship entity
643
+ * @returns Mapped sub-agent spec
644
+ */
645
+ private mapRelatedAgentToSpec(relationship: AIAgentRelationshipEntity): SubAgentSpec {
646
+ return {
647
+ Type: 'related',
648
+ SubAgent: {
649
+ ID: relationship.SubAgentID,
650
+ Name: relationship.SubAgent || '',
651
+ StartingPayloadValidationMode: 'Fail'
652
+ },
653
+ AgentRelationshipID: relationship.ID,
654
+ SubAgentInputMapping: this.parseJsonField<Record<string, string>>(relationship.SubAgentInputMapping),
655
+ SubAgentOutputMapping: this.parseJsonField<Record<string, string>>(relationship.SubAgentOutputMapping),
656
+ SubAgentContextPaths: this.parseJsonField<Record<string, string>>(relationship.SubAgentContextPaths)
657
+ };
658
+ }
469
659
 
470
- // Preserve the relationship metadata while adding full spec details
471
- subAgentSpec.SubAgent = {
472
- ...fullSubAgentSpec,
473
- // Ensure we preserve any relationship-specific overrides
474
- StartingPayloadValidationMode:
475
- subAgentSpec.SubAgent.StartingPayloadValidationMode || fullSubAgentSpec.StartingPayloadValidationMode,
660
+ /**
661
+ * Map AIAgentPromptEntity junction to AgentPromptSpec format.
662
+ *
663
+ * @private
664
+ * @param agentPrompt - The agent prompt junction entity (has PromptID, ExecutionOrder)
665
+ * @param fullPrompts - Array of full AI Prompt entities
666
+ * @returns Mapped prompt spec
667
+ */
668
+ private mapPromptEntityToSpec(agentPrompt: any, fullPrompts: any[]): any {
669
+ // Find the full prompt data by matching PromptID
670
+ const fullPrompt = fullPrompts.find((p: any) => p.ID === agentPrompt.PromptID);
671
+
672
+ if (!fullPrompt) {
673
+ // Fallback if prompt not found
674
+ return {
675
+ ID: agentPrompt.ID, // Junction record ID for orphan detection
676
+ PromptID: agentPrompt.PromptID || '',
677
+ PromptText: '',
678
+ PromptRole: 'System',
679
+ PromptPosition: 'First'
476
680
  };
477
- } catch (error) {
478
- LogError(`Failed to load sub-agent ${subAgentSpec.SubAgent.ID}: ${error instanceof Error ? error.message : String(error)}`);
479
- // Continue with partial data rather than failing completely
480
- }
481
681
  }
482
- }
682
+
683
+ return {
684
+ ID: agentPrompt.ID, // Junction record ID for orphan detection
685
+ PromptID: fullPrompt.ID || '',
686
+ PromptText: fullPrompt.TemplateText || '',
687
+ PromptRole: fullPrompt.PromptRole || 'System',
688
+ PromptPosition: fullPrompt.PromptPosition || 'First'
689
+ };
483
690
  }
484
691
 
485
- this._isLoaded = true;
486
- this._isDirty = false;
487
- }
488
-
489
- /**
490
- * Map database entities to AgentSpec format.
491
- *
492
- * Transforms the normalized database entities into a single denormalized specification
493
- * object that's easy to work with in code. Handles JSON parsing for all structured fields.
494
- *
495
- * @private
496
- * @param agent - The main agent entity
497
- * @param actions - Array of agent action entities
498
- * @param childAgents - Array of child agent entities (ParentID-based)
499
- * @param relatedAgents - Array of related agent relationship entities
500
- * @param agentPrompts - Array of agent prompt junction entities
501
- * @param fullPrompts - Array of full AI Prompt entities with PromptText, PromptRole, PromptPosition
502
- * @param steps - Array of agent step entities (for Flow agents)
503
- * @param paths - Array of step path entities (for Flow agents)
504
- * @returns Fully populated AgentSpec object
505
- */
506
- private mapEntitiesToRawSpec(
507
- agent: AIAgentEntity,
508
- actions: AIAgentActionEntity[],
509
- childAgents: AIAgentEntity[],
510
- relatedAgents: AIAgentRelationshipEntity[],
511
- agentPrompts: any[],
512
- fullPrompts: any[],
513
- steps: AIAgentStepEntity[],
514
- paths: AIAgentStepPathEntity[]
515
- ): AgentSpec {
516
- // Map all agent fields to spec
517
- const spec: AgentSpec = {
518
- ID: agent.ID,
519
- Name: agent.Name || '',
520
- Description: agent.Description || undefined,
521
- TypeID: agent.TypeID || undefined,
522
- Status: (agent.Status as 'Active' | 'Inactive' | 'Pending') || undefined,
523
- IconClass: agent.IconClass || undefined,
524
- LogoURL: agent.LogoURL || undefined,
525
- ParentID: agent.ParentID || undefined,
526
- DriverClass: agent.DriverClass || undefined,
527
- ModelSelectionMode: agent.ModelSelectionMode,
528
-
529
- // Parse JSON array fields
530
- PayloadDownstreamPaths: this.parseJsonField<string[]>(agent.PayloadDownstreamPaths),
531
- PayloadUpstreamPaths: this.parseJsonField<string[]>(agent.PayloadUpstreamPaths),
532
- PayloadSelfReadPaths: this.parseJsonField<string[]>(agent.PayloadSelfReadPaths),
533
- PayloadSelfWritePaths: this.parseJsonField<string[]>(agent.PayloadSelfWritePaths),
534
- PayloadScope: agent.PayloadScope || undefined,
535
-
536
- // Validation fields
537
- FinalPayloadValidation: agent.FinalPayloadValidation || null,
538
- FinalPayloadValidationMode: agent.FinalPayloadValidationMode,
539
- FinalPayloadValidationMaxRetries: agent.FinalPayloadValidationMaxRetries || undefined,
540
-
541
- StartingPayloadValidation: agent.StartingPayloadValidation || null,
542
- StartingPayloadValidationMode: agent.StartingPayloadValidationMode as any,
543
-
544
- // Resource limits
545
- MaxCostPerRun: agent.MaxCostPerRun || null,
546
- MaxTokensPerRun: agent.MaxTokensPerRun || null,
547
- MaxIterationsPerRun: agent.MaxIterationsPerRun || null,
548
- MaxTimePerRun: agent.MaxTimePerRun || undefined,
549
-
550
- // Execution frequency
551
- MinExecutionsPerRun: agent.MinExecutionsPerRun || undefined,
552
- MaxExecutionsPerRun: agent.MaxExecutionsPerRun || undefined,
553
-
554
- // Other config
555
- DefaultPromptEffortLevel: agent.DefaultPromptEffortLevel || undefined,
556
- ChatHandlingOption: agent.ChatHandlingOption || undefined,
557
- DefaultArtifactTypeID: agent.DefaultArtifactTypeID || undefined,
558
- OwnerUserID: agent.OwnerUserID || undefined,
559
- InvocationMode: agent.InvocationMode,
560
-
561
- // Requirements and design documentation
562
- FunctionalRequirements: (agent as any).FunctionalRequirements || null,
563
- TechnicalDesign: (agent as any).TechnicalDesign || null,
564
-
565
- // Map actions
566
- Actions: actions.map((action) => this.mapActionEntityToSpec(action)),
567
-
568
- // Map sub-agents (both child and related)
569
- SubAgents: [
570
- ...childAgents.map((child) => this.mapChildAgentToSpec(child)),
571
- ...relatedAgents.map((rel) => this.mapRelatedAgentToSpec(rel)),
572
- ],
573
-
574
- // Map prompts (agent-level prompts for Loop agents)
575
- Prompts: agentPrompts.map((agentPrompt) => this.mapPromptEntityToSpec(agentPrompt, fullPrompts)),
576
-
577
- // Map steps (for Flow agents)
578
- Steps: steps.map((step) => this.mapStepEntityToSpec(step)),
579
-
580
- // Map paths (for Flow agents)
581
- Paths: paths.map((path) => this.mapPathEntityToSpec(path)),
582
- };
583
-
584
- return spec;
585
- }
586
-
587
- /**
588
- * Map AIAgentActionEntity to AgentActionSpec format.
589
- *
590
- * @private
591
- * @param action - The agent action entity from the database
592
- * @returns Mapped action spec
593
- */
594
- private mapActionEntityToSpec(action: AIAgentActionEntity): AgentActionSpec {
595
- return {
596
- AgentActionID: action.ID,
597
- ActionID: action.ActionID || '',
598
- Status: action.Status,
599
- MaxExecutionsPerRun: action.MaxExecutionsPerRun || undefined,
600
- ResultExpirationTurns: action.ResultExpirationTurns || undefined,
601
- ResultExpirationMode: action.ResultExpirationMode || undefined,
602
- CompactMode: action.CompactMode || undefined,
603
- CompactLength: action.CompactLength || undefined,
604
- CompactPromptID: action.CompactPromptID || null,
605
- };
606
- }
607
-
608
- /**
609
- * Map child agent (ParentID-based) to SubAgentSpec format.
610
- *
611
- * @private
612
- * @param childAgent - The child agent entity
613
- * @returns Mapped sub-agent spec
614
- */
615
- private mapChildAgentToSpec(childAgent: AIAgentEntity): SubAgentSpec {
616
- return {
617
- Type: 'child',
618
- SubAgent: {
619
- ID: childAgent.ID,
620
- Name: childAgent.Name || '',
621
- StartingPayloadValidationMode: 'Fail',
622
- },
623
- };
624
- }
625
-
626
- /**
627
- * Map related agent (relationship-based) to SubAgentSpec format.
628
- *
629
- * @private
630
- * @param relationship - The agent relationship entity
631
- * @returns Mapped sub-agent spec
632
- */
633
- private mapRelatedAgentToSpec(relationship: AIAgentRelationshipEntity): SubAgentSpec {
634
- return {
635
- Type: 'related',
636
- SubAgent: {
637
- ID: relationship.SubAgentID,
638
- Name: relationship.SubAgent || '',
639
- StartingPayloadValidationMode: 'Fail',
640
- },
641
- AgentRelationshipID: relationship.ID,
642
- SubAgentInputMapping: this.parseJsonField<Record<string, string>>(relationship.SubAgentInputMapping),
643
- SubAgentOutputMapping: this.parseJsonField<Record<string, string>>(relationship.SubAgentOutputMapping),
644
- SubAgentContextPaths: this.parseJsonField<Record<string, string>>(relationship.SubAgentContextPaths),
645
- };
646
- }
647
-
648
- /**
649
- * Map AIAgentPromptEntity junction to AgentPromptSpec format.
650
- *
651
- * @private
652
- * @param agentPrompt - The agent prompt junction entity (has PromptID, ExecutionOrder)
653
- * @param fullPrompts - Array of full AI Prompt entities
654
- * @returns Mapped prompt spec
655
- */
656
- private mapPromptEntityToSpec(agentPrompt: any, fullPrompts: any[]): any {
657
- // Find the full prompt data by matching PromptID
658
- const fullPrompt = fullPrompts.find((p: any) => p.ID === agentPrompt.PromptID);
659
-
660
- if (!fullPrompt) {
661
- // Fallback if prompt not found
662
- return {
663
- ID: agentPrompt.ID, // Junction record ID for orphan detection
664
- PromptID: agentPrompt.PromptID || '',
665
- PromptText: '',
666
- PromptRole: 'System',
667
- PromptPosition: 'First',
668
- };
692
+ /**
693
+ * Map AIAgentStepEntity to AgentStep format.
694
+ *
695
+ * @private
696
+ * @param step - The agent step entity
697
+ * @returns Mapped step spec
698
+ */
699
+ private mapStepEntityToSpec(step: AIAgentStepEntity): any {
700
+ return {
701
+ ID: step.ID,
702
+ Name: step.Name,
703
+ Description: step.Description || undefined,
704
+ StepType: step.StepType,
705
+ StartingStep: step.StartingStep,
706
+ ActionID: step.ActionID || undefined,
707
+ SubAgentID: step.SubAgentID || undefined,
708
+ PromptID: step.PromptID || undefined,
709
+ ActionInputMapping: this.parseJsonField<any>(step.ActionInputMapping),
710
+ ActionOutputMapping: this.parseJsonField<any>(step.ActionOutputMapping)
711
+ };
669
712
  }
670
713
 
671
- return {
672
- ID: agentPrompt.ID, // Junction record ID for orphan detection
673
- PromptID: fullPrompt.ID || '',
674
- PromptText: fullPrompt.TemplateText || '',
675
- PromptRole: fullPrompt.PromptRole || 'System',
676
- PromptPosition: fullPrompt.PromptPosition || 'First',
677
- };
678
- }
679
-
680
- /**
681
- * Map AIAgentStepEntity to AgentStep format.
682
- *
683
- * @private
684
- * @param step - The agent step entity
685
- * @returns Mapped step spec
686
- */
687
- private mapStepEntityToSpec(step: AIAgentStepEntity): any {
688
- return {
689
- ID: step.ID,
690
- Name: step.Name,
691
- Description: step.Description || undefined,
692
- StepType: step.StepType,
693
- StartingStep: step.StartingStep,
694
- ActionID: step.ActionID || undefined,
695
- SubAgentID: step.SubAgentID || undefined,
696
- PromptID: step.PromptID || undefined,
697
- ActionInputMapping: this.parseJsonField<any>(step.ActionInputMapping),
698
- ActionOutputMapping: this.parseJsonField<any>(step.ActionOutputMapping),
699
- };
700
- }
701
-
702
- /**
703
- * Map AIAgentStepPathEntity to AgentStepPath format.
704
- *
705
- * @private
706
- * @param path - The agent step path entity
707
- * @returns Mapped path spec
708
- */
709
- private mapPathEntityToSpec(path: AIAgentStepPathEntity): any {
710
- return {
711
- ID: path.ID,
712
- OriginStepID: path.OriginStepID,
713
- DestinationStepID: path.DestinationStepID,
714
- Condition: path.Condition || undefined,
715
- Description: path.Description || undefined,
716
- Priority: path.Priority,
717
- };
718
- }
719
-
720
- // ===== SAVING METHODS =====
721
-
722
- /**
723
- * Save the current spec to the database.
724
- *
725
- * This will create new records or update existing ones based on whether IDs exist.
726
- * The save operation is performed atomically - if any part fails, no changes are committed.
727
- *
728
- * Validation is performed before saving. All entity-level validations defined in the
729
- * database schema are executed, and any failures will prevent the save.
730
- *
731
- * @param validate - Whether to validate before saving (default: true)
732
- * @returns Promise resolving to result with agent ID, success flag, and all mutations performed
733
- * @throws {Error} If validation fails or save operation fails
734
- *
735
- * @example
736
- * ```typescript
737
- * const spec = new AgentSpecSync({
738
- * Name: 'New Agent',
739
- * InvocationMode: 'Any'
740
- * }, contextUser);
741
- *
742
- * const result = await spec.SaveToDatabase();
743
- * console.log('Saved with ID:', result.agentId);
744
- * console.log('Mutations:', result.mutations.length);
745
- * ```
746
- */
747
- async SaveToDatabase(validate: boolean = true): Promise<AgentSpecSyncResult> {
748
- // Clear previous mutations
749
- this.clearMutations();
750
-
751
- if (!this._isDirty && this._isLoaded) {
752
- // No changes to save
753
- return {
754
- agentId: this.spec.ID,
755
- success: true,
756
- mutations: [],
757
- };
714
+ /**
715
+ * Map AIAgentStepPathEntity to AgentStepPath format.
716
+ *
717
+ * @private
718
+ * @param path - The agent step path entity
719
+ * @returns Mapped path spec
720
+ */
721
+ private mapPathEntityToSpec(path: AIAgentStepPathEntity): any {
722
+ return {
723
+ ID: path.ID,
724
+ OriginStepID: path.OriginStepID,
725
+ DestinationStepID: path.DestinationStepID,
726
+ Condition: path.Condition || undefined,
727
+ Description: path.Description || undefined,
728
+ Priority: path.Priority
729
+ };
758
730
  }
759
731
 
760
- try {
761
- // Step 0: Delete orphaned records (if this is an update)
762
- if (this._isLoaded && this.spec.ID) {
763
- console.log(`🗑️ SaveToDatabase: Loading current database state for agent ${this.spec.ID}...`);
764
- const dbState = await this.loadCurrentDatabaseState(this.spec.ID);
732
+ // ===== SAVING METHODS =====
733
+
734
+ /**
735
+ * Save the current spec to the database.
736
+ *
737
+ * This will create new records or update existing ones based on whether IDs exist.
738
+ * The save operation is performed atomically - if any part fails, no changes are committed.
739
+ *
740
+ * Validation is performed before saving. All entity-level validations defined in the
741
+ * database schema are executed, and any failures will prevent the save.
742
+ *
743
+ * @param validate - Whether to validate before saving (default: true)
744
+ * @returns Promise resolving to result with agent ID, success flag, and all mutations performed
745
+ * @throws {Error} If validation fails or save operation fails
746
+ *
747
+ * @example
748
+ * ```typescript
749
+ * const spec = new AgentSpecSync({
750
+ * Name: 'New Agent',
751
+ * InvocationMode: 'Any'
752
+ * }, contextUser);
753
+ *
754
+ * const result = await spec.SaveToDatabase();
755
+ * console.log('Saved with ID:', result.agentId);
756
+ * console.log('Mutations:', result.mutations.length);
757
+ * ```
758
+ */
759
+ async SaveToDatabase(validate: boolean = true): Promise<AgentSpecSyncResult> {
760
+ // Clear previous mutations
761
+ this.clearMutations();
762
+
763
+ if (!this._isDirty && this._isLoaded) {
764
+ // No changes to save
765
+ return {
766
+ agentId: this.spec.ID,
767
+ success: true,
768
+ mutations: []
769
+ };
770
+ }
765
771
 
766
- console.log(`🔍 SaveToDatabase: Identifying orphaned records...`);
767
- const orphans = this.identifyOrphans(dbState, this.spec);
772
+ try {
773
+ // Step 0: Delete orphaned records (if this is an update)
774
+ if (this._isLoaded && this.spec.ID) {
775
+ console.log(`🗑️ SaveToDatabase: Loading current database state for agent ${this.spec.ID}...`);
776
+ const dbState = await this.loadCurrentDatabaseState(this.spec.ID);
768
777
 
769
- console.log(
770
- `🗑️ SaveToDatabase: Deleting ${orphans.paths.length} paths, ${orphans.actions.length} actions, ${orphans.steps.length} steps, ${orphans.prompts.length} prompts, ${orphans.relationships.length} relationships, orphaning ${orphans.childAgents.length} child agents...`
771
- );
772
- await this.deleteOrphans(orphans);
778
+ console.log(`🔍 SaveToDatabase: Identifying orphaned records...`);
779
+ const orphans = this.identifyOrphans(dbState, this.spec);
773
780
 
774
- console.log(`✅ SaveToDatabase: Orphan cleanup complete`);
775
- }
781
+ console.log(`🗑️ SaveToDatabase: Deleting ${orphans.paths.length} paths, ${orphans.actions.length} actions, ${orphans.steps.length} steps, ${orphans.prompts.length} prompts, ${orphans.relationships.length} relationships, orphaning ${orphans.childAgents.length} child agents...`);
782
+ await this.deleteOrphans(orphans);
776
783
 
777
- // Step 1: Save main agent entity
778
- const agentId = await this.saveAgentEntity(validate);
784
+ console.log(`✅ SaveToDatabase: Orphan cleanup complete`);
785
+ }
779
786
 
780
- // Step 2: Save actions
781
- await this.saveActions(agentId);
787
+ // Step 1: Save main agent entity
788
+ const agentId = await this.saveAgentEntity(validate);
782
789
 
783
- // Step 3: Save sub-agents (both child and related)
784
- await this.saveSubAgents(agentId);
790
+ // Step 2: Save actions
791
+ await this.saveActions(agentId);
785
792
 
786
- // Step 4: Save prompts
787
- await this.savePrompts(agentId);
793
+ // Step 3: Save sub-agents (both child and related)
794
+ await this.saveSubAgents(agentId);
788
795
 
789
- // Step 5: Save steps (Flow agents only)
790
- await this.saveSteps(agentId);
796
+ // Step 4: Save prompts
797
+ await this.savePrompts(agentId);
791
798
 
792
- // Step 6: Save step paths (Flow agents only)
793
- await this.saveStepPaths(agentId);
799
+ // Step 5: Save steps (Flow agents only)
800
+ await this.saveSteps(agentId);
794
801
 
795
- this._isDirty = false;
796
- this._isLoaded = true;
802
+ // Step 6: Save step paths (Flow agents only)
803
+ await this.saveStepPaths(agentId);
797
804
 
798
- return {
799
- agentId,
800
- success: true,
801
- mutations: this.getMutations(),
802
- };
803
- } catch (error) {
804
- // Return failure with whatever mutations were completed before error
805
- return {
806
- agentId: this.spec.ID || '',
807
- success: false,
808
- mutations: this.getMutations(),
809
- };
810
- }
811
- }
812
-
813
- /**
814
- * Save the main AIAgent entity.
815
- *
816
- * @private
817
- * @param validate - Whether to perform validation before saving
818
- * @returns Promise resolving to the saved agent ID
819
- * @throws {Error} If validation fails or save fails
820
- */
821
- private async saveAgentEntity(validate: boolean): Promise<string> {
822
- const md = new Metadata();
823
- const agentEntity = await md.GetEntityObject<AIAgentEntity>('AI Agents', this._contextUser);
824
-
825
- // Track if this is an update or create
826
- const isUpdate = !!this.spec.ID;
827
-
828
- // If ID exists, load existing record
829
- if (this.spec.ID) {
830
- const loaded = await agentEntity.Load(this.spec.ID);
831
- if (!loaded) {
832
- throw new Error(`Cannot update non-existent agent with ID ${this.spec.ID}`);
833
- }
834
- }
805
+ this._isDirty = false;
806
+ this._isLoaded = true;
835
807
 
836
- // Map spec to entity fields
837
- agentEntity.Name = this.spec.Name;
838
- agentEntity.Description = this.spec.Description || null;
839
- agentEntity.IconClass = this.spec.IconClass || null;
840
- agentEntity.LogoURL = this.spec.LogoURL || null;
841
- agentEntity.ParentID = this.spec.ParentID || null;
842
- agentEntity.DriverClass = this.spec.DriverClass || null;
843
- agentEntity.ModelSelectionMode = this.spec.ModelSelectionMode || 'Agent Type';
844
-
845
- // Handle TypeID - supports lookup references or direct GUID
846
- if ((this.spec as any).TypeID) {
847
- agentEntity.TypeID = (this.spec as any).TypeID;
808
+ return {
809
+ agentId,
810
+ success: true,
811
+ mutations: this.getMutations()
812
+ };
813
+ } catch (error) {
814
+ // Return failure with whatever mutations were completed before error
815
+ return {
816
+ agentId: this.spec.ID || '',
817
+ success: false,
818
+ mutations: this.getMutations()
819
+ };
820
+ }
848
821
  }
849
822
 
850
- // Handle Status - defaults to Active if not specified
851
- if ((this.spec as any).Status) {
852
- agentEntity.Status = (this.spec as any).Status;
853
- } else {
854
- agentEntity.Status = 'Active';
855
- }
823
+ /**
824
+ * Save the main AIAgent entity.
825
+ *
826
+ * @private
827
+ * @param validate - Whether to perform validation before saving
828
+ * @returns Promise resolving to the saved agent ID
829
+ * @throws {Error} If validation fails or save fails
830
+ */
831
+ private async saveAgentEntity(validate: boolean): Promise<string> {
832
+ const md = new Metadata();
833
+ const agentEntity = await md.GetEntityObject<AIAgentEntity>(
834
+ 'AI Agents',
835
+ this._contextUser
836
+ );
856
837
 
857
- // Serialize JSON fields
858
- agentEntity.PayloadDownstreamPaths = JSON.stringify(this.spec.PayloadDownstreamPaths || ['*']);
859
- agentEntity.PayloadUpstreamPaths = JSON.stringify(this.spec.PayloadUpstreamPaths || ['*']);
860
- agentEntity.PayloadSelfReadPaths = this.spec.PayloadSelfReadPaths ? JSON.stringify(this.spec.PayloadSelfReadPaths) : null;
861
- agentEntity.PayloadSelfWritePaths = this.spec.PayloadSelfWritePaths ? JSON.stringify(this.spec.PayloadSelfWritePaths) : null;
862
- agentEntity.PayloadScope = this.spec.PayloadScope || null;
863
-
864
- // Validation fields
865
- agentEntity.FinalPayloadValidation = this.spec.FinalPayloadValidation || null;
866
- agentEntity.FinalPayloadValidationMode = this.spec.FinalPayloadValidationMode || 'Retry';
867
- agentEntity.FinalPayloadValidationMaxRetries = this.spec.FinalPayloadValidationMaxRetries || 3;
868
-
869
- agentEntity.StartingPayloadValidation = this.spec.StartingPayloadValidation || null;
870
- agentEntity.StartingPayloadValidationMode = this.spec.StartingPayloadValidationMode || 'Fail';
871
-
872
- // Resource limits
873
- agentEntity.MaxCostPerRun = this.spec.MaxCostPerRun || null;
874
- agentEntity.MaxTokensPerRun = this.spec.MaxTokensPerRun || null;
875
- agentEntity.MaxIterationsPerRun = this.spec.MaxIterationsPerRun || null;
876
- agentEntity.MaxTimePerRun = this.spec.MaxTimePerRun || null;
877
-
878
- // Execution frequency
879
- agentEntity.MinExecutionsPerRun = this.spec.MinExecutionsPerRun || null;
880
- agentEntity.MaxExecutionsPerRun = this.spec.MaxExecutionsPerRun || null;
881
-
882
- // Other config
883
- agentEntity.DefaultPromptEffortLevel = this.spec.DefaultPromptEffortLevel || null;
884
- agentEntity.ChatHandlingOption = this.spec.ChatHandlingOption || null;
885
- agentEntity.DefaultArtifactTypeID = this.spec.DefaultArtifactTypeID || null;
886
-
887
- // Set OwnerUserID - always use contextUser if available (user creating/modifying the agent)
888
- if (this._contextUser) {
889
- agentEntity.OwnerUserID = this._contextUser.ID;
890
- } else if (this.spec.OwnerUserID) {
891
- // Fallback to spec value if no contextUser provided
892
- agentEntity.OwnerUserID = this.spec.OwnerUserID;
893
- }
838
+ // Track if this is an update or create
839
+ const isUpdate = !!this.spec.ID;
894
840
 
895
- agentEntity.InvocationMode = this.spec.InvocationMode || 'Any';
841
+ // If ID exists, load existing record
842
+ if (this.spec.ID) {
843
+ const loaded = await agentEntity.Load(this.spec.ID);
844
+ if (!loaded) {
845
+ throw new Error(`Cannot update non-existent agent with ID ${this.spec.ID}`);
846
+ }
847
+ }
896
848
 
897
- // Requirements and design documentation
898
- agentEntity.FunctionalRequirements = this.spec.FunctionalRequirements || null;
899
- agentEntity.TechnicalDesign = this.spec.TechnicalDesign || null;
849
+ // Map spec to entity fields
850
+ agentEntity.Name = this.spec.Name;
851
+ agentEntity.Description = this.spec.Description || null;
852
+ agentEntity.IconClass = this.spec.IconClass || null;
853
+ agentEntity.LogoURL = this.spec.LogoURL || null;
854
+ agentEntity.ParentID = this.spec.ParentID || null;
855
+ agentEntity.DriverClass = this.spec.DriverClass || null;
856
+ agentEntity.ModelSelectionMode = this.spec.ModelSelectionMode || 'Agent Type';
857
+
858
+ // Handle TypeID - supports lookup references or direct GUID
859
+ if ((this.spec as any).TypeID) {
860
+ agentEntity.TypeID = (this.spec as any).TypeID;
861
+ }
900
862
 
901
- // Validate if requested
902
- if (validate) {
903
- const validation = agentEntity.Validate();
904
- if (!validation.Success) {
905
- const errors = validation.Errors.map((e) => e.Message).join(', ');
906
- throw new Error(`Agent validation failed: ${errors}`);
907
- }
908
- }
863
+ // Handle Status - defaults to Active if not specified
864
+ if ((this.spec as any).Status) {
865
+ agentEntity.Status = (this.spec as any).Status;
866
+ } else {
867
+ agentEntity.Status = 'Active';
868
+ }
909
869
 
910
- // Save
911
- const saved = await agentEntity.Save();
912
- if (!saved) {
913
- throw new Error('Failed to save agent entity');
914
- }
870
+ // Serialize JSON fields
871
+ agentEntity.PayloadDownstreamPaths = JSON.stringify(
872
+ this.spec.PayloadDownstreamPaths || ['*']
873
+ );
874
+ agentEntity.PayloadUpstreamPaths = JSON.stringify(
875
+ this.spec.PayloadUpstreamPaths || ['*']
876
+ );
877
+ agentEntity.PayloadSelfReadPaths = this.spec.PayloadSelfReadPaths
878
+ ? JSON.stringify(this.spec.PayloadSelfReadPaths)
879
+ : null;
880
+ agentEntity.PayloadSelfWritePaths = this.spec.PayloadSelfWritePaths
881
+ ? JSON.stringify(this.spec.PayloadSelfWritePaths)
882
+ : null;
883
+ agentEntity.PayloadScope = this.spec.PayloadScope || null;
884
+
885
+ // Validation fields
886
+ agentEntity.FinalPayloadValidation = this.spec.FinalPayloadValidation || null;
887
+ agentEntity.FinalPayloadValidationMode = this.spec.FinalPayloadValidationMode || 'Retry';
888
+ agentEntity.FinalPayloadValidationMaxRetries = this.spec.FinalPayloadValidationMaxRetries || 3;
889
+
890
+ agentEntity.StartingPayloadValidation = this.spec.StartingPayloadValidation || null;
891
+ agentEntity.StartingPayloadValidationMode = this.spec.StartingPayloadValidationMode || 'Fail';
892
+
893
+ // Resource limits
894
+ agentEntity.MaxCostPerRun = this.spec.MaxCostPerRun || null;
895
+ agentEntity.MaxTokensPerRun = this.spec.MaxTokensPerRun || null;
896
+ agentEntity.MaxIterationsPerRun = this.spec.MaxIterationsPerRun || null;
897
+ agentEntity.MaxTimePerRun = this.spec.MaxTimePerRun || null;
898
+
899
+ // Execution frequency
900
+ agentEntity.MinExecutionsPerRun = this.spec.MinExecutionsPerRun || null;
901
+ agentEntity.MaxExecutionsPerRun = this.spec.MaxExecutionsPerRun || null;
902
+
903
+ // Other config
904
+ agentEntity.DefaultPromptEffortLevel = this.spec.DefaultPromptEffortLevel || null;
905
+ agentEntity.ChatHandlingOption = this.spec.ChatHandlingOption || null;
906
+ agentEntity.DefaultArtifactTypeID = this.spec.DefaultArtifactTypeID || null;
907
+
908
+ // Set OwnerUserID - always use contextUser if available (user creating/modifying the agent)
909
+ if (this._contextUser) {
910
+ agentEntity.OwnerUserID = this._contextUser.ID;
911
+ } else if (this.spec.OwnerUserID) {
912
+ // Fallback to spec value if no contextUser provided
913
+ agentEntity.OwnerUserID = this.spec.OwnerUserID;
914
+ }
915
915
 
916
- // Update spec with saved ID
917
- this.spec.ID = agentEntity.ID;
918
-
919
- // Track the mutation
920
- this.trackMutation(
921
- 'AI Agents',
922
- isUpdate ? 'Update' : 'Create',
923
- agentEntity.ID,
924
- `${isUpdate ? 'Updated' : 'Created'} agent: ${this.spec.Name}`
925
- );
926
-
927
- return agentEntity.ID;
928
- }
929
-
930
- /**
931
- * Save all actions for this agent.
932
- *
933
- * Creates or updates agent action records. Existing actions are updated, new actions
934
- * are created. This method does not delete actions that are no longer in the spec -
935
- * use a separate delete method for that.
936
- *
937
- * @private
938
- * @param agentId - The parent agent ID
939
- * @throws {Error} If any action save fails
940
- */
941
- private async saveActions(agentId: string): Promise<void> {
942
- if (!this.spec.Actions || this.spec.Actions.length === 0) {
943
- return;
944
- }
916
+ agentEntity.InvocationMode = this.spec.InvocationMode || 'Any';
945
917
 
946
- const md = new Metadata();
947
-
948
- for (const actionSpec of this.spec.Actions) {
949
- const actionEntity = await md.GetEntityObject<AIAgentActionEntity>('AI Agent Actions', this._contextUser);
950
-
951
- // Track if this is an update or create
952
- const isUpdate = !!actionSpec.AgentActionID;
953
-
954
- // Load existing if ID provided
955
- if (actionSpec.AgentActionID) {
956
- await actionEntity.Load(actionSpec.AgentActionID);
957
- }
958
-
959
- // Map fields
960
- actionEntity.AgentID = agentId;
961
- actionEntity.ActionID = actionSpec.ActionID;
962
- actionEntity.Status = actionSpec.Status;
963
- actionEntity.MaxExecutionsPerRun = actionSpec.MaxExecutionsPerRun || null;
964
- actionEntity.ResultExpirationTurns = actionSpec.ResultExpirationTurns || null;
965
- actionEntity.ResultExpirationMode = actionSpec.ResultExpirationMode || 'None';
966
- actionEntity.CompactMode = actionSpec.CompactMode || null;
967
- actionEntity.CompactLength = actionSpec.CompactLength || null;
968
- actionEntity.CompactPromptID = actionSpec.CompactPromptID || null;
969
-
970
- const saved = await actionEntity.Save();
971
- if (!saved) {
972
- throw new Error(`Failed to save action ${actionSpec.ActionID}`);
973
- }
974
-
975
- // Update spec with saved ID
976
- actionSpec.AgentActionID = actionEntity.ID;
977
-
978
- // Track the mutation
979
- this.trackMutation(
980
- 'AI Agent Actions',
981
- isUpdate ? 'Update' : 'Create',
982
- actionEntity.ID,
983
- `${isUpdate ? 'Updated' : 'Created'} action junction for action ${actionSpec.ActionID}`
984
- );
985
- }
986
- }
987
-
988
- /**
989
- * Save all sub-agents (both child and related types).
990
- *
991
- * Handles both ParentID-based child agents and relationship-based related agents.
992
- * For child agents, updates the ParentID on the sub-agent entity. For related agents,
993
- * creates or updates the relationship record.
994
- *
995
- * @private
996
- * @param agentId - The parent agent ID
997
- * @throws {Error} If any sub-agent save fails
998
- */
999
- private async saveSubAgents(agentId: string): Promise<void> {
1000
- if (!this.spec.SubAgents || this.spec.SubAgents.length === 0) {
1001
- return;
1002
- }
918
+ // Requirements and design documentation
919
+ agentEntity.FunctionalRequirements = this.spec.FunctionalRequirements || null;
920
+ agentEntity.TechnicalDesign = this.spec.TechnicalDesign || null;
1003
921
 
1004
- for (const SubAgentSpec of this.spec.SubAgents) {
1005
- if (SubAgentSpec.Type === 'child') {
1006
- await this.saveChildSubAgent(agentId, SubAgentSpec);
1007
- } else {
1008
- await this.saveRelatedSubAgent(agentId, SubAgentSpec);
1009
- }
1010
- }
1011
- }
1012
-
1013
- /**
1014
- * Save a child sub-agent (ParentID-based relationship).
1015
- *
1016
- * For child agents, the relationship is established by setting the ParentID field
1017
- * on the child agent entity. If the SubAgent.ID is empty, this creates a new
1018
- * child agent recursively using AgentSpecSync.
1019
- *
1020
- * This enables creating complete agent hierarchies in one call - sub-agents are
1021
- * created first (depth-first), then parent references them via ParentID.
1022
- *
1023
- * @private
1024
- * @param parentId - The parent agent ID
1025
- * @param SubAgentSpec - The sub-agent specification
1026
- * @throws {Error} If child agent save fails
1027
- */
1028
- private async saveChildSubAgent(parentId: string, SubAgentSpec: SubAgentSpec): Promise<void> {
1029
- console.log(`🔗 saveChildSubAgent: Processing child "${SubAgentSpec.SubAgent?.Name}", ID="${SubAgentSpec.SubAgent?.ID}"`);
1030
-
1031
- // If SubAgent.ID is empty or missing, create the sub-agent recursively
1032
- if (!SubAgentSpec.SubAgent?.ID || SubAgentSpec.SubAgent.ID === '') {
1033
- console.log(`🔨 saveChildSubAgent: Creating new child sub-agent "${SubAgentSpec.SubAgent.Name}"...`);
1034
-
1035
- // Set ParentID in the sub-agent spec before creating it
1036
- const childSpec: AgentSpec = {
1037
- ...SubAgentSpec.SubAgent,
1038
- ParentID: parentId,
1039
- };
1040
-
1041
- // Recursively create the sub-agent using AgentSpecSync
1042
- const childSync = new AgentSpecSync(childSpec, this._contextUser);
1043
- childSync.markDirty();
1044
- const childResult = await childSync.SaveToDatabase();
1045
-
1046
- // Update the SubAgentSpec with the created ID so parent can reference it
1047
- SubAgentSpec.SubAgent.ID = childResult.agentId;
1048
-
1049
- console.log(`✅ saveChildSubAgent: Created child sub-agent with ID: ${childResult.agentId}`);
1050
- } else {
1051
- // SubAgent already exists - update it recursively to capture all field changes
1052
- console.log(`🔗 saveChildSubAgent: Updating existing child sub-agent "${SubAgentSpec.SubAgent.ID}"...`);
1053
-
1054
- // Set ParentID in the sub-agent spec
1055
- const childSpec: AgentSpec = {
1056
- ...SubAgentSpec.SubAgent,
1057
- ParentID: parentId,
1058
- };
1059
-
1060
- // Recursively update the sub-agent using AgentSpecSync
1061
- // This ensures all fields including FunctionalRequirements and TechnicalDesign are updated
1062
- const childSync = new AgentSpecSync(childSpec, this._contextUser);
1063
- childSync.markDirty();
1064
- childSync.markLoaded(); // Mark as loaded so delete logic runs for orphaned records
1065
- await childSync.SaveToDatabase();
1066
-
1067
- console.log(`✅ saveChildSubAgent: Updated existing child sub-agent`);
1068
- }
1069
- }
1070
-
1071
- /**
1072
- * Save a related sub-agent (relationship-based).
1073
- *
1074
- * Creates or updates an AIAgentRelationship record that links the parent and
1075
- * sub-agent. This includes the input/output mapping and context path configurations.
1076
- *
1077
- * @private
1078
- * @param agentId - The parent agent ID
1079
- * @param SubAgentSpec - The sub-agent specification
1080
- * @throws {Error} If relationship save fails
1081
- */
1082
- private async saveRelatedSubAgent(agentId: string, SubAgentSpec: SubAgentSpec): Promise<void> {
1083
- const md = new Metadata();
1084
- const relationshipEntity = await md.GetEntityObject<AIAgentRelationshipEntity>('MJ: AI Agent Relationships', this._contextUser);
1085
-
1086
- // Load existing if ID provided
1087
- const isUpdate = !!SubAgentSpec.AgentRelationshipID;
1088
- if (isUpdate && SubAgentSpec.AgentRelationshipID) {
1089
- await relationshipEntity.Load(SubAgentSpec.AgentRelationshipID);
1090
- }
922
+ // Validate if requested
923
+ if (validate) {
924
+ const validation = agentEntity.Validate();
925
+ if (!validation.Success) {
926
+ const errors = validation.Errors.map(e => e.Message).join(', ');
927
+ throw new Error(`Agent validation failed: ${errors}`);
928
+ }
929
+ }
1091
930
 
1092
- // Map fields
1093
- relationshipEntity.AgentID = agentId;
1094
- relationshipEntity.SubAgentID = SubAgentSpec.SubAgent.ID;
1095
- relationshipEntity.Status = 'Active';
931
+ // Save
932
+ const saved = await agentEntity.Save();
933
+ if (!saved) {
934
+ throw new Error('Failed to save agent entity');
935
+ }
1096
936
 
1097
- // Serialize mapping fields
1098
- if (SubAgentSpec.SubAgentInputMapping) {
1099
- relationshipEntity.SubAgentInputMapping = JSON.stringify(SubAgentSpec.SubAgentInputMapping);
1100
- } else {
1101
- relationshipEntity.SubAgentInputMapping = null;
1102
- }
937
+ // Update spec with saved ID
938
+ this.spec.ID = agentEntity.ID;
1103
939
 
1104
- if (SubAgentSpec.SubAgentOutputMapping) {
1105
- relationshipEntity.SubAgentOutputMapping = JSON.stringify(SubAgentSpec.SubAgentOutputMapping);
1106
- } else {
1107
- relationshipEntity.SubAgentOutputMapping = null;
1108
- }
940
+ // Track the mutation
941
+ this.trackMutation(
942
+ 'AI Agents',
943
+ isUpdate ? 'Update' : 'Create',
944
+ agentEntity.ID,
945
+ `${isUpdate ? 'Updated' : 'Created'} agent: ${this.spec.Name}`
946
+ );
1109
947
 
1110
- if (SubAgentSpec.SubAgentContextPaths) {
1111
- relationshipEntity.SubAgentContextPaths = JSON.stringify(SubAgentSpec.SubAgentContextPaths);
1112
- } else {
1113
- relationshipEntity.SubAgentContextPaths = null;
948
+ return agentEntity.ID;
1114
949
  }
1115
950
 
1116
- const saved = await relationshipEntity.Save();
1117
- if (!saved) {
1118
- throw new Error(`Failed to save relationship for sub-agent ${SubAgentSpec.SubAgent.ID}`);
951
+ /**
952
+ * Save all actions for this agent.
953
+ *
954
+ * Creates or updates agent action records. Existing actions are updated, new actions
955
+ * are created. This method does not delete actions that are no longer in the spec -
956
+ * use a separate delete method for that.
957
+ *
958
+ * @private
959
+ * @param agentId - The parent agent ID
960
+ * @throws {Error} If any action save fails
961
+ */
962
+ private async saveActions(agentId: string): Promise<void> {
963
+ if (!this.spec.Actions || this.spec.Actions.length === 0) {
964
+ return;
965
+ }
966
+
967
+ const md = new Metadata();
968
+
969
+ for (const actionSpec of this.spec.Actions) {
970
+ const actionEntity = await md.GetEntityObject<AIAgentActionEntity>(
971
+ 'AI Agent Actions',
972
+ this._contextUser
973
+ );
974
+
975
+ // Track if this is an update or create
976
+ const isUpdate = !!actionSpec.AgentActionID;
977
+
978
+ // Load existing if ID provided
979
+ if (actionSpec.AgentActionID) {
980
+ await actionEntity.Load(actionSpec.AgentActionID);
981
+ }
982
+
983
+ // Map fields
984
+ actionEntity.AgentID = agentId;
985
+ actionEntity.ActionID = actionSpec.ActionID;
986
+ actionEntity.Status = actionSpec.Status;
987
+ actionEntity.MaxExecutionsPerRun = actionSpec.MaxExecutionsPerRun || null;
988
+ actionEntity.ResultExpirationTurns = actionSpec.ResultExpirationTurns || null;
989
+ actionEntity.ResultExpirationMode = actionSpec.ResultExpirationMode || 'None';
990
+ actionEntity.CompactMode = actionSpec.CompactMode || null;
991
+ actionEntity.CompactLength = actionSpec.CompactLength || null;
992
+ actionEntity.CompactPromptID = actionSpec.CompactPromptID || null;
993
+
994
+ const saved = await actionEntity.Save();
995
+ if (!saved) {
996
+ throw new Error(`Failed to save action ${actionSpec.ActionID}`);
997
+ }
998
+
999
+ // Update spec with saved ID
1000
+ actionSpec.AgentActionID = actionEntity.ID;
1001
+
1002
+ // Track the mutation
1003
+ this.trackMutation(
1004
+ 'AI Agent Actions',
1005
+ isUpdate ? 'Update' : 'Create',
1006
+ actionEntity.ID,
1007
+ `${isUpdate ? 'Updated' : 'Created'} action junction for action ${actionSpec.ActionID}`
1008
+ );
1009
+ }
1119
1010
  }
1120
1011
 
1121
- // Track mutation
1122
- this.trackMutation(
1123
- 'AI Agent Relationships',
1124
- isUpdate ? 'Update' : 'Create',
1125
- relationshipEntity.ID,
1126
- `${isUpdate ? 'Updated' : 'Created'} agent relationship for sub-agent ${SubAgentSpec.SubAgent.ID}`
1127
- );
1128
-
1129
- // Update spec with saved ID
1130
- SubAgentSpec.AgentRelationshipID = relationshipEntity.ID;
1131
- }
1132
-
1133
- /**
1134
- * Save all prompts for this agent.
1135
- *
1136
- * Creates AIPrompt records (with template) and AIAgentPrompt junction records.
1137
- * Supports simplified prompt format from Architect Agent with just PromptText,
1138
- * PromptRole, and PromptPosition.
1139
- *
1140
- * @private
1141
- * @param agentId - The parent agent ID
1142
- * @throws {Error} If any prompt save fails
1143
- */
1144
- private async savePrompts(agentId: string): Promise<void> {
1145
- console.log(`💬 savePrompts: Called with agentId=${agentId}, Prompts=${this.spec.Prompts ? this.spec.Prompts.length : 'undefined'}`);
1146
-
1147
- if (!this.spec.Prompts || this.spec.Prompts.length === 0) {
1148
- console.log('💬 savePrompts: No prompts to save, returning early');
1149
- return;
1012
+ /**
1013
+ * Save all sub-agents (both child and related types).
1014
+ *
1015
+ * Handles both ParentID-based child agents and relationship-based related agents.
1016
+ * For child agents, updates the ParentID on the sub-agent entity. For related agents,
1017
+ * creates or updates the relationship record.
1018
+ *
1019
+ * @private
1020
+ * @param agentId - The parent agent ID
1021
+ * @throws {Error} If any sub-agent save fails
1022
+ */
1023
+ private async saveSubAgents(agentId: string): Promise<void> {
1024
+ if (!this.spec.SubAgents || this.spec.SubAgents.length === 0) {
1025
+ return;
1026
+ }
1027
+
1028
+ for (const SubAgentSpec of this.spec.SubAgents) {
1029
+ if (SubAgentSpec.Type === 'child') {
1030
+ await this.saveChildSubAgent(agentId, SubAgentSpec);
1031
+ } else {
1032
+ await this.saveRelatedSubAgent(agentId, SubAgentSpec);
1033
+ }
1034
+ }
1150
1035
  }
1151
1036
 
1152
- console.log(`💬 savePrompts: Processing ${this.spec.Prompts.length} prompt(s)...`);
1153
- const md = new Metadata();
1154
- const rv = new RunView();
1037
+ /**
1038
+ * Save a child sub-agent (ParentID-based relationship).
1039
+ *
1040
+ * For child agents, the relationship is established by setting the ParentID field
1041
+ * on the child agent entity. If the SubAgent.ID is empty, this creates a new
1042
+ * child agent recursively using AgentSpecSync.
1043
+ *
1044
+ * This enables creating complete agent hierarchies in one call - sub-agents are
1045
+ * created first (depth-first), then parent references them via ParentID.
1046
+ *
1047
+ * @private
1048
+ * @param parentId - The parent agent ID
1049
+ * @param SubAgentSpec - The sub-agent specification
1050
+ * @throws {Error} If child agent save fails
1051
+ */
1052
+ private async saveChildSubAgent(parentId: string, SubAgentSpec: SubAgentSpec): Promise<void> {
1053
+ console.log(`🔗 saveChildSubAgent: Processing child "${SubAgentSpec.SubAgent?.Name}", ID="${SubAgentSpec.SubAgent?.ID}"`);
1054
+
1055
+ // If SubAgent.ID is empty or missing, create the sub-agent recursively
1056
+ if (!SubAgentSpec.SubAgent?.ID || SubAgentSpec.SubAgent.ID === '') {
1057
+ console.log(`🔨 saveChildSubAgent: Creating new child sub-agent "${SubAgentSpec.SubAgent.Name}"...`);
1058
+
1059
+ // Set ParentID in the sub-agent spec before creating it
1060
+ const childSpec: AgentSpec = {
1061
+ ...SubAgentSpec.SubAgent,
1062
+ ParentID: parentId
1063
+ };
1155
1064
 
1156
- // Step 1: Create or update AIPrompt records
1157
- for (let i = 0; i < this.spec.Prompts.length; i++) {
1158
- const promptSpec = this.spec.Prompts[i];
1065
+ // Recursively create the sub-agent using AgentSpecSync
1066
+ const childSync = new AgentSpecSync(childSpec, this._contextUser);
1067
+ childSync.markDirty();
1068
+ const childResult = await childSync.SaveToDatabase();
1159
1069
 
1160
- // Check if this is an update (has PromptID) or create (no PromptID)
1161
- let promptEntity = await md.GetEntityObject<any>('AI Prompts', this._contextUser);
1070
+ // Update the SubAgentSpec with the created ID so parent can reference it
1071
+ SubAgentSpec.SubAgent.ID = childResult.agentId;
1162
1072
 
1163
- let isUpdate = false;
1164
- if ((promptSpec as any).PromptID && (promptSpec as any).PromptID !== '') {
1165
- // Load existing prompt for update
1166
- const loaded = await promptEntity.Load((promptSpec as any).PromptID);
1167
- if (loaded) {
1168
- isUpdate = true;
1169
- console.log(`💬 savePrompts: Updating existing prompt ID: ${(promptSpec as any).PromptID}`);
1073
+ console.log(`✅ saveChildSubAgent: Created child sub-agent with ID: ${childResult.agentId}`);
1170
1074
  } else {
1171
- console.warn(`💬 savePrompts: PromptID specified but not found, creating new prompt`);
1172
- }
1173
- }
1174
-
1175
- // Set all required fields (both create and update)
1176
- promptEntity.Name = `${this.spec.Name} - Prompt ${i + 1}`;
1177
- promptEntity.Description = `Agent prompt ${i + 1} for ${this.spec.Name}`;
1178
- promptEntity.TypeID = 'a6da423e-f36b-1410-8dac-00021f8b792e'; // Chat type
1179
- promptEntity.Status = 'Active';
1180
- promptEntity.ResponseFormat = 'JSON';
1181
-
1182
- // Handle prompt text - supports both string and object formats
1183
- if (typeof (promptSpec as any).PromptText === 'string') {
1184
- promptEntity.TemplateText = (promptSpec as any).PromptText;
1185
- } else if (typeof (promptSpec as any).PromptText === 'object') {
1186
- // Architect may send PromptText as {text: "...", json: {...}}
1187
- const promptTextObj = (promptSpec as any).PromptText as any;
1188
- let combinedText = promptTextObj.text || '';
1189
- if (promptTextObj.json) {
1190
- combinedText += '\n\n```json\n' + JSON.stringify(promptTextObj.json, null, 2) + '\n```';
1075
+ // SubAgent already exists - update it recursively to capture all field changes
1076
+ console.log(`🔗 saveChildSubAgent: Updating existing child sub-agent "${SubAgentSpec.SubAgent.ID}"...`);
1077
+
1078
+ // Set ParentID in the sub-agent spec
1079
+ const childSpec: AgentSpec = {
1080
+ ...SubAgentSpec.SubAgent,
1081
+ ParentID: parentId
1082
+ };
1083
+
1084
+ // Recursively update the sub-agent using AgentSpecSync
1085
+ // This ensures all fields including FunctionalRequirements and TechnicalDesign are updated
1086
+ const childSync = new AgentSpecSync(childSpec, this._contextUser);
1087
+ childSync.markDirty();
1088
+ childSync.markLoaded(); // Mark as loaded so delete logic runs for orphaned records
1089
+ await childSync.SaveToDatabase();
1090
+
1091
+ console.log(`✅ saveChildSubAgent: Updated existing child sub-agent`);
1191
1092
  }
1192
- promptEntity.TemplateText = combinedText;
1193
- }
1194
-
1195
- // Set prompt role and position if provided
1196
- if ((promptSpec as any).PromptRole) {
1197
- promptEntity.PromptRole = (promptSpec as any).PromptRole;
1198
- }
1199
- if ((promptSpec as any).PromptPosition) {
1200
- promptEntity.PromptPosition = (promptSpec as any).PromptPosition;
1201
- }
1202
-
1203
- const saved = await promptEntity.Save();
1204
- if (!saved) {
1205
- throw new Error(`Failed to save prompt ${i + 1} for agent ${this.spec.Name}`);
1206
- }
1207
-
1208
- // Track mutation
1209
- this.trackMutation(
1210
- 'AI Prompts',
1211
- isUpdate ? 'Update' : 'Create',
1212
- promptEntity.ID,
1213
- `${isUpdate ? 'Updated' : 'Created'} prompt: ${promptEntity.Name}`
1214
- );
1215
-
1216
- // Update the spec with the saved/updated ID
1217
- (promptSpec as any).PromptID = promptEntity.ID;
1218
-
1219
- console.log(`✅ savePrompts: ${isUpdate ? 'Updated' : 'Created'} AIPrompt with ID: ${promptEntity.ID}`);
1220
-
1221
- // Step 2: Create or update AIAgentPrompt junction
1222
- // Query for existing junction by AgentID + PromptID
1223
- const existingJunctionResult = await rv.RunView<any>(
1224
- {
1225
- EntityName: 'MJ: AI Agent Prompts',
1226
- ExtraFilter: `AgentID='${agentId}' AND PromptID='${promptEntity.ID}'`,
1227
- ResultType: 'entity_object',
1228
- },
1229
- this._contextUser
1230
- );
1231
-
1232
- let agentPromptEntity: any;
1233
- let isJunctionUpdate = false;
1234
-
1235
- if (existingJunctionResult.Success && existingJunctionResult.Results && existingJunctionResult.Results.length > 0) {
1236
- // Junction exists - load it for update
1237
- agentPromptEntity = existingJunctionResult.Results[0];
1238
- isJunctionUpdate = true;
1239
- console.log(`💬 savePrompts: Updating existing junction for prompt ${i + 1}`);
1240
- } else {
1241
- // Junction doesn't exist - create new one
1242
- agentPromptEntity = await md.GetEntityObject<any>('MJ: AI Agent Prompts', this._contextUser);
1243
- agentPromptEntity.AgentID = agentId;
1244
- agentPromptEntity.PromptID = promptEntity.ID;
1245
- console.log(`💬 savePrompts: Creating new junction for prompt ${i + 1}`);
1246
- }
1247
-
1248
- // Set/update common fields (ExecutionOrder might change)
1249
- agentPromptEntity.ExecutionOrder = i;
1250
- agentPromptEntity.Status = 'Active';
1251
-
1252
- const junctionSaved = await agentPromptEntity.Save();
1253
- if (!junctionSaved) {
1254
- throw new Error(`Failed to save agent-prompt junction for prompt ${i + 1}`);
1255
- }
1256
-
1257
- // Track mutation
1258
- this.trackMutation(
1259
- 'AI Agent Prompts',
1260
- isJunctionUpdate ? 'Update' : 'Create',
1261
- agentPromptEntity.ID,
1262
- `${isJunctionUpdate ? 'Updated' : 'Created'} agent-prompt junction for prompt ${promptEntity.ID}`
1263
- );
1264
-
1265
- console.log(`✅ savePrompts: ${isJunctionUpdate ? 'Updated' : 'Created'} AIAgentPrompt junction with ID: ${agentPromptEntity.ID}`);
1266
1093
  }
1267
1094
 
1268
- console.log(`✅ savePrompts: Successfully saved all ${this.spec.Prompts.length} prompt(s)`);
1269
- }
1270
-
1271
- /**
1272
- * Save all steps for this agent (Flow agents only).
1273
- *
1274
- * Creates AIAgentStep records for each step in the spec. Steps define the nodes
1275
- * in a Flow agent's execution graph.
1276
- *
1277
- * @private
1278
- * @param agentId - The parent agent ID
1279
- * @throws {Error} If any step save fails
1280
- */
1281
- private async saveSteps(agentId: string): Promise<void> {
1282
- console.log(`🔷 saveSteps: Called with agentId=${agentId}, Steps=${this.spec.Steps ? this.spec.Steps.length : 'undefined'}`);
1283
-
1284
- if (!this.spec.Steps || this.spec.Steps.length === 0) {
1285
- console.log('🔷 saveSteps: No steps to save, returning early');
1286
- return;
1287
- }
1095
+ /**
1096
+ * Save a related sub-agent (relationship-based).
1097
+ *
1098
+ * Creates or updates an AIAgentRelationship record that links the parent and
1099
+ * sub-agent. This includes the input/output mapping and context path configurations.
1100
+ *
1101
+ * @private
1102
+ * @param agentId - The parent agent ID
1103
+ * @param SubAgentSpec - The sub-agent specification
1104
+ * @throws {Error} If relationship save fails
1105
+ */
1106
+ private async saveRelatedSubAgent(agentId: string, SubAgentSpec: SubAgentSpec): Promise<void> {
1107
+ const md = new Metadata();
1108
+ const relationshipEntity = await md.GetEntityObject<AIAgentRelationshipEntity>(
1109
+ 'MJ: AI Agent Relationships',
1110
+ this._contextUser
1111
+ );
1112
+
1113
+ // Load existing if ID provided
1114
+ const isUpdate = !!SubAgentSpec.AgentRelationshipID;
1115
+ if (isUpdate && SubAgentSpec.AgentRelationshipID) {
1116
+ await relationshipEntity.Load(SubAgentSpec.AgentRelationshipID);
1117
+ }
1288
1118
 
1289
- console.log(`🔷 saveSteps: Processing ${this.spec.Steps.length} step(s)...`);
1290
- const md = new Metadata();
1291
-
1292
- for (const stepSpec of this.spec.Steps) {
1293
- const stepEntity = await md.GetEntityObject<AIAgentStepEntity>('MJ: AI Agent Steps', this._contextUser);
1294
-
1295
- // Load existing if ID provided
1296
- const isUpdate = !!(stepSpec.ID && stepSpec.ID !== '');
1297
- if (isUpdate) {
1298
- await stepEntity.Load(stepSpec.ID);
1299
- }
1300
-
1301
- // Map fields
1302
- stepEntity.AgentID = agentId;
1303
- stepEntity.Name = stepSpec.Name;
1304
- stepEntity.Description = stepSpec.Description || null;
1305
- stepEntity.StepType = stepSpec.StepType;
1306
- stepEntity.StartingStep = stepSpec.StartingStep;
1307
- stepEntity.ActionID = stepSpec.ActionID || null;
1308
- stepEntity.SubAgentID = stepSpec.SubAgentID || null;
1309
- stepEntity.Status = 'Active'; // Default to Active
1310
-
1311
- // Handle inline prompt creation for Prompt-type steps
1312
- // If StepType is Prompt and PromptID is empty, create a new AIPrompt record
1313
- if (stepSpec.StepType === 'Prompt' && (!stepSpec.PromptID || stepSpec.PromptID === '')) {
1314
- if (stepSpec.PromptText) {
1315
- console.log(`🔷 saveSteps: Creating inline prompt for step "${stepSpec.Name}"`);
1316
-
1317
- // Create AIPrompt entity (using any type for TemplateText dynamic property)
1318
- const promptEntity = await md.GetEntityObject<any>('AI Prompts', this._contextUser);
1319
-
1320
- // Set prompt fields
1321
- promptEntity.Name = stepSpec.PromptName || `${stepSpec.Name} Prompt`;
1322
- promptEntity.Description = stepSpec.PromptDescription || `Prompt for step: ${stepSpec.Name}`;
1323
- promptEntity.TypeID = 'a6da423e-f36b-1410-8dac-00021f8b792e'; // Chat type
1324
- promptEntity.Status = 'Active';
1325
- promptEntity.ResponseFormat = 'JSON';
1326
- promptEntity.TemplateText = stepSpec.PromptText; // TemplateText is a dynamic property
1327
-
1328
- const promptSaved = await promptEntity.Save();
1329
- if (!promptSaved) {
1330
- throw new Error(`Failed to save inline prompt for step "${stepSpec.Name}"`);
1331
- }
1332
-
1333
- // Track mutation
1334
- this.trackMutation('AI Prompts', 'Create', promptEntity.ID, `Created inline prompt: ${promptEntity.Name}`);
1335
-
1336
- console.log(`✅ saveSteps: Created inline AIPrompt with ID: ${promptEntity.ID}`);
1337
-
1338
- // Link the created prompt to this step
1339
- stepEntity.PromptID = promptEntity.ID;
1340
- stepSpec.PromptID = promptEntity.ID; // Update spec too
1119
+ // Map fields
1120
+ relationshipEntity.AgentID = agentId;
1121
+ relationshipEntity.SubAgentID = SubAgentSpec.SubAgent.ID;
1122
+ relationshipEntity.Status = 'Active';
1123
+
1124
+ // Serialize mapping fields
1125
+ if (SubAgentSpec.SubAgentInputMapping) {
1126
+ relationshipEntity.SubAgentInputMapping = JSON.stringify(SubAgentSpec.SubAgentInputMapping);
1341
1127
  } else {
1342
- console.warn(`⚠️ saveSteps: Step "${stepSpec.Name}" is Prompt type with empty PromptID but no PromptText provided`);
1343
- stepEntity.PromptID = null;
1128
+ relationshipEntity.SubAgentInputMapping = null;
1344
1129
  }
1345
- } else {
1346
- stepEntity.PromptID = stepSpec.PromptID || null;
1347
- }
1348
-
1349
- // Map Action I/O mappings for Flow agent action steps
1350
- // Handle ActionInputMapping - convert object to JSON string if needed
1351
- if (stepSpec.ActionInputMapping) {
1352
- stepEntity.ActionInputMapping =
1353
- typeof stepSpec.ActionInputMapping === 'string' ? stepSpec.ActionInputMapping : JSON.stringify(stepSpec.ActionInputMapping);
1354
- } else {
1355
- stepEntity.ActionInputMapping = null;
1356
- }
1357
-
1358
- // Handle ActionOutputMapping - convert object to JSON string if needed
1359
- if (stepSpec.ActionOutputMapping) {
1360
- stepEntity.ActionOutputMapping =
1361
- typeof stepSpec.ActionOutputMapping === 'string' ? stepSpec.ActionOutputMapping : JSON.stringify(stepSpec.ActionOutputMapping);
1362
- } else {
1363
- stepEntity.ActionOutputMapping = null;
1364
- }
1365
-
1366
- const saved = await stepEntity.Save();
1367
- if (!saved) {
1368
- throw new Error(`Failed to save step "${stepSpec.Name}" for agent ${this.spec.Name}`);
1369
- }
1370
-
1371
- // Track mutation
1372
- this.trackMutation(
1373
- 'AI Agent Steps',
1374
- isUpdate ? 'Update' : 'Create',
1375
- stepEntity.ID,
1376
- `${isUpdate ? 'Updated' : 'Created'} step: ${stepSpec.Name}`
1377
- );
1378
-
1379
- // Update the spec with the saved ID so paths can reference it
1380
- stepSpec.ID = stepEntity.ID;
1381
-
1382
- console.log(`✅ saveSteps: Created AIAgentStep with ID: ${stepEntity.ID}`);
1383
- }
1384
1130
 
1385
- console.log(`✅ saveSteps: Successfully saved all ${this.spec.Steps.length} step(s)`);
1386
- }
1387
-
1388
- /**
1389
- * Save all step paths for this agent (Flow agents only).
1390
- *
1391
- * Creates AIAgentStepPath records that define the edges in a Flow agent's
1392
- * execution graph, including conditional branching logic.
1393
- *
1394
- * @private
1395
- * @param agentId - The parent agent ID
1396
- * @throws {Error} If any path save fails
1397
- */
1398
- private async saveStepPaths(agentId: string): Promise<void> {
1399
- console.log(`🔶 saveStepPaths: Called with agentId=${agentId}, Paths=${this.spec.Paths ? this.spec.Paths.length : 'undefined'}`);
1400
-
1401
- if (!this.spec.Paths || this.spec.Paths.length === 0) {
1402
- console.log('🔶 saveStepPaths: No paths to save, returning early');
1403
- return;
1131
+ if (SubAgentSpec.SubAgentOutputMapping) {
1132
+ relationshipEntity.SubAgentOutputMapping = JSON.stringify(SubAgentSpec.SubAgentOutputMapping);
1133
+ } else {
1134
+ relationshipEntity.SubAgentOutputMapping = null;
1135
+ }
1136
+
1137
+ if (SubAgentSpec.SubAgentContextPaths) {
1138
+ relationshipEntity.SubAgentContextPaths = JSON.stringify(SubAgentSpec.SubAgentContextPaths);
1139
+ } else {
1140
+ relationshipEntity.SubAgentContextPaths = null;
1141
+ }
1142
+
1143
+ const saved = await relationshipEntity.Save();
1144
+ if (!saved) {
1145
+ throw new Error(`Failed to save relationship for sub-agent ${SubAgentSpec.SubAgent.ID}`);
1146
+ }
1147
+
1148
+ // Track mutation
1149
+ this.trackMutation(
1150
+ 'AI Agent Relationships',
1151
+ isUpdate ? 'Update' : 'Create',
1152
+ relationshipEntity.ID,
1153
+ `${isUpdate ? 'Updated' : 'Created'} agent relationship for sub-agent ${SubAgentSpec.SubAgent.ID}`
1154
+ );
1155
+
1156
+ // Update spec with saved ID
1157
+ SubAgentSpec.AgentRelationshipID = relationshipEntity.ID;
1404
1158
  }
1405
1159
 
1406
- console.log(`🔶 saveStepPaths: Processing ${this.spec.Paths.length} path(s)...`);
1407
- const md = new Metadata();
1160
+ /**
1161
+ * Save all prompts for this agent.
1162
+ *
1163
+ * Creates AIPrompt records (with template) and AIAgentPrompt junction records.
1164
+ * Supports simplified prompt format from Architect Agent with just PromptText,
1165
+ * PromptRole, and PromptPosition.
1166
+ *
1167
+ * @private
1168
+ * @param agentId - The parent agent ID
1169
+ * @throws {Error} If any prompt save fails
1170
+ */
1171
+ private async savePrompts(agentId: string): Promise<void> {
1172
+ console.log(`💬 savePrompts: Called with agentId=${agentId}, Prompts=${this.spec.Prompts ? this.spec.Prompts.length : 'undefined'}`);
1173
+
1174
+ if (!this.spec.Prompts || this.spec.Prompts.length === 0) {
1175
+ console.log('💬 savePrompts: No prompts to save, returning early');
1176
+ return;
1177
+ }
1178
+
1179
+ console.log(`💬 savePrompts: Processing ${this.spec.Prompts.length} prompt(s)...`);
1180
+ const md = new Metadata();
1181
+ const rv = new RunView();
1182
+
1183
+ // Step 1: Create or update AIPrompt records
1184
+ for (let i = 0; i < this.spec.Prompts.length; i++) {
1185
+ const promptSpec = this.spec.Prompts[i];
1408
1186
 
1409
- // Create a map of step names to IDs for easy lookup
1410
- const stepNameToId = new Map<string, string>();
1411
- if (this.spec.Steps) {
1412
- for (const step of this.spec.Steps) {
1413
- if (step.ID) {
1414
- stepNameToId.set(step.Name, step.ID);
1187
+ // Check if this is an update (has PromptID) or create (no PromptID)
1188
+ let promptEntity = await md.GetEntityObject<any>(
1189
+ 'AI Prompts',
1190
+ this._contextUser
1191
+ );
1192
+
1193
+ let isUpdate = false;
1194
+ if ((promptSpec as any).PromptID && (promptSpec as any).PromptID !== '') {
1195
+ // Load existing prompt for update
1196
+ const loaded = await promptEntity.Load((promptSpec as any).PromptID);
1197
+ if (loaded) {
1198
+ isUpdate = true;
1199
+ console.log(`💬 savePrompts: Updating existing prompt ID: ${(promptSpec as any).PromptID}`);
1200
+ } else {
1201
+ console.warn(`💬 savePrompts: PromptID specified but not found, creating new prompt`);
1202
+ }
1203
+ }
1204
+
1205
+ // Set all required fields (both create and update)
1206
+ promptEntity.Name = `${this.spec.Name} - Prompt ${i + 1}`;
1207
+ promptEntity.Description = `Agent prompt ${i + 1} for ${this.spec.Name}`;
1208
+ promptEntity.TypeID = 'a6da423e-f36b-1410-8dac-00021f8b792e'; // Chat type
1209
+ promptEntity.Status = 'Active';
1210
+ promptEntity.ResponseFormat = 'JSON';
1211
+
1212
+ // Handle prompt text - supports both string and object formats
1213
+ if (typeof (promptSpec as any).PromptText === 'string') {
1214
+ promptEntity.TemplateText = (promptSpec as any).PromptText;
1215
+ } else if (typeof (promptSpec as any).PromptText === 'object') {
1216
+ // Architect may send PromptText as {text: "...", json: {...}}
1217
+ const promptTextObj = (promptSpec as any).PromptText as any;
1218
+ let combinedText = promptTextObj.text || '';
1219
+ if (promptTextObj.json) {
1220
+ combinedText += '\n\n```json\n' + JSON.stringify(promptTextObj.json, null, 2) + '\n```';
1221
+ }
1222
+ promptEntity.TemplateText = combinedText;
1223
+ }
1224
+
1225
+ // Set prompt role and position if provided
1226
+ if ((promptSpec as any).PromptRole) {
1227
+ promptEntity.PromptRole = (promptSpec as any).PromptRole;
1228
+ }
1229
+ if ((promptSpec as any).PromptPosition) {
1230
+ promptEntity.PromptPosition = (promptSpec as any).PromptPosition;
1231
+ }
1232
+
1233
+ const saved = await promptEntity.Save();
1234
+ if (!saved) {
1235
+ throw new Error(`Failed to save prompt ${i + 1} for agent ${this.spec.Name}`);
1236
+ }
1237
+
1238
+ // Track mutation
1239
+ this.trackMutation(
1240
+ 'AI Prompts',
1241
+ isUpdate ? 'Update' : 'Create',
1242
+ promptEntity.ID,
1243
+ `${isUpdate ? 'Updated' : 'Created'} prompt: ${promptEntity.Name}`
1244
+ );
1245
+
1246
+ // Update the spec with the saved/updated ID
1247
+ (promptSpec as any).PromptID = promptEntity.ID;
1248
+
1249
+ console.log(`✅ savePrompts: ${isUpdate ? 'Updated' : 'Created'} AIPrompt with ID: ${promptEntity.ID}`);
1250
+
1251
+ // Step 2: Create or update AIAgentPrompt junction
1252
+ // Query for existing junction by AgentID + PromptID
1253
+ const existingJunctionResult = await rv.RunView<any>({
1254
+ EntityName: 'MJ: AI Agent Prompts',
1255
+ ExtraFilter: `AgentID='${agentId}' AND PromptID='${promptEntity.ID}'`,
1256
+ ResultType: 'entity_object'
1257
+ }, this._contextUser);
1258
+
1259
+ let agentPromptEntity: any;
1260
+ let isJunctionUpdate = false;
1261
+
1262
+ if (existingJunctionResult.Success && existingJunctionResult.Results && existingJunctionResult.Results.length > 0) {
1263
+ // Junction exists - load it for update
1264
+ agentPromptEntity = existingJunctionResult.Results[0];
1265
+ isJunctionUpdate = true;
1266
+ console.log(`💬 savePrompts: Updating existing junction for prompt ${i + 1}`);
1267
+ } else {
1268
+ // Junction doesn't exist - create new one
1269
+ agentPromptEntity = await md.GetEntityObject<any>(
1270
+ 'MJ: AI Agent Prompts',
1271
+ this._contextUser
1272
+ );
1273
+ agentPromptEntity.AgentID = agentId;
1274
+ agentPromptEntity.PromptID = promptEntity.ID;
1275
+ console.log(`💬 savePrompts: Creating new junction for prompt ${i + 1}`);
1276
+ }
1277
+
1278
+ // Set/update common fields (ExecutionOrder might change)
1279
+ agentPromptEntity.ExecutionOrder = i;
1280
+ agentPromptEntity.Status = 'Active';
1281
+
1282
+ const junctionSaved = await agentPromptEntity.Save();
1283
+ if (!junctionSaved) {
1284
+ throw new Error(`Failed to save agent-prompt junction for prompt ${i + 1}`);
1285
+ }
1286
+
1287
+ // Track mutation
1288
+ this.trackMutation(
1289
+ 'AI Agent Prompts',
1290
+ isJunctionUpdate ? 'Update' : 'Create',
1291
+ agentPromptEntity.ID,
1292
+ `${isJunctionUpdate ? 'Updated' : 'Created'} agent-prompt junction for prompt ${promptEntity.ID}`
1293
+ );
1294
+
1295
+ console.log(`✅ savePrompts: ${isJunctionUpdate ? 'Updated' : 'Created'} AIAgentPrompt junction with ID: ${agentPromptEntity.ID}`);
1415
1296
  }
1416
- }
1297
+
1298
+ console.log(`✅ savePrompts: Successfully saved all ${this.spec.Prompts.length} prompt(s)`);
1417
1299
  }
1418
1300
 
1419
- for (const pathSpec of this.spec.Paths) {
1420
- const pathEntity = await md.GetEntityObject<AIAgentStepPathEntity>('MJ: AI Agent Step Paths', this._contextUser);
1421
-
1422
- // Load existing if ID provided
1423
- const isUpdate = !!(pathSpec.ID && pathSpec.ID !== '');
1424
- if (isUpdate) {
1425
- await pathEntity.Load(pathSpec.ID);
1426
- }
1427
-
1428
- // Resolve step IDs from step names
1429
- // OriginStepID and DestinationStepID can be either step IDs or step names
1430
- // If they're names, look them up in our map
1431
- let originStepId = pathSpec.OriginStepID;
1432
- let destinationStepId = pathSpec.DestinationStepID;
1433
-
1434
- // Try to resolve as step names first
1435
- if (stepNameToId.has(pathSpec.OriginStepID)) {
1436
- originStepId = stepNameToId.get(pathSpec.OriginStepID)!;
1437
- }
1438
- if (stepNameToId.has(pathSpec.DestinationStepID)) {
1439
- destinationStepId = stepNameToId.get(pathSpec.DestinationStepID)!;
1440
- }
1441
-
1442
- // Map fields
1443
- pathEntity.OriginStepID = originStepId;
1444
- pathEntity.DestinationStepID = destinationStepId;
1445
- pathEntity.Condition = pathSpec.Condition || null;
1446
- pathEntity.Description = pathSpec.Description || null;
1447
- pathEntity.Priority = pathSpec.Priority;
1448
-
1449
- const saved = await pathEntity.Save();
1450
- if (!saved) {
1451
- throw new Error(`Failed to save path from "${pathSpec.OriginStepID}" to "${pathSpec.DestinationStepID}"`);
1452
- }
1453
-
1454
- // Track mutation
1455
- this.trackMutation(
1456
- 'AI Agent Step Paths',
1457
- isUpdate ? 'Update' : 'Create',
1458
- pathEntity.ID,
1459
- `${isUpdate ? 'Updated' : 'Created'} path from "${pathSpec.OriginStepID}" to "${pathSpec.DestinationStepID}"`
1460
- );
1461
-
1462
- // Update the spec with the saved ID
1463
- pathSpec.ID = pathEntity.ID;
1464
-
1465
- console.log(`✅ saveStepPaths: Created AIAgentStepPath with ID: ${pathEntity.ID}`);
1301
+ /**
1302
+ * Save all steps for this agent (Flow agents only).
1303
+ *
1304
+ * Creates AIAgentStep records for each step in the spec. Steps define the nodes
1305
+ * in a Flow agent's execution graph.
1306
+ *
1307
+ * @private
1308
+ * @param agentId - The parent agent ID
1309
+ * @throws {Error} If any step save fails
1310
+ */
1311
+ private async saveSteps(agentId: string): Promise<void> {
1312
+ console.log(`🔷 saveSteps: Called with agentId=${agentId}, Steps=${this.spec.Steps ? this.spec.Steps.length : 'undefined'}`);
1313
+
1314
+ if (!this.spec.Steps || this.spec.Steps.length === 0) {
1315
+ console.log('🔷 saveSteps: No steps to save, returning early');
1316
+ return;
1317
+ }
1318
+
1319
+ console.log(`🔷 saveSteps: Processing ${this.spec.Steps.length} step(s)...`);
1320
+ const md = new Metadata();
1321
+
1322
+ for (const stepSpec of this.spec.Steps) {
1323
+ const stepEntity = await md.GetEntityObject<AIAgentStepEntity>(
1324
+ 'MJ: AI Agent Steps',
1325
+ this._contextUser
1326
+ );
1327
+
1328
+ // Load existing if ID provided
1329
+ const isUpdate = !!(stepSpec.ID && stepSpec.ID !== '');
1330
+ if (isUpdate) {
1331
+ await stepEntity.Load(stepSpec.ID);
1332
+ }
1333
+
1334
+ // Map fields
1335
+ stepEntity.AgentID = agentId;
1336
+ stepEntity.Name = stepSpec.Name;
1337
+ stepEntity.Description = stepSpec.Description || null;
1338
+ stepEntity.StepType = stepSpec.StepType;
1339
+ stepEntity.StartingStep = stepSpec.StartingStep;
1340
+ stepEntity.ActionID = stepSpec.ActionID || null;
1341
+ stepEntity.SubAgentID = stepSpec.SubAgentID || null;
1342
+ stepEntity.Status = 'Active'; // Default to Active
1343
+
1344
+ // Handle inline prompt creation for Prompt-type steps
1345
+ // If StepType is Prompt and PromptID is empty, create a new AIPrompt record
1346
+ if (stepSpec.StepType === 'Prompt' && (!stepSpec.PromptID || stepSpec.PromptID === '')) {
1347
+ if (stepSpec.PromptText) {
1348
+ console.log(`🔷 saveSteps: Creating inline prompt for step "${stepSpec.Name}"`);
1349
+
1350
+ // Create AIPrompt entity (using any type for TemplateText dynamic property)
1351
+ const promptEntity = await md.GetEntityObject<any>(
1352
+ 'AI Prompts',
1353
+ this._contextUser
1354
+ );
1355
+
1356
+ // Set prompt fields
1357
+ promptEntity.Name = stepSpec.PromptName || `${stepSpec.Name} Prompt`;
1358
+ promptEntity.Description = stepSpec.PromptDescription || `Prompt for step: ${stepSpec.Name}`;
1359
+ promptEntity.TypeID = 'a6da423e-f36b-1410-8dac-00021f8b792e'; // Chat type
1360
+ promptEntity.Status = 'Active';
1361
+ promptEntity.ResponseFormat = 'JSON';
1362
+ promptEntity.TemplateText = stepSpec.PromptText; // TemplateText is a dynamic property
1363
+
1364
+ const promptSaved = await promptEntity.Save();
1365
+ if (!promptSaved) {
1366
+ throw new Error(`Failed to save inline prompt for step "${stepSpec.Name}"`);
1367
+ }
1368
+
1369
+ // Track mutation
1370
+ this.trackMutation(
1371
+ 'AI Prompts',
1372
+ 'Create',
1373
+ promptEntity.ID,
1374
+ `Created inline prompt: ${promptEntity.Name}`
1375
+ );
1376
+
1377
+ console.log(`✅ saveSteps: Created inline AIPrompt with ID: ${promptEntity.ID}`);
1378
+
1379
+ // Link the created prompt to this step
1380
+ stepEntity.PromptID = promptEntity.ID;
1381
+ stepSpec.PromptID = promptEntity.ID; // Update spec too
1382
+ } else {
1383
+ console.warn(`⚠️ saveSteps: Step "${stepSpec.Name}" is Prompt type with empty PromptID but no PromptText provided`);
1384
+ stepEntity.PromptID = null;
1385
+ }
1386
+ } else {
1387
+ stepEntity.PromptID = stepSpec.PromptID || null;
1388
+ }
1389
+
1390
+ // Map Action I/O mappings for Flow agent action steps
1391
+ // Handle ActionInputMapping - convert object to JSON string if needed
1392
+ if (stepSpec.ActionInputMapping) {
1393
+ stepEntity.ActionInputMapping = typeof stepSpec.ActionInputMapping === 'string'
1394
+ ? stepSpec.ActionInputMapping
1395
+ : JSON.stringify(stepSpec.ActionInputMapping);
1396
+ } else {
1397
+ stepEntity.ActionInputMapping = null;
1398
+ }
1399
+
1400
+ // Handle ActionOutputMapping - convert object to JSON string if needed
1401
+ if (stepSpec.ActionOutputMapping) {
1402
+ stepEntity.ActionOutputMapping = typeof stepSpec.ActionOutputMapping === 'string'
1403
+ ? stepSpec.ActionOutputMapping
1404
+ : JSON.stringify(stepSpec.ActionOutputMapping);
1405
+ } else {
1406
+ stepEntity.ActionOutputMapping = null;
1407
+ }
1408
+
1409
+ const saved = await stepEntity.Save();
1410
+ if (!saved) {
1411
+ throw new Error(`Failed to save step "${stepSpec.Name}" for agent ${this.spec.Name}`);
1412
+ }
1413
+
1414
+ // Track mutation
1415
+ this.trackMutation(
1416
+ 'AI Agent Steps',
1417
+ isUpdate ? 'Update' : 'Create',
1418
+ stepEntity.ID,
1419
+ `${isUpdate ? 'Updated' : 'Created'} step: ${stepSpec.Name}`
1420
+ );
1421
+
1422
+ // Update the spec with the saved ID so paths can reference it
1423
+ stepSpec.ID = stepEntity.ID;
1424
+
1425
+ console.log(`✅ saveSteps: Created AIAgentStep with ID: ${stepEntity.ID}`);
1426
+ }
1427
+
1428
+ console.log(`✅ saveSteps: Successfully saved all ${this.spec.Steps.length} step(s)`);
1466
1429
  }
1467
1430
 
1468
- console.log(`✅ saveStepPaths: Successfully saved all ${this.spec.Paths.length} path(s)`);
1469
- }
1470
-
1471
- // ===== UTILITY METHODS =====
1472
-
1473
- /**
1474
- * Parse a JSON string field, returning undefined if null/empty.
1475
- *
1476
- * Safely parses JSON fields from the database, handling null/undefined values
1477
- * and logging errors if parsing fails.
1478
- *
1479
- * @private
1480
- * @param jsonString - The JSON string to parse
1481
- * @returns Parsed object or undefined if null/empty/invalid
1482
- */
1483
- private parseJsonField<T>(jsonString: string | null | undefined): T | undefined {
1484
- if (!jsonString) return undefined;
1485
- try {
1486
- return JSON.parse(jsonString) as T;
1487
- } catch (error) {
1488
- LogError(`Failed to parse JSON field: ${error}`);
1489
- return undefined;
1431
+ /**
1432
+ * Save all step paths for this agent (Flow agents only).
1433
+ *
1434
+ * Creates AIAgentStepPath records that define the edges in a Flow agent's
1435
+ * execution graph, including conditional branching logic.
1436
+ *
1437
+ * @private
1438
+ * @param agentId - The parent agent ID
1439
+ * @throws {Error} If any path save fails
1440
+ */
1441
+ private async saveStepPaths(agentId: string): Promise<void> {
1442
+ console.log(`🔶 saveStepPaths: Called with agentId=${agentId}, Paths=${this.spec.Paths ? this.spec.Paths.length : 'undefined'}`);
1443
+
1444
+ if (!this.spec.Paths || this.spec.Paths.length === 0) {
1445
+ console.log('🔶 saveStepPaths: No paths to save, returning early');
1446
+ return;
1447
+ }
1448
+
1449
+ console.log(`🔶 saveStepPaths: Processing ${this.spec.Paths.length} path(s)...`);
1450
+ const md = new Metadata();
1451
+
1452
+ // Create a map of step names to IDs for easy lookup
1453
+ const stepNameToId = new Map<string, string>();
1454
+ if (this.spec.Steps) {
1455
+ for (const step of this.spec.Steps) {
1456
+ if (step.ID) {
1457
+ stepNameToId.set(step.Name, step.ID);
1458
+ }
1459
+ }
1460
+ }
1461
+
1462
+ for (const pathSpec of this.spec.Paths) {
1463
+ const pathEntity = await md.GetEntityObject<AIAgentStepPathEntity>(
1464
+ 'MJ: AI Agent Step Paths',
1465
+ this._contextUser
1466
+ );
1467
+
1468
+ // Load existing if ID provided
1469
+ const isUpdate = !!(pathSpec.ID && pathSpec.ID !== '');
1470
+ if (isUpdate) {
1471
+ await pathEntity.Load(pathSpec.ID);
1472
+ }
1473
+
1474
+ // Resolve step IDs from step names
1475
+ // OriginStepID and DestinationStepID can be either step IDs or step names
1476
+ // If they're names, look them up in our map
1477
+ let originStepId = pathSpec.OriginStepID;
1478
+ let destinationStepId = pathSpec.DestinationStepID;
1479
+
1480
+ // Try to resolve as step names first
1481
+ if (stepNameToId.has(pathSpec.OriginStepID)) {
1482
+ originStepId = stepNameToId.get(pathSpec.OriginStepID)!;
1483
+ }
1484
+ if (stepNameToId.has(pathSpec.DestinationStepID)) {
1485
+ destinationStepId = stepNameToId.get(pathSpec.DestinationStepID)!;
1486
+ }
1487
+
1488
+ // Map fields
1489
+ pathEntity.OriginStepID = originStepId;
1490
+ pathEntity.DestinationStepID = destinationStepId;
1491
+ pathEntity.Condition = pathSpec.Condition || null;
1492
+ pathEntity.Description = pathSpec.Description || null;
1493
+ pathEntity.Priority = pathSpec.Priority;
1494
+
1495
+ const saved = await pathEntity.Save();
1496
+ if (!saved) {
1497
+ throw new Error(`Failed to save path from "${pathSpec.OriginStepID}" to "${pathSpec.DestinationStepID}"`);
1498
+ }
1499
+
1500
+ // Track mutation
1501
+ this.trackMutation(
1502
+ 'AI Agent Step Paths',
1503
+ isUpdate ? 'Update' : 'Create',
1504
+ pathEntity.ID,
1505
+ `${isUpdate ? 'Updated' : 'Created'} path from "${pathSpec.OriginStepID}" to "${pathSpec.DestinationStepID}"`
1506
+ );
1507
+
1508
+ // Update the spec with the saved ID
1509
+ pathSpec.ID = pathEntity.ID;
1510
+
1511
+ console.log(`✅ saveStepPaths: Created AIAgentStepPath with ID: ${pathEntity.ID}`);
1512
+ }
1513
+
1514
+ console.log(`✅ saveStepPaths: Successfully saved all ${this.spec.Paths.length} path(s)`);
1490
1515
  }
1491
- }
1492
-
1493
- /**
1494
- * Initialize a spec with defaults for any missing required fields.
1495
- *
1496
- * Takes a partial spec and fills in defaults for any missing fields to ensure
1497
- * a valid AgentSpec structure.
1498
- *
1499
- * @private
1500
- * @param partial - Partial agent specification
1501
- * @returns Complete AgentSpec with defaults
1502
- */
1503
- private initializeSpec(partial: Partial<AgentSpec>): AgentSpec {
1504
- return {
1505
- ID: partial.ID || '',
1506
- Name: partial.Name || '',
1507
- Description: partial.Description,
1508
-
1509
- // TypeID and Status are extended fields (not in base AgentSpec interface)
1510
- TypeID: (partial as any).TypeID,
1511
- Status: (partial as any).Status || 'Active',
1512
-
1513
- IconClass: partial.IconClass,
1514
- LogoURL: partial.LogoURL,
1515
- ParentID: partial.ParentID,
1516
- DriverClass: partial.DriverClass,
1517
- ModelSelectionMode: partial.ModelSelectionMode || 'Agent Type',
1518
- PayloadDownstreamPaths: partial.PayloadDownstreamPaths,
1519
- PayloadUpstreamPaths: partial.PayloadUpstreamPaths,
1520
- PayloadSelfReadPaths: partial.PayloadSelfReadPaths,
1521
- PayloadSelfWritePaths: partial.PayloadSelfWritePaths,
1522
- PayloadScope: partial.PayloadScope,
1523
- FinalPayloadValidation: partial.FinalPayloadValidation,
1524
- FinalPayloadValidationMode: partial.FinalPayloadValidationMode || 'Retry',
1525
- FinalPayloadValidationMaxRetries: partial.FinalPayloadValidationMaxRetries,
1526
- MaxCostPerRun: partial.MaxCostPerRun,
1527
- MaxTokensPerRun: partial.MaxTokensPerRun,
1528
- MaxIterationsPerRun: partial.MaxIterationsPerRun,
1529
- MaxTimePerRun: partial.MaxTimePerRun,
1530
- MinExecutionsPerRun: partial.MinExecutionsPerRun,
1531
- MaxExecutionsPerRun: partial.MaxExecutionsPerRun,
1532
- StartingPayloadValidation: partial.StartingPayloadValidation,
1533
- StartingPayloadValidationMode: partial.StartingPayloadValidationMode || 'Fail',
1534
- DefaultPromptEffortLevel: partial.DefaultPromptEffortLevel,
1535
- ChatHandlingOption: partial.ChatHandlingOption,
1536
- DefaultArtifactTypeID: partial.DefaultArtifactTypeID,
1537
- OwnerUserID: partial.OwnerUserID,
1538
- InvocationMode: partial.InvocationMode || 'Any',
1539
- FunctionalRequirements: partial.FunctionalRequirements,
1540
- TechnicalDesign: partial.TechnicalDesign,
1541
- Actions: partial.Actions || [],
1542
- SubAgents: partial.SubAgents || [],
1543
- Prompts: partial.Prompts || [],
1544
-
1545
- // Flow agent fields - critical for Flow agent support
1546
- Steps: partial.Steps || [],
1547
- Paths: partial.Paths || [],
1548
- };
1549
- }
1550
-
1551
- /**
1552
- * Get a clean serializable version of the spec.
1553
- *
1554
- * Returns a plain JavaScript object suitable for JSON serialization,
1555
- * API responses, or storage. This is useful when you need to send the
1556
- * agent spec over the wire or store it in a file.
1557
- *
1558
- * @returns Clean copy of the agent specification
1559
- *
1560
- * @example
1561
- * ```typescript
1562
- * const spec = await AgentSpecSync.LoadFromDatabase('agent-uuid', contextUser);
1563
- * const json = spec.toJSON();
1564
- * res.json(json); // Send as API response
1565
- * ```
1566
- */
1567
- public toJSON(): AgentSpec {
1568
- return { ...this.spec };
1569
- }
1570
-
1571
- /**
1572
- * Check if this spec has unsaved changes.
1573
- *
1574
- * @returns True if there are unsaved changes, false otherwise
1575
- */
1576
- public get isDirty(): boolean {
1577
- return this._isDirty;
1578
- }
1579
-
1580
- /**
1581
- * Check if this spec has been loaded from the database.
1582
- *
1583
- * @returns True if loaded from database, false if created in memory
1584
- */
1585
- public get isLoaded(): boolean {
1586
- return this._isLoaded;
1587
- }
1588
-
1589
- /**
1590
- * Mark the spec as having changes.
1591
- *
1592
- * Call this method after modifying the spec to indicate that changes need to be saved.
1593
- * The spec is automatically marked dirty when created with the constructor, but if you
1594
- * load a spec and then modify it, you should call this method.
1595
- *
1596
- * @example
1597
- * ```typescript
1598
- * const spec = await AgentSpecSync.LoadFromDatabase('agent-uuid', contextUser);
1599
- * spec.spec.Description = 'New description';
1600
- * spec.markDirty();
1601
- * await spec.SaveToDatabase();
1602
- * ```
1603
- */
1604
- public markDirty(): void {
1605
- this._isDirty = true;
1606
- }
1607
-
1608
- /**
1609
- * Mark the spec as having been loaded from the database.
1610
- *
1611
- * This is used internally when updating existing child agents to ensure the delete
1612
- * logic runs correctly. When a child agent is updated via saveChildSubAgent(), we
1613
- * create a new AgentSpecSync instance from the spec data (not loaded from DB), but
1614
- * we need to mark it as loaded so orphaned records are properly deleted.
1615
- *
1616
- * @internal
1617
- * @example
1618
- * ```typescript
1619
- * const childSync = new AgentSpecSync(existingChildSpec, contextUser);
1620
- * childSync.markDirty();
1621
- * childSync.markLoaded(); // Ensure delete logic runs for existing agent
1622
- * await childSync.SaveToDatabase();
1623
- * ```
1624
- */
1625
- public markLoaded(): void {
1626
- this._isLoaded = true;
1627
- }
1628
-
1629
- // ===== DELETE/ORPHAN METHODS =====
1630
-
1631
- /**
1632
- * Load the current state of all agent-related records from the database.
1633
- *
1634
- * This method efficiently batches all queries using RunViews for optimal performance.
1635
- * It loads all junction records and child agents for the specified agent.
1636
- *
1637
- * @private
1638
- * @param agentId - The agent ID to load database state for
1639
- * @returns Promise resolving to DatabaseState with all current records
1640
- * @throws {Error} If any query fails
1641
- */
1642
- private async loadCurrentDatabaseState(agentId: string): Promise<DatabaseState> {
1643
- const rv = new RunView();
1644
-
1645
- // Batch load all related records for this agent
1646
- const [actions, prompts, relationships, steps, childAgents] = await rv.RunViews(
1647
- [
1648
- {
1649
- EntityName: 'AI Agent Actions',
1650
- ExtraFilter: `AgentID='${agentId}'`,
1651
- ResultType: 'entity_object',
1652
- },
1653
- {
1654
- EntityName: 'MJ: AI Agent Prompts',
1655
- ExtraFilter: `AgentID='${agentId}'`,
1656
- ResultType: 'entity_object',
1657
- },
1658
- {
1659
- EntityName: 'MJ: AI Agent Relationships',
1660
- ExtraFilter: `AgentID='${agentId}'`,
1661
- ResultType: 'entity_object',
1662
- },
1663
- {
1664
- EntityName: 'MJ: AI Agent Steps',
1665
- ExtraFilter: `AgentID='${agentId}'`,
1666
- ResultType: 'entity_object',
1667
- },
1668
- {
1669
- EntityName: 'AI Agents',
1670
- ExtraFilter: `ParentID='${agentId}'`,
1671
- ResultType: 'entity_object',
1672
- },
1673
- ],
1674
- this._contextUser
1675
- );
1676
-
1677
- // Check for errors
1678
- if (!actions.Success || !prompts.Success || !relationships.Success || !steps.Success || !childAgents.Success) {
1679
- const errors = [actions, prompts, relationships, steps, childAgents]
1680
- .filter((r) => !r.Success)
1681
- .map((r) => r.ErrorMessage)
1682
- .join('; ');
1683
- throw new Error(`Failed to load database state: ${errors}`);
1516
+
1517
+ // ===== UTILITY METHODS =====
1518
+
1519
+ /**
1520
+ * Parse a JSON string field, returning undefined if null/empty.
1521
+ *
1522
+ * Safely parses JSON fields from the database, handling null/undefined values
1523
+ * and logging errors if parsing fails.
1524
+ *
1525
+ * @private
1526
+ * @param jsonString - The JSON string to parse
1527
+ * @returns Parsed object or undefined if null/empty/invalid
1528
+ */
1529
+ private parseJsonField<T>(jsonString: string | null | undefined): T | undefined {
1530
+ if (!jsonString) return undefined;
1531
+ try {
1532
+ return JSON.parse(jsonString) as T;
1533
+ } catch (error) {
1534
+ LogError(`Failed to parse JSON field: ${error}`);
1535
+ return undefined;
1536
+ }
1684
1537
  }
1685
1538
 
1686
- // Load paths separately after we have steps (to avoid subquery in ExtraFilter)
1687
- let pathsResult;
1688
- const stepRecords = steps.Results || [];
1689
- if (stepRecords.length > 0) {
1690
- const stepIds = stepRecords.map((s: AIAgentStepEntity) => `'${s.ID}'`).join(',');
1691
- pathsResult = await rv.RunView<AIAgentStepPathEntity>(
1692
- {
1693
- EntityName: 'MJ: AI Agent Step Paths',
1694
- ExtraFilter: `OriginStepID IN (${stepIds})`,
1695
- ResultType: 'entity_object',
1696
- },
1697
- this._contextUser
1698
- );
1699
-
1700
- if (!pathsResult.Success) {
1701
- throw new Error(`Failed to load step paths: ${pathsResult.ErrorMessage}`);
1702
- }
1703
- } else {
1704
- // No steps, so no paths
1705
- pathsResult = { Success: true, Results: [] };
1539
+ /**
1540
+ * Initialize a spec with defaults for any missing required fields.
1541
+ *
1542
+ * Takes a partial spec and fills in defaults for any missing fields to ensure
1543
+ * a valid AgentSpec structure.
1544
+ *
1545
+ * @private
1546
+ * @param partial - Partial agent specification
1547
+ * @returns Complete AgentSpec with defaults
1548
+ */
1549
+ private initializeSpec(partial: Partial<AgentSpec>): AgentSpec {
1550
+ return {
1551
+ ID: partial.ID || '',
1552
+ Name: partial.Name || '',
1553
+ Description: partial.Description,
1554
+
1555
+ // TypeID and Status are extended fields (not in base AgentSpec interface)
1556
+ TypeID: (partial as any).TypeID,
1557
+ Status: (partial as any).Status || 'Active',
1558
+
1559
+ IconClass: partial.IconClass,
1560
+ LogoURL: partial.LogoURL,
1561
+ ParentID: partial.ParentID,
1562
+ DriverClass: partial.DriverClass,
1563
+ ModelSelectionMode: partial.ModelSelectionMode || 'Agent Type',
1564
+ PayloadDownstreamPaths: partial.PayloadDownstreamPaths,
1565
+ PayloadUpstreamPaths: partial.PayloadUpstreamPaths,
1566
+ PayloadSelfReadPaths: partial.PayloadSelfReadPaths,
1567
+ PayloadSelfWritePaths: partial.PayloadSelfWritePaths,
1568
+ PayloadScope: partial.PayloadScope,
1569
+ FinalPayloadValidation: partial.FinalPayloadValidation,
1570
+ FinalPayloadValidationMode: partial.FinalPayloadValidationMode || 'Retry',
1571
+ FinalPayloadValidationMaxRetries: partial.FinalPayloadValidationMaxRetries,
1572
+ MaxCostPerRun: partial.MaxCostPerRun,
1573
+ MaxTokensPerRun: partial.MaxTokensPerRun,
1574
+ MaxIterationsPerRun: partial.MaxIterationsPerRun,
1575
+ MaxTimePerRun: partial.MaxTimePerRun,
1576
+ MinExecutionsPerRun: partial.MinExecutionsPerRun,
1577
+ MaxExecutionsPerRun: partial.MaxExecutionsPerRun,
1578
+ StartingPayloadValidation: partial.StartingPayloadValidation,
1579
+ StartingPayloadValidationMode: partial.StartingPayloadValidationMode || 'Fail',
1580
+ DefaultPromptEffortLevel: partial.DefaultPromptEffortLevel,
1581
+ ChatHandlingOption: partial.ChatHandlingOption,
1582
+ DefaultArtifactTypeID: partial.DefaultArtifactTypeID,
1583
+ OwnerUserID: partial.OwnerUserID,
1584
+ InvocationMode: partial.InvocationMode || 'Any',
1585
+ FunctionalRequirements: partial.FunctionalRequirements,
1586
+ TechnicalDesign: partial.TechnicalDesign,
1587
+ Actions: partial.Actions || [],
1588
+ SubAgents: partial.SubAgents || [],
1589
+ Prompts: partial.Prompts || [],
1590
+
1591
+ // Flow agent fields - critical for Flow agent support
1592
+ Steps: partial.Steps || [],
1593
+ Paths: partial.Paths || []
1594
+ };
1706
1595
  }
1707
1596
 
1708
- return {
1709
- actions: actions.Results || [],
1710
- prompts: prompts.Results || [],
1711
- relationships: relationships.Results || [],
1712
- steps: stepRecords,
1713
- paths: pathsResult.Results || [],
1714
- childAgents: childAgents.Results || [],
1715
- };
1716
- }
1717
-
1718
- /**
1719
- * Identify orphaned records by comparing database state to AgentSpec.
1720
- *
1721
- * An "orphan" is a database record that exists but is not represented in the
1722
- * current AgentSpec. These records need to be deleted or orphaned.
1723
- *
1724
- * The diff logic matches by primary key ID:
1725
- * - If a DB record's ID is found in the spec → KEEP (may be updated)
1726
- * - If a DB record's ID is NOT in the spec → ORPHAN/DELETE
1727
- *
1728
- * @private
1729
- * @param dbState - Current database state loaded from loadCurrentDatabaseState
1730
- * @param spec - The new AgentSpec to compare against
1731
- * @returns Orphans object containing all orphaned records
1732
- */
1733
- private identifyOrphans(dbState: DatabaseState, spec: AgentSpec): Orphans {
1734
- return {
1735
- // Orphaned actions: DB actions not in spec.Actions
1736
- actions: dbState.actions.filter((dbAction) => !spec.Actions?.some((specAction) => specAction.AgentActionID === dbAction.ID)),
1737
-
1738
- // Orphaned prompt junctions: DB prompts not in spec.Prompts
1739
- prompts: dbState.prompts.filter((dbPrompt) => !spec.Prompts?.some((specPrompt) => (specPrompt as any).ID === dbPrompt.ID)),
1740
-
1741
- // Orphaned relationships: DB relationships not in spec.SubAgents (related type)
1742
- relationships: dbState.relationships.filter(
1743
- (dbRel) => !spec.SubAgents?.some((specSub) => specSub.Type === 'related' && specSub.AgentRelationshipID === dbRel.ID)
1744
- ),
1745
-
1746
- // Orphaned steps: DB steps not in spec.Steps
1747
- steps: dbState.steps.filter((dbStep) => !spec.Steps?.some((specStep) => specStep.ID === dbStep.ID)),
1748
-
1749
- // Orphaned paths: DB paths not in spec.Paths
1750
- paths: dbState.paths.filter((dbPath) => !spec.Paths?.some((specPath) => specPath.ID === dbPath.ID)),
1751
-
1752
- // Orphaned child agents: DB children not in spec.SubAgents (child type)
1753
- childAgents: dbState.childAgents.filter(
1754
- (dbChild) => !spec.SubAgents?.some((specSub) => specSub.Type === 'child' && specSub.SubAgent.ID === dbChild.ID)
1755
- ),
1756
- };
1757
- }
1758
-
1759
- /**
1760
- * Delete or orphan all orphaned records in the correct order.
1761
- *
1762
- * This method executes deletions in phases to maintain referential integrity:
1763
- * - Phase 1: Leaf entities (paths, actions) - no other entities reference these
1764
- * - Phase 2: Mid-level entities (steps, prompt junctions, relationships)
1765
- * - Phase 3: Child agents (orphan by setting ParentID = NULL)
1766
- *
1767
- * IMPORTANT: We NEVER delete AIPrompt entities themselves, only AIAgentPrompt junctions.
1768
- * IMPORTANT: We ORPHAN child agents instead of deleting them to avoid foreign key violations from run history.
1769
- *
1770
- * @private
1771
- * @param orphans - The orphaned records identified by identifyOrphans
1772
- * @throws {Error} If any delete operation fails
1773
- */
1774
- private async deleteOrphans(orphans: Orphans): Promise<void> {
1775
- const md = new Metadata();
1776
-
1777
- // Phase 1: Leaf entities (Paths, Actions)
1778
- console.log(`🗑️ Phase 1: Deleting ${orphans.paths.length} paths and ${orphans.actions.length} actions...`);
1779
-
1780
- for (const path of orphans.paths) {
1781
- console.log(`🗑️ deleteOrphans: Deleting path ${path.ID}...`);
1782
- const entity = await md.GetEntityObject<AIAgentStepPathEntity>('MJ: AI Agent Step Paths', this._contextUser);
1783
- const loaded = await entity.Load(path.ID);
1784
- if (!loaded) {
1785
- console.error(`❌ deleteOrphans: Failed to load path ${path.ID} - skipping`);
1786
- continue;
1787
- }
1788
- const deleted = await entity.Delete();
1789
- if (!deleted) {
1790
- console.error(`❌ deleteOrphans: Failed to delete path ${path.ID}`);
1791
- continue;
1792
- }
1793
- console.log(`✅ deleteOrphans: Deleted path ${path.ID}`);
1794
- this.trackMutation('AI Agent Step Paths', 'Delete', path.ID, `Deleted orphaned path`);
1597
+ /**
1598
+ * Get a clean serializable version of the spec.
1599
+ *
1600
+ * Returns a plain JavaScript object suitable for JSON serialization,
1601
+ * API responses, or storage. This is useful when you need to send the
1602
+ * agent spec over the wire or store it in a file.
1603
+ *
1604
+ * @returns Clean copy of the agent specification
1605
+ *
1606
+ * @example
1607
+ * ```typescript
1608
+ * const spec = await AgentSpecSync.LoadFromDatabase('agent-uuid', contextUser);
1609
+ * const json = spec.toJSON();
1610
+ * res.json(json); // Send as API response
1611
+ * ```
1612
+ */
1613
+ public toJSON(): AgentSpec {
1614
+ return { ...this.spec };
1795
1615
  }
1796
1616
 
1797
- for (const action of orphans.actions) {
1798
- console.log(`🗑️ deleteOrphans: Deleting action junction ${action.ID} (ActionID: ${action.ActionID})...`);
1799
- const entity = await md.GetEntityObject<AIAgentActionEntity>('AI Agent Actions', this._contextUser);
1800
- const loaded = await entity.Load(action.ID);
1801
- if (!loaded) {
1802
- console.error(`❌ deleteOrphans: Failed to load action junction ${action.ID} - skipping`);
1803
- continue;
1804
- }
1805
- const deleted = await entity.Delete();
1806
- if (!deleted) {
1807
- console.error(`❌ deleteOrphans: Failed to delete action junction ${action.ID}`);
1808
- continue;
1809
- }
1810
- console.log(`✅ deleteOrphans: Deleted action junction ${action.ID}`);
1811
- this.trackMutation('AI Agent Actions', 'Delete', action.ID, `Deleted orphaned action junction`);
1617
+ /**
1618
+ * Check if this spec has unsaved changes.
1619
+ *
1620
+ * @returns True if there are unsaved changes, false otherwise
1621
+ */
1622
+ public get isDirty(): boolean {
1623
+ return this._isDirty;
1812
1624
  }
1813
1625
 
1814
- // Phase 2: Mid-level entities (Steps, Prompt Junctions, Relationships)
1815
- console.log(
1816
- `🗑️ Phase 2: Deleting ${orphans.steps.length} steps, ${orphans.prompts.length} prompt junctions, ${orphans.relationships.length} relationships...`
1817
- );
1818
-
1819
- for (const step of orphans.steps) {
1820
- console.log(`🗑️ deleteOrphans: Deleting step ${step.ID} (${step.Name})...`);
1821
- const entity = await md.GetEntityObject<AIAgentStepEntity>('MJ: AI Agent Steps', this._contextUser);
1822
- const loaded = await entity.Load(step.ID);
1823
- if (!loaded) {
1824
- console.error(`❌ deleteOrphans: Failed to load step ${step.ID} - skipping`);
1825
- continue;
1826
- }
1827
- const deleted = await entity.Delete();
1828
- if (!deleted) {
1829
- console.error(`❌ deleteOrphans: Failed to delete step ${step.ID}`);
1830
- continue;
1831
- }
1832
- console.log(`✅ deleteOrphans: Deleted step ${step.ID}`);
1833
- this.trackMutation('AI Agent Steps', 'Delete', step.ID, `Deleted orphaned step`);
1626
+ /**
1627
+ * Check if this spec has been loaded from the database.
1628
+ *
1629
+ * @returns True if loaded from database, false if created in memory
1630
+ */
1631
+ public get isLoaded(): boolean {
1632
+ return this._isLoaded;
1834
1633
  }
1835
1634
 
1836
- for (const promptJunction of orphans.prompts) {
1837
- console.log(`🗑️ deleteOrphans: Deleting prompt junction ${promptJunction.ID} (PromptID: ${promptJunction.PromptID})...`);
1838
- const entity = await md.GetEntityObject<any>('MJ: AI Agent Prompts', this._contextUser);
1839
- const loaded = await entity.Load(promptJunction.ID);
1840
- if (!loaded) {
1841
- console.error(`❌ deleteOrphans: Failed to load prompt junction ${promptJunction.ID} - skipping`);
1842
- continue;
1843
- }
1844
- const deleted = await entity.Delete();
1845
- if (!deleted) {
1846
- console.error(`❌ deleteOrphans: Failed to delete prompt junction ${promptJunction.ID}`);
1847
- continue;
1848
- }
1849
- console.log(`✅ deleteOrphans: Deleted prompt junction ${promptJunction.ID}`);
1850
- this.trackMutation('AI Agent Prompts', 'Delete', promptJunction.ID, `Deleted orphaned prompt junction`);
1851
- // NOTE: We do NOT delete the AIPrompt itself - it's a shared resource
1635
+ /**
1636
+ * Mark the spec as having changes.
1637
+ *
1638
+ * Call this method after modifying the spec to indicate that changes need to be saved.
1639
+ * The spec is automatically marked dirty when created with the constructor, but if you
1640
+ * load a spec and then modify it, you should call this method.
1641
+ *
1642
+ * @example
1643
+ * ```typescript
1644
+ * const spec = await AgentSpecSync.LoadFromDatabase('agent-uuid', contextUser);
1645
+ * spec.spec.Description = 'New description';
1646
+ * spec.markDirty();
1647
+ * await spec.SaveToDatabase();
1648
+ * ```
1649
+ */
1650
+ public markDirty(): void {
1651
+ this._isDirty = true;
1852
1652
  }
1853
1653
 
1854
- for (const relationship of orphans.relationships) {
1855
- console.log(`🗑️ deleteOrphans: Deleting relationship ${relationship.ID} (SubAgentID: ${relationship.SubAgentID})...`);
1856
- const entity = await md.GetEntityObject<AIAgentRelationshipEntity>('MJ: AI Agent Relationships', this._contextUser);
1857
- const loaded = await entity.Load(relationship.ID);
1858
- if (!loaded) {
1859
- console.error(`❌ deleteOrphans: Failed to load relationship ${relationship.ID} - skipping`);
1860
- continue;
1861
- }
1862
- const deleted = await entity.Delete();
1863
- if (!deleted) {
1864
- console.error(`❌ deleteOrphans: Failed to delete relationship ${relationship.ID}`);
1865
- continue;
1866
- }
1867
- console.log(`✅ deleteOrphans: Deleted relationship ${relationship.ID}`);
1868
- this.trackMutation('AI Agent Relationships', 'Delete', relationship.ID, `Deleted orphaned relationship`);
1654
+ /**
1655
+ * Mark the spec as having been loaded from the database.
1656
+ *
1657
+ * This is used internally when updating existing child agents to ensure the delete
1658
+ * logic runs correctly. When a child agent is updated via saveChildSubAgent(), we
1659
+ * create a new AgentSpecSync instance from the spec data (not loaded from DB), but
1660
+ * we need to mark it as loaded so orphaned records are properly deleted.
1661
+ *
1662
+ * @internal
1663
+ * @example
1664
+ * ```typescript
1665
+ * const childSync = new AgentSpecSync(existingChildSpec, contextUser);
1666
+ * childSync.markDirty();
1667
+ * childSync.markLoaded(); // Ensure delete logic runs for existing agent
1668
+ * await childSync.SaveToDatabase();
1669
+ * ```
1670
+ */
1671
+ public markLoaded(): void {
1672
+ this._isLoaded = true;
1869
1673
  }
1870
1674
 
1871
- // Phase 3: Orphan child agents (set ParentID = NULL)
1872
- console.log(`🔗 Phase 3: Orphaning ${orphans.childAgents.length} child agents (set ParentID = NULL)...`);
1675
+ // ===== DELETE/ORPHAN METHODS =====
1676
+
1677
+ /**
1678
+ * Load the current state of all agent-related records from the database.
1679
+ *
1680
+ * This method efficiently batches all queries using RunViews for optimal performance.
1681
+ * It loads all junction records and child agents for the specified agent.
1682
+ *
1683
+ * @private
1684
+ * @param agentId - The agent ID to load database state for
1685
+ * @returns Promise resolving to DatabaseState with all current records
1686
+ * @throws {Error} If any query fails
1687
+ */
1688
+ private async loadCurrentDatabaseState(agentId: string): Promise<DatabaseState> {
1689
+ const rv = new RunView();
1690
+
1691
+ // Batch load all related records for this agent
1692
+ const [actions, prompts, relationships, steps, childAgents] = await rv.RunViews([
1693
+ {
1694
+ EntityName: 'AI Agent Actions',
1695
+ ExtraFilter: `AgentID='${agentId}'`,
1696
+ ResultType: 'entity_object'
1697
+ },
1698
+ {
1699
+ EntityName: 'MJ: AI Agent Prompts',
1700
+ ExtraFilter: `AgentID='${agentId}'`,
1701
+ ResultType: 'entity_object'
1702
+ },
1703
+ {
1704
+ EntityName: 'MJ: AI Agent Relationships',
1705
+ ExtraFilter: `AgentID='${agentId}'`,
1706
+ ResultType: 'entity_object'
1707
+ },
1708
+ {
1709
+ EntityName: 'MJ: AI Agent Steps',
1710
+ ExtraFilter: `AgentID='${agentId}'`,
1711
+ ResultType: 'entity_object'
1712
+ },
1713
+ {
1714
+ EntityName: 'AI Agents',
1715
+ ExtraFilter: `ParentID='${agentId}'`,
1716
+ ResultType: 'entity_object'
1717
+ }
1718
+ ], this._contextUser);
1719
+
1720
+ // Check for errors
1721
+ if (!actions.Success || !prompts.Success || !relationships.Success ||
1722
+ !steps.Success || !childAgents.Success) {
1723
+ const errors = [actions, prompts, relationships, steps, childAgents]
1724
+ .filter(r => !r.Success)
1725
+ .map(r => r.ErrorMessage)
1726
+ .join('; ');
1727
+ throw new Error(`Failed to load database state: ${errors}`);
1728
+ }
1729
+
1730
+ // Load paths separately after we have steps (to avoid subquery in ExtraFilter)
1731
+ let pathsResult;
1732
+ const stepRecords = steps.Results || [];
1733
+ if (stepRecords.length > 0) {
1734
+ const stepIds = stepRecords.map((s: AIAgentStepEntity) => `'${s.ID}'`).join(',');
1735
+ pathsResult = await rv.RunView<AIAgentStepPathEntity>({
1736
+ EntityName: 'MJ: AI Agent Step Paths',
1737
+ ExtraFilter: `OriginStepID IN (${stepIds})`,
1738
+ ResultType: 'entity_object'
1739
+ }, this._contextUser);
1740
+
1741
+ if (!pathsResult.Success) {
1742
+ throw new Error(`Failed to load step paths: ${pathsResult.ErrorMessage}`);
1743
+ }
1744
+ } else {
1745
+ // No steps, so no paths
1746
+ pathsResult = { Success: true, Results: [] };
1747
+ }
1873
1748
 
1874
- for (const childAgent of orphans.childAgents) {
1875
- await this.orphanChildAgent(childAgent.ID);
1876
- this.trackMutation('AI Agents', 'Update', childAgent.ID, `Orphaned child agent: ${childAgent.Name} (set ParentID = NULL)`);
1749
+ return {
1750
+ actions: actions.Results || [],
1751
+ prompts: prompts.Results || [],
1752
+ relationships: relationships.Results || [],
1753
+ steps: stepRecords,
1754
+ paths: pathsResult.Results || [],
1755
+ childAgents: childAgents.Results || []
1756
+ };
1877
1757
  }
1878
- }
1879
-
1880
- /**
1881
- * Orphan a child agent by setting its ParentID to NULL.
1882
- *
1883
- * This removes the child from the parent's hierarchy without deleting it.
1884
- * This approach is used instead of full deletion because:
1885
- * - Child agent may have AIAgentRun records (execution history)
1886
- * - AIAgentRun.AgentID is NOT NULL with NO CASCADE DELETE
1887
- * - Deleting child would cause foreign key violation
1888
- * - Orphaning preserves history and avoids violation
1889
- * - Child becomes a standalone agent
1890
- *
1891
- * @private
1892
- * @param childAgentId - The ID of the child agent to orphan
1893
- * @throws {Error} If orphaning fails
1894
- */
1895
- private async orphanChildAgent(childAgentId: string): Promise<void> {
1896
- console.log(`🔗 orphanChildAgent: Orphaning child agent ${childAgentId}...`);
1897
-
1898
- const md = new Metadata();
1899
- const childAgent = await md.GetEntityObject<AIAgentEntity>('AI Agents', this._contextUser);
1900
-
1901
- const loaded = await childAgent.Load(childAgentId);
1902
- if (!loaded) {
1903
- throw new Error(`Failed to load child agent ${childAgentId} for orphaning`);
1758
+
1759
+ /**
1760
+ * Identify orphaned records by comparing database state to AgentSpec.
1761
+ *
1762
+ * An "orphan" is a database record that exists but is not represented in the
1763
+ * current AgentSpec. These records need to be deleted or orphaned.
1764
+ *
1765
+ * The diff logic matches by primary key ID:
1766
+ * - If a DB record's ID is found in the spec → KEEP (may be updated)
1767
+ * - If a DB record's ID is NOT in the spec → ORPHAN/DELETE
1768
+ *
1769
+ * @private
1770
+ * @param dbState - Current database state loaded from loadCurrentDatabaseState
1771
+ * @param spec - The new AgentSpec to compare against
1772
+ * @returns Orphans object containing all orphaned records
1773
+ */
1774
+ private identifyOrphans(dbState: DatabaseState, spec: AgentSpec): Orphans {
1775
+ return {
1776
+ // Orphaned actions: DB actions not in spec.Actions
1777
+ actions: dbState.actions.filter(dbAction =>
1778
+ !spec.Actions?.some(specAction => specAction.AgentActionID === dbAction.ID)
1779
+ ),
1780
+
1781
+ // Orphaned prompt junctions: DB prompts not in spec.Prompts
1782
+ prompts: dbState.prompts.filter(dbPrompt =>
1783
+ !spec.Prompts?.some(specPrompt => (specPrompt as any).ID === dbPrompt.ID)
1784
+ ),
1785
+
1786
+ // Orphaned relationships: DB relationships not in spec.SubAgents (related type)
1787
+ relationships: dbState.relationships.filter(dbRel =>
1788
+ !spec.SubAgents?.some(specSub =>
1789
+ specSub.Type === 'related' && specSub.AgentRelationshipID === dbRel.ID
1790
+ )
1791
+ ),
1792
+
1793
+ // Orphaned steps: DB steps not in spec.Steps
1794
+ steps: dbState.steps.filter(dbStep =>
1795
+ !spec.Steps?.some(specStep => specStep.ID === dbStep.ID)
1796
+ ),
1797
+
1798
+ // Orphaned paths: DB paths not in spec.Paths
1799
+ paths: dbState.paths.filter(dbPath =>
1800
+ !spec.Paths?.some(specPath => specPath.ID === dbPath.ID)
1801
+ ),
1802
+
1803
+ // Orphaned child agents: DB children not in spec.SubAgents (child type)
1804
+ childAgents: dbState.childAgents.filter(dbChild =>
1805
+ !spec.SubAgents?.some(specSub =>
1806
+ specSub.Type === 'child' && specSub.SubAgent.ID === dbChild.ID
1807
+ )
1808
+ )
1809
+ };
1904
1810
  }
1905
1811
 
1906
- childAgent.ParentID = null; // Orphan the child
1907
- const saved = await childAgent.Save();
1908
- if (!saved) {
1909
- throw new Error(`Failed to orphan child agent ${childAgentId}`);
1812
+ /**
1813
+ * Delete or orphan all orphaned records in the correct order.
1814
+ *
1815
+ * This method executes deletions in phases to maintain referential integrity:
1816
+ * - Phase 1: Leaf entities (paths, actions) - no other entities reference these
1817
+ * - Phase 2: Mid-level entities (steps, prompt junctions, relationships)
1818
+ * - Phase 3: Child agents (orphan by setting ParentID = NULL)
1819
+ *
1820
+ * IMPORTANT: We NEVER delete AIPrompt entities themselves, only AIAgentPrompt junctions.
1821
+ * IMPORTANT: We ORPHAN child agents instead of deleting them to avoid foreign key violations from run history.
1822
+ *
1823
+ * @private
1824
+ * @param orphans - The orphaned records identified by identifyOrphans
1825
+ * @throws {Error} If any delete operation fails
1826
+ */
1827
+ private async deleteOrphans(orphans: Orphans): Promise<void> {
1828
+ const md = new Metadata();
1829
+
1830
+ // Phase 1: Leaf entities (Paths, Actions)
1831
+ console.log(`🗑️ Phase 1: Deleting ${orphans.paths.length} paths and ${orphans.actions.length} actions...`);
1832
+
1833
+ for (const path of orphans.paths) {
1834
+ console.log(`🗑️ deleteOrphans: Deleting path ${path.ID}...`);
1835
+ const entity = await md.GetEntityObject<AIAgentStepPathEntity>(
1836
+ 'MJ: AI Agent Step Paths',
1837
+ this._contextUser
1838
+ );
1839
+ const loaded = await entity.Load(path.ID);
1840
+ if (!loaded) {
1841
+ console.error(`❌ deleteOrphans: Failed to load path ${path.ID} - skipping`);
1842
+ continue;
1843
+ }
1844
+ const deleted = await entity.Delete();
1845
+ if (!deleted) {
1846
+ console.error(`❌ deleteOrphans: Failed to delete path ${path.ID}`);
1847
+ continue;
1848
+ }
1849
+ console.log(`✅ deleteOrphans: Deleted path ${path.ID}`);
1850
+ this.trackMutation('AI Agent Step Paths', 'Delete', path.ID, `Deleted orphaned path`);
1851
+ }
1852
+
1853
+ for (const action of orphans.actions) {
1854
+ console.log(`🗑️ deleteOrphans: Deleting action junction ${action.ID} (ActionID: ${action.ActionID})...`);
1855
+ const entity = await md.GetEntityObject<AIAgentActionEntity>(
1856
+ 'AI Agent Actions',
1857
+ this._contextUser
1858
+ );
1859
+ const loaded = await entity.Load(action.ID);
1860
+ if (!loaded) {
1861
+ console.error(`❌ deleteOrphans: Failed to load action junction ${action.ID} - skipping`);
1862
+ continue;
1863
+ }
1864
+ const deleted = await entity.Delete();
1865
+ if (!deleted) {
1866
+ console.error(`❌ deleteOrphans: Failed to delete action junction ${action.ID}`);
1867
+ continue;
1868
+ }
1869
+ console.log(`✅ deleteOrphans: Deleted action junction ${action.ID}`);
1870
+ this.trackMutation('AI Agent Actions', 'Delete', action.ID, `Deleted orphaned action junction`);
1871
+ }
1872
+
1873
+ // Phase 2: Mid-level entities (Steps, Prompt Junctions, Relationships)
1874
+ console.log(`🗑️ Phase 2: Deleting ${orphans.steps.length} steps, ${orphans.prompts.length} prompt junctions, ${orphans.relationships.length} relationships...`);
1875
+
1876
+ for (const step of orphans.steps) {
1877
+ console.log(`🗑️ deleteOrphans: Deleting step ${step.ID} (${step.Name})...`);
1878
+ const entity = await md.GetEntityObject<AIAgentStepEntity>(
1879
+ 'MJ: AI Agent Steps',
1880
+ this._contextUser
1881
+ );
1882
+ const loaded = await entity.Load(step.ID);
1883
+ if (!loaded) {
1884
+ console.error(`❌ deleteOrphans: Failed to load step ${step.ID} - skipping`);
1885
+ continue;
1886
+ }
1887
+ const deleted = await entity.Delete();
1888
+ if (!deleted) {
1889
+ console.error(`❌ deleteOrphans: Failed to delete step ${step.ID}`);
1890
+ continue;
1891
+ }
1892
+ console.log(`✅ deleteOrphans: Deleted step ${step.ID}`);
1893
+ this.trackMutation('AI Agent Steps', 'Delete', step.ID, `Deleted orphaned step`);
1894
+ }
1895
+
1896
+ for (const promptJunction of orphans.prompts) {
1897
+ console.log(`🗑️ deleteOrphans: Deleting prompt junction ${promptJunction.ID} (PromptID: ${promptJunction.PromptID})...`);
1898
+ const entity = await md.GetEntityObject<any>(
1899
+ 'MJ: AI Agent Prompts',
1900
+ this._contextUser
1901
+ );
1902
+ const loaded = await entity.Load(promptJunction.ID);
1903
+ if (!loaded) {
1904
+ console.error(`❌ deleteOrphans: Failed to load prompt junction ${promptJunction.ID} - skipping`);
1905
+ continue;
1906
+ }
1907
+ const deleted = await entity.Delete();
1908
+ if (!deleted) {
1909
+ console.error(`❌ deleteOrphans: Failed to delete prompt junction ${promptJunction.ID}`);
1910
+ continue;
1911
+ }
1912
+ console.log(`✅ deleteOrphans: Deleted prompt junction ${promptJunction.ID}`);
1913
+ this.trackMutation('AI Agent Prompts', 'Delete', promptJunction.ID, `Deleted orphaned prompt junction`);
1914
+ // NOTE: We do NOT delete the AIPrompt itself - it's a shared resource
1915
+ }
1916
+
1917
+ for (const relationship of orphans.relationships) {
1918
+ console.log(`🗑️ deleteOrphans: Deleting relationship ${relationship.ID} (SubAgentID: ${relationship.SubAgentID})...`);
1919
+ const entity = await md.GetEntityObject<AIAgentRelationshipEntity>(
1920
+ 'MJ: AI Agent Relationships',
1921
+ this._contextUser
1922
+ );
1923
+ const loaded = await entity.Load(relationship.ID);
1924
+ if (!loaded) {
1925
+ console.error(`❌ deleteOrphans: Failed to load relationship ${relationship.ID} - skipping`);
1926
+ continue;
1927
+ }
1928
+ const deleted = await entity.Delete();
1929
+ if (!deleted) {
1930
+ console.error(`❌ deleteOrphans: Failed to delete relationship ${relationship.ID}`);
1931
+ continue;
1932
+ }
1933
+ console.log(`✅ deleteOrphans: Deleted relationship ${relationship.ID}`);
1934
+ this.trackMutation('AI Agent Relationships', 'Delete', relationship.ID, `Deleted orphaned relationship`);
1935
+ }
1936
+
1937
+ // Phase 3: Orphan child agents (set ParentID = NULL)
1938
+ console.log(`🔗 Phase 3: Orphaning ${orphans.childAgents.length} child agents (set ParentID = NULL)...`);
1939
+
1940
+ for (const childAgent of orphans.childAgents) {
1941
+ await this.orphanChildAgent(childAgent.ID);
1942
+ this.trackMutation('AI Agents', 'Update', childAgent.ID, `Orphaned child agent: ${childAgent.Name} (set ParentID = NULL)`);
1943
+ }
1910
1944
  }
1911
1945
 
1912
- console.log(`✅ orphanChildAgent: Orphaned child agent ${childAgentId} (ParentID = NULL)`);
1913
- }
1914
- }
1946
+ /**
1947
+ * Orphan a child agent by setting its ParentID to NULL.
1948
+ *
1949
+ * This removes the child from the parent's hierarchy without deleting it.
1950
+ * This approach is used instead of full deletion because:
1951
+ * - Child agent may have AIAgentRun records (execution history)
1952
+ * - AIAgentRun.AgentID is NOT NULL with NO CASCADE DELETE
1953
+ * - Deleting child would cause foreign key violation
1954
+ * - Orphaning preserves history and avoids violation
1955
+ * - Child becomes a standalone agent
1956
+ *
1957
+ * @private
1958
+ * @param childAgentId - The ID of the child agent to orphan
1959
+ * @throws {Error} If orphaning fails
1960
+ */
1961
+ private async orphanChildAgent(childAgentId: string): Promise<void> {
1962
+ console.log(`🔗 orphanChildAgent: Orphaning child agent ${childAgentId}...`);
1963
+
1964
+ const md = new Metadata();
1965
+ const childAgent = await md.GetEntityObject<AIAgentEntity>('AI Agents', this._contextUser);
1966
+
1967
+ const loaded = await childAgent.Load(childAgentId);
1968
+ if (!loaded) {
1969
+ throw new Error(`Failed to load child agent ${childAgentId} for orphaning`);
1970
+ }
1971
+
1972
+ childAgent.ParentID = null; // Orphan the child
1973
+ const saved = await childAgent.Save();
1974
+ if (!saved) {
1975
+ throw new Error(`Failed to orphan child agent ${childAgentId}`);
1976
+ }
1977
+
1978
+ console.log(`✅ orphanChildAgent: Orphaned child agent ${childAgentId} (ParentID = NULL)`);
1979
+ }
1980
+ }