@memberjunction/server 5.28.0 → 5.29.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/config.d.ts +151 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +15 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/generated/generated.d.ts +332 -4
  6. package/dist/generated/generated.d.ts.map +1 -1
  7. package/dist/generated/generated.js +1862 -24
  8. package/dist/generated/generated.js.map +1 -1
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/resolvers/FeedbackResolver.d.ts +150 -0
  14. package/dist/resolvers/FeedbackResolver.d.ts.map +1 -0
  15. package/dist/resolvers/FeedbackResolver.js +876 -0
  16. package/dist/resolvers/FeedbackResolver.js.map +1 -0
  17. package/dist/resolvers/FileResolver.d.ts +27 -0
  18. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  19. package/dist/resolvers/FileResolver.js +32 -3
  20. package/dist/resolvers/FileResolver.js.map +1 -1
  21. package/dist/resolvers/MCPResolver.d.ts +77 -0
  22. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  23. package/dist/resolvers/MCPResolver.js +300 -1
  24. package/dist/resolvers/MCPResolver.js.map +1 -1
  25. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  26. package/dist/resolvers/RunAIAgentResolver.js +87 -32
  27. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  28. package/package.json +68 -66
  29. package/src/config.ts +19 -0
  30. package/src/generated/generated.ts +1281 -21
  31. package/src/index.ts +1 -0
  32. package/src/resolvers/FeedbackResolver.ts +940 -0
  33. package/src/resolvers/FileResolver.ts +33 -4
  34. package/src/resolvers/MCPResolver.ts +297 -1
  35. package/src/resolvers/RunAIAgentResolver.ts +89 -32
@@ -368,9 +368,21 @@ export class FileResolver extends FileResolverBase {
368
368
  return { account, provider };
369
369
  }
370
370
 
371
+ /**
372
+ * Legacy file upload path — used by the `<mj-files-file-upload>` Angular component.
373
+ * Creates a File entity record in the database AND generates a pre-authenticated upload URL.
374
+ * The client then PUTs the file binary directly to that URL.
375
+ *
376
+ * Driver initialization: uses `buildUserContext()` (no storage account). The driver
377
+ * initializes from environment variables (e.g. STORAGE_AZURE_ACCOUNT_NAME, STORAGE_DROPBOX_ACCESS_TOKEN).
378
+ *
379
+ * Input: `ProviderID` identifies which storage provider to use.
380
+ * Returns: `{ File, UploadUrl, NameExists }` — the persisted File record, upload URL, and duplicate check.
381
+ *
382
+ * @see CreatePreAuthUploadUrl for the enterprise storage-account-based path (used by File Browser).
383
+ */
371
384
  @Mutation(() => CreateFilePayload)
372
385
  async CreateFile(@Arg('input', () => CreateMJFileInput) input: CreateMJFileInput, @Ctx() context: AppContext, @PubSub() pubSub: PubSubEngine) {
373
- // Check to see if there's already an object with that name
374
386
  const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
375
387
  const user = this.GetUserFromPayload(context.userPayload);
376
388
  const fileEntity = await provider.GetEntityObject<MJFileEntity>('MJ: Files', user);
@@ -389,12 +401,15 @@ export class FileResolver extends FileResolverBase {
389
401
 
390
402
  // Create the upload URL and get the record updates (provider key, content type, etc)
391
403
  const userContext = this.buildUserContext(context);
392
- const { updatedInput, UploadUrl } = await createUploadUrl(providerEntity, fileEntity, userContext);
404
+ const { updatedInput, UploadUrl } = await createUploadUrl(providerEntity, fileEntity as unknown as { ID: string; Name: string; ProviderID: string; ContentType?: string; ProviderKey?: string }, userContext);
393
405
 
394
406
  // Save the file record with the updated input
395
407
  const mapper = new FieldMapper();
396
- fileEntity.SetMany(mapper.ReverseMapFields({ ...updatedInput }), true, true);
397
- await fileEntity.Save();
408
+ fileEntity.SetMany(mapper.ReverseMapFields({ ...updatedInput }), true, false);
409
+ const saved = await fileEntity.Save();
410
+ if (!saved) {
411
+ console.error('[CreateFile] File save failed:', fileEntity.LatestResult?.CompleteMessage);
412
+ }
398
413
  const File = mapper.MapFields({ ...fileEntity.GetAll() });
399
414
 
400
415
  return { File, UploadUrl, NameExists };
@@ -534,6 +549,20 @@ export class FileResolver extends FileResolverBase {
534
549
  return downloadUrl;
535
550
  }
536
551
 
552
+ /**
553
+ * Enterprise file upload path — used by the File Browser UI.
554
+ * Generates a pre-authenticated upload URL only (does NOT create a File entity record).
555
+ * The client handles the upload directly to the storage provider via the returned URL.
556
+ *
557
+ * Driver initialization: uses `buildExtendedUserContext()` with a FileStorageAccount entity.
558
+ * Credentials are loaded from the Credential Engine (encrypted in the database), with
559
+ * automatic token refresh for OAuth providers like Dropbox and Box.com.
560
+ *
561
+ * Input: `AccountID` identifies which storage account (and its linked provider/credentials) to use.
562
+ * Returns: `{ UploadUrl, ProviderKey }` — the pre-authenticated URL and optional provider key.
563
+ *
564
+ * @see CreateFile for the legacy path that also creates a File entity record.
565
+ */
537
566
  @Mutation(() => CreatePreAuthUploadUrlPayload)
538
567
  async CreatePreAuthUploadUrl(@Arg('input', () => CreatePreAuthUploadUrlInput) input: CreatePreAuthUploadUrlInput, @Ctx() context: AppContext) {
539
568
  const md = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
@@ -5,7 +5,7 @@
5
5
  * including tool synchronization with progress streaming and OAuth management.
6
6
  */
7
7
 
8
- import { Resolver, Mutation, Query, Subscription, Arg, Ctx, Root, Field, ObjectType, InputType, PubSub, registerEnumType } from 'type-graphql';
8
+ import { Resolver, Mutation, Query, Subscription, Arg, Ctx, Root, Field, Int, ObjectType, InputType, PubSub, registerEnumType } from 'type-graphql';
9
9
  import { PubSubEngine } from 'type-graphql';
10
10
  import { LogError, LogStatus, UserInfo, Metadata, RunView } from '@memberjunction/core';
11
11
  import {
@@ -455,6 +455,96 @@ export class MCPOAuthEventNotification {
455
455
  RequiresReauthorization?: boolean;
456
456
  }
457
457
 
458
+ /**
459
+ * Summary of an MCP Server Tool record — only the fields needed for
460
+ * paginated card/list display. Heavy fields (InputSchema, OutputSchema,
461
+ * Annotations) are intentionally excluded to keep responses small.
462
+ */
463
+ @ObjectType()
464
+ export class MCPToolSummary {
465
+ @Field()
466
+ ID: string;
467
+
468
+ @Field()
469
+ MCPServerID: string;
470
+
471
+ @Field()
472
+ ToolName: string;
473
+
474
+ @Field({ nullable: true })
475
+ ToolTitle?: string;
476
+
477
+ @Field({ nullable: true })
478
+ ToolDescription?: string;
479
+
480
+ @Field()
481
+ Status: string;
482
+
483
+ /**
484
+ * MCP Server display name (from the vwMCPServerTools view — column name is "MCPServer").
485
+ */
486
+ @Field({ nullable: true })
487
+ ServerName?: string;
488
+ }
489
+
490
+ /**
491
+ * Paginated result for MCPToolSummary listings.
492
+ */
493
+ @ObjectType()
494
+ export class MCPToolPageResult {
495
+ @Field(() => [MCPToolSummary])
496
+ items: MCPToolSummary[];
497
+
498
+ @Field(() => Int)
499
+ totalCount: number;
500
+
501
+ @Field()
502
+ hasMore: boolean;
503
+ }
504
+
505
+ /**
506
+ * Count-per-server aggregate.
507
+ */
508
+ @ObjectType()
509
+ export class MCPToolServerCount {
510
+ @Field()
511
+ serverID: string;
512
+
513
+ @Field()
514
+ serverName: string;
515
+
516
+ @Field(() => Int)
517
+ count: number;
518
+ }
519
+
520
+ /**
521
+ * Count-per-category aggregate. Category is derived from the ToolName
522
+ * prefix (substring before first underscore).
523
+ */
524
+ @ObjectType()
525
+ export class MCPToolCategoryCount {
526
+ @Field()
527
+ category: string;
528
+
529
+ @Field(() => Int)
530
+ count: number;
531
+ }
532
+
533
+ /**
534
+ * Top-level counts payload for MCP tool group/badge displays.
535
+ */
536
+ @ObjectType()
537
+ export class MCPToolCountsResult {
538
+ @Field(() => Int)
539
+ totalCount: number;
540
+
541
+ @Field(() => [MCPToolServerCount])
542
+ countByServer: MCPToolServerCount[];
543
+
544
+ @Field(() => [MCPToolCategoryCount])
545
+ countByCategory: MCPToolCategoryCount[];
546
+ }
547
+
458
548
  /**
459
549
  * MCP Resolver for GraphQL operations
460
550
  */
@@ -1325,6 +1415,212 @@ export class MCPResolver extends ResolverBase {
1325
1415
  };
1326
1416
  }
1327
1417
 
1418
+ /**
1419
+ * Returns a paginated list of MCP tool summaries filtered by optional
1420
+ * server, category, and free-text search. Uses narrow Fields + simple
1421
+ * ResultType to keep payload sizes manageable at thousands of tools.
1422
+ *
1423
+ * @param skip Number of rows to skip (for pagination)
1424
+ * @param take Page size
1425
+ * @param searchText Free-text filter against ToolName, ToolTitle, ToolDescription
1426
+ * @param serverID Optional MCPServerID filter
1427
+ * @param category Optional category filter (ToolName prefix before first underscore)
1428
+ * @param ctx GraphQL context
1429
+ */
1430
+ @Query(() => MCPToolPageResult)
1431
+ async GetMCPToolsPage(
1432
+ @Arg('skip', () => Int) skip: number,
1433
+ @Arg('take', () => Int) take: number,
1434
+ @Arg('searchText', { nullable: true }) searchText: string | null,
1435
+ @Arg('serverID', { nullable: true }) serverID: string | null,
1436
+ @Arg('category', { nullable: true }) category: string | null,
1437
+ @Ctx() ctx: AppContext
1438
+ ): Promise<MCPToolPageResult> {
1439
+ const user = ctx.userPayload.userRecord;
1440
+ if (!user) {
1441
+ return { items: [], totalCount: 0, hasMore: false };
1442
+ }
1443
+
1444
+ try {
1445
+ const extraFilter = this.buildMCPToolsFilter(searchText, serverID, category);
1446
+ const rv = new RunView();
1447
+
1448
+ const result = await rv.RunView<{
1449
+ ID: string;
1450
+ MCPServerID: string;
1451
+ ToolName: string;
1452
+ ToolTitle: string | null;
1453
+ ToolDescription: string | null;
1454
+ Status: string;
1455
+ MCPServer: string | null;
1456
+ }>({
1457
+ EntityName: 'MJ: MCP Server Tools',
1458
+ ExtraFilter: extraFilter,
1459
+ OrderBy: 'ToolName ASC',
1460
+ Fields: ['ID', 'MCPServerID', 'ToolName', 'ToolTitle', 'ToolDescription', 'Status', 'MCPServer'],
1461
+ StartRow: skip,
1462
+ MaxRows: take,
1463
+ IgnoreMaxRows: false,
1464
+ ResultType: 'simple'
1465
+ }, user);
1466
+
1467
+ if (!result.Success) {
1468
+ LogError(`MCPResolver: GetMCPToolsPage failed: ${result.ErrorMessage}`);
1469
+ return { items: [], totalCount: 0, hasMore: false };
1470
+ }
1471
+
1472
+ const rows = result.Results || [];
1473
+ // RunView's TotalRowCount can be capped when MaxRows is applied. Do an explicit
1474
+ // count query (ignoring MaxRows) so the client sees the true filtered match total.
1475
+ let totalCount = result.TotalRowCount ?? rows.length;
1476
+ try {
1477
+ const countResult = await rv.RunView<{ ID: string }>({
1478
+ EntityName: 'MJ: MCP Server Tools',
1479
+ ExtraFilter: extraFilter,
1480
+ Fields: ['ID'],
1481
+ IgnoreMaxRows: true,
1482
+ ResultType: 'simple'
1483
+ }, user);
1484
+ if (countResult.Success) {
1485
+ totalCount = countResult.Results.length;
1486
+ }
1487
+ } catch {
1488
+ // fall back to the initial totalCount on any error
1489
+ }
1490
+ const items: MCPToolSummary[] = rows.map(r => ({
1491
+ ID: r.ID,
1492
+ MCPServerID: r.MCPServerID,
1493
+ ToolName: r.ToolName,
1494
+ ToolTitle: r.ToolTitle ?? undefined,
1495
+ ToolDescription: r.ToolDescription ?? undefined,
1496
+ Status: r.Status,
1497
+ ServerName: r.MCPServer ?? undefined
1498
+ }));
1499
+
1500
+ return {
1501
+ items,
1502
+ totalCount,
1503
+ hasMore: skip + items.length < totalCount
1504
+ };
1505
+ } catch (error) {
1506
+ const errorMsg = error instanceof Error ? error.message : String(error);
1507
+ LogError(`MCPResolver: GetMCPToolsPage exception: ${errorMsg}`);
1508
+ return { items: [], totalCount: 0, hasMore: false };
1509
+ }
1510
+ }
1511
+
1512
+ /**
1513
+ * Returns aggregate counts for MCP tools — total, per-server, and per-category
1514
+ * (category = ToolName prefix before the first underscore). Used to render
1515
+ * group headers with badges like "Zapier (1,247 tools)".
1516
+ *
1517
+ * @param serverID Optional MCPServerID filter (counts scoped to that server)
1518
+ * @param searchText Optional free-text filter (same semantics as GetMCPToolsPage)
1519
+ * @param ctx GraphQL context
1520
+ */
1521
+ @Query(() => MCPToolCountsResult)
1522
+ async GetMCPToolCounts(
1523
+ @Arg('serverID', { nullable: true }) serverID: string | null,
1524
+ @Arg('searchText', { nullable: true }) searchText: string | null,
1525
+ @Ctx() ctx: AppContext
1526
+ ): Promise<MCPToolCountsResult> {
1527
+ const user = ctx.userPayload.userRecord;
1528
+ if (!user) {
1529
+ return { totalCount: 0, countByServer: [], countByCategory: [] };
1530
+ }
1531
+
1532
+ try {
1533
+ // Build filter WITHOUT the category constraint — counts need the full
1534
+ // matching set so we can bucket by server and by category ourselves.
1535
+ const extraFilter = this.buildMCPToolsFilter(searchText, serverID, null);
1536
+ const rv = new RunView();
1537
+
1538
+ // Fetch minimal fields for ALL matching rows. IgnoreMaxRows so we
1539
+ // don't silently truncate at entity caps when counting.
1540
+ const result = await rv.RunView<{
1541
+ MCPServerID: string;
1542
+ ToolName: string;
1543
+ MCPServer: string | null;
1544
+ }>({
1545
+ EntityName: 'MJ: MCP Server Tools',
1546
+ ExtraFilter: extraFilter,
1547
+ Fields: ['MCPServerID', 'ToolName', 'MCPServer'],
1548
+ IgnoreMaxRows: true,
1549
+ ResultType: 'simple'
1550
+ }, user);
1551
+
1552
+ if (!result.Success) {
1553
+ LogError(`MCPResolver: GetMCPToolCounts failed: ${result.ErrorMessage}`);
1554
+ return { totalCount: 0, countByServer: [], countByCategory: [] };
1555
+ }
1556
+
1557
+ const rows = result.Results || [];
1558
+ const totalCount = rows.length;
1559
+
1560
+ // Bucket by server
1561
+ const serverMap = new Map<string, { serverID: string; serverName: string; count: number }>();
1562
+ // Bucket by category (ToolName prefix before first underscore)
1563
+ const categoryMap = new Map<string, number>();
1564
+
1565
+ for (const r of rows) {
1566
+ const sid = r.MCPServerID;
1567
+ const sname = r.MCPServer ?? '';
1568
+ const existing = serverMap.get(sid);
1569
+ if (existing) {
1570
+ existing.count++;
1571
+ } else {
1572
+ serverMap.set(sid, { serverID: sid, serverName: sname, count: 1 });
1573
+ }
1574
+
1575
+ const underscore = r.ToolName.indexOf('_');
1576
+ const cat = underscore > 0 ? r.ToolName.substring(0, underscore) : r.ToolName;
1577
+ categoryMap.set(cat, (categoryMap.get(cat) ?? 0) + 1);
1578
+ }
1579
+
1580
+ const countByServer = Array.from(serverMap.values()).sort((a, b) => b.count - a.count);
1581
+ const countByCategory = Array.from(categoryMap.entries())
1582
+ .map(([category, count]) => ({ category, count }))
1583
+ .sort((a, b) => b.count - a.count);
1584
+
1585
+ return { totalCount, countByServer, countByCategory };
1586
+ } catch (error) {
1587
+ const errorMsg = error instanceof Error ? error.message : String(error);
1588
+ LogError(`MCPResolver: GetMCPToolCounts exception: ${errorMsg}`);
1589
+ return { totalCount: 0, countByServer: [], countByCategory: [] };
1590
+ }
1591
+ }
1592
+
1593
+ /**
1594
+ * Builds the ExtraFilter string for MCP Server Tools queries. All user
1595
+ * inputs are single-quote-escaped to prevent SQL injection.
1596
+ */
1597
+ private buildMCPToolsFilter(
1598
+ searchText: string | null | undefined,
1599
+ serverID: string | null | undefined,
1600
+ category: string | null | undefined
1601
+ ): string {
1602
+ const clauses: string[] = [];
1603
+
1604
+ if (serverID && serverID.trim().length > 0) {
1605
+ const safe = serverID.replace(/'/g, "''");
1606
+ clauses.push(`MCPServerID='${safe}'`);
1607
+ }
1608
+
1609
+ if (category && category.trim().length > 0) {
1610
+ const safe = category.replace(/'/g, "''");
1611
+ clauses.push(`ToolName LIKE '${safe}\\_%' ESCAPE '\\'`);
1612
+ }
1613
+
1614
+ if (searchText && searchText.trim().length > 0) {
1615
+ const safe = searchText.replace(/'/g, "''");
1616
+ clauses.push(
1617
+ `(ToolName LIKE '%${safe}%' OR ToolTitle LIKE '%${safe}%' OR ToolDescription LIKE '%${safe}%')`
1618
+ );
1619
+ }
1620
+
1621
+ return clauses.join(' AND ');
1622
+ }
1623
+
1328
1624
  /**
1329
1625
  * Gets the public URL for OAuth callbacks
1330
1626
  */
@@ -2,7 +2,7 @@ import { Resolver, Mutation, Query, Arg, Ctx, ObjectType, Field, PubSub, PubSubE
2
2
  import { AppContext, UserPayload } from '../types.js';
3
3
  import { DatabaseProviderBase, LogError, LogStatus, Metadata, RunView, UserInfo, IMetadataProvider } from '@memberjunction/core';
4
4
  import { MJConversationDetailEntity, MJConversationDetailAttachmentEntity, MJConversationDetailArtifactEntity, MJArtifactVersionEntity, MJAIAgentRequestEntity } from '@memberjunction/core-entities';
5
- import { AgentRunner } from '@memberjunction/ai-agents';
5
+ import { AgentRunner, ArtifactToolManager } from '@memberjunction/ai-agents';
6
6
  import { MJAIAgentEntityExtended, MJAIAgentRunEntityExtended, ExecuteAgentResult, ConversationUtility, AttachmentData } from '@memberjunction/ai-core-plus';
7
7
  import { AIEngine } from '@memberjunction/aiengine';
8
8
  import { ChatMessage, ChatMessageContent } from '@memberjunction/ai';
@@ -1310,43 +1310,100 @@ export class RunAIAgentResolver extends ResolverBase {
1310
1310
  );
1311
1311
  const attachmentDataResults = await Promise.all(attachmentDataPromises);
1312
1312
 
1313
- // Filter out nulls and convert to AttachmentData format
1314
- const validAttachments: AttachmentData[] = attachmentDataResults
1315
- .filter((result): result is NonNullable<typeof result> => result !== null)
1316
- .map(result => ({
1317
- type: ConversationUtility.GetAttachmentTypeFromMime(result.attachment.MimeType),
1318
- mimeType: result.attachment.MimeType,
1319
- fileName: result.attachment.FileName ?? undefined,
1320
- sizeBytes: result.attachment.FileSizeBytes ?? undefined,
1321
- width: result.attachment.Width ?? undefined,
1322
- height: result.attachment.Height ?? undefined,
1323
- durationSeconds: result.attachment.DurationSeconds ?? undefined,
1324
- content: result.contentUrl
1325
- }));
1326
-
1327
- // Get input artifacts for this message and convert to AttachmentData
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
+
1326
+ const validAttachments: AttachmentData[] = [];
1327
+
1328
+ for (const result of attachmentDataResults) {
1329
+ if (!result) continue;
1330
+ 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
+ });
1350
+ }
1351
+ }
1352
+
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.
1328
1357
  const inputArtifacts = inputArtifactsByDetailId.get(detail.ID) || [];
1329
1358
  for (const artifactVersion of inputArtifacts) {
1359
+ const artifactMime = artifactVersion.MimeType || '';
1360
+ const isArtifactToolHandled = ARTIFACT_TOOL_MIME_PREFIXES.some(
1361
+ prefix => artifactMime.startsWith(prefix)
1362
+ );
1363
+
1330
1364
  if (artifactVersion.ContentMode === 'File' && artifactVersion.FileID) {
1331
- // File-backed artifact — download content and treat like a document attachment
1332
- const fileContent = await this.downloadArtifactFileContent(artifactVersion, contextUser, provider);
1333
- if (fileContent) {
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
+ });
1383
+ }
1384
+ }
1385
+ } else if (artifactVersion.Content) {
1386
+ // Text artifact — use BuildInlinePreview for large DataSnapshots
1387
+ // to preserve table structure instead of raw substring truncation.
1388
+ const textContent = artifactVersion.Content;
1389
+ const MAX_INLINE_ARTIFACT_CHARS = 10_000;
1390
+
1391
+ if (textContent.length > MAX_INLINE_ARTIFACT_CHARS) {
1392
+ const preview = ArtifactToolManager.BuildInlinePreview(textContent, 5);
1393
+ validAttachments.push({
1394
+ type: 'Document' as AttachmentData['type'],
1395
+ mimeType: 'text/plain',
1396
+ fileName: undefined,
1397
+ content: `[Artifact: ${artifactVersion.Name || 'Untitled'} — ${textContent.length.toLocaleString()} chars, accessible via artifact tools]\n\nPreview (first 5 rows per table):\n${preview}`
1398
+ });
1399
+ } else {
1334
1400
  validAttachments.push({
1335
- type: ConversationUtility.GetAttachmentTypeFromMime(artifactVersion.MimeType || ''),
1336
- mimeType: artifactVersion.MimeType || 'application/octet-stream',
1337
- fileName: artifactVersion.FileName || artifactVersion.Name || undefined,
1338
- sizeBytes: artifactVersion.ContentSizeBytes || undefined,
1339
- content: fileContent
1401
+ type: 'Document' as AttachmentData['type'],
1402
+ mimeType: 'text/plain',
1403
+ fileName: artifactVersion.Name || 'artifact.txt',
1404
+ content: `[Artifact: ${artifactVersion.Name || 'Untitled'}]\n\n${textContent}`
1340
1405
  });
1341
1406
  }
1342
- } else if (artifactVersion.Content) {
1343
- // Text artifact — include content directly as a text attachment
1344
- validAttachments.push({
1345
- type: 'Document' as AttachmentData['type'],
1346
- mimeType: 'text/plain',
1347
- fileName: artifactVersion.Name || 'artifact.txt',
1348
- content: `[Artifact: ${artifactVersion.Name || 'Untitled'}]\n\n${artifactVersion.Content}`
1349
- });
1350
1407
  }
1351
1408
  }
1352
1409