@memberjunction/ai-agents 6.1.0-edge.4 → 6.1.0-edge.6

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.
Files changed (35) hide show
  1. package/dist/AgentRunner.d.ts +192 -10
  2. package/dist/AgentRunner.d.ts.map +1 -1
  3. package/dist/AgentRunner.js +472 -102
  4. package/dist/AgentRunner.js.map +1 -1
  5. package/dist/PayloadManager.d.ts +92 -0
  6. package/dist/PayloadManager.d.ts.map +1 -1
  7. package/dist/PayloadManager.js +164 -0
  8. package/dist/PayloadManager.js.map +1 -1
  9. package/dist/artifact-target-plan.d.ts +77 -0
  10. package/dist/artifact-target-plan.d.ts.map +1 -0
  11. package/dist/artifact-target-plan.js +60 -0
  12. package/dist/artifact-target-plan.js.map +1 -0
  13. package/dist/base-agent.d.ts +114 -5
  14. package/dist/base-agent.d.ts.map +1 -1
  15. package/dist/base-agent.js +307 -20
  16. package/dist/base-agent.js.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/memory-manager-agent.js +1 -1
  22. package/dist/memory-manager-agent.js.map +1 -1
  23. package/dist/realtime/bridge-realtime-session-factory.d.ts +1 -1
  24. package/dist/realtime/bridge-realtime-session-factory.d.ts.map +1 -1
  25. package/dist/realtime/bridge-realtime-session-factory.js +52 -9
  26. package/dist/realtime/bridge-realtime-session-factory.js.map +1 -1
  27. package/dist/realtime/realtime-coagent-config.d.ts +45 -0
  28. package/dist/realtime/realtime-coagent-config.d.ts.map +1 -1
  29. package/dist/realtime/realtime-coagent-config.js +92 -0
  30. package/dist/realtime/realtime-coagent-config.js.map +1 -1
  31. package/dist/realtime/realtime-recording-store.d.ts +11 -3
  32. package/dist/realtime/realtime-recording-store.d.ts.map +1 -1
  33. package/dist/realtime/realtime-recording-store.js +7 -5
  34. package/dist/realtime/realtime-recording-store.js.map +1 -1
  35. package/package.json +18 -18
@@ -11,11 +11,23 @@
11
11
  */
12
12
  import { createHash } from 'crypto';
13
13
  import { LogError, LogStatusEx, IsVerboseLoggingEnabled, LogStatus, Metadata, RunView, RunQuery, ProviderType } from '@memberjunction/core';
14
- import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
14
+ import { MJGlobal, UUIDsEqual, IsValidUUID, EscapeSQLString } from '@memberjunction/global';
15
15
  import { AIEngine } from '@memberjunction/aiengine';
16
+ import { planArtifactTarget, IsKnownArtifactBehavior } from './artifact-target-plan.js';
16
17
  import { BaseAgent } from './base-agent.js';
17
- import { ArtifactMetadataEngine, ExtractBase64FromDataUrl, DecideInlineStorage } from '@memberjunction/core-entities';
18
+ import { MJEnvironmentEntityExtended, ArtifactMetadataEngine, ExtractBase64FromDataUrl, DecideInlineStorage } from '@memberjunction/core-entities';
18
19
  import { FileStorageEngine } from '@memberjunction/storage';
20
+ /**
21
+ * Choose the artifact a run should be represented by.
22
+ *
23
+ * A FILE artifact wins over the payload artifact: the file is the deliverable the user asked for
24
+ * (a .docx, a .pdf), while the payload artifact is a snapshot of the agent's internal state.
25
+ * Callers surface this as a single "open the artifact" affordance, so a document-generating run
26
+ * used to point at raw JSON. With several files, the first is the primary one.
27
+ */
28
+ export function selectPrimaryArtifact(fileArtifacts, payloadArtifact) {
29
+ return fileArtifacts?.[0] ?? payloadArtifact;
30
+ }
19
31
  /**
20
32
  * AgentRunner provides a thin wrapper for executing AI agents.
21
33
  *
@@ -36,6 +48,17 @@ import { FileStorageEngine } from '@memberjunction/storage';
36
48
  * ```
37
49
  */
38
50
  export class AgentRunner {
51
+ /** Fallback artifact type for agent payloads when the agent declares no DefaultArtifactTypeID. */
52
+ static { this.JSON_ARTIFACT_TYPE_ID = 'ae674c7e-ea0d-49ea-89e4-0649f5eb20d4'; }
53
+ /**
54
+ * Max length of `MJ: Artifacts.Name` (`nvarchar(255)`).
55
+ *
56
+ * A directive's `name` is model output and routinely arrives as a full sentence. `BaseEntity.Validate`
57
+ * enforces MaxLength, so an over-long title makes `Save()` return false — which used to abort the whole
58
+ * artifact write and leave the deliverable reachable only through `AIAgentRun.FinalPayload`. Clamped
59
+ * instead: a truncated title still names the artifact the user is looking at.
60
+ */
61
+ static { this.ARTIFACT_NAME_MAX_LENGTH = 255; }
39
62
  constructor(provider) {
40
63
  this._provider = provider ?? Metadata.Provider;
41
64
  }
@@ -128,11 +151,11 @@ export class AgentRunner {
128
151
  if (!contextUser) {
129
152
  throw new Error('contextUser is required for RunAgentInConversation');
130
153
  }
154
+ let agentResponseDetail;
131
155
  try {
132
156
  let conversationId;
133
157
  let userMessageDetailId;
134
158
  let agentResponseDetailId;
135
- let agentResponseDetail;
136
159
  // If conversationDetailId is provided, use it (UI-created agent response detail)
137
160
  if (options.conversationDetailId) {
138
161
  agentResponseDetailId = options.conversationDetailId;
@@ -364,6 +387,14 @@ export class AgentRunner {
364
387
  LogStatus(`Updated agent response detail ${agentResponseDetailId} with final status: ${agentResponseDetail.Status}`);
365
388
  }
366
389
  };
390
+ // A 'suppress' directive is a per-STEP instruction about everything the step would
391
+ // persist as an artifact — the payload artifact, the artifacts wrapping generated files,
392
+ // and the ones wrapping generated media. Honoring it for the payload alone left a
393
+ // suppressed step still producing artifact cards in chat, contradicting the documented
394
+ // contract ("create or version nothing for this step"). Audit rows are NOT suppressed:
395
+ // AIAgentRunMedia still records every byte the run produced, since suppression is about
396
+ // what the user is shown, not about lineage.
397
+ const suppressArtifacts = agentResult.artifactDirective?.behavior === 'suppress';
367
398
  // Step 6: Process artifacts if requested and agent succeeded.
368
399
  const processArtifacts = async () => {
369
400
  const shouldCreateArtifacts = options.createArtifacts !== false; // Default true
@@ -373,10 +404,22 @@ export class AgentRunner {
373
404
  return undefined;
374
405
  };
375
406
  // Step 6b: Process file artifacts produced by file-generation actions.
407
+ //
408
+ // The created artifacts are RETURNED, not discarded: a file artifact is the
409
+ // deliverable the user asked for, so it is what `artifactInfo` should point at.
410
+ // Previously only the payload artifact was reported, which meant a run whose whole
411
+ // purpose was to produce a .docx or .pdf handed callers a link to the agent's
412
+ // internal state instead — MJ Explorer and the Slack/Teams bridge both surface
413
+ // `artifactInfo` as "open the artifact".
376
414
  const processFileArtifacts = async () => {
415
+ if (suppressArtifacts && agentResult.fileOutputs?.length) {
416
+ LogStatus(`Skipping ${agentResult.fileOutputs.length} file artifact(s) - the agent suppressed artifacts for this step`);
417
+ return [];
418
+ }
377
419
  if (agentResult.success && agentResponseDetailId && agentResult.fileOutputs?.length) {
378
- await this.ProcessFileArtifacts(agentResult.fileOutputs, agentResponseDetailId, contextUser, agentResult.resolvedStorageAccountId, md, params.agent.AcceptUnregisteredFiles);
420
+ return this.ProcessFileArtifacts(agentResult.fileOutputs, agentResponseDetailId, contextUser, agentResult.resolvedStorageAccountId, md, params.agent.AcceptUnregisteredFiles);
379
421
  }
422
+ return [];
380
423
  };
381
424
  // Step 7: Save media outputs to AIAgentRunMedia (audit) and create artifacts (display)
382
425
  const saveMedia = async () => {
@@ -404,8 +447,11 @@ export class AgentRunner {
404
447
  const mediaForArtifacts = payloadStr
405
448
  ? mediaToSave.filter(m => !this.isMediaEmbeddedInPayload(m, payloadStr))
406
449
  : mediaToSave;
407
- if (agentResponseDetailId && mediaForArtifacts.length > 0) {
408
- await this.CreateMediaArtifacts(agentResponseDetailId, mediaForArtifacts, contextUser, md);
450
+ if (suppressArtifacts && mediaForArtifacts.length > 0) {
451
+ LogStatus(`Skipping ${mediaForArtifacts.length} media artifact(s) - the agent suppressed artifacts for this step (the media itself is still recorded on the run)`);
452
+ }
453
+ else if (agentResponseDetailId && mediaForArtifacts.length > 0) {
454
+ await this.CreateMediaArtifacts(agentResponseDetailId, mediaForArtifacts, contextUser, md, agentResult.resolvedStorageAccountId);
409
455
  }
410
456
  return ids;
411
457
  }
@@ -422,9 +468,14 @@ export class AgentRunner {
422
468
  // before we return, so the resolver's 'complete' event still guarantees the client sees
423
469
  // every write.
424
470
  await updateDetail();
425
- const artifactInfo = await processArtifacts();
426
- await processFileArtifacts();
471
+ const payloadArtifact = await processArtifacts();
472
+ const fileArtifacts = await processFileArtifacts();
427
473
  await saveMedia();
474
+ // A file artifact wins: it is the thing the user asked for. The payload artifact is a
475
+ // snapshot of the agent's internal state, useful when the payload IS the deliverable
476
+ // (a report agent) and misleading when it is not — a document run reported the
477
+ // payload, so "open the artifact" opened raw JSON instead of the document.
478
+ const artifactInfo = selectPrimaryArtifact(fileArtifacts, payloadArtifact);
428
479
  return {
429
480
  agentResult,
430
481
  conversationId,
@@ -436,6 +487,18 @@ export class AgentRunner {
436
487
  catch (error) {
437
488
  const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
438
489
  LogError(`RunAgentInConversation failed: ${errorMessage}`, undefined, error);
490
+ if (agentResponseDetail && agentResponseDetail.Status === 'In-Progress') {
491
+ try {
492
+ await agentResponseDetail.EnsureSaveComplete();
493
+ agentResponseDetail.Status = 'Error';
494
+ agentResponseDetail.Message = errorMessage;
495
+ agentResponseDetail.Error = errorMessage;
496
+ await agentResponseDetail.Save();
497
+ }
498
+ catch (persistError) {
499
+ LogError(`Failed to persist Error on conversation detail after agent crash: ${persistError}`, undefined, persistError);
500
+ }
501
+ }
439
502
  throw error;
440
503
  }
441
504
  }
@@ -459,7 +522,7 @@ export class AgentRunner {
459
522
  const rv = RunView.FromMetadataProvider(provider || this._provider);
460
523
  const result = await rv.RunView({
461
524
  EntityName: 'MJ: Artifact Versions',
462
- ExtraFilter: `ArtifactID='${artifactId}'`,
525
+ ExtraFilter: `ArtifactID='${AgentRunner.FilterId(artifactId, 'GetMaxVersionForArtifact artifactId')}'`,
463
526
  OrderBy: 'VersionNumber DESC',
464
527
  MaxRows: 1,
465
528
  ResultType: 'entity_object'
@@ -491,10 +554,21 @@ export class AgentRunner {
491
554
  */
492
555
  async CheckForDuplicateVersion(artifactId, candidateContent, latestVersionNumber, contextUser, provider) {
493
556
  const candidateHash = createHash('sha256').update(candidateContent, 'utf8').digest('hex');
557
+ // `Number()` alone yields NaN for a non-numeric input, which would reach SQL Server as
558
+ // `VersionNumber=NaN` and fail the query rather than match nothing. Every caller is
559
+ // internal so this cannot fire today; the guard matches the care taken on the id path
560
+ // above, and skipping the dedup check is the safe direction — it costs a duplicate
561
+ // version at worst, where a thrown query costs the whole artifact.
562
+ const latestVersion = Number(latestVersionNumber);
563
+ if (!Number.isFinite(latestVersion)) {
564
+ LogError(`CheckForDuplicateVersion: latestVersionNumber is not a finite number ` +
565
+ `("${AgentRunner.DescribeUntrustedValue(latestVersionNumber)}") — skipping the duplicate check`);
566
+ return null;
567
+ }
494
568
  const rv = RunView.FromMetadataProvider(provider || this._provider);
495
569
  const result = await rv.RunView({
496
570
  EntityName: 'MJ: Artifact Versions',
497
- ExtraFilter: `ArtifactID='${artifactId}' AND VersionNumber=${latestVersionNumber}`,
571
+ ExtraFilter: `ArtifactID='${AgentRunner.FilterId(artifactId, 'CheckForDuplicateVersion artifactId')}' AND VersionNumber=${latestVersion}`,
498
572
  Fields: ['ID', 'ContentHash'],
499
573
  MaxRows: 1,
500
574
  ResultType: 'simple'
@@ -552,7 +626,7 @@ export class AgentRunner {
552
626
  const rv = RunView.FromMetadataProvider(provider || this._provider);
553
627
  const result = await rv.RunView({
554
628
  EntityName: 'MJ: Conversation Detail Artifacts',
555
- ExtraFilter: `ConversationDetailID='${conversationDetailId}' AND Direction='Output'`,
629
+ ExtraFilter: `ConversationDetailID='${AgentRunner.FilterId(conversationDetailId, 'FindPreviousArtifactForMessage conversationDetailId')}' AND Direction='Output'`,
556
630
  OrderBy: '__mj_CreatedAt DESC',
557
631
  MaxRows: 1,
558
632
  ResultType: 'entity_object'
@@ -581,7 +655,14 @@ export class AgentRunner {
581
655
  * Handles artifact creation, versioning, and linking to conversation details.
582
656
  *
583
657
  * This method implements intelligent artifact versioning:
584
- * 1. If sourceArtifactId is provided (explicit continuity), creates new version of that artifact
658
+ * 0. If the agent supplied an artifactDirective, it decides: 'suppress' → nothing;
659
+ * 'create-new' → new artifact (sourceArtifactId ignored); 'version-source' → version
660
+ * targetArtifactId, else sourceArtifactId. A directive-named target is model output and is
661
+ * vetted first (UUID shape, existence, and the caller's right to write to it — see
662
+ * {@link VetArtifactVersionTarget}); anything failing falls back down the ladder to the
663
+ * caller's sourceArtifactId and then to the historical chain, so a directive can never widen
664
+ * what the caller was already allowed to do.
665
+ * 1. Otherwise, if sourceArtifactId is provided (explicit continuity), creates new version of that artifact
585
666
  * 2. Otherwise, checks for previous artifacts on this conversation detail
586
667
  * 3. If previous artifact exists, creates new version of it
587
668
  * 4. If no previous artifact, creates entirely new artifact
@@ -631,22 +712,70 @@ export class AgentRunner {
631
712
  }
632
713
  try {
633
714
  const md = provider || this._provider;
634
- const JSON_ARTIFACT_TYPE_ID = 'ae674c7e-ea0d-49ea-89e4-0649f5eb20d4';
635
- // Determine if creating new artifact or new version
715
+ // Target selection: the agent's directive first (it knows what the payload IS —
716
+ // a deliverable, a draft, a plan), then the legacy chain
717
+ // (sourceArtifactId → previous artifact on this message → new artifact).
718
+ // An unrecognized behavior drops the WHOLE directive, not just its targeting. Every
719
+ // field of it is model output, and a `behavior` this consumer cannot parse means the
720
+ // producer and this consumer disagree about the wire format — which is no basis for
721
+ // trusting the object's other fields. Keeping the raw directive here would let one the
722
+ // log says is being ignored still name the artifact through `createArtifactHeader` AND
723
+ // suppress the extracted-name fallback below, so "ignored" would silently mean two
724
+ // behavior changes.
725
+ const rawDirective = agentResult.artifactDirective;
726
+ const behaviorRecognized = !rawDirective || IsKnownArtifactBehavior(rawDirective.behavior);
727
+ const directive = behaviorRecognized ? rawDirective : undefined;
728
+ if (!behaviorRecognized) {
729
+ LogError(`Ignoring artifact directive from agent "${agent?.Name}": unrecognized behavior ` +
730
+ `("${AgentRunner.DescribeUntrustedValue(rawDirective.behavior)}") — using the historical chain instead`);
731
+ }
732
+ // planArtifactTarget discards a non-string targetArtifactId silently (it is pure and
733
+ // cannot log). Before this check lived in the pure function, VetArtifactVersionTarget
734
+ // logged such a value as "not a valid artifact ID"; keep that visibility here so a
735
+ // producer sending the wrong JSON type shows up in the run log rather than only as an
736
+ // artifact that versioned the run's source.
737
+ if (directive?.behavior === 'version-source') {
738
+ const rawTarget = directive.targetArtifactId;
739
+ if (rawTarget !== undefined && rawTarget !== null && typeof rawTarget !== 'string') {
740
+ LogError(`Ignoring targetArtifactId from agent "${agent?.Name}": ` +
741
+ `"${AgentRunner.DescribeUntrustedValue(rawTarget)}" is not a string — ` +
742
+ `versioning the run's sourceArtifactId instead`);
743
+ }
744
+ }
745
+ let plan = planArtifactTarget(directive, sourceArtifactId);
746
+ if (plan.kind === 'suppress') {
747
+ LogStatus(`Skipping artifact creation - agent "${agent?.Name}" suppressed artifacts for this step`);
748
+ return undefined;
749
+ }
750
+ // Vet whatever artifact we are about to version, then walk DOWN the fallback ladder on
751
+ // failure: a directive-named target degrades to the caller's sourceArtifactId, and a bad
752
+ // sourceArtifactId degrades to the historical chain. Each rung is re-vetted, so a value
753
+ // rejected on one rung can never re-enter on the next (an agent echoing the run's own
754
+ // source id back as its target used to do exactly that: rejected as model output, then
755
+ // versioned anyway as a caller id, logged as both "not readable" and "creating version N").
756
+ const rejectedIds = new Set();
757
+ while (plan.kind === 'version') {
758
+ const vetted = await this.VetArtifactVersionTarget(plan, contextUser, md, agent?.Name, rejectedIds);
759
+ if (vetted) {
760
+ plan = vetted;
761
+ break;
762
+ }
763
+ plan = plan.source === 'directive'
764
+ ? planArtifactTarget(undefined, sourceArtifactId) // drop the directive, keep the caller's id
765
+ : { kind: 'legacy' }; // the caller's own id failed; nothing left to try
766
+ }
636
767
  let artifactId;
637
768
  let newVersionNumber;
638
769
  let isNewArtifact = false;
639
- // Priority 1: Use explicit source artifact if provided
640
- if (sourceArtifactId) {
641
- const maxVersion = await this.GetMaxVersionForArtifact(sourceArtifactId, contextUser, provider);
642
- artifactId = sourceArtifactId;
770
+ if (plan.kind === 'version') {
771
+ const maxVersion = await this.GetMaxVersionForArtifact(plan.artifactId, contextUser, provider);
772
+ artifactId = plan.artifactId;
643
773
  newVersionNumber = maxVersion + 1;
644
- LogStatus(`Creating version ${newVersionNumber} of source artifact ${artifactId}`);
774
+ LogStatus(`Creating version ${newVersionNumber} of artifact ${artifactId} (${plan.source === 'directive' ? 'agent directive' : 'sourceArtifactId'})`);
645
775
  }
646
- // Priority 2: Try to find previous artifact for this message (only when running in
647
- // a conversation context — outside one there is no message to look behind)
648
776
  else {
649
- const previousArtifact = conversationDetailId
777
+ // Legacy: look behind this message (only inside a conversation). 'create-new' skips the lookup.
778
+ const previousArtifact = (plan.kind === 'legacy' && conversationDetailId)
650
779
  ? await this.FindPreviousArtifactForMessage(conversationDetailId, contextUser, md)
651
780
  : null;
652
781
  if (previousArtifact) {
@@ -655,32 +784,11 @@ export class AgentRunner {
655
784
  LogStatus(`Creating version ${newVersionNumber} of existing artifact ${artifactId}`);
656
785
  }
657
786
  else {
658
- // Create new artifact header
659
- const artifact = await md.GetEntityObject('MJ: Artifacts', contextUser);
660
- const agentName = agent?.Name || 'Agent';
661
- artifact.Name = `${agentName} Payload - ${new Date().toLocaleString()}`;
662
- artifact.Description = `Payload returned by ${agentName}`;
663
- // Use agent's DefaultArtifactTypeID if available
664
- const defaultArtifactTypeId = agent?.DefaultArtifactTypeID;
665
- artifact.TypeID = defaultArtifactTypeId || JSON_ARTIFACT_TYPE_ID;
666
- artifact.UserID = contextUser.ID;
667
- artifact.EnvironmentID = contextUser.EnvironmentID ||
668
- 'F51358F3-9447-4176-B313-BF8025FD8D09';
669
- // Set visibility based on agent's ArtifactCreationMode
670
- if (creationMode === 'System Only') {
671
- artifact.Visibility = 'System Only';
672
- LogStatus(`Artifact marked as "System Only" per agent configuration`);
673
- }
674
- else {
675
- artifact.Visibility = 'Always';
676
- }
677
- if (!(await artifact.Save())) {
678
- throw new Error('Failed to save artifact');
679
- }
787
+ const artifact = await this.createArtifactHeader(md, contextUser, agent, creationMode, directive);
680
788
  artifactId = artifact.ID;
681
789
  newVersionNumber = 1;
682
790
  isNewArtifact = true;
683
- LogStatus(`Created new artifact: ${artifact.Name} (${artifactId})`);
791
+ LogStatus(`Created new artifact: ${artifact.Name} (${artifactId})${plan.kind === 'create-new' ? ' per agent directive' : ''}`);
684
792
  }
685
793
  }
686
794
  // Serialize the payload once — used for both dedup check and version creation
@@ -703,8 +811,8 @@ export class AgentRunner {
703
811
  throw new Error('Failed to save artifact version');
704
812
  }
705
813
  LogStatus(`Created artifact version ${newVersionNumber} (${version.ID})`);
706
- // If first version of new artifact, check for extracted Name attribute
707
- if (isNewArtifact && newVersionNumber === 1) {
814
+ // First version of a new artifact: adopt the extracted Name attribute unless the agent named it
815
+ if (isNewArtifact && newVersionNumber === 1 && !AgentRunner.SafeDirectiveText(directive?.name, AgentRunner.ARTIFACT_NAME_MAX_LENGTH)) {
708
816
  const nameAttr = version.Attributes?.find((attr) => attr.StandardProperty === 'name' || attr.Name?.toLowerCase() === 'name');
709
817
  let extractedName = nameAttr?.Value?.trim();
710
818
  if (extractedName && extractedName.toLowerCase() !== 'null') {
@@ -730,6 +838,231 @@ export class AgentRunner {
730
838
  return undefined;
731
839
  }
732
840
  }
841
+ /**
842
+ * Renders an untrusted value for a log line without trusting it to render itself.
843
+ *
844
+ * Directives arrive as parsed JSON, so a field declared `string` can hold any JSON value.
845
+ * `String(value)` is not safe on such a value — `{ toString: 'x' }` shadows `toString` with a
846
+ * non-callable, and `String()` throws — and a value that throws while being logged aborts the
847
+ * whole artifact write, which is the failure this helper exists to prevent.
848
+ *
849
+ * @param value - Any value, however malformed.
850
+ * @returns A short single-line description, never throwing.
851
+ */
852
+ static DescribeUntrustedValue(value) {
853
+ let text;
854
+ try {
855
+ text = typeof value === 'string' ? value : (JSON.stringify(value) ?? Object.prototype.toString.call(value));
856
+ }
857
+ catch {
858
+ // Circular structure, a throwing getter, or a BigInt.
859
+ text = Object.prototype.toString.call(value);
860
+ }
861
+ return text.replace(/\s+/g, ' ').slice(0, 64);
862
+ }
863
+ /**
864
+ * Escapes an id for interpolation into a `RunView.ExtraFilter`, warning when the value cannot
865
+ * be an id at all.
866
+ *
867
+ * `RunViewParams.ExtraFilter` is a raw SQL fragment with no parameterized form, so every id
868
+ * this class puts into one is escaped HERE — at the query builder — rather than at whichever
869
+ * call site happened to be audited. `ValidateUserProvidedSQLClause` upstream blacklists
870
+ * statement keywords but permits `OR`, so an unescaped id remains a real predicate-injection
871
+ * surface; escaping at the builder covers every caller, present and future, including the
872
+ * caller-supplied `sourceArtifactId` that arrives straight from the GraphQL boundary.
873
+ *
874
+ * @param id - The id to interpolate.
875
+ * @param label - What the id is, for the warning.
876
+ * @returns The escaped id, ready to sit inside single quotes.
877
+ */
878
+ static FilterId(id, label) {
879
+ if (!IsValidUUID(id)) {
880
+ LogError(`${label} is not a UUID-shaped id ("${AgentRunner.DescribeUntrustedValue(id)}") — this query cannot match a row`);
881
+ }
882
+ return EscapeSQLString(id);
883
+ }
884
+ /**
885
+ * Coerces one of a directive's free-text fields into something safe to persist.
886
+ *
887
+ * The field is model output: it may be absent, may not be a string at all, may be whitespace,
888
+ * and may be far longer than its column. Anything unusable becomes `undefined` so the caller
889
+ * falls back to its own default, and anything over-long is clamped rather than allowed to fail
890
+ * `BaseEntity.Validate` — a rejected `Save()` would discard the entire artifact, so a truncated
891
+ * title is strictly better than no artifact.
892
+ *
893
+ * @param value - The directive field (`name` or `description`).
894
+ * @param maxLength - Column limit to clamp to; omit for `nvarchar(MAX)` columns.
895
+ * @returns The trimmed (and clamped) text, or `undefined` when there is nothing usable.
896
+ */
897
+ static SafeDirectiveText(value, maxLength) {
898
+ if (typeof value !== 'string') {
899
+ return undefined;
900
+ }
901
+ const trimmed = value.trim();
902
+ if (trimmed.length === 0) {
903
+ return undefined;
904
+ }
905
+ return maxLength != null && trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed;
906
+ }
907
+ /**
908
+ * Vets one rung of the version-target ladder, returning the plan with a normalized id when the
909
+ * target is usable and `null` when the caller should fall back.
910
+ *
911
+ * Three things are checked, in cost order:
912
+ *
913
+ * 1. **Shape.** The id must be a UUID-shaped string. This matters because it is interpolated
914
+ * into the `ExtraFilter` fragments built by {@link GetMaxVersionForArtifact} and
915
+ * {@link CheckForDuplicateVersion}; those escape what they are handed, but an id that is not
916
+ * a UUID cannot name an artifact, so rejecting it here turns a doomed query into a clean
917
+ * fallback. The id is then TRIMMED for downstream use — `IsValidUUID` tolerates surrounding
918
+ * whitespace, so a value with a trailing newline passes validation and would otherwise reach
919
+ * `ArtifactID='<uuid>\n'` and fail as a SQL conversion error.
920
+ * 2. **Existence** — directive-named targets only.
921
+ * 3. **Authorization** — directive-named targets only. `vwArtifacts` carries no per-user
922
+ * predicate and no row-level-security filter, so a row loading successfully proves only that
923
+ * it EXISTS. Without this check an agent could name any artifact id in the instance and have
924
+ * the run's payload appended to it as a new version. The caller must own the artifact or hold
925
+ * an explicit `CanEdit` grant on it.
926
+ *
927
+ * Existence and authorization resolve in ONE `RunViews` round trip, and via `RunView` rather
928
+ * than `BaseEntity.Load` deliberately: `Load` THROWS on a permission denial, a SQL conversion
929
+ * error or any transient DB fault, and a throw here would propagate to the method-wide catch and
930
+ * lose the whole artifact — the opposite of the graceful fallback this is meant to provide.
931
+ *
932
+ * A caller-supplied `sourceArtifactId` is shape-checked but NOT loaded or authorized: it is a
933
+ * server-side argument rather than model output, and adding a round trip plus a new denial mode
934
+ * to that path would change behavior for every existing agent. (The pre-existing exposure on
935
+ * that path — any authenticated caller may name any artifact id — is unchanged by this PR and
936
+ * wants its own fix.) One exception, and it is deliberate rather than incidental: if the agent
937
+ * named the SAME id first and it failed authorization, `rejectedIds` refuses it on the caller
938
+ * rung too. The caller path still performs no authorization of its own; it just cannot be used
939
+ * to launder an id this run has already refused.
940
+ *
941
+ * **Why this does not call `PermissionEngine` / `ArtifactPermissionProvider`.** That provider
942
+ * (`MJCoreEntities/src/custom/PermissionProviders/ArtifactPermissionProvider.ts`) answers
943
+ * "may this user Update this artifact" from the permission ROWS, and is the right home for that
944
+ * question in general — see `guides/UNIFIED_PERMISSIONS_GUIDE.md` §1. It is not used here for
945
+ * two reasons specific to this path. First, it answers only half the question: it reads grant
946
+ * rows and does not treat the artifact's OWNER as an editor, so the `UserID` check below would
947
+ * remain regardless and the owner half would still live in two places. Second, and decisive:
948
+ * `PermissionEngine.CheckPermission` returns `Allowed: false` with "Unknown permission domain"
949
+ * when its domain is not loaded, and this path's response to a denial is a SILENT fallback to
950
+ * creating a new artifact. A deployment that has not synced `metadata/permission-domains` — or
951
+ * any caller that reaches the runner before `PermissionEngine.Config()` — would therefore stop
952
+ * versioning for every legitimate editor, with nothing in the logs to say why. A direct query
953
+ * has no such dependency on engine state. As of this writing nothing server-side calls
954
+ * `PermissionEngine` or runs its `Config()`: its callers are Explorer's Permissions dashboard,
955
+ * the Sharing Center, and the integration test suite. (`Lists/server`,
956
+ * `Communication/notifications` and the MJCoreEntities entity extensions call
957
+ * `ResourcePermissionEngine`, a different class over `MJ: Resource Permissions`.)
958
+ *
959
+ * The cost of that choice is drift: this is a third answer to "can this user edit this
960
+ * artifact", alongside the provider and `ng-conversations`' `artifact-permission.service.ts`.
961
+ * **If artifact sharing grows a new grant shape — role grantees, `SupportsDeny`, cascade from
962
+ * collections — this method must be updated with the provider.** Revisit the delegation once
963
+ * `PermissionEngine` is routinely configured server-side. The cleaner precondition is upstream:
964
+ * `CollectionPermissionProvider` already treats a collection's owner as a synthetic full-access
965
+ * grantee, and the same treatment in `ArtifactPermissionProvider` would make it a complete
966
+ * answer, at which point this method should delegate to `CheckPermission(..., 'Update')`
967
+ * outright rather than compose an owner check around it.
968
+ *
969
+ * @param plan - The `version` plan to vet.
970
+ * @param contextUser - User the run executes as.
971
+ * @param md - Provider for the lookups.
972
+ * @param agentName - Agent name, for log lines.
973
+ * @param rejectedIds - Ids already rejected on a higher rung; mutated with any new rejection so
974
+ * the same id cannot be re-admitted further down the ladder.
975
+ * @returns The vetted plan with a trimmed id, or `null` to fall back.
976
+ */
977
+ async VetArtifactVersionTarget(plan, contextUser, md, agentName, rejectedIds) {
978
+ const origin = plan.source === 'directive' ? `artifact directive from agent "${agentName}"` : 'sourceArtifactId';
979
+ if (typeof plan.artifactId !== 'string' || !IsValidUUID(plan.artifactId)) {
980
+ LogError(`Ignoring ${origin}: "${AgentRunner.DescribeUntrustedValue(plan.artifactId)}" is not a valid artifact ID`);
981
+ return null;
982
+ }
983
+ const artifactId = plan.artifactId.trim();
984
+ if (rejectedIds.has(artifactId.toLowerCase())) {
985
+ LogError(`Ignoring ${origin}: artifact ${artifactId} was already rejected for this run`);
986
+ return null;
987
+ }
988
+ if (plan.source !== 'directive') {
989
+ return { ...plan, artifactId };
990
+ }
991
+ const reject = (reason) => {
992
+ rejectedIds.add(artifactId.toLowerCase());
993
+ LogError(`Ignoring ${origin}: target artifact ${artifactId} ${reason}`);
994
+ return null;
995
+ };
996
+ try {
997
+ const rv = RunView.FromMetadataProvider(md);
998
+ // One round trip for both questions: does the artifact exist, and may this user add to it.
999
+ const [ownerResult, grantResult] = await rv.RunViews([
1000
+ {
1001
+ EntityName: 'MJ: Artifacts',
1002
+ ExtraFilter: `ID='${EscapeSQLString(artifactId)}'`,
1003
+ Fields: ['ID', 'UserID'],
1004
+ MaxRows: 1,
1005
+ ResultType: 'simple',
1006
+ },
1007
+ {
1008
+ EntityName: 'MJ: Artifact Permissions',
1009
+ ExtraFilter: `ArtifactID='${EscapeSQLString(artifactId)}' AND UserID='${EscapeSQLString(contextUser.ID)}' AND CanEdit=1`,
1010
+ Fields: ['ID'],
1011
+ MaxRows: 1,
1012
+ ResultType: 'simple',
1013
+ },
1014
+ ], contextUser);
1015
+ const owner = ownerResult?.Success ? ownerResult.Results?.[0] : undefined;
1016
+ if (!owner) {
1017
+ return reject('was not found or is not readable');
1018
+ }
1019
+ if (UUIDsEqual(owner.UserID, contextUser.ID)) {
1020
+ return { ...plan, artifactId };
1021
+ }
1022
+ if (grantResult?.Success && (grantResult.Results?.length ?? 0) > 0) {
1023
+ return { ...plan, artifactId };
1024
+ }
1025
+ return reject(`is owned by another user and this user holds no CanEdit grant on it`);
1026
+ }
1027
+ catch (error) {
1028
+ // Fail closed: an unavailable lookup must not become an unchecked write.
1029
+ return reject(`could not be verified (${error?.message ?? 'unknown error'})`);
1030
+ }
1031
+ }
1032
+ /**
1033
+ * Creates the artifact header row. Name and description come from the agent's directive when
1034
+ * it supplied them (e.g. Skip's artifactRequest); otherwise the historical placeholder.
1035
+ */
1036
+ async createArtifactHeader(md, contextUser, agent, creationMode, directive) {
1037
+ const artifact = await md.GetEntityObject('MJ: Artifacts', contextUser);
1038
+ const agentName = agent?.Name || 'Agent';
1039
+ // Both fields are model output: coerced, trimmed, and (for Name) clamped to its column so a
1040
+ // long or non-string title cannot fail validation and take the whole artifact down with it.
1041
+ artifact.Name = AgentRunner.SafeDirectiveText(directive?.name, AgentRunner.ARTIFACT_NAME_MAX_LENGTH)
1042
+ || `${agentName} Payload - ${new Date().toLocaleString()}`;
1043
+ // Description is nvarchar(MAX), so there is no length to clamp to — only the type to check.
1044
+ artifact.Description = AgentRunner.SafeDirectiveText(directive?.description)
1045
+ || `Payload returned by ${agentName}`;
1046
+ // Use agent's DefaultArtifactTypeID if available
1047
+ artifact.TypeID = agent?.DefaultArtifactTypeID || AgentRunner.JSON_ARTIFACT_TYPE_ID;
1048
+ artifact.UserID = contextUser.ID;
1049
+ artifact.EnvironmentID = contextUser.EnvironmentID || MJEnvironmentEntityExtended.DefaultEnvironmentID;
1050
+ // Set visibility based on agent's ArtifactCreationMode
1051
+ if (creationMode === 'System Only') {
1052
+ artifact.Visibility = 'System Only';
1053
+ LogStatus(`Artifact marked as "System Only" per agent configuration`);
1054
+ }
1055
+ else {
1056
+ artifact.Visibility = 'Always';
1057
+ }
1058
+ if (!(await artifact.Save())) {
1059
+ // Surface WHY. A bare message here sent every cause — a validation failure on a
1060
+ // model-supplied name, a permission denial, a transient DB fault — to the same
1061
+ // indistinguishable log line.
1062
+ throw new Error(`Failed to save artifact: ${artifact.LatestResult?.Message || 'no error message reported'}`);
1063
+ }
1064
+ return artifact;
1065
+ }
733
1066
  /**
734
1067
  * Generates a conversation name and description using the "Name Conversation" AI prompt.
735
1068
  * Same approach as UI - uses AI to create a meaningful title and description from the first user message.
@@ -937,14 +1270,25 @@ export class AgentRunner {
937
1270
  * @param mediaOutputs - Media outputs to persist as artifacts
938
1271
  * @param contextUser - User context for DB operations
939
1272
  * @param provider - Optional metadata provider for multi-provider support
1273
+ * @param resolvedStorageAccountId - Pre-resolved storage account from the agent's resolution
1274
+ * chain. Optional, and optional on purpose: this is a public method, and callers that
1275
+ * predate storage-backed media keep working — omitting it just falls back to the first
1276
+ * active account inside {@link uploadBase64ToStorage}.
940
1277
  *
941
1278
  * @since 5.38.0
942
1279
  */
943
- async CreateMediaArtifacts(conversationDetailId, mediaOutputs, contextUser, provider) {
1280
+ async CreateMediaArtifacts(conversationDetailId, mediaOutputs, contextUser, provider, resolvedStorageAccountId) {
944
1281
  if (!mediaOutputs || mediaOutputs.length === 0) {
945
1282
  return;
946
1283
  }
947
- await ArtifactMetadataEngine.Instance.Config(false, contextUser);
1284
+ // BOTH engines, as ProcessFileArtifacts does. FileStorageEngine reports zero accounts
1285
+ // until it has loaded, so checking HasStorageAccounts without configuring it first reads
1286
+ // an empty cache and silently routes every media artifact inline — indistinguishable from
1287
+ // a deployment that genuinely has no storage.
1288
+ await Promise.all([
1289
+ ArtifactMetadataEngine.Instance.Config(false, contextUser),
1290
+ FileStorageEngine.Instance.Config(false, contextUser),
1291
+ ]);
948
1292
  const md = provider || this._provider;
949
1293
  let successCount = 0;
950
1294
  for (let i = 0; i < mediaOutputs.length; i++) {
@@ -955,28 +1299,36 @@ export class AgentRunner {
955
1299
  const estimatedSizeBytes = media.data
956
1300
  ? Math.ceil(media.data.length * 0.75)
957
1301
  : undefined;
958
- await this.createArtifactWithVersion({
959
- mimeType: media.mimeType,
960
- fileName,
961
- sizeBytes: estimatedSizeBytes,
962
- conversationDetailId,
963
- contextUser,
964
- provider: md,
965
- acceptUnregisteredFiles: true,
966
- label: `media ${media.modality}`,
967
- setVersionFields: (version) => {
968
- // DecideInlineStorage applies consistent text-vs-binary storage
969
- // decisions across all artifact creation paths. For media (images,
970
- // audio, video), it wraps the base64 in a data URL; for text-y
971
- // MIMEs it decodes to UTF-8. Same helper the server hook and
972
- // ConversationAttachmentService use.
973
- if (media.data) {
974
- const stored = DecideInlineStorage(media.mimeType, media.data);
975
- version.ContentMode = stored.contentMode;
976
- version.Content = stored.content;
1302
+ // Storage-backed when an account is configured, exactly as ProcessFileArtifacts
1303
+ // does for file outputs.
1304
+ const fileId = media.data
1305
+ ? await this.uploadToStorageIfConfigured(media.data, fileName, media.mimeType, contextUser, resolvedStorageAccountId, md, 'CreateMediaArtifacts')
1306
+ : undefined;
1307
+ if (fileId) {
1308
+ await this.createFileArtifact(fileId, media.mimeType, fileName, estimatedSizeBytes, conversationDetailId, contextUser, md, true, `media ${media.modality}`);
1309
+ }
1310
+ else {
1311
+ await this.createArtifactWithVersion({
1312
+ mimeType: media.mimeType,
1313
+ fileName,
1314
+ sizeBytes: estimatedSizeBytes,
1315
+ conversationDetailId,
1316
+ contextUser,
1317
+ provider: md,
1318
+ acceptUnregisteredFiles: true,
1319
+ label: `media ${media.modality}`,
1320
+ setVersionFields: (version) => {
1321
+ // DecideInlineStorage applies consistent text-vs-binary storage
1322
+ // decisions across all artifact creation paths: for media it wraps
1323
+ // the base64 in a data URL; for text-y MIMEs it decodes to UTF-8.
1324
+ if (media.data) {
1325
+ const stored = DecideInlineStorage(media.mimeType, media.data);
1326
+ version.ContentMode = stored.contentMode;
1327
+ version.Content = stored.content;
1328
+ }
977
1329
  }
978
- }
979
- });
1330
+ });
1331
+ }
980
1332
  successCount++;
981
1333
  }
982
1334
  catch (error) {
@@ -985,6 +1337,30 @@ export class AgentRunner {
985
1337
  }
986
1338
  LogStatus(`Created ${successCount} of ${mediaOutputs.length} media artifact(s) for detail ${conversationDetailId}`);
987
1339
  }
1340
+ /**
1341
+ * Uploads bytes to MJStorage when a storage account is configured, returning the new
1342
+ * `MJ: Files` ID — or `undefined` to tell the caller to store the bytes inline.
1343
+ *
1344
+ * The single home of the storage-or-inline contract for every artifact this runner creates,
1345
+ * file outputs and media alike. Deliberately never throws: delivery must not fail because
1346
+ * storage is unavailable, so an upload error degrades to inline with a log line.
1347
+ *
1348
+ * @param logPrefix - Names the calling path in the log so an operator can tell which kind of
1349
+ * artifact fell back, without the two paths carrying separate copies of this logic.
1350
+ */
1351
+ async uploadToStorageIfConfigured(base64Data, fileName, mimeType, contextUser, resolvedStorageAccountId, provider, logPrefix) {
1352
+ if (!FileStorageEngine.Instance.HasStorageAccounts) {
1353
+ LogStatus(`${logPrefix}: no storage accounts configured for "${fileName}", storing inline`);
1354
+ return undefined;
1355
+ }
1356
+ try {
1357
+ return await this.uploadBase64ToStorage(base64Data, fileName, mimeType, contextUser, resolvedStorageAccountId, provider);
1358
+ }
1359
+ catch (storageError) {
1360
+ LogStatus(`${logPrefix}: storage upload failed for "${fileName}", storing inline: ${storageError.message}`);
1361
+ return undefined;
1362
+ }
1363
+ }
988
1364
  // ── File artifact processing ───────────────────────────────────────────────
989
1365
  /**
990
1366
  * Creates MJ: Artifact records for file outputs collected during agent execution.
@@ -1001,7 +1377,7 @@ export class AgentRunner {
1001
1377
  */
1002
1378
  async ProcessFileArtifacts(fileOutputs, conversationDetailId, contextUser, resolvedStorageAccountId, provider, acceptUnregisteredFiles) {
1003
1379
  if (fileOutputs.length === 0)
1004
- return;
1380
+ return [];
1005
1381
  const md = provider || this._provider;
1006
1382
  const acceptUnregistered = acceptUnregisteredFiles ?? false;
1007
1383
  // Hoist engine configs before parallel processing
@@ -1009,7 +1385,10 @@ export class AgentRunner {
1009
1385
  ArtifactMetadataEngine.Instance.Config(false, contextUser),
1010
1386
  FileStorageEngine.Instance.Config(false, contextUser),
1011
1387
  ]);
1012
- await Promise.all(fileOutputs.map(fo => this.processFileOutput(fo, conversationDetailId, contextUser, resolvedStorageAccountId, md, acceptUnregistered)));
1388
+ const created = await Promise.all(fileOutputs.map(fo => this.processFileOutput(fo, conversationDetailId, contextUser, resolvedStorageAccountId, md, acceptUnregistered)));
1389
+ // Entries are undefined only for outputs whose creation failed (logged above), so the
1390
+ // caller sees exactly the artifacts that exist.
1391
+ return created.filter((info) => info != null);
1013
1392
  }
1014
1393
  /** Uploads or resolves a single file output and creates the artifact records.
1015
1394
  * Falls back to inline base64 artifact if storage is unavailable or upload fails. */
@@ -1017,30 +1396,16 @@ export class AgentRunner {
1017
1396
  try {
1018
1397
  if (fo.fileId) {
1019
1398
  // File already in storage — create file-backed artifact
1020
- await this.createFileArtifact(fo.fileId, fo.mimeType, fo.fileName, fo.sizeBytes, conversationDetailId, contextUser, provider, acceptUnregisteredFiles);
1021
- return;
1022
- }
1023
- // Check if any storage accounts are configured
1024
- const hasStorage = FileStorageEngine.Instance.HasStorageAccounts;
1025
- if (!hasStorage) {
1026
- // No storage configured — go straight to inline artifact
1027
- LogStatus(`ProcessFileArtifacts: no storage accounts configured for "${fo.fileName}", creating inline artifact`);
1028
- await this.createInlineFileArtifact(fo.fileData, fo.mimeType, fo.fileName, fo.sizeBytes, conversationDetailId, contextUser, provider, acceptUnregisteredFiles);
1029
- return;
1030
- }
1031
- // Try to upload to storage
1032
- try {
1033
- const fileId = await this.uploadBase64ToStorage(fo.fileData, fo.fileName, fo.mimeType, contextUser, resolvedStorageAccountId, provider);
1034
- await this.createFileArtifact(fileId, fo.mimeType, fo.fileName, fo.sizeBytes, conversationDetailId, contextUser, provider, acceptUnregisteredFiles);
1035
- }
1036
- catch (storageError) {
1037
- // Upload failed — fall back to inline artifact
1038
- LogStatus(`ProcessFileArtifacts: storage upload failed for "${fo.fileName}", creating inline artifact: ${storageError.message}`);
1039
- await this.createInlineFileArtifact(fo.fileData, fo.mimeType, fo.fileName, fo.sizeBytes, conversationDetailId, contextUser, provider, acceptUnregisteredFiles);
1399
+ return await this.createFileArtifact(fo.fileId, fo.mimeType, fo.fileName, fo.sizeBytes, conversationDetailId, contextUser, provider, acceptUnregisteredFiles, 'file', fo.visibility);
1040
1400
  }
1401
+ const fileId = await this.uploadToStorageIfConfigured(fo.fileData, fo.fileName, fo.mimeType, contextUser, resolvedStorageAccountId, provider, 'ProcessFileArtifacts');
1402
+ return fileId
1403
+ ? await this.createFileArtifact(fileId, fo.mimeType, fo.fileName, fo.sizeBytes, conversationDetailId, contextUser, provider, acceptUnregisteredFiles, 'file', fo.visibility)
1404
+ : await this.createInlineFileArtifact(fo.fileData, fo.mimeType, fo.fileName, fo.sizeBytes, conversationDetailId, contextUser, provider, acceptUnregisteredFiles, fo.visibility);
1041
1405
  }
1042
1406
  catch (error) {
1043
1407
  LogError(`ProcessFileArtifacts: failed for "${fo.fileName}": ${error.message}`);
1408
+ return undefined;
1044
1409
  }
1045
1410
  }
1046
1411
  /**
@@ -1067,7 +1432,7 @@ export class AgentRunner {
1067
1432
  * vs. inline content).
1068
1433
  */
1069
1434
  async createArtifactWithVersion(params) {
1070
- const { mimeType, fileName, sizeBytes, conversationDetailId, contextUser, provider, acceptUnregisteredFiles, setVersionFields, label } = params;
1435
+ const { mimeType, fileName, sizeBytes, conversationDetailId, contextUser, provider, acceptUnregisteredFiles, setVersionFields, label, visibility } = params;
1071
1436
  // Resolve the artifact type using the wildcard-aware resolver with an
1072
1437
  // extension hint for application/octet-stream uploads.
1073
1438
  const fileExtension = fileName.includes('.') ? fileName.split('.').pop() : undefined;
@@ -1104,7 +1469,7 @@ export class AgentRunner {
1104
1469
  artifact.Name = fileName;
1105
1470
  artifact.TypeID = artifactTypeId;
1106
1471
  artifact.UserID = contextUser.ID;
1107
- artifact.Visibility = 'Always';
1472
+ artifact.Visibility = visibility ?? 'Always';
1108
1473
  if (!(await artifact.Save())) {
1109
1474
  throw new Error(`Failed to save artifact for ${label}: ${fileName}`);
1110
1475
  }
@@ -1134,6 +1499,7 @@ export class AgentRunner {
1134
1499
  await dbProvider.CommitTransaction();
1135
1500
  }
1136
1501
  LogStatus(`Created ${label} artifact: ${fileName} (${mimeType}) → artifact ${artifact.ID}, version ${version.ID}`);
1502
+ return { artifactId: artifact.ID, versionId: version.ID, versionNumber: version.VersionNumber };
1137
1503
  }
1138
1504
  catch (error) {
1139
1505
  if (useTransaction) {
@@ -1147,11 +1513,15 @@ export class AgentRunner {
1147
1513
  throw error;
1148
1514
  }
1149
1515
  }
1150
- /** Creates a file-backed artifact (version references a FileID in MJStorage). */
1151
- async createFileArtifact(fileId, mimeType, fileName, sizeBytes, conversationDetailId, contextUser, provider, acceptUnregisteredFiles) {
1152
- await this.createArtifactWithVersion({
1153
- mimeType, fileName, sizeBytes, conversationDetailId, contextUser, provider, acceptUnregisteredFiles,
1154
- label: 'file',
1516
+ /**
1517
+ * Creates a file-backed artifact (version references a FileID in MJStorage). `label` names the
1518
+ * thing being stored in diagnostics ("file", "media Image") so a failure reads as specifically as
1519
+ * the inline path's would.
1520
+ */
1521
+ async createFileArtifact(fileId, mimeType, fileName, sizeBytes, conversationDetailId, contextUser, provider, acceptUnregisteredFiles, label = 'file', visibility) {
1522
+ return this.createArtifactWithVersion({
1523
+ mimeType, fileName, sizeBytes, conversationDetailId, contextUser, provider, acceptUnregisteredFiles, visibility,
1524
+ label,
1155
1525
  setVersionFields: (version) => {
1156
1526
  version.ContentMode = 'File';
1157
1527
  version.FileID = fileId;
@@ -1159,9 +1529,9 @@ export class AgentRunner {
1159
1529
  });
1160
1530
  }
1161
1531
  /** Creates an inline artifact (version stores base64 data URL directly, no MJStorage). */
1162
- async createInlineFileArtifact(base64Data, mimeType, fileName, sizeBytes, conversationDetailId, contextUser, provider, acceptUnregisteredFiles) {
1163
- await this.createArtifactWithVersion({
1164
- mimeType, fileName, sizeBytes, conversationDetailId, contextUser, provider, acceptUnregisteredFiles,
1532
+ async createInlineFileArtifact(base64Data, mimeType, fileName, sizeBytes, conversationDetailId, contextUser, provider, acceptUnregisteredFiles, visibility) {
1533
+ return this.createArtifactWithVersion({
1534
+ mimeType, fileName, sizeBytes, conversationDetailId, contextUser, provider, acceptUnregisteredFiles, visibility,
1165
1535
  label: 'inline file',
1166
1536
  setVersionFields: (version) => {
1167
1537
  version.ContentMode = 'Text';