@memberjunction/server 5.34.0 → 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.
Files changed (35) hide show
  1. package/dist/generated/generated.d.ts +386 -365
  2. package/dist/generated/generated.d.ts.map +1 -1
  3. package/dist/generated/generated.js +2425 -2325
  4. package/dist/generated/generated.js.map +1 -1
  5. package/dist/generic/ResolverBase.d.ts +1 -1
  6. package/dist/generic/ResolverBase.d.ts.map +1 -1
  7. package/dist/generic/ResolverBase.js +7 -4
  8. package/dist/generic/ResolverBase.js.map +1 -1
  9. package/dist/generic/RunViewResolver.d.ts +4 -0
  10. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  11. package/dist/generic/RunViewResolver.js +28 -0
  12. package/dist/generic/RunViewResolver.js.map +1 -1
  13. package/dist/resolvers/QuerySystemUserResolver.d.ts +12 -0
  14. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  15. package/dist/resolvers/QuerySystemUserResolver.js +38 -6
  16. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  17. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  18. package/dist/resolvers/RunAIAgentResolver.js +111 -58
  19. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  20. package/dist/resolvers/artifact-routing.d.ts +39 -0
  21. package/dist/resolvers/artifact-routing.d.ts.map +1 -0
  22. package/dist/resolvers/artifact-routing.js +40 -0
  23. package/dist/resolvers/artifact-routing.js.map +1 -0
  24. package/dist/types.d.ts +6 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/types.js.map +1 -1
  27. package/package.json +68 -68
  28. package/src/generated/generated.ts +1930 -1857
  29. package/src/generic/ResolverBase.ts +13 -4
  30. package/src/generic/RunViewResolver.ts +28 -0
  31. package/src/resolvers/QuerySystemUserResolver.ts +42 -6
  32. package/src/resolvers/RunAIAgentResolver.ts +119 -61
  33. package/src/resolvers/__tests__/artifact-routing.test.ts +88 -0
  34. package/src/resolvers/artifact-routing.ts +80 -0
  35. package/src/types.ts +6 -0
@@ -359,7 +359,8 @@ export class ResolverBase {
359
359
  viewInput.Aggregates,
360
360
  viewInput.AfterKey
361
361
  ? CompositeKey.FromKeyValuePairs((viewInput.AfterKey as { KeyValuePairs: { FieldName: string; Value: string }[] }).KeyValuePairs)
362
- : undefined
362
+ : undefined,
363
+ viewInput.BypassCache
363
364
  );
364
365
  }
365
366
  else {
@@ -400,7 +401,9 @@ export class ResolverBase {
400
401
  userPayload,
401
402
  viewInput.MaxRows,
402
403
  viewInput.StartRow,
403
- viewInput.Aggregates
404
+ viewInput.Aggregates,
405
+ undefined,
406
+ viewInput.BypassCache
404
407
  );
405
408
  } catch (err) {
406
409
  console.log(err);
@@ -444,7 +447,9 @@ export class ResolverBase {
444
447
  userPayload,
445
448
  viewInput.MaxRows,
446
449
  viewInput.StartRow,
447
- viewInput.Aggregates
450
+ viewInput.Aggregates,
451
+ undefined,
452
+ viewInput.BypassCache
448
453
  );
449
454
  } catch (err) {
450
455
  console.log(err);
@@ -517,6 +522,7 @@ export class ResolverBase {
517
522
  resultType: viewInput.ResultType,
518
523
  userPayload,
519
524
  aggregates: viewInput.Aggregates,
525
+ bypassCache: viewInput.BypassCache,
520
526
  });
521
527
  } catch (err) {
522
528
  LogError(err);
@@ -693,7 +699,8 @@ export class ResolverBase {
693
699
  maxRows: number | undefined,
694
700
  startRow: number | undefined,
695
701
  aggregates?: AggregateExpression[],
696
- afterKey?: CompositeKey
702
+ afterKey?: CompositeKey,
703
+ bypassCache?: boolean
697
704
  ) {
698
705
  try {
699
706
  if (!viewInfo || !userPayload) return null;
@@ -757,6 +764,7 @@ export class ResolverBase {
757
764
  AuditLogDescription: auditLogDescription,
758
765
  ResultType: rt,
759
766
  Aggregates: aggregates,
767
+ BypassCache: bypassCache,
760
768
  },
761
769
  user
762
770
  );
@@ -870,6 +878,7 @@ export class ResolverBase {
870
878
  AuditLogDescription: param.auditLogDescription,
871
879
  ResultType: rt,
872
880
  Aggregates: param.aggregates,
881
+ BypassCache: param.bypassCache,
873
882
  });
874
883
  }
875
884
 
@@ -170,6 +170,13 @@ export class RunViewByIDInput {
170
170
  description: 'Optional aggregate expressions to calculate on the full result set (e.g., SUM, COUNT, AVG). Results are returned in AggregateResults.',
171
171
  })
172
172
  Aggregates?: AggregateExpressionInput[];
173
+
174
+ @Field(() => Boolean, {
175
+ nullable: true,
176
+ description:
177
+ 'Optional, when true bypasses ALL server-side caching for this view run — the pre-check cache lookup is skipped and the result is not stored in the cache. Use for maintenance/audit queries that must see true database state, or to force-refresh views whose filters reference rows the server cache invalidator cannot follow (e.g., cross-entity subqueries against vwListDetails).',
178
+ })
179
+ BypassCache?: boolean;
173
180
  }
174
181
 
175
182
  @InputType()
@@ -277,6 +284,13 @@ export class RunViewByNameInput {
277
284
  description: 'Optional aggregate expressions to calculate on the full result set (e.g., SUM, COUNT, AVG). Results are returned in AggregateResults.',
278
285
  })
279
286
  Aggregates?: AggregateExpressionInput[];
287
+
288
+ @Field(() => Boolean, {
289
+ nullable: true,
290
+ description:
291
+ 'Optional, when true bypasses ALL server-side caching for this view run — the pre-check cache lookup is skipped and the result is not stored in the cache. Use for maintenance/audit queries that must see true database state, or to force-refresh views whose filters reference rows the server cache invalidator cannot follow (e.g., cross-entity subqueries against vwListDetails).',
292
+ })
293
+ BypassCache?: boolean;
280
294
  }
281
295
 
282
296
  @InputType()
@@ -370,6 +384,13 @@ export class RunDynamicViewInput {
370
384
  description: 'Optional aggregate expressions to calculate on the full result set (e.g., SUM, COUNT, AVG). Results are returned in AggregateResults.',
371
385
  })
372
386
  Aggregates?: AggregateExpressionInput[];
387
+
388
+ @Field(() => Boolean, {
389
+ nullable: true,
390
+ description:
391
+ 'Optional, when true bypasses ALL server-side caching for this view run — the pre-check cache lookup is skipped and the result is not stored in the cache. Use for maintenance/audit queries that must see true database state, or to force-refresh views whose filters reference rows the server cache invalidator cannot follow (e.g., cross-entity subqueries against vwListDetails).',
392
+ })
393
+ BypassCache?: boolean;
373
394
  }
374
395
 
375
396
  @InputType()
@@ -492,6 +513,13 @@ export class RunViewGenericInput {
492
513
  description: 'Optional aggregate expressions to calculate on the full result set (e.g., SUM, COUNT, AVG). Results are returned in AggregateResults.',
493
514
  })
494
515
  Aggregates?: AggregateExpressionInput[];
516
+
517
+ @Field(() => Boolean, {
518
+ nullable: true,
519
+ description:
520
+ 'Optional, when true bypasses ALL server-side caching for this view run — the pre-check cache lookup is skipped and the result is not stored in the cache. Use for maintenance/audit queries that must see true database state, or to force-refresh views whose filters reference rows the server cache invalidator cannot follow (e.g., cross-entity subqueries against vwListDetails).',
521
+ })
522
+ BypassCache?: boolean;
495
523
  }
496
524
 
497
525
  //****************************************************************************
@@ -110,6 +110,9 @@ export class CreateQuerySystemUserInput {
110
110
 
111
111
  @Field(() => [QueryParameterHintInput], { nullable: true })
112
112
  ParameterHints?: QueryParameterHintInput[];
113
+
114
+ @Field(() => Boolean, { nullable: true, defaultValue: false })
115
+ AutoResolveCollision?: boolean;
113
116
  }
114
117
 
115
118
  @InputType()
@@ -250,18 +253,24 @@ export class MJQueryResolverExtended extends MJQueryResolver {
250
253
  const existingQuery = await this.findExistingQuery(provider, input.Name, finalCategoryID, context.userPayload.userRecord);
251
254
 
252
255
  if (existingQuery) {
253
- const categoryInfo = input.CategoryPath ? `category path '${input.CategoryPath}'` : `category ID '${finalCategoryID}'`;
254
- return {
255
- Success: false,
256
- ErrorMessage: `Query with name '${input.Name}' already exists in ${categoryInfo}`
257
- };
256
+ if (input.AutoResolveCollision) {
257
+ // Server-side collision resolution: find a unique name using sequential numeric suffixes
258
+ input.Name = await this.findUniqueName(provider, input.Name, finalCategoryID, context.userPayload.userRecord);
259
+ LogStatus(`[CreateQuery] Auto-resolved name collision: "${existingQuery.Name}" -> "${input.Name}"`);
260
+ } else {
261
+ const categoryInfo = input.CategoryPath ? `category path '${input.CategoryPath}'` : `category ID '${finalCategoryID}'`;
262
+ return {
263
+ Success: false,
264
+ ErrorMessage: `Query with name '${input.Name}' already exists in ${categoryInfo}`
265
+ };
266
+ }
258
267
  }
259
268
 
260
269
  // Use MJQueryEntityServer which handles AI processing
261
270
  const record = await provider.GetEntityObject<MJQueryEntityServer>("MJ: Queries", context.userPayload.userRecord);
262
271
 
263
272
  // Destructure out non-database fields, keep only fields to persist
264
- const { Permissions: _permissions, CategoryPath: _categoryPath, ParameterHints: _parameterHints, ...fieldsToSet } = {
273
+ const { Permissions: _permissions, CategoryPath: _categoryPath, ParameterHints: _parameterHints, AutoResolveCollision: _autoResolve, ...fieldsToSet } = {
265
274
  ...input,
266
275
  CategoryID: finalCategoryID || input.CategoryID,
267
276
  Status: input.Status || 'Approved',
@@ -734,6 +743,33 @@ export class MJQueryResolverExtended extends MJQueryResolver {
734
743
  }
735
744
  }
736
745
 
746
+ /**
747
+ * Finds a unique query name by appending sequential numeric suffixes.
748
+ * Format: "Name (1)", "Name (2)", etc.
749
+ * Uses direct DB queries (not cache) for authoritative uniqueness checks.
750
+ * @param provider - Database provider for direct DB queries
751
+ * @param baseName - The original desired name that is already taken
752
+ * @param categoryID - Category ID to check uniqueness within
753
+ * @param contextUser - User context for database operations
754
+ * @returns A unique name in the format "baseName (N)"
755
+ */
756
+ private async findUniqueName(
757
+ provider: DatabaseProviderBase,
758
+ baseName: string,
759
+ categoryID: string | null,
760
+ contextUser: UserInfo
761
+ ): Promise<string> {
762
+ const MAX_ATTEMPTS = 50;
763
+ for (let i = 1; i <= MAX_ATTEMPTS; i++) {
764
+ const candidateName = `${baseName} (${i})`;
765
+ const existing = await this.findExistingQuery(provider, candidateName, categoryID, contextUser);
766
+ if (!existing) {
767
+ return candidateName;
768
+ }
769
+ }
770
+ throw new Error(`Unable to find unique name for "${baseName}" after ${MAX_ATTEMPTS} attempts`);
771
+ }
772
+
737
773
  /**
738
774
  * Finds a category by name and parent ID using RunView.
739
775
  * Bypasses metadata cache to ensure we get the latest data from database.
@@ -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