@memberjunction/server 5.34.1 → 5.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,8 @@
1
1
  import { Resolver, Mutation, Query, Arg, Ctx, ObjectType, Field, PubSub, PubSubEngine, Subscription, Root, ResolverFilterData, ID, Int } from 'type-graphql';
2
2
  import { AppContext, UserPayload } from '../types.js';
3
3
  import { DatabaseProviderBase, LogError, LogStatus, Metadata, RunView, UserInfo, IMetadataProvider } from '@memberjunction/core';
4
- import { MJConversationDetailEntity, MJConversationDetailAttachmentEntity, MJConversationDetailArtifactEntity, MJArtifactVersionEntity, MJAIAgentRequestEntity } from '@memberjunction/core-entities';
4
+ import { MJConversationDetailEntity, MJConversationDetailAttachmentEntity, MJConversationDetailArtifactEntity, MJArtifactVersionEntity, MJAIAgentRequestEntity, ArtifactMetadataEngine } from '@memberjunction/core-entities';
5
+ import { RouteArtifact } from './artifact-routing.js';
5
6
  import { AgentRunner, ArtifactToolManager } from '@memberjunction/ai-agents';
6
7
  import { MJAIAgentEntityExtended, MJAIAgentRunEntityExtended, ExecuteAgentResult, ConversationUtility, AttachmentData } from '@memberjunction/ai-core-plus';
7
8
  import { AIEngine } from '@memberjunction/aiengine';
@@ -14,6 +15,15 @@ import { SafeJSONParse, UUIDsEqual } from '@memberjunction/global';
14
15
  import { GetAttachmentService } from '@memberjunction/aiengine';
15
16
  import { NotificationEngine } from '@memberjunction/notifications';
16
17
 
18
+ /**
19
+ * Absolute server-side cap for inline content blocks emitted to the LLM, in
20
+ * bytes. Defense in depth: even if an Artifact Type is configured for Inline
21
+ * delivery, anything past this size falls back to the tool dispatch path with
22
+ * a visible annotation on the manifest. Single source of truth — replaces the
23
+ * pre-existing per-call MAX_INLINE_ARTIFACT_CHARS / maxInlineChars constants.
24
+ */
25
+ const INLINE_SIZE_CAP = 100 * 1024;
26
+
17
27
  @ObjectType()
18
28
  export class AIAgentRunResult {
19
29
  @Field()
@@ -1310,84 +1320,132 @@ export class RunAIAgentResolver extends ResolverBase {
1310
1320
  );
1311
1321
  const attachmentDataResults = await Promise.all(attachmentDataPromises);
1312
1322
 
1313
- // Filter out nulls and convert to AttachmentData format.
1314
- // IMPORTANT: Skip large document attachments that are handled by artifact tools.
1315
- // These file types (PDF, Excel, Word) are accessible via ArtifactToolManager
1316
- // and embedding their multi-MB base64 content in the conversation causes
1317
- // context overflow on follow-up messages when conversation history is replayed.
1318
- // Images are kept inline since LLMs handle them natively via multimodal.
1319
- const ARTIFACT_TOOL_MIME_PREFIXES = [
1320
- 'application/pdf',
1321
- 'application/vnd.openxmlformats-officedocument', // .docx, .xlsx, .pptx
1322
- 'application/vnd.ms-excel', // .xls
1323
- 'application/msword', // .doc
1324
- ];
1325
-
1323
+ // Decide inline vs. tools per-attachment via the artifact-type registry
1324
+ // and the pure RouteArtifact() function. See plans/artifact-attachment-unification.md.
1325
+ //
1326
+ // Note on the modality check: this resolver doesn't know which model will
1327
+ // ultimately run, so RouteArtifact's modality predicate is passed as a no-op.
1328
+ // Model-specific modality enforcement is the driver layer's responsibility;
1329
+ // a follow-up PR can thread the active model through here to surface modality
1330
+ // mismatches at the hard-error path described in plan §4.
1326
1331
  const validAttachments: AttachmentData[] = [];
1327
1332
 
1328
1333
  for (const result of attachmentDataResults) {
1329
1334
  if (!result) continue;
1335
+ // Storage-unified attachments link forward to their artifact version
1336
+ // via ArtifactVersionID; the artifact path handles delivery, so skip
1337
+ // the attachment row here to avoid double-processing.
1338
+ if (result.attachment.ArtifactVersionID) {
1339
+ continue;
1340
+ }
1330
1341
  const mime = result.attachment.MimeType || '';
1331
- const isArtifactToolType = ARTIFACT_TOOL_MIME_PREFIXES.some(prefix => mime.startsWith(prefix));
1332
- if (isArtifactToolType) {
1333
- // Skip raw file embedding — the agent accesses the file via
1334
- // artifact tools (manifest injected into prompt) and/or native
1335
- // file input (resolved per-driver in AIPromptRunner).
1336
- // Do NOT add a placeholder attachment: 'Document' maps to 'file_url'
1337
- // content blocks, and drivers attempt base64 decoding of the text,
1338
- // causing API errors.
1339
- } else {
1340
- validAttachments.push({
1341
- type: ConversationUtility.GetAttachmentTypeFromMime(result.attachment.MimeType),
1342
- mimeType: result.attachment.MimeType,
1343
- fileName: result.attachment.FileName ?? undefined,
1344
- sizeBytes: result.attachment.FileSizeBytes ?? undefined,
1345
- width: result.attachment.Width ?? undefined,
1346
- height: result.attachment.Height ?? undefined,
1347
- durationSeconds: result.attachment.DurationSeconds ?? undefined,
1348
- content: result.contentUrl
1349
- });
1342
+ const fileName = result.attachment.FileName ?? '';
1343
+ const ext = fileName.includes('.') ? fileName.split('.').pop() : undefined;
1344
+ const artifactType = ArtifactMetadataEngine.Instance.GetArtifactTypeByMimeType(mime, ext);
1345
+
1346
+ const decision = RouteArtifact({
1347
+ typeDefault: artifactType?.DefaultDeliveryMode ?? 'ToolsOnly',
1348
+ forceToolsOnly: false,
1349
+ mimeType: mime,
1350
+ sizeBytes: result.attachment.FileSizeBytes ?? 0,
1351
+ inlineSizeCap: INLINE_SIZE_CAP,
1352
+ modelSupportsModality: () => true,
1353
+ modelName: '<resolver>',
1354
+ artifactTypeName: artifactType?.Name ?? mime,
1355
+ });
1356
+
1357
+ if (decision.delivery !== 'inline') {
1358
+ // Tools or error: skip inline embedding. The agent reaches the
1359
+ // bytes via artifact tools. Driver layer handles modality enforcement.
1360
+ if (decision.delivery === 'tools' && decision.annotation) {
1361
+ LogStatus(`[RunAIAgentResolver] ${decision.annotation}`);
1362
+ }
1363
+ continue;
1350
1364
  }
1365
+ validAttachments.push({
1366
+ type: ConversationUtility.GetAttachmentTypeFromMime(result.attachment.MimeType),
1367
+ mimeType: result.attachment.MimeType,
1368
+ fileName: result.attachment.FileName ?? undefined,
1369
+ sizeBytes: result.attachment.FileSizeBytes ?? undefined,
1370
+ width: result.attachment.Width ?? undefined,
1371
+ height: result.attachment.Height ?? undefined,
1372
+ durationSeconds: result.attachment.DurationSeconds ?? undefined,
1373
+ content: result.contentUrl
1374
+ });
1351
1375
  }
1352
1376
 
1353
- // Get input artifacts for this message and convert to AttachmentData.
1354
- // Like regular attachments above, skip file-backed artifacts whose MIME
1355
- // types are handled by ArtifactToolManager — embedding their multi-MB
1356
- // base64 content in every conversation replay causes context overflow.
1377
+ // Get input artifacts for this message same routing logic, plus ForceToolsOnly.
1357
1378
  const inputArtifacts = inputArtifactsByDetailId.get(detail.ID) || [];
1358
1379
  for (const artifactVersion of inputArtifacts) {
1359
1380
  const artifactMime = artifactVersion.MimeType || '';
1360
- const isArtifactToolHandled = ARTIFACT_TOOL_MIME_PREFIXES.some(
1361
- prefix => artifactMime.startsWith(prefix)
1362
- );
1381
+ const fileName = artifactVersion.FileName ?? '';
1382
+ const ext = fileName.includes('.') ? fileName.split('.').pop() : undefined;
1383
+ const artifactType = ArtifactMetadataEngine.Instance.GetArtifactTypeByMimeType(artifactMime, ext);
1384
+
1385
+ const decision = RouteArtifact({
1386
+ typeDefault: artifactType?.DefaultDeliveryMode ?? 'ToolsOnly',
1387
+ forceToolsOnly: artifactVersion.ForceToolsOnly,
1388
+ mimeType: artifactMime,
1389
+ sizeBytes: artifactVersion.ContentSizeBytes ?? 0,
1390
+ inlineSizeCap: INLINE_SIZE_CAP,
1391
+ modelSupportsModality: () => true,
1392
+ modelName: '<resolver>',
1393
+ artifactTypeName: artifactType?.Name ?? artifactMime,
1394
+ });
1363
1395
 
1364
1396
  if (artifactVersion.ContentMode === 'File' && artifactVersion.FileID) {
1365
- if (isArtifactToolHandled) {
1366
- // Skip raw file embedding the agent accesses the file via
1367
- // artifact tools (manifest injected into prompt) and/or native
1368
- // file input (resolved per-driver in AIPromptRunner).
1369
- // Do NOT add a placeholder attachment here: 'Document' maps to
1370
- // 'file_url' content blocks, and drivers attempt base64 decoding
1371
- // of the placeholder text, causing API errors.
1372
- } else {
1373
- // Non-artifact-tool file types: embed normally
1374
- const fileContent = await this.downloadArtifactFileContent(artifactVersion, contextUser, provider);
1375
- if (fileContent) {
1376
- validAttachments.push({
1377
- type: ConversationUtility.GetAttachmentTypeFromMime(artifactMime),
1378
- mimeType: artifactMime || 'application/octet-stream',
1379
- fileName: artifactVersion.FileName || artifactVersion.Name || undefined,
1380
- sizeBytes: artifactVersion.ContentSizeBytes || undefined,
1381
- content: fileContent
1382
- });
1397
+ if (decision.delivery !== 'inline') {
1398
+ if (decision.delivery === 'tools' && decision.annotation) {
1399
+ LogStatus(`[RunAIAgentResolver] ${decision.annotation}`);
1383
1400
  }
1401
+ continue;
1402
+ }
1403
+ const fileContent = await this.downloadArtifactFileContent(artifactVersion, contextUser, provider);
1404
+ if (fileContent) {
1405
+ validAttachments.push({
1406
+ type: ConversationUtility.GetAttachmentTypeFromMime(artifactMime),
1407
+ mimeType: artifactMime || 'application/octet-stream',
1408
+ fileName: artifactVersion.FileName || artifactVersion.Name || undefined,
1409
+ sizeBytes: artifactVersion.ContentSizeBytes || undefined,
1410
+ content: fileContent
1411
+ });
1384
1412
  }
1385
1413
  } else if (artifactVersion.Content) {
1386
- // Text artifact use BuildInlinePreview for large DataSnapshots
1387
- // to preserve table structure instead of raw substring truncation.
1414
+ // Text-mode artifact (ContentMode = 'Text'). Honor the
1415
+ // routing decision the same way as for file-mode.
1416
+ if (decision.delivery !== 'inline') {
1417
+ if (decision.delivery === 'tools' && decision.annotation) {
1418
+ LogStatus(`[RunAIAgentResolver] ${decision.annotation}`);
1419
+ }
1420
+ continue;
1421
+ }
1422
+
1388
1423
  const textContent = artifactVersion.Content;
1389
- const MAX_INLINE_ARTIFACT_CHARS = 10_000;
1390
1424
 
1425
+ // Media artifacts (image / audio / video) stored inline by
1426
+ // the server hook arrive here as `Text` mode with a base64
1427
+ // data URL in Content. We must route them as their native
1428
+ // modality, not as text — otherwise the LLM sees the raw
1429
+ // base64 as text content, can't process it, and either
1430
+ // hallucinates or admits confusion. Use the artifact's
1431
+ // declared MIME (not the wrapper string) so
1432
+ // ConversationUtility builds the right content block type.
1433
+ const mediaModality = artifactMime.startsWith('image/')
1434
+ || artifactMime.startsWith('audio/')
1435
+ || artifactMime.startsWith('video/');
1436
+
1437
+ if (mediaModality && textContent.startsWith('data:')) {
1438
+ validAttachments.push({
1439
+ type: ConversationUtility.GetAttachmentTypeFromMime(artifactMime),
1440
+ mimeType: artifactMime,
1441
+ fileName: artifactVersion.FileName || artifactVersion.Name || undefined,
1442
+ sizeBytes: artifactVersion.ContentSizeBytes || undefined,
1443
+ content: textContent,
1444
+ });
1445
+ continue;
1446
+ }
1447
+
1448
+ const MAX_INLINE_ARTIFACT_CHARS = 10_000;
1391
1449
  if (textContent.length > MAX_INLINE_ARTIFACT_CHARS) {
1392
1450
  const preview = ArtifactToolManager.BuildInlinePreview(textContent, 5);
1393
1451
  validAttachments.push({
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { RouteArtifact, type ArtifactRoutingInput } from '../artifact-routing';
3
+
4
+ const baseInput = (overrides: Partial<ArtifactRoutingInput> = {}): ArtifactRoutingInput => ({
5
+ typeDefault: 'Inline',
6
+ forceToolsOnly: false,
7
+ mimeType: 'image/png',
8
+ sizeBytes: 5_000,
9
+ inlineSizeCap: 100 * 1024,
10
+ modelSupportsModality: () => true,
11
+ modelName: 'TestModel',
12
+ artifactTypeName: 'Image',
13
+ ...overrides,
14
+ });
15
+
16
+ describe('RouteArtifact', () => {
17
+ it('routes Inline default + modality supported + under cap to inline', () => {
18
+ const result = RouteArtifact(baseInput());
19
+ expect(result).toEqual({ delivery: 'inline' });
20
+ });
21
+
22
+ it('routes ToolsOnly type default to tools', () => {
23
+ const result = RouteArtifact(baseInput({ typeDefault: 'ToolsOnly' }));
24
+ expect(result).toEqual({ delivery: 'tools' });
25
+ });
26
+
27
+ it('routes ForceToolsOnly per-instance override to tools regardless of type default', () => {
28
+ const result = RouteArtifact(baseInput({ forceToolsOnly: true }));
29
+ expect(result).toEqual({ delivery: 'tools' });
30
+ });
31
+
32
+ it('returns an error when the model lacks modality support for an Inline type', () => {
33
+ const result = RouteArtifact(baseInput({
34
+ modelSupportsModality: () => false,
35
+ }));
36
+ expect(result.delivery).toBe('error');
37
+ if (result.delivery !== 'error') return;
38
+ expect(result.message).toContain('Image');
39
+ expect(result.message).toContain('TestModel');
40
+ expect(result.message).toContain('image/png');
41
+ // The error message lists all three remediation paths.
42
+ expect(result.message).toMatch(/ToolsOnly/);
43
+ expect(result.message).toMatch(/ForceToolsOnly/);
44
+ expect(result.message).toMatch(/switch to a model/);
45
+ });
46
+
47
+ it('falls back to tools with annotation when size exceeds the cap', () => {
48
+ const result = RouteArtifact(baseInput({
49
+ sizeBytes: 200 * 1024,
50
+ inlineSizeCap: 100 * 1024,
51
+ }));
52
+ expect(result.delivery).toBe('tools');
53
+ if (result.delivery !== 'tools') return;
54
+ expect(result.annotation).toBeDefined();
55
+ expect(result.annotation).toMatch(/exceeds the inline cap/);
56
+ expect(result.annotation).toContain('204800');
57
+ expect(result.annotation).toContain('102400');
58
+ });
59
+
60
+ it('checks ToolsOnly before modality (modality check is irrelevant when type is ToolsOnly)', () => {
61
+ const modelSupportsModality = vi.fn(() => false);
62
+ const result = RouteArtifact(baseInput({
63
+ typeDefault: 'ToolsOnly',
64
+ modelSupportsModality,
65
+ }));
66
+ expect(result).toEqual({ delivery: 'tools' });
67
+ expect(modelSupportsModality).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it('checks modality before size (modality error wins over size fallback)', () => {
71
+ const result = RouteArtifact(baseInput({
72
+ modelSupportsModality: () => false,
73
+ sizeBytes: 200 * 1024,
74
+ }));
75
+ expect(result.delivery).toBe('error');
76
+ });
77
+
78
+ it('ForceToolsOnly bypasses both modality and size checks', () => {
79
+ const modelSupportsModality = vi.fn(() => false);
80
+ const result = RouteArtifact(baseInput({
81
+ forceToolsOnly: true,
82
+ modelSupportsModality,
83
+ sizeBytes: 200 * 1024,
84
+ }));
85
+ expect(result).toEqual({ delivery: 'tools' });
86
+ expect(modelSupportsModality).not.toHaveBeenCalled();
87
+ });
88
+ });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Pure routing function: decides whether an artifact reaches the LLM via an
3
+ * inline content block (image_url, audio_url, file_url) or via the artifact
4
+ * tool dispatch path. Has no entity-type or framework dependency — operates
5
+ * on plain inputs and returns a discriminated result. Lives next to the
6
+ * resolver but is independently testable.
7
+ *
8
+ * See plans/artifact-attachment-unification.md §4 for the contract.
9
+ */
10
+
11
+ export type ArtifactDeliveryMode = 'Inline' | 'ToolsOnly';
12
+
13
+ export interface ArtifactRoutingInput {
14
+ /** The Artifact Type's DefaultDeliveryMode. */
15
+ typeDefault: ArtifactDeliveryMode;
16
+ /** Per-instance opt-out — `true` forces tools regardless of typeDefault. */
17
+ forceToolsOnly: boolean;
18
+ /** MIME type of the artifact content (e.g. 'image/png'). */
19
+ mimeType: string;
20
+ /** Size of the content in bytes. */
21
+ sizeBytes: number;
22
+ /** Maximum inline size in bytes; over this, even Inline-default artifacts go to tools. */
23
+ inlineSizeCap: number;
24
+ /** Predicate: does the active model driver support the given MIME modality inline? */
25
+ modelSupportsModality: (mimeType: string) => boolean;
26
+ /** Model name used in error messages — never used to make decisions. */
27
+ modelName: string;
28
+ /** Artifact type name used in error messages. */
29
+ artifactTypeName: string;
30
+ }
31
+
32
+ export type ArtifactRoutingDecision =
33
+ | { delivery: 'inline' }
34
+ | { delivery: 'tools'; annotation?: string }
35
+ | { delivery: 'error'; message: string };
36
+
37
+ export function RouteArtifact(input: ArtifactRoutingInput): ArtifactRoutingDecision {
38
+ const {
39
+ typeDefault,
40
+ forceToolsOnly,
41
+ mimeType,
42
+ sizeBytes,
43
+ inlineSizeCap,
44
+ modelSupportsModality,
45
+ modelName,
46
+ artifactTypeName,
47
+ } = input;
48
+
49
+ // Path 1: ToolsOnly default or per-instance opt-out — always tools.
50
+ if (typeDefault === 'ToolsOnly' || forceToolsOnly) {
51
+ return { delivery: 'tools' };
52
+ }
53
+
54
+ // Path 2: Inline default + modality mismatch — hard error.
55
+ // The admin / user has paired an Inline-default type with a model that does
56
+ // not support the modality. There is no defensible runtime fix, so surface
57
+ // it with a remediable message rather than silently falling back to tools.
58
+ if (!modelSupportsModality(mimeType)) {
59
+ return {
60
+ delivery: 'error',
61
+ message:
62
+ `Artifact type "${artifactTypeName}" is configured for Inline delivery but model "${modelName}" does not support modality "${mimeType}". ` +
63
+ `Either configure the type as ToolsOnly, set ForceToolsOnly on this instance, or switch to a model that supports this modality.`,
64
+ };
65
+ }
66
+
67
+ // Path 3: Inline default + over size cap — documented, annotated fallback.
68
+ // Not silent: the manifest entry carries a visible note and the caller is
69
+ // expected to log at WARN. Both the LLM and the operator can see it
70
+ // happened, so this isn't the same as the silent-fallback antipattern.
71
+ if (sizeBytes >= inlineSizeCap) {
72
+ return {
73
+ delivery: 'tools',
74
+ annotation: `Artifact type "${artifactTypeName}" is configured for Inline delivery but content size (${sizeBytes} bytes) exceeds the inline cap (${inlineSizeCap} bytes); delivered via tools instead.`,
75
+ };
76
+ }
77
+
78
+ // Path 4: Inline, modality supported, under cap — emit inline content block.
79
+ return { delivery: 'inline' };
80
+ }
package/src/types.ts CHANGED
@@ -101,6 +101,12 @@ export type RunViewGenericParams = {
101
101
  resultType?: string;
102
102
  userPayload?: UserPayload;
103
103
  aggregates?: AggregateExpression[];
104
+ /**
105
+ * When true, the server-side cache layer is bypassed for this view run —
106
+ * neither the pre-check cache lookup nor the post-query cache write
107
+ * happens. Propagated to `RunViewParams.BypassCache`.
108
+ */
109
+ bypassCache?: boolean;
104
110
  };
105
111
 
106
112