@memberjunction/ai-agents 5.23.0 → 5.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -332,6 +332,38 @@ New code should import these directly from `@memberjunction/ai-reranker`.
332
332
  - `@memberjunction/aiengine` -- AIEngine for metadata and vector search
333
333
  - `@memberjunction/ai-core-plus` -- Shared types (ExecuteAgentParams, ExecuteAgentResult)
334
334
  - `@memberjunction/ai-engine-base` -- Base metadata cache and permissions
335
+ ## Storage Account Resolution
336
+
337
+ When agents create file-based artifacts (PDF, Excel, Word), the system resolves which `FileStorageAccount` to use via a hierarchical chain. The first non-null value wins:
338
+
339
+ | Priority | Source | Field |
340
+ |----------|--------|-------|
341
+ | 1 (highest) | Runtime | `ExecuteAgentParams.override.storageAccountId` |
342
+ | 2 | Agent | `AIAgent.DefaultStorageAccountID` |
343
+ | 3 | Category tree | `AIAgentCategory.DefaultStorageAccountID` (walks up `ParentID`) |
344
+ | 4 (lowest) | Agent Type | `AIAgentType.DefaultStorageAccountID` |
345
+ | Fallback | System | Single active account (if only one exists) |
346
+
347
+ ### How it works
348
+
349
+ - `BaseAgent.getStorageAccountID(params)` implements the resolution logic. It is `protected` so subclasses can override it for custom routing.
350
+ - The resolved ID is stored in `ExecuteAgentResult.resolvedStorageAccountId` and passed to `AgentRunner.ProcessFileArtifacts()` for upload routing.
351
+ - `AgentRunner.uploadBase64ToStorage()` uses `FileStorageEngine.Instance.GetAccountWithProvider()` to get the account + provider, then `initializeDriverWithAccountCredentials()` for proper OAuth credential handling.
352
+
353
+ ### Startup validation
354
+
355
+ `AIEngine.validateStorageAccountDefaults()` runs at server startup. If 2+ active storage accounts exist but agent types lack a `DefaultStorageAccountID`, it auto-assigns the highest-priority account and logs a prominent warning.
356
+
357
+ ### Configuration
358
+
359
+ Set `DefaultStorageAccountID` at any level via the admin UI or metadata sync:
360
+ - **Agent Type** -- broadest default (e.g., all Loop agents → Dropbox)
361
+ - **Agent Category** -- business-domain default (e.g., Marketing → Box, Finance → SharePoint)
362
+ - **Agent** -- per-agent override
363
+ - **Runtime** -- `ExecuteAgentParams.override.storageAccountId` for programmatic callers
364
+
365
+ ## Dependencies
366
+
335
367
  - `@memberjunction/ai` -- Core AI abstractions
336
368
  - `@memberjunction/ai-reranker` -- Two-stage retrieval reranking
337
369
  - `@memberjunction/actions` -- Server-side action execution
@@ -10,7 +10,7 @@
10
10
  * @since 2.49.0
11
11
  */
12
12
  import { UserInfo, IMetadataProvider } from '@memberjunction/core';
13
- import { ExecuteAgentResult, ExecuteAgentParams, MediaOutput } from '@memberjunction/ai-core-plus';
13
+ import { ExecuteAgentResult, ExecuteAgentParams, MediaOutput, FileOutputRef } from '@memberjunction/ai-core-plus';
14
14
  /**
15
15
  * AgentRunner provides a thin wrapper for executing AI agents.
16
16
  *
@@ -123,7 +123,7 @@ export declare class AgentRunner {
123
123
  * const newVersionNumber = maxVersion + 1;
124
124
  * ```
125
125
  */
126
- GetMaxVersionForArtifact(artifactId: string, contextUser: UserInfo): Promise<number>;
126
+ GetMaxVersionForArtifact(artifactId: string, contextUser: UserInfo, provider?: IMetadataProvider): Promise<number>;
127
127
  /**
128
128
  * Checks whether the serialized content for a new artifact version is identical to the
129
129
  * latest existing version of the same artifact, using SHA-256 content hashing.
@@ -139,7 +139,7 @@ export declare class AgentRunner {
139
139
  * @param contextUser - User context for the RunView query
140
140
  * @returns The existing version's ID if content is identical, or `null` if a new version should be created
141
141
  */
142
- protected CheckForDuplicateVersion(artifactId: string, candidateContent: string, latestVersionNumber: number, contextUser: UserInfo): Promise<string | null>;
142
+ protected CheckForDuplicateVersion(artifactId: string, candidateContent: string, latestVersionNumber: number, contextUser: UserInfo, provider?: IMetadataProvider): Promise<string | null>;
143
143
  /**
144
144
  * Creates a `ConversationDetailArtifact` junction record linking an artifact version
145
145
  * to a conversation detail, then returns the standard artifact result tuple.
@@ -280,6 +280,61 @@ export declare class AgentRunner {
280
280
  * ```
281
281
  */
282
282
  CreateConversationMediaAttachments(conversationDetailId: string, mediaOutputs: MediaOutput[], agentRunMediaIds: string[], contextUser: UserInfo, provider?: IMetadataProvider): Promise<string[]>;
283
+ /**
284
+ * Creates MJ: Artifact records for file outputs collected during agent execution.
285
+ * Reads directly from `ExecuteAgentResult.fileOutputs` — no DB query needed.
286
+ *
287
+ * Called automatically by RunAgentInConversation after the agent completes.
288
+ *
289
+ * @param fileOutputs - File outputs collected by BaseAgent during action execution
290
+ * @param conversationDetailId - The conversation detail to link artifacts to
291
+ * @param contextUser - User context for DB operations
292
+ * @param resolvedStorageAccountId - Pre-resolved FileStorageAccount ID from the agent's
293
+ * hierarchical resolution chain (Runtime → Agent → Category → Type → fallback).
294
+ * When provided, uploads use this specific account instead of picking the first active one.
295
+ */
296
+ ProcessFileArtifacts(fileOutputs: FileOutputRef[], conversationDetailId: string, contextUser: UserInfo, resolvedStorageAccountId?: string, provider?: IMetadataProvider): Promise<void>;
297
+ /**
298
+ * Re-processes file artifacts from a historical agent run by querying its persisted
299
+ * action step OutputData. Use this to recover artifacts for runs that completed before
300
+ * ProcessFileArtifacts was introduced, or for debugging.
301
+ *
302
+ * @param agentRunId - The agent run whose action steps to inspect
303
+ * @param conversationDetailId - The conversation detail to link artifacts to
304
+ * @param contextUser - User context for DB operations
305
+ */
306
+ ReprocessRunFileArtifacts(agentRunId: string, conversationDetailId: string, contextUser: UserInfo, provider?: IMetadataProvider): Promise<void>;
307
+ /** Loads all completed action steps for a given agent run (read-only, narrow fields). */
308
+ private loadActionStepsForRun;
309
+ /**
310
+ * Parses persisted OutputData JSON from an action step and extracts file output metadata.
311
+ * Detection is shape-based (looks for objects with fileName + mimeType + fileData/fileId),
312
+ * not name-based — works regardless of what the action named its output parameter.
313
+ *
314
+ * Used only by the historical reprocessing path — live runs use BaseAgent's detectFileOutputs.
315
+ */
316
+ private parseStepFileOutputs;
317
+ /** Uploads or resolves a single file output and creates the artifact records.
318
+ * Falls back to inline base64 artifact if storage is unavailable or upload fails. */
319
+ private processFileOutput;
320
+ /**
321
+ * Uploads base64-encoded file content to MJStorage and creates an MJ: Files record.
322
+ * Returns the new MJ: Files record ID.
323
+ *
324
+ * @param resolvedStorageAccountId - Pre-resolved account from the agent's storage resolution chain.
325
+ * When provided, uploads to this specific account. Otherwise falls back to the first active account.
326
+ */
327
+ private uploadBase64ToStorage;
328
+ /**
329
+ * Shared logic for creating an artifact + version + conversation detail link in a single
330
+ * transaction. The caller provides a callback to set the version-specific fields (file-backed
331
+ * vs. inline content).
332
+ */
333
+ private createArtifactWithVersion;
334
+ /** Creates a file-backed artifact (version references a FileID in MJStorage). */
335
+ private createFileArtifact;
336
+ /** Creates an inline artifact (version stores base64 data URL directly, no MJStorage). */
337
+ private createInlineFileArtifact;
283
338
  /**
284
339
  * Gets file extension from MIME type
285
340
  * @private
@@ -1 +1 @@
1
- {"version":3,"file":"AgentRunner.d.ts","sourceRoot":"","sources":["../src/AgentRunner.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"AgentRunner.d.ts","sourceRoot":"","sources":["../src/AgentRunner.ts"],"names":[],"mappings":"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"}
@@ -10,10 +10,13 @@
10
10
  * @since 2.49.0
11
11
  */
12
12
  import { createHash } from 'crypto';
13
- import { LogError, LogStatusEx, IsVerboseLoggingEnabled, LogStatus, Metadata, RunView } from '@memberjunction/core';
13
+ import { LogError, LogStatusEx, IsVerboseLoggingEnabled, LogStatus, Metadata, RunView, ProviderType } from '@memberjunction/core';
14
14
  import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
15
15
  import { AIEngine } from '@memberjunction/aiengine';
16
+ import { ParseFileOutputRef } from '@memberjunction/ai-core-plus';
16
17
  import { BaseAgent } from './base-agent.js';
18
+ import { ArtifactMetadataEngine } from '@memberjunction/core-entities';
19
+ import { FileStorageEngine } from '@memberjunction/storage';
17
20
  /**
18
21
  * AgentRunner provides a thin wrapper for executing AI agents.
19
22
  *
@@ -131,18 +134,39 @@ export class AgentRunner {
131
134
  // If conversationDetailId is provided, use it (UI-created agent response detail)
132
135
  if (options.conversationDetailId) {
133
136
  agentResponseDetailId = options.conversationDetailId;
134
- // Load the conversation detail to get the conversation ID AND keep reference for final status update
135
- // This ensures backend can update Status/Message even if frontend disconnects (browser refresh)
136
- agentResponseDetail = await md.GetEntityObject('MJ: Conversation Details', contextUser);
137
- if (await agentResponseDetail.Load(agentResponseDetailId)) {
138
- conversationId = agentResponseDetail.ConversationID;
139
- LogStatus(`Using existing conversation ${conversationId} and agent response detail ${agentResponseDetailId}`);
140
- // Note: In this case, we don't know the user message detail ID
141
- userMessageDetailId = agentResponseDetailId; // For backward compatibility
137
+ // LATENCY OPTIMIZATION (Opt #2): When conversationId is pre-resolved by the caller
138
+ // (e.g., the resolver already loaded the ConversationDetail to build history), we
139
+ // skip the redundant DB load that was ONLY needed to extract ConversationID.
140
+ //
141
+ // We still must load the ConversationDetail entity object because it serves double
142
+ // duty: (a) progress callback updates its Message field during execution, and
143
+ // (b) step 5 sets the final Status/Message/ResponseForm after the agent completes.
144
+ // What we save here is the serial dependency: previously we had to wait for the
145
+ // Load to complete before we even knew conversationId. Now we know conversationId
146
+ // immediately and can resolve the entity object's load in parallel with other setup.
147
+ if (options.conversationId) {
148
+ conversationId = options.conversationId;
149
+ LogStatus(`Using pre-resolved conversation ${conversationId} and agent response detail ${agentResponseDetailId}`);
142
150
  }
143
151
  else {
144
- throw new Error(`Failed to load conversation detail ${agentResponseDetailId}`);
152
+ // Fallback for callers that don't provide conversationId (backward compatibility).
153
+ // This path still works but incurs the extra DB round-trip to extract conversationId.
154
+ const tempDetail = await md.GetEntityObject('MJ: Conversation Details', contextUser);
155
+ if (await tempDetail.Load(agentResponseDetailId)) {
156
+ conversationId = tempDetail.ConversationID;
157
+ LogStatus(`Using existing conversation ${conversationId} and agent response detail ${agentResponseDetailId}`);
158
+ }
159
+ else {
160
+ throw new Error(`Failed to load conversation detail ${agentResponseDetailId}`);
161
+ }
162
+ }
163
+ // Load the entity object for progress updates and final status Save at step 5.
164
+ // This is needed regardless of whether conversationId was pre-resolved.
165
+ agentResponseDetail = await md.GetEntityObject('MJ: Conversation Details', contextUser);
166
+ if (!await agentResponseDetail.Load(agentResponseDetailId)) {
167
+ throw new Error(`Failed to load conversation detail entity for status updates: ${agentResponseDetailId}`);
145
168
  }
169
+ userMessageDetailId = agentResponseDetailId; // For backward compatibility
146
170
  }
147
171
  else {
148
172
  // Server creates BOTH user message and agent response details
@@ -260,55 +284,90 @@ export class AgentRunner {
260
284
  const agentResult = await this.RunAgent(modifiedParams);
261
285
  // Mark execution as completed to stop progress saves
262
286
  agentExecutionCompleted = true;
263
- // Step 5: Update agent response detail with final result
264
- // ALWAYS update status - don't rely on frontend (browser may refresh during execution)
265
- if (agentResponseDetail && agentResponseDetailId) {
266
- // Wait for any in-flight progress save to complete
267
- // EnsureSaveComplete() resolves immediately if no save in progress
268
- await agentResponseDetail.EnsureSaveComplete();
269
- LogStatus('Updating agent response detail with final result');
270
- // Reload to get any updates from agent execution
271
- await agentResponseDetail.Load(agentResponseDetailId);
272
- agentResponseDetail.Message = agentResult.agentRun?.Message ||
273
- (agentResult.success
274
- ? '✅ Completed'
275
- : agentResult.agentRun?.ErrorMessage || '❌ Failed');
276
- agentResponseDetail.Status = agentResult.success ? 'Complete' : 'Error';
277
- // Set response form and command fields
278
- if (agentResult.responseForm) {
279
- agentResponseDetail.ResponseForm = JSON.stringify(agentResult.responseForm);
287
+ // LATENCY OPTIMIZATION (Opt #5): Steps 5, 6, and 7 are now parallelized.
288
+ //
289
+ // Previously these ran sequentially: save final status → process artifacts → save media.
290
+ // This added their individual latencies together (~130ms for steps 6+7, plus ~80ms for
291
+ // step 5's EnsureSaveComplete + Load + Save cycle).
292
+ //
293
+ // These operations are safe to parallelize because:
294
+ // - Step 5 (status update) writes to the ConversationDetail record (Status, Message fields)
295
+ // - Step 6 (artifacts) creates new Artifact/ArtifactVersion records and a junction record
296
+ // linking to the ConversationDetail — it only needs the agentResponseDetailId (string),
297
+ // not the entity object, and doesn't read/write the same fields as step 5
298
+ // - Step 7 (media) creates new AIAgentRunMedia and ConversationDetailAttachment records —
299
+ // entirely separate from steps 5 and 6
300
+ //
301
+ // IMPORTANT: All three MUST complete before the resolver publishes the 'complete' event,
302
+ // because the client reloads all conversation data from the DB when it receives that event.
303
+ // If any write hasn't flushed yet, the client would see stale data. Promise.all guarantees
304
+ // all three finish before we return.
305
+ // Step 5: Update agent response detail with final result (async)
306
+ const updateDetailPromise = (async () => {
307
+ if (agentResponseDetail && agentResponseDetailId) {
308
+ // Wait for any in-flight progress save to complete
309
+ // EnsureSaveComplete() resolves immediately if no save in progress
310
+ await agentResponseDetail.EnsureSaveComplete();
311
+ LogStatus('Updating agent response detail with final result');
312
+ // Reload to get any updates from agent execution
313
+ await agentResponseDetail.Load(agentResponseDetailId);
314
+ agentResponseDetail.Message = agentResult.agentRun?.Message ||
315
+ (agentResult.success
316
+ ? '✅ Completed'
317
+ : agentResult.agentRun?.ErrorMessage || '❌ Failed');
318
+ agentResponseDetail.Status = agentResult.success ? 'Complete' : 'Error';
319
+ // Set response form and command fields
320
+ if (agentResult.responseForm) {
321
+ agentResponseDetail.ResponseForm = JSON.stringify(agentResult.responseForm);
322
+ }
323
+ if (agentResult.actionableCommands && agentResult.actionableCommands.length > 0) {
324
+ agentResponseDetail.ActionableCommands = JSON.stringify(agentResult.actionableCommands);
325
+ }
326
+ if (agentResult.automaticCommands && agentResult.automaticCommands.length > 0) {
327
+ agentResponseDetail.AutomaticCommands = JSON.stringify(agentResult.automaticCommands);
328
+ }
329
+ await agentResponseDetail.Save();
330
+ LogStatus(`Updated agent response detail ${agentResponseDetailId} with final status: ${agentResponseDetail.Status}`);
280
331
  }
281
- if (agentResult.actionableCommands && agentResult.actionableCommands.length > 0) {
282
- agentResponseDetail.ActionableCommands = JSON.stringify(agentResult.actionableCommands);
332
+ })();
333
+ // Step 6: Process artifacts if requested and agent succeeded (async)
334
+ const processArtifactsPromise = (async () => {
335
+ const shouldCreateArtifacts = options.createArtifacts !== false; // Default true
336
+ if (shouldCreateArtifacts && agentResult.success && agentResult.payload) {
337
+ return this.ProcessAgentArtifacts(agentResult, agentResponseDetailId, options.sourceArtifactId, contextUser, md);
283
338
  }
284
- if (agentResult.automaticCommands && agentResult.automaticCommands.length > 0) {
285
- agentResponseDetail.AutomaticCommands = JSON.stringify(agentResult.automaticCommands);
339
+ return undefined;
340
+ })();
341
+ // Step 6b: Process file artifacts produced by file-generation actions (async)
342
+ const processFileArtifactsPromise = (async () => {
343
+ if (agentResult.success && agentResponseDetailId && agentResult.fileOutputs?.length) {
344
+ await this.ProcessFileArtifacts(agentResult.fileOutputs, agentResponseDetailId, contextUser, agentResult.resolvedStorageAccountId, md);
286
345
  }
287
- await agentResponseDetail.Save();
288
- LogStatus(`Updated agent response detail ${agentResponseDetailId} with final status: ${agentResponseDetail.Status}`);
289
- }
290
- // Step 6: Process artifacts if requested and agent succeeded
291
- let artifactInfo;
292
- const shouldCreateArtifacts = options.createArtifacts !== false; // Default true
293
- if (shouldCreateArtifacts && agentResult.success && agentResult.payload) {
294
- // Artifacts link to agent response detail ID
295
- artifactInfo = await this.ProcessAgentArtifacts(agentResult, agentResponseDetailId, options.sourceArtifactId, contextUser, md);
296
- }
297
- // Step 7: Save media outputs to AIAgentRunMedia and create conversation attachments
298
- let mediaIds = [];
299
- if (agentResult.mediaOutputs && agentResult.mediaOutputs.length > 0) {
300
- // Filter to only media that should be persisted (persist !== false)
301
- const mediaToSave = agentResult.mediaOutputs.filter(m => m.persist !== false);
302
- LogStatus(`Processing ${mediaToSave.length} of ${agentResult.mediaOutputs.length} media outputs (filtered by persist flag)`);
303
- // Save to AIAgentRunMedia for permanent storage
304
- mediaIds = await this.SaveAgentRunMedia(agentResult.agentRun.ID, mediaToSave, // Pass filtered array
305
- contextUser, md);
306
- // Create ConversationDetailAttachment records for UI display
307
- if (agentResponseDetailId && mediaIds.length > 0) {
308
- await this.CreateConversationMediaAttachments(agentResponseDetailId, mediaToSave, // Pass same filtered array to keep indices aligned
309
- mediaIds, contextUser, md);
346
+ })();
347
+ // Step 7: Save media outputs to AIAgentRunMedia and create conversation attachments (async)
348
+ const saveMediaPromise = (async () => {
349
+ if (agentResult.mediaOutputs && agentResult.mediaOutputs.length > 0) {
350
+ const mediaToSave = agentResult.mediaOutputs.filter(m => m.persist !== false);
351
+ LogStatus(`Processing ${mediaToSave.length} of ${agentResult.mediaOutputs.length} media outputs (filtered by persist flag)`);
352
+ // Save to AIAgentRunMedia for permanent storage
353
+ const ids = await this.SaveAgentRunMedia(agentResult.agentRun.ID, mediaToSave, contextUser, md);
354
+ // Create ConversationDetailAttachment records for UI display
355
+ if (agentResponseDetailId && ids.length > 0) {
356
+ await this.CreateConversationMediaAttachments(agentResponseDetailId, mediaToSave, ids, contextUser, md);
357
+ }
358
+ return ids;
310
359
  }
311
- }
360
+ return [];
361
+ })();
362
+ // Wait for all three post-execution operations to complete before returning.
363
+ // The resolver publishes the 'complete' event after this returns, so the client
364
+ // is guaranteed to see all DB writes when it reloads.
365
+ const [, artifactInfo] = await Promise.all([
366
+ updateDetailPromise,
367
+ processArtifactsPromise,
368
+ processFileArtifactsPromise,
369
+ saveMediaPromise
370
+ ]);
312
371
  return {
313
372
  agentResult,
314
373
  conversationId,
@@ -338,9 +397,9 @@ export class AgentRunner {
338
397
  * const newVersionNumber = maxVersion + 1;
339
398
  * ```
340
399
  */
341
- async GetMaxVersionForArtifact(artifactId, contextUser) {
400
+ async GetMaxVersionForArtifact(artifactId, contextUser, provider) {
342
401
  try {
343
- const rv = new RunView();
402
+ const rv = RunView.FromMetadataProvider(provider || this._provider);
344
403
  const result = await rv.RunView({
345
404
  EntityName: 'MJ: Artifact Versions',
346
405
  ExtraFilter: `ArtifactID='${artifactId}'`,
@@ -373,9 +432,9 @@ export class AgentRunner {
373
432
  * @param contextUser - User context for the RunView query
374
433
  * @returns The existing version's ID if content is identical, or `null` if a new version should be created
375
434
  */
376
- async CheckForDuplicateVersion(artifactId, candidateContent, latestVersionNumber, contextUser) {
435
+ async CheckForDuplicateVersion(artifactId, candidateContent, latestVersionNumber, contextUser, provider) {
377
436
  const candidateHash = createHash('sha256').update(candidateContent, 'utf8').digest('hex');
378
- const rv = new RunView();
437
+ const rv = RunView.FromMetadataProvider(provider || this._provider);
379
438
  const result = await rv.RunView({
380
439
  EntityName: 'MJ: Artifact Versions',
381
440
  ExtraFilter: `ArtifactID='${artifactId}' AND VersionNumber=${latestVersionNumber}`,
@@ -433,7 +492,7 @@ export class AgentRunner {
433
492
  */
434
493
  async FindPreviousArtifactForMessage(conversationDetailId, contextUser, provider) {
435
494
  try {
436
- const rv = new RunView();
495
+ const rv = RunView.FromMetadataProvider(provider || this._provider);
437
496
  const result = await rv.RunView({
438
497
  EntityName: 'MJ: Conversation Detail Artifacts',
439
498
  ExtraFilter: `ConversationDetailID='${conversationDetailId}' AND Direction='Output'`,
@@ -519,7 +578,7 @@ export class AgentRunner {
519
578
  let isNewArtifact = false;
520
579
  // Priority 1: Use explicit source artifact if provided
521
580
  if (sourceArtifactId) {
522
- const maxVersion = await this.GetMaxVersionForArtifact(sourceArtifactId, contextUser);
581
+ const maxVersion = await this.GetMaxVersionForArtifact(sourceArtifactId, contextUser, provider);
523
582
  artifactId = sourceArtifactId;
524
583
  newVersionNumber = maxVersion + 1;
525
584
  LogStatus(`Creating version ${newVersionNumber} of source artifact ${artifactId}`);
@@ -565,7 +624,7 @@ export class AgentRunner {
565
624
  const serializedContent = JSON.stringify(payload, null, 2);
566
625
  // Skip version creation if content is identical to the latest version
567
626
  if (!isNewArtifact && newVersionNumber > 1) {
568
- const existingVersionId = await this.CheckForDuplicateVersion(artifactId, serializedContent, newVersionNumber - 1, contextUser);
627
+ const existingVersionId = await this.CheckForDuplicateVersion(artifactId, serializedContent, newVersionNumber - 1, contextUser, provider);
569
628
  if (existingVersionId) {
570
629
  console.debug(`Skipping duplicate artifact version — content identical to version ${newVersionNumber - 1}`);
571
630
  return undefined;
@@ -863,6 +922,233 @@ export class AgentRunner {
863
922
  return attachmentIds;
864
923
  }
865
924
  }
925
+ // ── File artifact processing ───────────────────────────────────────────────
926
+ /**
927
+ * Creates MJ: Artifact records for file outputs collected during agent execution.
928
+ * Reads directly from `ExecuteAgentResult.fileOutputs` — no DB query needed.
929
+ *
930
+ * Called automatically by RunAgentInConversation after the agent completes.
931
+ *
932
+ * @param fileOutputs - File outputs collected by BaseAgent during action execution
933
+ * @param conversationDetailId - The conversation detail to link artifacts to
934
+ * @param contextUser - User context for DB operations
935
+ * @param resolvedStorageAccountId - Pre-resolved FileStorageAccount ID from the agent's
936
+ * hierarchical resolution chain (Runtime → Agent → Category → Type → fallback).
937
+ * When provided, uploads use this specific account instead of picking the first active one.
938
+ */
939
+ async ProcessFileArtifacts(fileOutputs, conversationDetailId, contextUser, resolvedStorageAccountId, provider) {
940
+ if (fileOutputs.length === 0)
941
+ return;
942
+ const md = provider || this._provider;
943
+ // Hoist engine configs before parallel processing
944
+ await Promise.all([
945
+ ArtifactMetadataEngine.Instance.Config(false, contextUser),
946
+ FileStorageEngine.Instance.Config(false, contextUser),
947
+ ]);
948
+ await Promise.all(fileOutputs.map(fo => this.processFileOutput(fo, conversationDetailId, contextUser, resolvedStorageAccountId, md)));
949
+ }
950
+ /**
951
+ * Re-processes file artifacts from a historical agent run by querying its persisted
952
+ * action step OutputData. Use this to recover artifacts for runs that completed before
953
+ * ProcessFileArtifacts was introduced, or for debugging.
954
+ *
955
+ * @param agentRunId - The agent run whose action steps to inspect
956
+ * @param conversationDetailId - The conversation detail to link artifacts to
957
+ * @param contextUser - User context for DB operations
958
+ */
959
+ async ReprocessRunFileArtifacts(agentRunId, conversationDetailId, contextUser, provider) {
960
+ const md = provider || this._provider;
961
+ const steps = await this.loadActionStepsForRun(agentRunId, contextUser, md);
962
+ if (steps.length === 0)
963
+ return;
964
+ const fileOutputs = steps.flatMap(step => this.parseStepFileOutputs(step));
965
+ await this.ProcessFileArtifacts(fileOutputs, conversationDetailId, contextUser, undefined, md);
966
+ }
967
+ /** Loads all completed action steps for a given agent run (read-only, narrow fields). */
968
+ async loadActionStepsForRun(agentRunId, contextUser, provider) {
969
+ const rv = RunView.FromMetadataProvider(provider || this._provider);
970
+ const result = await rv.RunView({
971
+ EntityName: 'MJ: AI Agent Run Steps',
972
+ ExtraFilter: `AgentRunID='${agentRunId}' AND StepType='Actions' AND Status='Completed'`,
973
+ Fields: ['ID', 'OutputData'],
974
+ ResultType: 'simple'
975
+ }, contextUser);
976
+ return result.Success ? (result.Results ?? []) : [];
977
+ }
978
+ /**
979
+ * Parses persisted OutputData JSON from an action step and extracts file output metadata.
980
+ * Detection is shape-based (looks for objects with fileName + mimeType + fileData/fileId),
981
+ * not name-based — works regardless of what the action named its output parameter.
982
+ *
983
+ * Used only by the historical reprocessing path — live runs use BaseAgent's detectFileOutputs.
984
+ */
985
+ parseStepFileOutputs(step) {
986
+ if (!step.OutputData)
987
+ return [];
988
+ let outputData;
989
+ try {
990
+ outputData = JSON.parse(step.OutputData);
991
+ }
992
+ catch {
993
+ return [];
994
+ }
995
+ const params = outputData.actionResult?.parameters;
996
+ if (!params)
997
+ return [];
998
+ const results = [];
999
+ for (const param of params) {
1000
+ if (param.Value == null)
1001
+ continue;
1002
+ const ref = ParseFileOutputRef(param.Value);
1003
+ if (ref)
1004
+ results.push(ref);
1005
+ }
1006
+ return results;
1007
+ }
1008
+ /** Uploads or resolves a single file output and creates the artifact records.
1009
+ * Falls back to inline base64 artifact if storage is unavailable or upload fails. */
1010
+ async processFileOutput(fo, conversationDetailId, contextUser, resolvedStorageAccountId, provider) {
1011
+ try {
1012
+ if (fo.fileId) {
1013
+ // File already in storage — create file-backed artifact
1014
+ await this.createFileArtifact(fo.fileId, fo.mimeType, fo.fileName, fo.sizeBytes, conversationDetailId, contextUser, provider);
1015
+ return;
1016
+ }
1017
+ // Check if any storage accounts are configured
1018
+ const hasStorage = FileStorageEngine.Instance.HasStorageAccounts;
1019
+ if (!hasStorage) {
1020
+ // No storage configured — go straight to inline artifact
1021
+ LogStatus(`ProcessFileArtifacts: no storage accounts configured for "${fo.fileName}", creating inline artifact`);
1022
+ await this.createInlineFileArtifact(fo.fileData, fo.mimeType, fo.fileName, fo.sizeBytes, conversationDetailId, contextUser, provider);
1023
+ return;
1024
+ }
1025
+ // Try to upload to storage
1026
+ try {
1027
+ const fileId = await this.uploadBase64ToStorage(fo.fileData, fo.fileName, fo.mimeType, contextUser, resolvedStorageAccountId, provider);
1028
+ await this.createFileArtifact(fileId, fo.mimeType, fo.fileName, fo.sizeBytes, conversationDetailId, contextUser, provider);
1029
+ }
1030
+ catch (storageError) {
1031
+ // Upload failed — fall back to inline artifact
1032
+ LogStatus(`ProcessFileArtifacts: storage upload failed for "${fo.fileName}", creating inline artifact: ${storageError.message}`);
1033
+ await this.createInlineFileArtifact(fo.fileData, fo.mimeType, fo.fileName, fo.sizeBytes, conversationDetailId, contextUser, provider);
1034
+ }
1035
+ }
1036
+ catch (error) {
1037
+ LogError(`ProcessFileArtifacts: failed for "${fo.fileName}": ${error.message}`);
1038
+ }
1039
+ }
1040
+ /**
1041
+ * Uploads base64-encoded file content to MJStorage and creates an MJ: Files record.
1042
+ * Returns the new MJ: Files record ID.
1043
+ *
1044
+ * @param resolvedStorageAccountId - Pre-resolved account from the agent's storage resolution chain.
1045
+ * When provided, uploads to this specific account. Otherwise falls back to the first active account.
1046
+ */
1047
+ async uploadBase64ToStorage(base64Data, fileName, mimeType, contextUser, resolvedStorageAccountId, provider) {
1048
+ const result = await FileStorageEngine.Instance.UploadFile({
1049
+ content: Buffer.from(base64Data, 'base64'),
1050
+ fileName,
1051
+ mimeType,
1052
+ contextUser,
1053
+ storageAccountId: resolvedStorageAccountId,
1054
+ provider
1055
+ });
1056
+ return result.FileID;
1057
+ }
1058
+ /**
1059
+ * Shared logic for creating an artifact + version + conversation detail link in a single
1060
+ * transaction. The caller provides a callback to set the version-specific fields (file-backed
1061
+ * vs. inline content).
1062
+ */
1063
+ async createArtifactWithVersion(params) {
1064
+ const { mimeType, fileName, sizeBytes, conversationDetailId, contextUser, provider, setVersionFields, label } = params;
1065
+ // Resolve the artifact type by MIME type; fall back to the built-in JSON type
1066
+ const JSON_ARTIFACT_TYPE_ID = 'ae674c7e-ea0d-49ea-89e4-0649f5eb20d4';
1067
+ const artifactType = ArtifactMetadataEngine.Instance.GetArtifactTypeByMimeType(mimeType);
1068
+ if (!artifactType) {
1069
+ LogStatus(`ProcessFileArtifacts: no ArtifactType found for MIME ${mimeType}, using JSON fallback`);
1070
+ }
1071
+ const artifactTypeId = artifactType?.ID ?? JSON_ARTIFACT_TYPE_ID;
1072
+ // Use direct provider transaction (BeginTransaction/CommitTransaction) instead of
1073
+ // TransactionGroup. This ensures saves execute immediately within the SQL transaction,
1074
+ // so server-side entity hooks (e.g. ArtifactVersion.ExtractAndSaveAttributes) can
1075
+ // Load() parent records that were saved earlier in the same transaction.
1076
+ const dbProvider = provider;
1077
+ const useTransaction = dbProvider.ProviderType === ProviderType.Database;
1078
+ if (useTransaction) {
1079
+ await dbProvider.BeginTransaction();
1080
+ }
1081
+ try {
1082
+ // Create the artifact header
1083
+ const artifact = await provider.GetEntityObject('MJ: Artifacts', contextUser);
1084
+ artifact.Name = fileName;
1085
+ artifact.TypeID = artifactTypeId;
1086
+ artifact.UserID = contextUser.ID;
1087
+ artifact.Visibility = 'Always';
1088
+ if (!(await artifact.Save())) {
1089
+ throw new Error(`Failed to save artifact for ${label}: ${fileName}`);
1090
+ }
1091
+ // Create the artifact version — shared fields first, then caller-specific fields
1092
+ const version = await provider.GetEntityObject('MJ: Artifact Versions', contextUser);
1093
+ version.ArtifactID = artifact.ID;
1094
+ version.VersionNumber = 1;
1095
+ version.MimeType = mimeType;
1096
+ version.FileName = fileName;
1097
+ version.UserID = contextUser.ID;
1098
+ if (sizeBytes !== undefined) {
1099
+ version.ContentSizeBytes = sizeBytes;
1100
+ }
1101
+ setVersionFields(version);
1102
+ if (!(await version.Save())) {
1103
+ throw new Error(`Failed to save artifact version for ${label}: ${fileName}`);
1104
+ }
1105
+ // Link the artifact version to the conversation detail
1106
+ const junction = await provider.GetEntityObject('MJ: Conversation Detail Artifacts', contextUser);
1107
+ junction.ConversationDetailID = conversationDetailId;
1108
+ junction.ArtifactVersionID = version.ID;
1109
+ junction.Direction = 'Output';
1110
+ if (!(await junction.Save())) {
1111
+ throw new Error(`Failed to link ${label} artifact to conversation detail: ${conversationDetailId}`);
1112
+ }
1113
+ if (useTransaction) {
1114
+ await dbProvider.CommitTransaction();
1115
+ }
1116
+ LogStatus(`Created ${label} artifact: ${fileName} (${mimeType}) → artifact ${artifact.ID}, version ${version.ID}`);
1117
+ }
1118
+ catch (error) {
1119
+ if (useTransaction) {
1120
+ try {
1121
+ await dbProvider.RollbackTransaction();
1122
+ }
1123
+ catch (rollbackError) {
1124
+ LogError(`Failed to rollback ${label} artifact transaction: ${rollbackError}`);
1125
+ }
1126
+ }
1127
+ throw error;
1128
+ }
1129
+ }
1130
+ /** Creates a file-backed artifact (version references a FileID in MJStorage). */
1131
+ async createFileArtifact(fileId, mimeType, fileName, sizeBytes, conversationDetailId, contextUser, provider) {
1132
+ await this.createArtifactWithVersion({
1133
+ mimeType, fileName, sizeBytes, conversationDetailId, contextUser, provider,
1134
+ label: 'file',
1135
+ setVersionFields: (version) => {
1136
+ version.ContentMode = 'File';
1137
+ version.FileID = fileId;
1138
+ }
1139
+ });
1140
+ }
1141
+ /** Creates an inline artifact (version stores base64 data URL directly, no MJStorage). */
1142
+ async createInlineFileArtifact(base64Data, mimeType, fileName, sizeBytes, conversationDetailId, contextUser, provider) {
1143
+ await this.createArtifactWithVersion({
1144
+ mimeType, fileName, sizeBytes, conversationDetailId, contextUser, provider,
1145
+ label: 'inline file',
1146
+ setVersionFields: (version) => {
1147
+ version.ContentMode = 'Text';
1148
+ version.Content = `data:${mimeType};base64,${base64Data}`;
1149
+ }
1150
+ });
1151
+ }
866
1152
  /**
867
1153
  * Gets file extension from MIME type
868
1154
  * @private