@memberjunction/server 5.36.0 → 5.38.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 (38) hide show
  1. package/README.md +12 -12
  2. package/dist/agents/skip-agent.d.ts.map +1 -1
  3. package/dist/agents/skip-agent.js +14 -2
  4. package/dist/agents/skip-agent.js.map +1 -1
  5. package/dist/agents/skip-sdk.d.ts +10 -0
  6. package/dist/agents/skip-sdk.d.ts.map +1 -1
  7. package/dist/agents/skip-sdk.js +155 -13
  8. package/dist/agents/skip-sdk.js.map +1 -1
  9. package/dist/generated/generated.d.ts +97 -5
  10. package/dist/generated/generated.d.ts.map +1 -1
  11. package/dist/generated/generated.js +553 -13
  12. package/dist/generated/generated.js.map +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +57 -14
  15. package/dist/index.js.map +1 -1
  16. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  17. package/dist/resolvers/AdhocQueryResolver.js +42 -28
  18. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  19. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  20. package/dist/resolvers/QueryResolver.js +5 -4
  21. package/dist/resolvers/QueryResolver.js.map +1 -1
  22. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  23. package/dist/resolvers/QuerySystemUserResolver.js +6 -18
  24. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  25. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  26. package/dist/resolvers/RunAIAgentResolver.js +6 -59
  27. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  28. package/package.json +70 -70
  29. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +269 -0
  30. package/src/__tests__/resolverBase.rls.test.ts +224 -0
  31. package/src/agents/skip-agent.ts +15 -2
  32. package/src/agents/skip-sdk.ts +163 -13
  33. package/src/generated/generated.ts +388 -14
  34. package/src/index.ts +62 -17
  35. package/src/resolvers/AdhocQueryResolver.ts +42 -28
  36. package/src/resolvers/QueryResolver.ts +7 -6
  37. package/src/resolvers/QuerySystemUserResolver.ts +11 -25
  38. package/src/resolvers/RunAIAgentResolver.ts +6 -66
package/src/index.ts CHANGED
@@ -26,7 +26,9 @@ import sql from 'mssql';
26
26
  import { WebSocketServer } from 'ws';
27
27
  import buildApolloServer from './apolloServer/index.js';
28
28
  import { configInfo, dbDatabase, dbHost, dbPort, dbUsername, graphqlPort, graphqlRootPath, mj_core_schema, websiteRunFromPackage, RESTApiOptions } from './config.js';
29
+ import { default as jwt } from 'jsonwebtoken';
29
30
  import { contextFunction, createUnifiedAuthMiddleware, getUserPayload } from './context.js';
31
+ import { UserPayload } from './types.js';
30
32
  import { requireSystemUserDirective, publicDirective } from './directives/index.js';
31
33
  import createMSSQLConfig from './orm.js';
32
34
  import { setupRESTEndpoints } from './rest/setupRESTEndpoints.js';
@@ -667,28 +669,71 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
667
669
  const httpServer = createServer(app);
668
670
 
669
671
  const webSocketServer = new WebSocketServer({ server: httpServer, path: graphqlRootPath });
670
- const serverCleanup = useServer(
672
+
673
+ // Track per-connection expiry timers so we can clean them up on close
674
+ const expiryTimers = new WeakMap<object, ReturnType<typeof setTimeout>>();
675
+
676
+ const serverCleanup = useServer<Record<string, unknown>, { userPayload: UserPayload }>(
671
677
  {
672
678
  schema,
673
- context: async ({ connectionParams }) => {
674
- const userPayload = await getUserPayload(String(connectionParams?.Authorization), undefined, dataSources);
675
- return { userPayload };
676
- },
677
- onError: (ctx, message, errors) => {
678
- // Check if error is token expiration (expected behavior)
679
- const isTokenExpired = errors.some(err =>
680
- err.extensions?.code === 'JWT_EXPIRED' ||
681
- err.message?.includes('token has expired')
682
- );
679
+ // RAII: validate the token once at connection time and cache the result.
680
+ // This prevents re-validating (and re-logging) on every subscription message.
681
+ onConnect: async (ctx) => {
682
+ try {
683
+ const token = String(ctx.connectionParams?.Authorization);
684
+ const userPayload = await getUserPayload(token, undefined, dataSources);
685
+
686
+ // Store validated payload on the connection for use in context()
687
+ ctx.extra.userPayload = userPayload;
688
+
689
+ // Schedule proactive socket close at token expiry so the client
690
+ // reconnects with a fresh token instead of spamming errors.
691
+ const decoded = jwt.decode(token.replace('Bearer ', ''));
692
+ if (decoded && typeof decoded !== 'string' && decoded.exp) {
693
+ const msUntilExpiry = decoded.exp * 1000 - Date.now();
694
+ if (msUntilExpiry > 0) {
695
+ const timer = setTimeout(() => {
696
+ console.log(`WebSocket token expiring — closing connection for ${userPayload.email}`);
697
+ // 4403 = Forbidden — retriable in graphql-ws, signals "get a new token"
698
+ // (4401 is reserved as fatal/"Unauthorized" in the graphql-ws protocol)
699
+ try {
700
+ ctx.extra.socket.close(4403, 'Token expired');
701
+ } catch {
702
+ // Socket may already be closed; benign
703
+ }
704
+ }, msUntilExpiry);
705
+ // Prevent the timer from keeping the process alive during shutdown
706
+ timer.unref();
707
+ expiryTimers.set(ctx, timer);
708
+ }
709
+ }
683
710
 
684
- if (isTokenExpired) {
685
- // Log at warn level - this is expected from long-lived browser sessions
686
- console.warn('WebSocket connection token expired - client should reconnect with refreshed token');
687
- } else {
688
- // Log actual errors at error level
689
- console.error('WebSocket error:', errors);
711
+ return true;
712
+ } catch (error: unknown) {
713
+ // Return false instead of throwing graphql-ws sends 4403 Forbidden
714
+ // (retriable) rather than letting the throw produce 1011 (fatal).
715
+ const msg = error instanceof Error ? error.message : 'Authentication failed';
716
+ console.warn(`WebSocket connection rejected: ${msg}`);
717
+ return false;
690
718
  }
691
719
  },
720
+ // context() now just reads the already-validated payload — no I/O, no logging
721
+ context: async (ctx) => {
722
+ return { userPayload: ctx.extra.userPayload as UserPayload };
723
+ },
724
+ onClose: (ctx) => {
725
+ // Clear the expiry timer if the connection closes before the token expires
726
+ const timer = expiryTimers.get(ctx);
727
+ if (timer) {
728
+ clearTimeout(timer);
729
+ expiryTimers.delete(ctx);
730
+ }
731
+ },
732
+ onError: (_ctx, _message, errors) => {
733
+ // Token expiry errors can't happen anymore (handled in onConnect + timer),
734
+ // so all errors here are genuine and worth logging.
735
+ console.error('WebSocket error:', errors);
736
+ },
692
737
  },
693
738
  webSocketServer
694
739
  );
@@ -1,8 +1,9 @@
1
1
  import { Arg, Ctx, Query, Resolver, Field, Int, InputType } from 'type-graphql';
2
- import { LogError } from '@memberjunction/core';
2
+ import { DatabasePlatform, LogError } from '@memberjunction/core';
3
3
  import { SQLExpressionValidator } from '@memberjunction/global';
4
+ import { RenderPipeline } from '@memberjunction/generic-database-provider';
4
5
  import { AppContext } from '../types.js';
5
- import { GetReadOnlyDataSource } from '../util.js';
6
+ import { GetReadOnlyDataSource, GetReadOnlyProvider } from '../util.js';
6
7
  import { ResolverBase } from '../generic/ResolverBase.js';
7
8
  import { RunQueryResultType } from './QueryResolver.js';
8
9
  import sql from 'mssql';
@@ -19,10 +20,10 @@ class AdhocQueryInput {
19
20
  @Field(() => Int, { nullable: true, description: 'Query timeout in seconds. Defaults to 30.' })
20
21
  TimeoutSeconds?: number;
21
22
 
22
- @Field(() => Int, { nullable: true, description: 'Maximum number of rows to return. Applied in-memory after SQL execution; SQL still runs unbounded server-side.' })
23
+ @Field(() => Int, { nullable: true, description: 'Maximum number of rows to return; applied at the database via the render pipeline.' })
23
24
  MaxRows?: number;
24
25
 
25
- @Field(() => Int, { nullable: true, description: 'Zero-based offset for pagination. Used in conjunction with MaxRows.' })
26
+ @Field(() => Int, { nullable: true, description: 'Zero-based offset for pagination. When > 0, the row cap switches to OFFSET/FETCH pagination.' })
26
27
  StartRow?: number;
27
28
  }
28
29
 
@@ -62,25 +63,45 @@ export class AdhocQueryResolver extends ResolverBase {
62
63
  return this.buildErrorResult('No read-only data source available for ad-hoc query execution');
63
64
  }
64
65
 
65
- // 3. Build executable SQL. When MaxRows is provided, wrap in a derived table
66
- // with outer TOP so the engine can short-circuit at the source instead of
67
- // scanning the full result. Skipped for SQL that begins with WITH/CTE — those
68
- // can't be nested in a derived table on SQL Server and fall through to the
69
- // in-memory slice below.
66
+ // 3. Resolve platform from the read-only provider for the render pipeline.
67
+ let platform: DatabasePlatform = 'sqlserver';
68
+ try {
69
+ const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: false });
70
+ if (provider?.PlatformKey) platform = provider.PlatformKey;
71
+ } catch {
72
+ // Provider not configured — keep the default platform.
73
+ }
74
+ const contextUser = context.userPayload?.userRecord;
75
+
76
+ // 4. Route the SQL through RenderPipeline so composition tokens
77
+ // resolve, comments and templates are processed, and the row cap
78
+ // is applied at the database (via TOP / LIMIT / OFFSET-FETCH).
70
79
  const startRow = input.StartRow ?? 0;
71
80
  const maxRows = input.MaxRows;
72
- const canWrap =
81
+ const usePaging =
73
82
  maxRows != null &&
74
83
  Number.isInteger(maxRows) &&
75
84
  maxRows > 0 &&
76
85
  Number.isInteger(startRow) &&
77
- startRow >= 0 &&
78
- !/^\s*WITH\b/i.test(input.SQL);
79
- const executableSql = canWrap
80
- ? `SELECT TOP ${startRow + maxRows} * FROM (\n${input.SQL}\n) AS _adhoc_capped`
81
- : input.SQL;
86
+ startRow > 0;
87
+ let executableSql: string;
88
+ try {
89
+ const rendered = RenderPipeline.Run(input.SQL, {
90
+ Platform: platform,
91
+ ContextUser: contextUser,
92
+ ...(usePaging
93
+ ? { Paging: { StartRow: startRow, MaxRows: maxRows! } }
94
+ : maxRows != null && maxRows > 0
95
+ ? { MaxRows: maxRows }
96
+ : {}),
97
+ });
98
+ executableSql = rendered.FinalSQL;
99
+ } catch (renderErr) {
100
+ const renderMsg = renderErr instanceof Error ? renderErr.message : String(renderErr);
101
+ return this.buildErrorResult(`Ad-hoc query rendering failed: ${renderMsg}`);
102
+ }
82
103
 
83
- // 4. Execute with timeout
104
+ // 5. Execute with timeout
84
105
  const timeoutMs = (input.TimeoutSeconds ?? 30) * 1000;
85
106
  const request = new sql.Request(readOnlyDS);
86
107
 
@@ -92,23 +113,16 @@ export class AdhocQueryResolver extends ResolverBase {
92
113
  ]);
93
114
  const executionTimeMs = Date.now() - startTime;
94
115
 
95
- // 5. Apply in-memory pagination. With the wrap applied this is a no-op for
96
- // first-page reads; for StartRow > 0 (or CTE-headed SQL where the wrap was
97
- // skipped) it carves out the requested page.
98
- const fullRecordset = result.recordset ?? [];
99
- const totalRowCount = fullRecordset.length;
100
- let paginated = fullRecordset;
101
- if (startRow > 0) paginated = paginated.slice(startRow);
102
- if (maxRows != null && maxRows > 0) paginated = paginated.slice(0, maxRows);
103
-
104
116
  // 6. Return as RunQueryResultType
117
+ const recordset = result.recordset ?? [];
118
+
105
119
  return {
106
120
  QueryID: '',
107
121
  QueryName: 'Ad-Hoc Query',
108
122
  Success: true,
109
- Results: JSON.stringify(paginated),
110
- RowCount: paginated.length,
111
- TotalRowCount: totalRowCount,
123
+ Results: JSON.stringify(recordset),
124
+ RowCount: recordset.length,
125
+ TotalRowCount: recordset.length,
112
126
  PageNumber: maxRows != null && maxRows > 0 ? Math.floor(startRow / maxRows) + 1 : undefined,
113
127
  PageSize: maxRows ?? undefined,
114
128
  ExecutionTime: executionTimeMs,
@@ -1,9 +1,10 @@
1
1
  import { Arg, Ctx, ObjectType, Query, Resolver, Field, Int, InputType } from 'type-graphql';
2
- import { RunQuery, QueryInfo, IRunQueryProvider, IMetadataProvider, RunQueryParams, LogError, RunQueryWithCacheCheckParams, RunQueriesWithCacheCheckResponse, RunQueryWithCacheCheckResult } from '@memberjunction/core';
2
+ import { RunQuery, IRunQueryProvider, IMetadataProvider, RunQueryParams, LogError, RunQueryWithCacheCheckParams, RunQueriesWithCacheCheckResponse, RunQueryWithCacheCheckResult } from '@memberjunction/core';
3
3
  import { AppContext } from '../types.js';
4
4
  import { RequireSystemUser } from '../directives/RequireSystemUser.js';
5
5
  import { GraphQLJSONObject } from 'graphql-type-json';
6
6
  import { Metadata } from '@memberjunction/core';
7
+ import { MJQueryEntity, QueryEngine } from '@memberjunction/core-entities';
7
8
  import { GetReadOnlyProvider } from '../util.js';
8
9
  import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
9
10
  import { ResolverBase } from '../generic/ResolverBase.js';
@@ -149,9 +150,9 @@ export class RunQueriesWithCacheCheckOutput {
149
150
 
150
151
  @Resolver()
151
152
  export class RunQueryResolver extends ResolverBase {
152
- private async findQuery(md: IMetadataProvider, QueryID: string, QueryName?: string, CategoryID?: string, CategoryPath?: string, refreshMetadataIfNotFound: boolean = false): Promise<QueryInfo | null> {
153
- // Filter queries based on provided criteria
154
- const queries = md.Queries.filter(q => {
153
+ private async findQuery(md: IMetadataProvider, QueryID: string, QueryName?: string, CategoryID?: string, CategoryPath?: string, refreshMetadataIfNotFound: boolean = false): Promise<MJQueryEntity | null> {
154
+ const qe = QueryEngine.Instance;
155
+ const queries = qe.Queries.filter(q => {
155
156
  if (QueryID) {
156
157
  return q.ID.trim().toLowerCase() === QueryID.trim().toLowerCase();
157
158
  } else if (QueryName) {
@@ -169,8 +170,8 @@ export class RunQueryResolver extends ResolverBase {
169
170
 
170
171
  if (queries.length === 0) {
171
172
  if (refreshMetadataIfNotFound) {
172
- // If we didn't find the query, refresh metadata and try again
173
- await md.Refresh();
173
+ // If we didn't find the query, force-refresh QueryEngine and retry
174
+ await QueryEngine.Instance.Config(true);
174
175
  return this.findQuery(md, QueryID, QueryName, CategoryID, CategoryPath, false); // change the refresh flag to false so we don't loop infinitely
175
176
  }
176
177
  else {
@@ -1,8 +1,8 @@
1
1
  import { Arg, Ctx, Field, InputType, Mutation, ObjectType, registerEnumType, Resolver, PubSub, PubSubEngine } from 'type-graphql';
2
2
  import { AppContext } from '../types.js';
3
- import { LogError, RunView, UserInfo, CompositeKey, DatabaseProviderBase, LogStatus, QueryFieldInfo, QueryParameterInfo, QueryEntityInfo, QueryPermissionInfo } from '@memberjunction/core';
3
+ import { LogError, UserInfo, CompositeKey, DatabaseProviderBase, LogStatus } from '@memberjunction/core';
4
4
  import { RequireSystemUser } from '../directives/RequireSystemUser.js';
5
- import { MJQueryCategoryEntity, MJQueryPermissionEntity } from '@memberjunction/core-entities';
5
+ import { MJQueryCategoryEntity, MJQueryPermissionEntity, MJQueryFieldEntity, MJQueryParameterEntity, MJQueryEntityEntity, QueryEngine } from '@memberjunction/core-entities';
6
6
  import { MJQueryResolver, MJQuery_, MJQueryField_, MJQueryParameter_, MJQueryEntity_, MJQueryPermission_ } from '../generated/generated.js';
7
7
  import { GetReadWriteProvider } from '../util.js';
8
8
  import { DeleteOptionsInput } from '../generic/DeleteOptionsInput.js';
@@ -301,12 +301,9 @@ export class MJQueryResolverExtended extends MJQueryResolver {
301
301
 
302
302
  if (input.Permissions && input.Permissions.length > 0) {
303
303
  await this.createPermissions(provider, input.Permissions, queryID, context.userPayload.userRecord);
304
- await record.RefreshRelatedMetadata(true); // force DB update since we just created new permissions
304
+
305
305
  }
306
306
 
307
- // Refresh metadata cache to include the newly created query
308
- // This ensures subsequent operations can find the query without additional DB calls
309
- await provider.Refresh();
310
307
 
311
308
  return this.buildSuccessResult(record);
312
309
  }
@@ -365,7 +362,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
365
362
  };
366
363
  }
367
364
 
368
- private mapFields(fields: QueryFieldInfo[]): MJQueryField_[] {
365
+ private mapFields(fields: MJQueryFieldEntity[]): MJQueryField_[] {
369
366
  return fields.map(f => ({
370
367
  ID: f.ID,
371
368
  QueryID: f.QueryID,
@@ -389,7 +386,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
389
386
  }) as MJQueryField_);
390
387
  }
391
388
 
392
- private mapParameters(params: QueryParameterInfo[]): MJQueryParameter_[] {
389
+ private mapParameters(params: MJQueryParameterEntity[]): MJQueryParameter_[] {
393
390
  return params.map(p => ({
394
391
  ID: p.ID,
395
392
  QueryID: p.QueryID,
@@ -408,7 +405,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
408
405
  }) as MJQueryParameter_);
409
406
  }
410
407
 
411
- private mapEntities(entities: QueryEntityInfo[]): MJQueryEntity_[] {
408
+ private mapEntities(entities: MJQueryEntityEntity[]): MJQueryEntity_[] {
412
409
  return entities.map(e => ({
413
410
  ID: e.ID,
414
411
  QueryID: e.QueryID,
@@ -422,7 +419,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
422
419
  }) as MJQueryEntity_);
423
420
  }
424
421
 
425
- private mapPermissions(permissions: QueryPermissionInfo[]): MJQueryPermission_[] {
422
+ private mapPermissions(permissions: MJQueryPermissionEntity[]): MJQueryPermission_[] {
426
423
  return permissions.map(p => ({
427
424
  ID: p.ID,
428
425
  QueryID: p.QueryID,
@@ -528,25 +525,14 @@ export class MJQueryResolverExtended extends MJQueryResolver {
528
525
 
529
526
  // Handle permissions update if provided
530
527
  if (input.Permissions !== undefined) {
531
- // Delete existing permissions
532
- const rv = new RunView();
533
- const existingPermissions = await rv.RunView<MJQueryPermissionEntity>({
534
- EntityName: 'MJ: Query Permissions',
535
- ExtraFilter: `QueryID='${queryID}'`,
536
- ResultType: 'entity_object'
537
- }, context.userPayload.userRecord);
538
-
539
- if (existingPermissions.Success && existingPermissions.Results) {
540
- for (const perm of existingPermissions.Results) {
541
- await perm.Delete();
542
- }
528
+ // Delete existing permissions (read from QueryEngine cache)
529
+ const existingPermissions = QueryEngine.Instance.GetQueryPermissions(queryID);
530
+ for (const perm of existingPermissions) {
531
+ await perm.Delete();
543
532
  }
544
533
 
545
534
  // Create new permissions
546
535
  await this.createPermissions(provider, input.Permissions, queryID, context.userPayload.userRecord);
547
-
548
- // Refresh the metadata to get updated permissions
549
- await queryEntity.RefreshRelatedMetadata(true);
550
536
  }
551
537
 
552
538
  return this.buildSuccessResult(queryEntity);
@@ -1298,13 +1298,13 @@ export class RunAIAgentResolver extends ResolverBase {
1298
1298
  // Reverse to get chronological order (oldest first)
1299
1299
  const details = detailsResult.Results.reverse();
1300
1300
 
1301
- // Get all message IDs for batch loading attachments
1301
+ // Get all message IDs for batch loading artifacts
1302
1302
  const messageIds = details.map(d => d.ID);
1303
1303
 
1304
- // Batch load all attachments for these messages
1305
- const attachmentsByDetailId = await attachmentService.GetAttachmentsBatch(messageIds, contextUser, provider);
1306
-
1307
- // Batch load input artifacts for these messages
1304
+ // Batch load input artifacts for these messages. Since the backfill migration
1305
+ // (V202605271400__Backfill_Attachment_Artifacts) converted all legacy
1306
+ // ConversationDetailAttachment rows to artifact pairs, the artifact junction
1307
+ // is the single source of truth — no separate attachment query needed.
1308
1308
  const inputArtifactsByDetailId = await this.loadInputArtifactsBatch(messageIds, contextUser, provider);
1309
1309
 
1310
1310
  // Build ChatMessage array with attachments and input artifacts
@@ -1312,69 +1312,9 @@ export class RunAIAgentResolver extends ResolverBase {
1312
1312
 
1313
1313
  for (const detail of details) {
1314
1314
  const role = this.mapDetailRoleToMessageRole(detail.Role);
1315
- const attachments = attachmentsByDetailId.get(detail.ID) || [];
1316
-
1317
- // Get attachment data with content URLs (handles both inline and FileID storage)
1318
- const attachmentDataPromises = attachments.map(att =>
1319
- attachmentService.GetAttachmentData(att, contextUser, provider)
1320
- );
1321
- const attachmentDataResults = await Promise.all(attachmentDataPromises);
1322
-
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.
1331
1315
  const validAttachments: AttachmentData[] = [];
1332
1316
 
1333
- for (const result of attachmentDataResults) {
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
- }
1341
- const mime = result.attachment.MimeType || '';
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;
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
- });
1375
- }
1376
-
1377
- // Get input artifacts for this message — same routing logic, plus ForceToolsOnly.
1317
+ // Get input artifacts for this message routing via RouteArtifact.
1378
1318
  const inputArtifacts = inputArtifactsByDetailId.get(detail.ID) || [];
1379
1319
  for (const artifactVersion of inputArtifacts) {
1380
1320
  const artifactMime = artifactVersion.MimeType || '';