@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.
- package/dist/generated/generated.d.ts +386 -365
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +2425 -2325
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +1 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +7 -4
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +4 -0
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +28 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts +12 -0
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +38 -6
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +111 -58
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/artifact-routing.d.ts +39 -0
- package/dist/resolvers/artifact-routing.d.ts.map +1 -0
- package/dist/resolvers/artifact-routing.js +40 -0
- package/dist/resolvers/artifact-routing.js.map +1 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +68 -68
- package/src/generated/generated.ts +1930 -1857
- package/src/generic/ResolverBase.ts +13 -4
- package/src/generic/RunViewResolver.ts +28 -0
- package/src/resolvers/QuerySystemUserResolver.ts +42 -6
- package/src/resolvers/RunAIAgentResolver.ts +119 -61
- package/src/resolvers/__tests__/artifact-routing.test.ts +88 -0
- package/src/resolvers/artifact-routing.ts +80 -0
- 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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
//
|
|
1314
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
//
|
|
1317
|
-
//
|
|
1318
|
-
//
|
|
1319
|
-
|
|
1320
|
-
|
|
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
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
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
|
|
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
|
|
1361
|
-
|
|
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 (
|
|
1366
|
-
|
|
1367
|
-
|
|
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
|
|
1387
|
-
//
|
|
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
|
|