@librechat/agents 3.3.7 → 3.3.8

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 (42) hide show
  1. package/dist/cjs/graphs/MultiAgentGraph.cjs +21 -4
  2. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  3. package/dist/cjs/messages/format.cjs +124 -15
  4. package/dist/cjs/messages/format.cjs.map +1 -1
  5. package/dist/cjs/messages/injected.cjs +10 -1
  6. package/dist/cjs/messages/injected.cjs.map +1 -1
  7. package/dist/cjs/prompts/activityLabel.cjs +29 -1
  8. package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
  9. package/dist/cjs/run.cjs +7 -2
  10. package/dist/cjs/run.cjs.map +1 -1
  11. package/dist/cjs/summarization/node.cjs +55 -0
  12. package/dist/cjs/summarization/node.cjs.map +1 -1
  13. package/dist/esm/graphs/MultiAgentGraph.mjs +21 -4
  14. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  15. package/dist/esm/messages/format.mjs +124 -15
  16. package/dist/esm/messages/format.mjs.map +1 -1
  17. package/dist/esm/messages/injected.mjs +10 -1
  18. package/dist/esm/messages/injected.mjs.map +1 -1
  19. package/dist/esm/prompts/activityLabel.mjs +29 -1
  20. package/dist/esm/prompts/activityLabel.mjs.map +1 -1
  21. package/dist/esm/run.mjs +7 -2
  22. package/dist/esm/run.mjs.map +1 -1
  23. package/dist/esm/summarization/node.mjs +55 -0
  24. package/dist/esm/summarization/node.mjs.map +1 -1
  25. package/dist/types/messages/format.d.ts +9 -8
  26. package/dist/types/prompts/activityLabel.d.ts +8 -1
  27. package/dist/types/run.d.ts +1 -1
  28. package/dist/types/types/activityLabel.d.ts +8 -0
  29. package/dist/types/types/stream.d.ts +19 -0
  30. package/package.json +1 -1
  31. package/src/graphs/MultiAgentGraph.ts +18 -4
  32. package/src/messages/format.ts +222 -50
  33. package/src/messages/formatAgentMessages.test.ts +308 -6
  34. package/src/messages/injected.test.ts +18 -1
  35. package/src/messages/injected.ts +8 -1
  36. package/src/prompts/activityLabel.ts +48 -0
  37. package/src/run.ts +10 -1
  38. package/src/specs/activity-label-prompt.test.ts +93 -0
  39. package/src/summarization/__tests__/node.test.ts +188 -0
  40. package/src/summarization/node.ts +67 -0
  41. package/src/types/activityLabel.ts +8 -0
  42. package/src/types/stream.ts +20 -0
@@ -20,6 +20,7 @@ import type {
20
20
  MessageContentComplex,
21
21
  ReasoningContentText,
22
22
  SummaryContentBlock,
23
+ SummaryCoverage,
23
24
  ThinkingContentText,
24
25
  ToolCallContent,
25
26
  ToolResultContent,
@@ -1185,20 +1186,73 @@ function extractToolNamesFromSearchOutput(output: string): string[] {
1185
1186
  return [];
1186
1187
  }
1187
1188
 
1188
- type SummaryBoundary = {
1189
- messageIndex: number;
1190
- contentIndex: number;
1191
- text: string;
1192
- tokenCount: number;
1189
+ /**
1190
+ * How far back a persisted summary reaches.
1191
+ *
1192
+ * `coverage` is authoritative: the block named the first source message that
1193
+ * compaction retained, so `messageIndex` is exclusive — everything before it is
1194
+ * covered and it survives whole. `positional` is the legacy reading for blocks
1195
+ * written before coverage existed (or whose anchor is no longer in the payload)
1196
+ * — the block's own location is the boundary, which is why it cannot
1197
+ * distinguish a retained tail from covered history.
1198
+ */
1199
+ type SummaryBoundary =
1200
+ | {
1201
+ mode: 'coverage';
1202
+ messageIndex: number;
1203
+ text: string;
1204
+ tokenCount: number;
1205
+ }
1206
+ | {
1207
+ mode: 'positional';
1208
+ messageIndex: number;
1209
+ contentIndex: number;
1210
+ text: string;
1211
+ tokenCount: number;
1212
+ };
1213
+
1214
+ type SummaryTokenAdjustment = {
1215
+ original: number;
1216
+ adjusted: number;
1217
+ remainingChars: number;
1218
+ totalChars: number;
1193
1219
  };
1194
1220
 
1195
- function getLatestSummaryBoundary(
1196
- payload: TPayload
1197
- ): SummaryBoundary | undefined {
1198
- let summaryBoundary: SummaryBoundary | undefined;
1221
+ type SummaryScan = {
1222
+ boundary?: SummaryBoundary;
1223
+ };
1224
+
1225
+ function resolveCoverageIndex(
1226
+ coverage: SummaryCoverage | undefined,
1227
+ indexBySourceId: Map<string, number>,
1228
+ summaryMessageIndex: number
1229
+ ): number | undefined {
1230
+ /** Persisted JSON, so the declared string type is not a runtime guarantee. */
1231
+ if (typeof coverage?.retainedFromMessageId !== 'string') {
1232
+ return undefined;
1233
+ }
1234
+ const retainedFromMessageId = coverage.retainedFromMessageId.trim();
1235
+ if (retainedFromMessageId === '') {
1236
+ return undefined;
1237
+ }
1238
+ const retainedIndex = indexBySourceId.get(retainedFromMessageId);
1239
+ return retainedIndex != null && retainedIndex <= summaryMessageIndex
1240
+ ? retainedIndex
1241
+ : undefined;
1242
+ }
1243
+
1244
+ function scanSummaryBlocks(payload: TPayload): SummaryScan {
1245
+ let boundary: SummaryBoundary | undefined;
1246
+ /** Filled as the scan advances, so a coverage lookup only ever resolves to a
1247
+ * message already passed — no second pass over the payload. */
1248
+ const indexBySourceId = new Map<string, number>();
1199
1249
 
1200
1250
  for (let i = 0; i < payload.length; i++) {
1201
1251
  const message = payload[i];
1252
+ const sourceMessageId = getSourceMessageId(message);
1253
+ if (sourceMessageId != null) {
1254
+ indexBySourceId.set(sourceMessageId, i);
1255
+ }
1202
1256
  if (!Array.isArray(message.content)) {
1203
1257
  continue;
1204
1258
  }
@@ -1230,20 +1284,37 @@ function getLatestSummaryBoundary(
1230
1284
  continue;
1231
1285
  }
1232
1286
 
1233
- summaryBoundary = {
1234
- messageIndex: i,
1235
- contentIndex: j,
1236
- text: summaryText,
1237
- tokenCount:
1238
- typeof summaryPart.tokenCount === 'number' &&
1239
- Number.isFinite(summaryPart.tokenCount)
1240
- ? summaryPart.tokenCount
1241
- : 0,
1242
- };
1287
+ const tokenCount =
1288
+ typeof summaryPart.tokenCount === 'number' &&
1289
+ Number.isFinite(summaryPart.tokenCount)
1290
+ ? summaryPart.tokenCount
1291
+ : 0;
1292
+
1293
+ const retainedIndex = resolveCoverageIndex(
1294
+ summaryPart.coverage,
1295
+ indexBySourceId,
1296
+ i
1297
+ );
1298
+
1299
+ boundary =
1300
+ retainedIndex != null
1301
+ ? {
1302
+ mode: 'coverage',
1303
+ messageIndex: retainedIndex,
1304
+ text: summaryText,
1305
+ tokenCount,
1306
+ }
1307
+ : {
1308
+ mode: 'positional',
1309
+ messageIndex: i,
1310
+ contentIndex: j,
1311
+ text: summaryText,
1312
+ tokenCount,
1313
+ };
1243
1314
  }
1244
1315
  }
1245
1316
 
1246
- return summaryBoundary;
1317
+ return { boundary };
1247
1318
  }
1248
1319
 
1249
1320
  function applySummaryBoundary(
@@ -1255,6 +1326,14 @@ function applySummaryBoundary(
1255
1326
  return message;
1256
1327
  }
1257
1328
 
1329
+ /** The boundary names the first retained message, so it is exclusive: that
1330
+ * message and everything after it — the recency tail included — stays
1331
+ * verbatim, and only genuinely covered history is dropped. Summary parts on
1332
+ * surviving messages are filtered later by `formatAssistantMessage`. */
1333
+ if (summaryBoundary.mode === 'coverage') {
1334
+ return messageIndex < summaryBoundary.messageIndex ? null : message;
1335
+ }
1336
+
1258
1337
  if (messageIndex < summaryBoundary.messageIndex) {
1259
1338
  return null;
1260
1339
  }
@@ -1272,6 +1351,61 @@ function applySummaryBoundary(
1272
1351
  };
1273
1352
  }
1274
1353
 
1354
+ /**
1355
+ * Whether `formatAssistantMessage` filters this part out of the emitted message.
1356
+ * Such a part contributes no prompt tokens, so measuring it as zero characters
1357
+ * is accurate — it must not be mistaken for content the heuristic cannot see.
1358
+ */
1359
+ function isDroppedByFormatting(
1360
+ part: MessageContentComplex | undefined
1361
+ ): boolean {
1362
+ if (part == null) {
1363
+ return true;
1364
+ }
1365
+ if (
1366
+ part.type === ContentTypes.SUMMARY ||
1367
+ part.type === ContentTypes.ERROR ||
1368
+ part.type === ContentTypes.AGENT_UPDATE ||
1369
+ part.type === ContentTypes.ACTIVITY_LABEL
1370
+ ) {
1371
+ return true;
1372
+ }
1373
+ return part.type === ContentTypes.TEXT && getTextContent(part).trim() === '';
1374
+ }
1375
+
1376
+ function measureValueChars(value: unknown): number {
1377
+ if (typeof value === 'string') {
1378
+ return value.length;
1379
+ }
1380
+ if (value == null || typeof value !== 'object') {
1381
+ return 0;
1382
+ }
1383
+ const measured = serializeStructuredValueBounded(value, 0).originalChars;
1384
+ return measured === Number.MAX_SAFE_INTEGER
1385
+ ? HARD_MAX_TOOL_RESULT_CHARS
1386
+ : Math.min(measured, HARD_MAX_TOOL_RESULT_CHARS);
1387
+ }
1388
+
1389
+ /**
1390
+ * Whether a retained part's whole prompt cost is the text the char heuristic
1391
+ * reads, making it safe to represent in a character ratio.
1392
+ *
1393
+ * An allowlist, not a denylist. Media, resources, and tool calls carry cost that
1394
+ * is unrelated to their serialized length — a short image URL nested in
1395
+ * `tool_call.output` stands in for a fixed four-figure media charge — and
1396
+ * rejecting those case by case has repeatedly missed a nesting level. Listing
1397
+ * the two shapes whose characters `contentPartCharLength` actually reads makes
1398
+ * every other shape, present or future, ineligible by default: the ratio is
1399
+ * skipped and the entry keeps its original count, which prunes early rather than
1400
+ * exceeding the window.
1401
+ */
1402
+ function isCharRatioEligible(part: MessageContentComplex | undefined): boolean {
1403
+ if (part == null) {
1404
+ return false;
1405
+ }
1406
+ return part.type === ContentTypes.TEXT || part.type === ContentTypes.THINKING;
1407
+ }
1408
+
1275
1409
  function contentPartCharLength(part: MessageContentComplex): number {
1276
1410
  const record = part as Record<string, unknown>;
1277
1411
  let len = 0;
@@ -1281,15 +1415,15 @@ function contentPartCharLength(part: MessageContentComplex): number {
1281
1415
  if (typeof record.thinking === 'string') {
1282
1416
  len += record.thinking.length;
1283
1417
  }
1284
- const { input } = record;
1285
- if (typeof input === 'string') {
1286
- len += input.length;
1287
- } else if (input != null && typeof input === 'object') {
1288
- const measured = serializeStructuredValueBounded(input, 0).originalChars;
1289
- len +=
1290
- measured === Number.MAX_SAFE_INTEGER
1291
- ? HARD_MAX_TOOL_RESULT_CHARS
1292
- : Math.min(measured, HARD_MAX_TOOL_RESULT_CHARS);
1418
+ len += measureValueChars(record.input);
1419
+ /** Tool calls nest their payload a level down, so measuring only the
1420
+ * top-level fields scores an entire tool turn as zero characters. */
1421
+ const { tool_call: toolCall } = record;
1422
+ if (toolCall != null && typeof toolCall === 'object') {
1423
+ const call = toolCall as Record<string, unknown>;
1424
+ len += measureValueChars(call.name);
1425
+ len += measureValueChars(call.args);
1426
+ len += measureValueChars(call.output);
1293
1427
  }
1294
1428
  return len;
1295
1429
  }
@@ -1340,14 +1474,9 @@ export const formatAgentMessages = (
1340
1474
  /** Cross-run summary extracted from the payload. Should be forwarded to the
1341
1475
  * agent run so it can be included in the system message via AgentContext. */
1342
1476
  summary?: { text: string; tokenCount: number };
1343
- /** When a summary boundary sliced content from a message, the token count
1344
- * was proportionally reduced. Returned so the caller can log it. */
1345
- boundaryTokenAdjustment?: {
1346
- original: number;
1347
- adjusted: number;
1348
- remainingChars: number;
1349
- totalChars: number;
1350
- };
1477
+ /** When a positional summary boundary sliced content from a message, the token
1478
+ * count was proportionally reduced. Returned so the caller can log it. */
1479
+ boundaryTokenAdjustment?: SummaryTokenAdjustment;
1351
1480
  } => {
1352
1481
  const messages: Array<
1353
1482
  | RoleBearingMessage<HumanMessage>
@@ -1387,17 +1516,10 @@ export const formatAgentMessages = (
1387
1516
  };
1388
1517
  // If indexTokenCountMap is provided, create a new map to track the updated indices
1389
1518
  const updatedIndexTokenCountMap: Record<number, number> = {};
1390
- let boundaryTokenAdjustment:
1391
- | {
1392
- original: number;
1393
- adjusted: number;
1394
- remainingChars: number;
1395
- totalChars: number;
1396
- }
1397
- | undefined;
1519
+ let boundaryTokenAdjustment: SummaryTokenAdjustment | undefined;
1398
1520
  // Keep track of the mapping from original payload indices to result indices
1399
1521
  const indexMapping: Record<number, number[] | undefined> = {};
1400
- const summaryBoundary = getLatestSummaryBoundary(payload);
1522
+ const { boundary: summaryBoundary } = scanSummaryBlocks(payload);
1401
1523
 
1402
1524
  // Summary metadata is returned to the caller so it can be forwarded to the
1403
1525
  // agent run and included in the single system message via AgentContext.
@@ -1723,8 +1845,24 @@ export const formatAgentMessages = (
1723
1845
  continue;
1724
1846
  }
1725
1847
 
1848
+ /**
1849
+ * Coverage mode deliberately leaves the count alone, even though the entry
1850
+ * holding the block is charged for summary text that `formatAssistantMessage`
1851
+ * filters out and `summary.tokenCount` accounts separately.
1852
+ *
1853
+ * Discounting it needs the summary's cost in the same units as
1854
+ * `indexTokenCountMap`, and that figure is not obtainable here: this
1855
+ * function receives no tokenizer, and a count recorded at write time is in
1856
+ * the writing run's units — `Run.create` derives its counter from the model
1857
+ * in play, and a consumer may supply its own — so a conversation continued
1858
+ * on a different model would subtract across encodings. Attempts to proxy
1859
+ * it (character ratios, provider identity) all under-count some shape,
1860
+ * which risks an over-context request; over-counting merely prunes early.
1861
+ * Fixing it properly means passing the reader a tokenizer, which is a
1862
+ * consumer-facing change and out of scope here.
1863
+ */
1726
1864
  if (
1727
- summaryBoundary &&
1865
+ summaryBoundary?.mode === 'positional' &&
1728
1866
  originalIndex === summaryBoundary.messageIndex &&
1729
1867
  Array.isArray(payload[originalIndex].content)
1730
1868
  ) {
@@ -1734,14 +1872,48 @@ export const formatAgentMessages = (
1734
1872
  if (contentIndex >= 0 && contentIndex < content.length - 1) {
1735
1873
  let totalCharLen = 0;
1736
1874
  let remainingCharLen = 0;
1875
+ /**
1876
+ * The ratio applies only when *every* part of the entry is one whose
1877
+ * token cost tracks its character length. A single ineligible part
1878
+ * cancels the discount, whichever side of the boundary it sits on.
1879
+ *
1880
+ * Both sides can break it, in opposite directions. A retained image has
1881
+ * its fixed cost scaled away, collapsing the entry. A removed base64
1882
+ * payload inflates the denominator — serializing to a huge length while
1883
+ * the counter charges a fixed estimate — dragging retained text below
1884
+ * its real cost. Either way the request can exceed the window.
1885
+ *
1886
+ * Telling a text-bearing tool payload from a media-bearing one means
1887
+ * recursing into arbitrary nested output, which has already missed a
1888
+ * level twice here. Cancelling instead keeps the original count: an
1889
+ * over-count that prunes early rather than overflowing. Entries of
1890
+ * plain text and reasoning — the common shape — still proportion.
1891
+ */
1892
+ let everyRetainedPartMeasurable = true;
1737
1893
  for (let p = 0; p < content.length; p++) {
1738
- const charLen = contentPartCharLength(content[p]);
1894
+ const part = content[p];
1895
+ const retained = p > contentIndex;
1896
+
1897
+ if (isDroppedByFormatting(part)) {
1898
+ /** Removed summary text is real removed content: it is read as
1899
+ * plain text and belongs in the denominator. */
1900
+ if (!retained && part.type === ContentTypes.SUMMARY) {
1901
+ totalCharLen += contentPartCharLength(part);
1902
+ }
1903
+ continue;
1904
+ }
1905
+
1906
+ const charLen = contentPartCharLength(part);
1907
+ if (!isCharRatioEligible(part) || (retained && charLen === 0)) {
1908
+ everyRetainedPartMeasurable = false;
1909
+ break;
1910
+ }
1739
1911
  totalCharLen += charLen;
1740
- if (p > contentIndex) {
1912
+ if (retained) {
1741
1913
  remainingCharLen += charLen;
1742
1914
  }
1743
1915
  }
1744
- if (totalCharLen > 0) {
1916
+ if (totalCharLen > 0 && everyRetainedPartMeasurable) {
1745
1917
  const original = tokenCount;
1746
1918
  tokenCount = Math.max(
1747
1919
  1,