@librechat/agents 3.9.3 → 3.9.5

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 (125) hide show
  1. package/README.md +32 -0
  2. package/dist/cjs/common/enum.cjs +2 -0
  3. package/dist/cjs/common/enum.cjs.map +1 -1
  4. package/dist/cjs/events.cjs +11 -0
  5. package/dist/cjs/events.cjs.map +1 -1
  6. package/dist/cjs/graphs/Graph.cjs +41 -1
  7. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  8. package/dist/cjs/graphs/acceptedModelResponse.cjs +168 -0
  9. package/dist/cjs/graphs/acceptedModelResponse.cjs.map +1 -0
  10. package/dist/cjs/llm/invoke.cjs +10 -5
  11. package/dist/cjs/llm/invoke.cjs.map +1 -1
  12. package/dist/cjs/llm/streamLimits.cjs +1 -1
  13. package/dist/cjs/llm/streamLimits.cjs.map +1 -1
  14. package/dist/cjs/main.cjs +2 -0
  15. package/dist/cjs/messages/fading.cjs +14 -6
  16. package/dist/cjs/messages/fading.cjs.map +1 -1
  17. package/dist/cjs/messages/prune.cjs +95 -36
  18. package/dist/cjs/messages/prune.cjs.map +1 -1
  19. package/dist/cjs/openai/index.cjs +2 -0
  20. package/dist/cjs/openai/index.cjs.map +1 -1
  21. package/dist/cjs/openai/toolProjection.cjs +196 -0
  22. package/dist/cjs/openai/toolProjection.cjs.map +1 -0
  23. package/dist/cjs/run.cjs +8 -1
  24. package/dist/cjs/run.cjs.map +1 -1
  25. package/dist/cjs/session/AgentSession.cjs +1 -1
  26. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  27. package/dist/cjs/stream.cjs +9 -4
  28. package/dist/cjs/stream.cjs.map +1 -1
  29. package/dist/cjs/tools/ArtifactTruncation.cjs +45 -0
  30. package/dist/cjs/tools/ArtifactTruncation.cjs.map +1 -0
  31. package/dist/cjs/tools/BashExecutor.cjs +6 -1
  32. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  33. package/dist/cjs/tools/CodeExecutor.cjs +6 -1
  34. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  35. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +5 -1
  36. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  37. package/dist/cjs/tools/ToolNode.cjs +2 -1
  38. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  39. package/dist/cjs/tools/subagent/SubagentReplay.cjs +4 -1
  40. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -1
  41. package/dist/cjs/utils/acceptedToolArguments.cjs +143 -0
  42. package/dist/cjs/utils/acceptedToolArguments.cjs.map +1 -0
  43. package/dist/esm/common/enum.mjs +2 -0
  44. package/dist/esm/common/enum.mjs.map +1 -1
  45. package/dist/esm/events.mjs +11 -0
  46. package/dist/esm/events.mjs.map +1 -1
  47. package/dist/esm/graphs/Graph.mjs +41 -1
  48. package/dist/esm/graphs/Graph.mjs.map +1 -1
  49. package/dist/esm/graphs/acceptedModelResponse.mjs +165 -0
  50. package/dist/esm/graphs/acceptedModelResponse.mjs.map +1 -0
  51. package/dist/esm/llm/invoke.mjs +10 -5
  52. package/dist/esm/llm/invoke.mjs.map +1 -1
  53. package/dist/esm/llm/streamLimits.mjs +1 -1
  54. package/dist/esm/llm/streamLimits.mjs.map +1 -1
  55. package/dist/esm/main.mjs +3 -3
  56. package/dist/esm/messages/fading.mjs +14 -7
  57. package/dist/esm/messages/fading.mjs.map +1 -1
  58. package/dist/esm/messages/prune.mjs +95 -37
  59. package/dist/esm/messages/prune.mjs.map +1 -1
  60. package/dist/esm/openai/index.mjs +2 -1
  61. package/dist/esm/openai/index.mjs.map +1 -1
  62. package/dist/esm/openai/toolProjection.mjs +196 -0
  63. package/dist/esm/openai/toolProjection.mjs.map +1 -0
  64. package/dist/esm/run.mjs +8 -1
  65. package/dist/esm/run.mjs.map +1 -1
  66. package/dist/esm/session/AgentSession.mjs +1 -1
  67. package/dist/esm/session/AgentSession.mjs.map +1 -1
  68. package/dist/esm/stream.mjs +9 -4
  69. package/dist/esm/stream.mjs.map +1 -1
  70. package/dist/esm/tools/ArtifactTruncation.mjs +44 -0
  71. package/dist/esm/tools/ArtifactTruncation.mjs.map +1 -0
  72. package/dist/esm/tools/BashExecutor.mjs +6 -1
  73. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  74. package/dist/esm/tools/CodeExecutor.mjs +6 -1
  75. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  76. package/dist/esm/tools/ProgrammaticToolCalling.mjs +5 -1
  77. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  78. package/dist/esm/tools/ToolNode.mjs +2 -1
  79. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  80. package/dist/esm/tools/subagent/SubagentReplay.mjs +5 -2
  81. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -1
  82. package/dist/esm/utils/acceptedToolArguments.mjs +142 -0
  83. package/dist/esm/utils/acceptedToolArguments.mjs.map +1 -0
  84. package/dist/types/common/enum.d.ts +4 -0
  85. package/dist/types/graphs/Graph.d.ts +3 -1
  86. package/dist/types/graphs/acceptedModelResponse.d.ts +15 -0
  87. package/dist/types/messages/fading.d.ts +14 -2
  88. package/dist/types/messages/prune.d.ts +7 -0
  89. package/dist/types/openai/arguments.d.ts +2 -0
  90. package/dist/types/openai/index.d.ts +2 -0
  91. package/dist/types/openai/toolProjection.d.ts +30 -0
  92. package/dist/types/run.d.ts +1 -0
  93. package/dist/types/tools/ArtifactTruncation.d.ts +3 -0
  94. package/dist/types/tools/ToolNode.d.ts +1 -1
  95. package/dist/types/types/graph.d.ts +5 -3
  96. package/dist/types/types/run.d.ts +5 -0
  97. package/dist/types/types/stream.d.ts +22 -1
  98. package/dist/types/types/tools.d.ts +14 -0
  99. package/dist/types/utils/acceptedToolArguments.d.ts +10 -0
  100. package/package.json +1 -1
  101. package/src/common/enum.ts +5 -0
  102. package/src/events.ts +24 -1
  103. package/src/graphs/Graph.ts +95 -0
  104. package/src/graphs/acceptedModelResponse.ts +307 -0
  105. package/src/llm/invoke.ts +39 -14
  106. package/src/llm/streamLimits.ts +1 -1
  107. package/src/messages/fading.ts +30 -5
  108. package/src/messages/prune.ts +168 -55
  109. package/src/openai/arguments.ts +2 -0
  110. package/src/openai/index.ts +6 -0
  111. package/src/openai/toolProjection.ts +318 -0
  112. package/src/run.ts +21 -0
  113. package/src/session/AgentSession.ts +2 -2
  114. package/src/stream.ts +21 -1
  115. package/src/tools/ArtifactTruncation.ts +85 -0
  116. package/src/tools/BashExecutor.ts +22 -5
  117. package/src/tools/CodeExecutor.ts +18 -1
  118. package/src/tools/ProgrammaticToolCalling.ts +17 -3
  119. package/src/tools/ToolNode.ts +5 -0
  120. package/src/tools/subagent/SubagentReplay.ts +10 -9
  121. package/src/types/graph.ts +15 -9
  122. package/src/types/run.ts +5 -0
  123. package/src/types/stream.ts +26 -0
  124. package/src/types/tools.ts +21 -0
  125. package/src/utils/acceptedToolArguments.ts +204 -0
@@ -4,7 +4,12 @@ import {
4
4
  calculateMaxToolResultChars,
5
5
  } from '@/utils/truncation';
6
6
 
7
- export const FADING_TIER_VERSION = 1;
7
+ /**
8
+ * Version 2: exchange width counts only the current turn. Version 1 tiers may
9
+ * have latched on history whose steps storage had merged into one assistant
10
+ * message, so they are discarded and re-derived once.
11
+ */
12
+ export const FADING_TIER_VERSION = 2;
8
13
 
9
14
  /** Context pressure at which observation masking activates. */
10
15
  export const PRESSURE_THRESHOLD_MASKING = 0.8;
@@ -34,7 +39,7 @@ export type FadingSignals = {
34
39
  /** (pruningBudget − instruction tokens) ÷ calibrationRatio, in raw token space. */
35
40
  effectiveRawTokens: number;
36
41
  summarizationEnabled: boolean;
37
- /** Largest number of parallel calls observed in one assistant exchange. */
42
+ /** Largest number of parallel calls in one assistant exchange of the current turn. */
38
43
  toolExchangeWidth?: number;
39
44
  /** Recovery paths force at least this rung on the current window's ladder. */
40
45
  minRung?: number;
@@ -59,7 +64,10 @@ export function createFadingTier(window: number): FadingTier {
59
64
  return { v: FADING_TIER_VERSION, budgetTokens: window, masked: false };
60
65
  }
61
66
 
62
- export function isFadingTier(value: unknown): value is FadingTier {
67
+ /** Tier version before exchange width became turn-scoped. */
68
+ const LEGACY_FADING_TIER_VERSION = 1;
69
+
70
+ function isFadingTierOfVersion(value: unknown, version: number): boolean {
63
71
  if (typeof value !== 'object' || value === null) {
64
72
  return false;
65
73
  }
@@ -67,7 +75,7 @@ export function isFadingTier(value: unknown): value is FadingTier {
67
75
  Record<keyof FadingTier, unknown>
68
76
  >;
69
77
  return (
70
- v === FADING_TIER_VERSION &&
78
+ v === version &&
71
79
  typeof budgetTokens === 'number' &&
72
80
  Number.isFinite(budgetTokens) &&
73
81
  budgetTokens > 0 &&
@@ -76,6 +84,20 @@ export function isFadingTier(value: unknown): value is FadingTier {
76
84
  );
77
85
  }
78
86
 
87
+ export function isFadingTier(value: unknown): value is FadingTier {
88
+ return isFadingTierOfVersion(value, FADING_TIER_VERSION);
89
+ }
90
+
91
+ /**
92
+ * A well-formed tier persisted before version 2. Stored snapshots that carry
93
+ * one (resume manifests, session files) stay valid; every consumer then drops
94
+ * it through `isFadingTier`, so the tier re-derives once instead of the
95
+ * snapshot being rejected as corrupt.
96
+ */
97
+ export function isLegacyFadingTier(value: unknown): boolean {
98
+ return isFadingTierOfVersion(value, LEGACY_FADING_TIER_VERSION);
99
+ }
100
+
79
101
  /** Deepest rung for a window: the point where the budget reaches its floor. */
80
102
  export function maxFadingRung(window: number): number {
81
103
  const floor = floorBudgetTokens(window);
@@ -199,7 +221,10 @@ export function fadingRungForExchangeChars(
199
221
  maxToolResultChars == null
200
222
  ? windowResultChars
201
223
  : Math.min(windowResultChars, maxToolResultChars);
202
- if (resultChars + calculateMaxToolCallInputChars(budgetTokens) <= targetChars) {
224
+ if (
225
+ resultChars + calculateMaxToolCallInputChars(budgetTokens) <=
226
+ targetChars
227
+ ) {
203
228
  return rung;
204
229
  }
205
230
  }
@@ -1556,7 +1556,28 @@ function cloneAIMessageWithProjectedStreamContent(
1556
1556
  ) as AIMessage | AIMessageChunk;
1557
1557
  }
1558
1558
 
1559
- const TOOL_INPUT_TRUNCATION_MARKER = '… [truncated]\n';
1559
+ /** Marker that led the preview in the legacy `{_truncated, _originalChars}` envelope. */
1560
+ const LEGACY_TOOL_INPUT_TRUNCATION_MARKER = '… [truncated]\n';
1561
+
1562
+ /**
1563
+ * Leads every shortened tool-call input. Fading only shortens calls already in
1564
+ * history, which ran with their full input; a model that reads a shortened copy
1565
+ * as a call that was cut off re-issues it, and a side-effecting tool (sending an
1566
+ * email) then runs again. The note says what actually happened.
1567
+ */
1568
+ export const TOOL_INPUT_ELISION_NOTE =
1569
+ 'Completed call; input shortened to save context.';
1570
+
1571
+ function toolInputElision(
1572
+ originalChars: number,
1573
+ inputPrefix: string
1574
+ ): { _note: string; _originalChars: number; _inputPrefix: string } {
1575
+ return {
1576
+ _note: TOOL_INPUT_ELISION_NOTE,
1577
+ _originalChars: originalChars,
1578
+ _inputPrefix: inputPrefix,
1579
+ };
1580
+ }
1560
1581
 
1561
1582
  function createBoundedTruncationValue(
1562
1583
  preview: string,
@@ -1564,14 +1585,22 @@ function createBoundedTruncationValue(
1564
1585
  maxChars: number
1565
1586
  ): unknown {
1566
1587
  const normalizedMaxChars = normalizeToolInputLimit(maxChars);
1567
- const canonicalPrefix = preview.startsWith(TOOL_INPUT_TRUNCATION_MARKER)
1568
- ? preview.slice(TOOL_INPUT_TRUNCATION_MARKER.length)
1588
+ const canonicalPrefix = preview.startsWith(
1589
+ LEGACY_TOOL_INPUT_TRUNCATION_MARKER
1590
+ )
1591
+ ? preview.slice(LEGACY_TOOL_INPUT_TRUNCATION_MARKER.length)
1569
1592
  : preview;
1570
- const emptyEnvelope = {
1571
- _truncated: TOOL_INPUT_TRUNCATION_MARKER,
1572
- _originalChars: originalChars,
1573
- };
1593
+ const emptyEnvelope = toolInputElision(originalChars, '');
1574
1594
  if (JSON.stringify(emptyEnvelope).length > normalizedMaxChars) {
1595
+ /** The deepest fading rung caps inputs near 100 chars, below the full
1596
+ * envelope for large inputs, so the note and size survive on their own. */
1597
+ const sizedNote = {
1598
+ _note: TOOL_INPUT_ELISION_NOTE,
1599
+ _originalChars: originalChars,
1600
+ };
1601
+ if (JSON.stringify(sizedNote).length <= normalizedMaxChars) {
1602
+ return sizedNote;
1603
+ }
1575
1604
  /**
1576
1605
  * Even the empty envelope overflows the cap, so no preview survives —
1577
1606
  * but the result must still be a JSON OBJECT, never `null`. This value
@@ -1590,49 +1619,84 @@ function createBoundedTruncationValue(
1590
1619
  let high = Math.min(canonicalPrefix.length, normalizedMaxChars);
1591
1620
  while (low < high) {
1592
1621
  const next = Math.ceil((low + high) / 2);
1593
- const candidate = {
1594
- _truncated:
1595
- TOOL_INPUT_TRUNCATION_MARKER +
1596
- sliceWithoutSplittingSurrogates(canonicalPrefix, 0, next),
1597
- _originalChars: originalChars,
1598
- };
1622
+ const candidate = toolInputElision(
1623
+ originalChars,
1624
+ sliceWithoutSplittingSurrogates(canonicalPrefix, 0, next)
1625
+ );
1599
1626
  if (JSON.stringify(candidate).length <= normalizedMaxChars) {
1600
1627
  low = next;
1601
1628
  } else {
1602
1629
  high = next - 1;
1603
1630
  }
1604
1631
  }
1605
- return {
1606
- // Keep the marker separate from a pure canonical prefix so another,
1607
- // slightly smaller cap can be derived without nesting the envelope.
1608
- _truncated:
1609
- TOOL_INPUT_TRUNCATION_MARKER +
1610
- sliceWithoutSplittingSurrogates(canonicalPrefix, 0, low),
1611
- _originalChars: originalChars,
1612
- };
1632
+ // The prefix stays a pure canonical prefix, so another, slightly smaller cap
1633
+ // can be derived from it without nesting the envelope.
1634
+ return toolInputElision(
1635
+ originalChars,
1636
+ sliceWithoutSplittingSurrogates(canonicalPrefix, 0, low)
1637
+ );
1638
+ }
1639
+
1640
+ const ELISION_ENVELOPE_KEYS = ['_note', '_originalChars', '_inputPrefix'];
1641
+ const SIZED_NOTE_KEYS = ['_note', '_originalChars'];
1642
+ const LEGACY_ENVELOPE_KEYS = ['_truncated', '_originalChars'];
1643
+
1644
+ function hasExactKeys(keys: string[], expected: string[]): boolean {
1645
+ return (
1646
+ keys.length === expected.length &&
1647
+ expected.every((key) => keys.includes(key))
1648
+ );
1649
+ }
1650
+
1651
+ function hasElisionNote(input: object): boolean {
1652
+ const note = readPropertyWithoutAccessors(input, '_note');
1653
+ return note.own && !note.accessor && note.value === TOOL_INPUT_ELISION_NOTE;
1613
1654
  }
1614
1655
 
1656
+ /** Reads a shortened-input envelope, including the legacy `{_truncated,
1657
+ * _originalChars}` shape persisted before the elision note existed. */
1615
1658
  function readBoundedTruncationValue(
1616
1659
  input: unknown
1617
- ): { preview: string; originalChars: number } | undefined {
1660
+ ): { preview: string; originalChars: number; legacy: boolean } | undefined {
1618
1661
  if (input == null || typeof input !== 'object' || isProxy(input)) {
1619
1662
  return undefined;
1620
1663
  }
1664
+ let preview: PropertyRead;
1665
+ let legacy = false;
1621
1666
  try {
1622
1667
  const prototype = Object.getPrototypeOf(input);
1623
1668
  const keys = Object.keys(input);
1624
- if (
1625
- (prototype !== Object.prototype && prototype !== null) ||
1626
- keys.length !== 2 ||
1627
- !keys.includes('_truncated') ||
1628
- !keys.includes('_originalChars')
1629
- ) {
1669
+ if (prototype !== Object.prototype && prototype !== null) {
1670
+ return undefined;
1671
+ }
1672
+ if (hasExactKeys(keys, ELISION_ENVELOPE_KEYS)) {
1673
+ if (!hasElisionNote(input)) {
1674
+ return undefined;
1675
+ }
1676
+ preview = readPropertyWithoutAccessors(input, '_inputPrefix');
1677
+ } else if (hasExactKeys(keys, SIZED_NOTE_KEYS)) {
1678
+ /** The fallback that keeps only the note and size at the tightest caps. */
1679
+ if (!hasElisionNote(input)) {
1680
+ return undefined;
1681
+ }
1682
+ preview = { found: true, own: true, accessor: false, value: '' };
1683
+ } else if (hasExactKeys(keys, LEGACY_ENVELOPE_KEYS)) {
1684
+ preview = readPropertyWithoutAccessors(input, '_truncated');
1685
+ /** Every legacy envelope led with the marker; without it, a genuine input
1686
+ * that merely shares the two field names is left alone. */
1687
+ if (
1688
+ typeof preview.value !== 'string' ||
1689
+ !preview.value.startsWith(LEGACY_TOOL_INPUT_TRUNCATION_MARKER)
1690
+ ) {
1691
+ return undefined;
1692
+ }
1693
+ legacy = true;
1694
+ } else {
1630
1695
  return undefined;
1631
1696
  }
1632
1697
  } catch {
1633
1698
  return undefined;
1634
1699
  }
1635
- const preview = readPropertyWithoutAccessors(input, '_truncated');
1636
1700
  const originalChars = readPropertyWithoutAccessors(input, '_originalChars');
1637
1701
  return preview.own &&
1638
1702
  !preview.accessor &&
@@ -1642,7 +1706,11 @@ function readBoundedTruncationValue(
1642
1706
  typeof originalChars.value === 'number' &&
1643
1707
  Number.isFinite(originalChars.value) &&
1644
1708
  originalChars.value >= 0
1645
- ? { preview: preview.value, originalChars: originalChars.value }
1709
+ ? {
1710
+ preview: preview.value,
1711
+ originalChars: originalChars.value,
1712
+ legacy,
1713
+ }
1646
1714
  : undefined;
1647
1715
  }
1648
1716
 
@@ -1657,7 +1725,9 @@ function projectToolInputWithinLimit(
1657
1725
  input,
1658
1726
  normalizedMaxChars
1659
1727
  );
1660
- if (!serializedLength.truncated) {
1728
+ /** A legacy envelope converts even when it fits: its `_truncated` wording
1729
+ * is what led models to re-issue completed calls. */
1730
+ if (!serializedLength.truncated && !priorTruncation.legacy) {
1661
1731
  return { value: input, changed: false };
1662
1732
  }
1663
1733
  return {
@@ -1801,17 +1871,17 @@ function projectSerializedArguments(
1801
1871
  maxChars: number
1802
1872
  ): { value: string; changed: boolean } {
1803
1873
  const normalizedMaxChars = normalizeToolInputLimit(maxChars);
1804
- if (typeof value === 'string' && value.length <= normalizedMaxChars) {
1805
- return { value, changed: false };
1806
- }
1807
1874
  if (
1808
1875
  typeof value === 'string' &&
1809
- value.includes('"_truncated"') &&
1810
- value.includes('"_originalChars"')
1876
+ value.includes('"_originalChars"') &&
1877
+ (value.includes('"_inputPrefix"') || value.includes('"_truncated"'))
1811
1878
  ) {
1812
1879
  try {
1813
1880
  const priorTruncation = readBoundedTruncationValue(JSON.parse(value));
1814
1881
  if (priorTruncation != null) {
1882
+ if (!priorTruncation.legacy && value.length <= normalizedMaxChars) {
1883
+ return { value, changed: false };
1884
+ }
1815
1885
  return {
1816
1886
  value: JSON.stringify(
1817
1887
  createBoundedTruncationValue(
@@ -1827,26 +1897,34 @@ function projectSerializedArguments(
1827
1897
  // Fall through to the accessor-safe serializer for malformed JSON.
1828
1898
  }
1829
1899
  }
1900
+ if (typeof value === 'string' && value.length <= normalizedMaxChars) {
1901
+ return { value, changed: false };
1902
+ }
1830
1903
  return {
1831
1904
  value: serializeToolCallInput(value, normalizedMaxChars),
1832
1905
  changed: true,
1833
1906
  };
1834
1907
  }
1835
1908
 
1836
- const TRUNCATED_STRING_INPUT_PATTERN = /\n… \[truncated: (\d+) chars\]$/u;
1909
+ const TRUNCATED_STRING_INPUT_PATTERN =
1910
+ /\n… \[(?:truncated|shortened; call completed): (\d+) chars\]$/u;
1911
+ const LEGACY_TRUNCATED_STRING_INPUT_PATTERN = /\n… \[truncated: \d+ chars\]$/u;
1837
1912
 
1838
1913
  function projectStringInputWithinLimit(
1839
1914
  value: string,
1840
1915
  maxChars: number
1841
1916
  ): { value: string; changed: boolean } {
1842
1917
  const normalizedMaxChars = normalizeToolInputLimit(maxChars);
1843
- if (value.length <= normalizedMaxChars) {
1918
+ if (
1919
+ value.length <= normalizedMaxChars &&
1920
+ !LEGACY_TRUNCATED_STRING_INPUT_PATTERN.test(value)
1921
+ ) {
1844
1922
  return { value, changed: false };
1845
1923
  }
1846
1924
  const match = TRUNCATED_STRING_INPUT_PATTERN.exec(value);
1847
1925
  const originalChars = match == null ? value.length : Number(match[1]);
1848
1926
  const prefix = match == null ? value : value.slice(0, match.index);
1849
- const marker = `\n… [truncated: ${originalChars} chars]`;
1927
+ const marker = `\n… [shortened; call completed: ${originalChars} chars]`;
1850
1928
  return {
1851
1929
  value:
1852
1930
  marker.length >= normalizedMaxChars
@@ -1938,6 +2016,19 @@ function projectRawOpenAIToolCalls(
1938
2016
  };
1939
2017
  }
1940
2018
 
2019
+ /** A serialized legacy `{_truncated, _originalChars}` envelope, which is rewritten
2020
+ * even when it fits because its wording led models to re-issue completed calls. */
2021
+ function isLegacyEnvelopeString(value: string): boolean {
2022
+ if (!value.includes('"_truncated"') || !value.includes('"_originalChars"')) {
2023
+ return false;
2024
+ }
2025
+ try {
2026
+ return readBoundedTruncationValue(JSON.parse(value))?.legacy === true;
2027
+ } catch {
2028
+ return false;
2029
+ }
2030
+ }
2031
+
1941
2032
  function projectLegacyFunctionCall(
1942
2033
  property: PropertyRead,
1943
2034
  maxChars: number
@@ -1984,6 +2075,7 @@ function projectLegacyFunctionCall(
1984
2075
  enumerableKeys.includes('arguments') &&
1985
2076
  typeof argsProperty.value === 'string' &&
1986
2077
  argsProperty.value.length <= normalizeToolInputLimit(maxChars) &&
2078
+ !isLegacyEnvelopeString(argsProperty.value) &&
1987
2079
  !hasUnsafeStructuredSerialization(property.value)
1988
2080
  ) {
1989
2081
  return { value: property.value, changed: false };
@@ -2064,16 +2156,9 @@ function projectResponsesOutput(
2064
2156
  ? ACCESSOR_INPUT_PLACEHOLDER
2065
2157
  : canonicalProperty.value;
2066
2158
  }
2067
- if (
2068
- type === 'custom_tool_call' &&
2069
- typeof source === 'string' &&
2070
- source.length <= normalizeToolInputLimit(maxChars)
2071
- ) {
2072
- projectedInput = {
2073
- value: source,
2074
- changed: !inputProperty.own || inputProperty.value !== source,
2075
- };
2076
- } else if (type === 'custom_tool_call') {
2159
+ if (type === 'custom_tool_call') {
2160
+ /** The string projection keeps fitting input as-is, except a legacy
2161
+ * `[truncated: N chars]` marker, which it rewrites. */
2077
2162
  const value =
2078
2163
  typeof source === 'string'
2079
2164
  ? projectStringInputWithinLimit(source, maxChars).value
@@ -2389,6 +2474,29 @@ function applyToolCallInputCaps(params: {
2389
2474
  return truncatedCount;
2390
2475
  }
2391
2476
 
2477
+ /**
2478
+ * Whether a message opens a user turn: a human message or a role-based `user`
2479
+ * chat message. The SDK stamps every `HumanMessage` it synthesizes with a
2480
+ * `source` (hook context, steers, routing and handoff cues, skills) and often
2481
+ * `injected`, `isMeta` or `role: 'system'`; those belong to the turn they follow.
2482
+ */
2483
+ function startsUserTurn(message: BaseMessage): boolean {
2484
+ const type = message.getType();
2485
+ if (type === 'generic') {
2486
+ return (message as { role?: unknown }).role === 'user';
2487
+ }
2488
+ if (type !== 'human') {
2489
+ return false;
2490
+ }
2491
+ const kwargs = message.additional_kwargs;
2492
+ return (
2493
+ kwargs.source === undefined &&
2494
+ kwargs.injected !== true &&
2495
+ kwargs.isMeta !== true &&
2496
+ kwargs.role !== 'system'
2497
+ );
2498
+ }
2499
+
2392
2500
  export function preFlightTruncateToolCallInputs(params: {
2393
2501
  messages: BaseMessage[];
2394
2502
  maxContextTokens: number;
@@ -2466,7 +2574,8 @@ export function createPruneMessages(factoryParams: PruneMessagesFactoryParams) {
2466
2574
  factoryParams.fadingTier
2467
2575
  );
2468
2576
  let restoredTierPending = isFadingTier(factoryParams.fadingTier);
2469
- /** Widest exchange seen so far; updated only from the appended suffix. */
2577
+ /** Widest exchange of the current turn (since the last human message);
2578
+ * updated only from the appended suffix. */
2470
2579
  let maxToolExchangeWidth = 1;
2471
2580
  let toolExchangeWidthThrough = 0;
2472
2581
  let toolExchangeWidthSources: BaseMessage[] = [];
@@ -2533,11 +2642,15 @@ export function createPruneMessages(factoryParams: PruneMessagesFactoryParams) {
2533
2642
  originalToolContentSize = 0;
2534
2643
  }
2535
2644
  for (let i = toolExchangeWidthThrough; i < canonicalMessages.length; i++) {
2536
- maxToolExchangeWidth = Math.max(
2537
- maxToolExchangeWidth,
2538
- getToolCallIds(canonicalMessages[i]).size
2539
- );
2540
- toolExchangeWidthSources[i] = canonicalMessages[i];
2645
+ const message = canonicalMessages[i];
2646
+ /** Earlier turns come back from storage with each turn's steps merged into
2647
+ * one assistant message, so their call counts are an artifact of that
2648
+ * reconstruction, not fan-out; only the current turn's are real. The tier
2649
+ * itself stays latched, so a narrower width never loosens it. */
2650
+ maxToolExchangeWidth = startsUserTurn(message)
2651
+ ? 1
2652
+ : Math.max(maxToolExchangeWidth, getToolCallIds(message).size);
2653
+ toolExchangeWidthSources[i] = message;
2541
2654
  }
2542
2655
  toolExchangeWidthThrough = canonicalMessages.length;
2543
2656
  let newOriginalToolContent: Map<number, string> | undefined;
@@ -0,0 +1,2 @@
1
+ /** OpenAI-only compatibility import; validation belongs to the graph-safe utility. */
2
+ export { serializeToolArguments } from '@/utils/acceptedToolArguments';
@@ -402,3 +402,9 @@ export async function sendOpenAIFinalChunk(
402
402
  );
403
403
  await writeOpenAISSE(config.writer, '[DONE]');
404
404
  }
405
+
406
+ export { createOpenAIToolCallStream } from './toolProjection';
407
+ export type {
408
+ OpenAIToolCallStream,
409
+ OpenAIToolCallStreamConfig,
410
+ } from './toolProjection';