@librechat/agents 3.9.3 → 3.9.4

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 (104) 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/ToolNode.cjs +2 -1
  30. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  31. package/dist/cjs/tools/subagent/SubagentReplay.cjs +4 -1
  32. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -1
  33. package/dist/cjs/utils/acceptedToolArguments.cjs +143 -0
  34. package/dist/cjs/utils/acceptedToolArguments.cjs.map +1 -0
  35. package/dist/esm/common/enum.mjs +2 -0
  36. package/dist/esm/common/enum.mjs.map +1 -1
  37. package/dist/esm/events.mjs +11 -0
  38. package/dist/esm/events.mjs.map +1 -1
  39. package/dist/esm/graphs/Graph.mjs +41 -1
  40. package/dist/esm/graphs/Graph.mjs.map +1 -1
  41. package/dist/esm/graphs/acceptedModelResponse.mjs +165 -0
  42. package/dist/esm/graphs/acceptedModelResponse.mjs.map +1 -0
  43. package/dist/esm/llm/invoke.mjs +10 -5
  44. package/dist/esm/llm/invoke.mjs.map +1 -1
  45. package/dist/esm/llm/streamLimits.mjs +1 -1
  46. package/dist/esm/llm/streamLimits.mjs.map +1 -1
  47. package/dist/esm/main.mjs +3 -3
  48. package/dist/esm/messages/fading.mjs +14 -7
  49. package/dist/esm/messages/fading.mjs.map +1 -1
  50. package/dist/esm/messages/prune.mjs +95 -37
  51. package/dist/esm/messages/prune.mjs.map +1 -1
  52. package/dist/esm/openai/index.mjs +2 -1
  53. package/dist/esm/openai/index.mjs.map +1 -1
  54. package/dist/esm/openai/toolProjection.mjs +196 -0
  55. package/dist/esm/openai/toolProjection.mjs.map +1 -0
  56. package/dist/esm/run.mjs +8 -1
  57. package/dist/esm/run.mjs.map +1 -1
  58. package/dist/esm/session/AgentSession.mjs +1 -1
  59. package/dist/esm/session/AgentSession.mjs.map +1 -1
  60. package/dist/esm/stream.mjs +9 -4
  61. package/dist/esm/stream.mjs.map +1 -1
  62. package/dist/esm/tools/ToolNode.mjs +2 -1
  63. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  64. package/dist/esm/tools/subagent/SubagentReplay.mjs +5 -2
  65. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -1
  66. package/dist/esm/utils/acceptedToolArguments.mjs +142 -0
  67. package/dist/esm/utils/acceptedToolArguments.mjs.map +1 -0
  68. package/dist/types/common/enum.d.ts +4 -0
  69. package/dist/types/graphs/Graph.d.ts +3 -1
  70. package/dist/types/graphs/acceptedModelResponse.d.ts +15 -0
  71. package/dist/types/messages/fading.d.ts +14 -2
  72. package/dist/types/messages/prune.d.ts +7 -0
  73. package/dist/types/openai/arguments.d.ts +2 -0
  74. package/dist/types/openai/index.d.ts +2 -0
  75. package/dist/types/openai/toolProjection.d.ts +30 -0
  76. package/dist/types/run.d.ts +1 -0
  77. package/dist/types/tools/ToolNode.d.ts +1 -1
  78. package/dist/types/types/graph.d.ts +5 -3
  79. package/dist/types/types/run.d.ts +5 -0
  80. package/dist/types/types/stream.d.ts +22 -1
  81. package/dist/types/types/tools.d.ts +2 -0
  82. package/dist/types/utils/acceptedToolArguments.d.ts +10 -0
  83. package/package.json +1 -1
  84. package/src/common/enum.ts +5 -0
  85. package/src/events.ts +24 -1
  86. package/src/graphs/Graph.ts +95 -0
  87. package/src/graphs/acceptedModelResponse.ts +307 -0
  88. package/src/llm/invoke.ts +39 -14
  89. package/src/llm/streamLimits.ts +1 -1
  90. package/src/messages/fading.ts +30 -5
  91. package/src/messages/prune.ts +168 -55
  92. package/src/openai/arguments.ts +2 -0
  93. package/src/openai/index.ts +6 -0
  94. package/src/openai/toolProjection.ts +318 -0
  95. package/src/run.ts +21 -0
  96. package/src/session/AgentSession.ts +2 -2
  97. package/src/stream.ts +21 -1
  98. package/src/tools/ToolNode.ts +5 -0
  99. package/src/tools/subagent/SubagentReplay.ts +10 -9
  100. package/src/types/graph.ts +15 -9
  101. package/src/types/run.ts +5 -0
  102. package/src/types/stream.ts +26 -0
  103. package/src/types/tools.ts +2 -0
  104. package/src/utils/acceptedToolArguments.ts +204 -0
@@ -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';
@@ -0,0 +1,318 @@
1
+ import type {
2
+ OpenAIChatCompletionChunkChoice,
3
+ OpenAIToolCall,
4
+ OpenAIStreamTracker,
5
+ } from './index';
6
+ import type { EventHandler, ModelResponseEvent } from '@/types';
7
+ import { serializeToolArguments } from './arguments';
8
+ import { GraphEvents } from '@/common';
9
+
10
+ interface OpenAIToolCallStreamOptions {
11
+ /** Synchronous framing only. Async transport/backpressure is a separate boundary. */
12
+ emit?: (delta: OpenAIChatCompletionChunkChoice['delta']) => void;
13
+ signal?: AbortSignal;
14
+ /** Bound retained complete-before-publish output, not model execution. Default: 1024. */
15
+ maxToolCalls?: number;
16
+ /** UTF-8 bytes of serialized arguments, names and IDs retained across accepted responses. Default: 4 MiB. */
17
+ maxBufferedBytes?: number;
18
+ }
19
+
20
+ /** Streaming hosts share the finalizer's tracker; map-only hosts collect JSON output. */
21
+ export type OpenAIToolCallStreamConfig = OpenAIToolCallStreamOptions &
22
+ (
23
+ | {
24
+ tracker: OpenAIStreamTracker;
25
+ toolCalls?: never;
26
+ emit: NonNullable<OpenAIToolCallStreamOptions['emit']>;
27
+ }
28
+ | { toolCalls: Map<number, OpenAIToolCall>; tracker?: never }
29
+ );
30
+
31
+ export interface OpenAIToolCallStream {
32
+ /** Pass directly to Run.create({ customHandlers: stream.handlers }). */
33
+ handlers: Record<string, EventHandler>;
34
+ /** Publish only after the host verifies that the entire run completed successfully. */
35
+ finish: () => void;
36
+ abort: () => void;
37
+ }
38
+
39
+ function positiveLimit(value: number | undefined, fallback: number): number {
40
+ const limit = value ?? fallback;
41
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
42
+ throw new Error('Tool projection limits must be positive safe integers');
43
+ }
44
+ return limit;
45
+ }
46
+
47
+ /** Serializes finalized, accepted tool calls. No provider fragments or attempt inference. */
48
+ export function createOpenAIToolCallStream(
49
+ config: OpenAIToolCallStreamConfig
50
+ ): OpenAIToolCallStream {
51
+ const { emit, signal, tracker } = config;
52
+ const suppliedMap: unknown = config.toolCalls;
53
+ const suppliedEmitter: unknown = emit;
54
+ if (tracker != null && typeof suppliedEmitter !== 'function') {
55
+ throw new Error('A streaming tracker requires an emitter');
56
+ }
57
+ if (tracker != null && suppliedMap != null) {
58
+ throw new Error('Provide a tracker or a tool-call map, not both');
59
+ }
60
+ const toolCalls = tracker?.toolCalls ?? config.toolCalls;
61
+ if (toolCalls == null)
62
+ throw new Error('Provide a tracker or a tool-call map');
63
+ if (toolCalls.size !== 0)
64
+ throw new Error('Tool projection requires an empty output map');
65
+ let previousChunkKind: OpenAIStreamTracker['lastChunkKind'];
66
+ let acceptedTerminalKind: OpenAIStreamTracker['lastChunkKind'];
67
+ const maxCalls = positiveLimit(config.maxToolCalls, 1024);
68
+ const maxBytes = positiveLimit(config.maxBufferedBytes, 4 * 1024 * 1024);
69
+ const calls: Array<{
70
+ id: string;
71
+ name: string;
72
+ arguments: string;
73
+ agentId: string;
74
+ messageId?: string;
75
+ acceptedId: string;
76
+ bytes: number;
77
+ }> = [];
78
+ const providerIds = new Set<string>();
79
+ const acceptedIds = new Set<string>();
80
+ let bufferedBytes = 0;
81
+ let phase: 'open' | 'emitting' | 'finished' | 'aborted' = 'open';
82
+
83
+ const release = (): void => {
84
+ calls.length = 0;
85
+ acceptedIds.clear();
86
+ providerIds.clear();
87
+ bufferedBytes = 0;
88
+ };
89
+ const discard = (agentId: string, messageId?: string): void => {
90
+ for (let i = calls.length - 1; i >= 0; i--) {
91
+ const call = calls[i];
92
+ if (
93
+ call.agentId !== agentId ||
94
+ (messageId != null && call.messageId !== messageId)
95
+ )
96
+ continue;
97
+ bufferedBytes -= call.bytes;
98
+ calls.splice(i, 1);
99
+ }
100
+ acceptedIds.clear();
101
+ providerIds.clear();
102
+ for (const call of calls) {
103
+ acceptedIds.add(call.acceptedId);
104
+ if (call.id !== '') providerIds.add(call.id);
105
+ }
106
+ acceptedTerminalKind = calls.length > 0 ? 'tool_call' : 'text';
107
+ };
108
+ const abort = (): void => {
109
+ const phaseBeforeAbort = phase;
110
+ if (phase !== 'finished') {
111
+ phase = 'aborted';
112
+ toolCalls.clear();
113
+ if (tracker != null && phaseBeforeAbort === 'emitting')
114
+ tracker.lastChunkKind = previousChunkKind;
115
+ }
116
+ release();
117
+ };
118
+ const checkCancellation = (): void => {
119
+ if (signal?.aborted === true) abort();
120
+ if (phase === 'aborted') {
121
+ const error = new Error('Agent response aborted');
122
+ error.name = 'AbortError';
123
+ throw error;
124
+ }
125
+ };
126
+ const emitDelta = (delta: OpenAIChatCompletionChunkChoice['delta']): void => {
127
+ const result: unknown = emit?.(delta);
128
+ if (
129
+ result != null &&
130
+ typeof result === 'object' &&
131
+ 'then' in result &&
132
+ typeof result.then === 'function'
133
+ ) {
134
+ // Observe a mistakenly async sink's rejection, then fail closed. Never silently
135
+ // publish a successful terminal response before pending writes settle.
136
+ void Promise.resolve(result).catch(() => undefined);
137
+ throw new Error('Tool projection requires a synchronous emitter');
138
+ }
139
+ };
140
+ const accept = (result: ModelResponseEvent): void => {
141
+ if (phase !== 'open') return;
142
+ try {
143
+ checkCancellation();
144
+ if (result.invalidToolCalls.length > 0) {
145
+ throw new Error('Accepted model response contains invalid tool calls');
146
+ }
147
+ const ownership = result.toolCallDispositions;
148
+ if (
149
+ !Array.isArray(ownership) ||
150
+ ownership.length !== result.toolCalls.length ||
151
+ ownership.some(
152
+ (value) =>
153
+ value !== 'sdk' && value !== 'provider' && value !== 'client'
154
+ )
155
+ ) {
156
+ throw new Error('Accepted tool calls lack trusted execution ownership');
157
+ }
158
+ const delegated = result.toolCalls.filter(
159
+ (_call, index) => ownership[index] === 'client'
160
+ );
161
+ if (delegated.length === 0) {
162
+ // A text or internal-only response supersedes this agent's request,
163
+ // never a sibling's. Neither 'sdk' nor 'provider' enters the wire.
164
+ discard(result.agentId);
165
+ return;
166
+ }
167
+ if (result.id.trim() === '' || acceptedIds.has(result.id)) {
168
+ throw new Error('Missing or repeated accepted model response identity');
169
+ }
170
+ if (calls.length + delegated.length > maxCalls) {
171
+ throw new Error('Tool projection call limit exceeded');
172
+ }
173
+ acceptedIds.add(result.id);
174
+ for (const call of delegated) {
175
+ if (typeof call.name !== 'string' || call.name.trim() === '') {
176
+ throw new Error('Accepted tool call is missing its name');
177
+ }
178
+ const id = call.id ?? '';
179
+ if (typeof id !== 'string')
180
+ throw new Error('Accepted tool call ID must be a string');
181
+ if (id !== '') providerIds.add(id);
182
+ const identityBytes =
183
+ Buffer.byteLength(id, 'utf8') + Buffer.byteLength(call.name, 'utf8');
184
+ const args = serializeToolArguments(
185
+ call.args,
186
+ maxBytes - bufferedBytes - identityBytes
187
+ );
188
+ const bytes = identityBytes + Buffer.byteLength(args, 'utf8');
189
+ bufferedBytes += bytes;
190
+ calls.push({
191
+ id,
192
+ name: call.name,
193
+ arguments: args,
194
+ bytes,
195
+ agentId: result.agentId,
196
+ messageId: result.messageId,
197
+ acceptedId: result.id,
198
+ });
199
+ }
200
+ acceptedTerminalKind = 'tool_call';
201
+ } catch (error) {
202
+ abort();
203
+ throw error;
204
+ }
205
+ };
206
+
207
+ return {
208
+ handlers: {
209
+ [GraphEvents.ON_MODEL_TOOLS_CLAIMED]: {
210
+ handle: (event, data): void => {
211
+ if (
212
+ event !== GraphEvents.ON_MODEL_TOOLS_CLAIMED ||
213
+ data == null ||
214
+ !('type' in data) ||
215
+ data.type !== 'model_tools_claimed'
216
+ )
217
+ return;
218
+ if (phase !== 'open')
219
+ throw new Error('Tool projection is already finalized');
220
+ checkCancellation();
221
+ discard(data.agentId, data.messageId);
222
+ },
223
+ },
224
+ [GraphEvents.ON_MODEL_RESPONSE]: {
225
+ handle: (event, data): void => {
226
+ if (
227
+ event !== GraphEvents.ON_MODEL_RESPONSE ||
228
+ data == null ||
229
+ !('type' in data) ||
230
+ data.type !== 'model_response'
231
+ )
232
+ return;
233
+ accept(data);
234
+ },
235
+ },
236
+ },
237
+ abort,
238
+ finish: (): void => {
239
+ if (phase === 'finished' || phase === 'emitting') return;
240
+ checkCancellation();
241
+ previousChunkKind = tracker?.lastChunkKind;
242
+ phase = 'emitting';
243
+ try {
244
+ if (toolCalls.size !== 0)
245
+ throw new Error(
246
+ 'Tool projection output was modified during collection'
247
+ );
248
+ // Do not allocate synthetic IDs until every accepted response has arrived.
249
+ // This reserves provider IDs even when they occur in a later invocation.
250
+ const usedIds = new Set<string>();
251
+ const ready: OpenAIToolCall[] = [];
252
+ let reservedBytes = bufferedBytes;
253
+ let nextSyntheticId = 0;
254
+ for (const call of calls) {
255
+ let id = call.id;
256
+ if (id === '' || usedIds.has(id)) {
257
+ do {
258
+ id = `call_${nextSyntheticId++}`;
259
+ } while (providerIds.has(id) || usedIds.has(id));
260
+ reservedBytes +=
261
+ Buffer.byteLength(id, 'utf8') -
262
+ Buffer.byteLength(call.id, 'utf8');
263
+ if (reservedBytes > maxBytes)
264
+ throw new Error('Tool projection buffer limit exceeded');
265
+ }
266
+ usedIds.add(id);
267
+ ready.push(
268
+ Object.freeze({
269
+ id,
270
+ type: 'function',
271
+ function: Object.freeze({
272
+ name: call.name,
273
+ arguments: call.arguments,
274
+ }),
275
+ })
276
+ );
277
+ }
278
+ if (tracker != null && ready.length > 0 && !tracker.hasRole) {
279
+ tracker.hasRole = true;
280
+ emitDelta({ role: 'assistant' });
281
+ checkCancellation();
282
+ }
283
+ for (let index = 0; index < ready.length; index++) {
284
+ checkCancellation();
285
+ const call = ready[index];
286
+ toolCalls.set(index, call);
287
+ if (tracker != null) tracker.lastChunkKind = acceptedTerminalKind;
288
+ emitDelta({
289
+ tool_calls: [
290
+ {
291
+ index,
292
+ id: call.id,
293
+ type: 'function',
294
+ function: { name: call.function.name, arguments: '' },
295
+ },
296
+ ],
297
+ });
298
+ checkCancellation();
299
+ emitDelta({
300
+ tool_calls: [
301
+ { index, function: { arguments: call.function.arguments } },
302
+ ],
303
+ });
304
+ checkCancellation();
305
+ }
306
+ if (tracker != null && acceptedTerminalKind != null) {
307
+ tracker.lastChunkKind = acceptedTerminalKind;
308
+ }
309
+ phase = 'finished';
310
+ } catch (error) {
311
+ abort();
312
+ throw error;
313
+ } finally {
314
+ release();
315
+ }
316
+ },
317
+ };
318
+ }