@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.
- package/dist/AgentRunner.d.ts +192 -10
- package/dist/AgentRunner.d.ts.map +1 -1
- package/dist/AgentRunner.js +472 -102
- package/dist/AgentRunner.js.map +1 -1
- package/dist/PayloadManager.d.ts +92 -0
- package/dist/PayloadManager.d.ts.map +1 -1
- package/dist/PayloadManager.js +164 -0
- package/dist/PayloadManager.js.map +1 -1
- package/dist/artifact-target-plan.d.ts +77 -0
- package/dist/artifact-target-plan.d.ts.map +1 -0
- package/dist/artifact-target-plan.js +60 -0
- package/dist/artifact-target-plan.js.map +1 -0
- package/dist/base-agent.d.ts +114 -5
- package/dist/base-agent.d.ts.map +1 -1
- package/dist/base-agent.js +307 -20
- package/dist/base-agent.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/memory-manager-agent.js +1 -1
- package/dist/memory-manager-agent.js.map +1 -1
- package/dist/realtime/bridge-realtime-session-factory.d.ts +1 -1
- package/dist/realtime/bridge-realtime-session-factory.d.ts.map +1 -1
- package/dist/realtime/bridge-realtime-session-factory.js +52 -9
- package/dist/realtime/bridge-realtime-session-factory.js.map +1 -1
- package/dist/realtime/realtime-coagent-config.d.ts +45 -0
- package/dist/realtime/realtime-coagent-config.d.ts.map +1 -1
- package/dist/realtime/realtime-coagent-config.js +92 -0
- package/dist/realtime/realtime-coagent-config.js.map +1 -1
- package/dist/realtime/realtime-recording-store.d.ts +11 -3
- package/dist/realtime/realtime-recording-store.d.ts.map +1 -1
- package/dist/realtime/realtime-recording-store.js +7 -5
- package/dist/realtime/realtime-recording-store.js.map +1 -1
- package/package.json +18 -18
package/dist/AgentRunner.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
408
|
-
|
|
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
|
|
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=${
|
|
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
|
-
*
|
|
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
|
-
|
|
635
|
-
//
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
/**
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
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
|
-
|
|
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';
|