@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.
- package/README.md +32 -0
- package/dist/cjs/common/enum.cjs +2 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/events.cjs +11 -0
- package/dist/cjs/events.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +41 -1
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/acceptedModelResponse.cjs +168 -0
- package/dist/cjs/graphs/acceptedModelResponse.cjs.map +1 -0
- package/dist/cjs/llm/invoke.cjs +10 -5
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/llm/streamLimits.cjs +1 -1
- package/dist/cjs/llm/streamLimits.cjs.map +1 -1
- package/dist/cjs/main.cjs +2 -0
- package/dist/cjs/messages/fading.cjs +14 -6
- package/dist/cjs/messages/fading.cjs.map +1 -1
- package/dist/cjs/messages/prune.cjs +95 -36
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/openai/index.cjs +2 -0
- package/dist/cjs/openai/index.cjs.map +1 -1
- package/dist/cjs/openai/toolProjection.cjs +196 -0
- package/dist/cjs/openai/toolProjection.cjs.map +1 -0
- package/dist/cjs/run.cjs +8 -1
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/AgentSession.cjs +1 -1
- package/dist/cjs/session/AgentSession.cjs.map +1 -1
- package/dist/cjs/stream.cjs +9 -4
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +2 -1
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentReplay.cjs +4 -1
- package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -1
- package/dist/cjs/utils/acceptedToolArguments.cjs +143 -0
- package/dist/cjs/utils/acceptedToolArguments.cjs.map +1 -0
- package/dist/esm/common/enum.mjs +2 -0
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/events.mjs +11 -0
- package/dist/esm/events.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +41 -1
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/acceptedModelResponse.mjs +165 -0
- package/dist/esm/graphs/acceptedModelResponse.mjs.map +1 -0
- package/dist/esm/llm/invoke.mjs +10 -5
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/llm/streamLimits.mjs +1 -1
- package/dist/esm/llm/streamLimits.mjs.map +1 -1
- package/dist/esm/main.mjs +3 -3
- package/dist/esm/messages/fading.mjs +14 -7
- package/dist/esm/messages/fading.mjs.map +1 -1
- package/dist/esm/messages/prune.mjs +95 -37
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/openai/index.mjs +2 -1
- package/dist/esm/openai/index.mjs.map +1 -1
- package/dist/esm/openai/toolProjection.mjs +196 -0
- package/dist/esm/openai/toolProjection.mjs.map +1 -0
- package/dist/esm/run.mjs +8 -1
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/AgentSession.mjs +1 -1
- package/dist/esm/session/AgentSession.mjs.map +1 -1
- package/dist/esm/stream.mjs +9 -4
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +2 -1
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentReplay.mjs +5 -2
- package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -1
- package/dist/esm/utils/acceptedToolArguments.mjs +142 -0
- package/dist/esm/utils/acceptedToolArguments.mjs.map +1 -0
- package/dist/types/common/enum.d.ts +4 -0
- package/dist/types/graphs/Graph.d.ts +3 -1
- package/dist/types/graphs/acceptedModelResponse.d.ts +15 -0
- package/dist/types/messages/fading.d.ts +14 -2
- package/dist/types/messages/prune.d.ts +7 -0
- package/dist/types/openai/arguments.d.ts +2 -0
- package/dist/types/openai/index.d.ts +2 -0
- package/dist/types/openai/toolProjection.d.ts +30 -0
- package/dist/types/run.d.ts +1 -0
- package/dist/types/tools/ToolNode.d.ts +1 -1
- package/dist/types/types/graph.d.ts +5 -3
- package/dist/types/types/run.d.ts +5 -0
- package/dist/types/types/stream.d.ts +22 -1
- package/dist/types/types/tools.d.ts +2 -0
- package/dist/types/utils/acceptedToolArguments.d.ts +10 -0
- package/package.json +1 -1
- package/src/common/enum.ts +5 -0
- package/src/events.ts +24 -1
- package/src/graphs/Graph.ts +95 -0
- package/src/graphs/acceptedModelResponse.ts +307 -0
- package/src/llm/invoke.ts +39 -14
- package/src/llm/streamLimits.ts +1 -1
- package/src/messages/fading.ts +30 -5
- package/src/messages/prune.ts +168 -55
- package/src/openai/arguments.ts +2 -0
- package/src/openai/index.ts +6 -0
- package/src/openai/toolProjection.ts +318 -0
- package/src/run.ts +21 -0
- package/src/session/AgentSession.ts +2 -2
- package/src/stream.ts +21 -1
- package/src/tools/ToolNode.ts +5 -0
- package/src/tools/subagent/SubagentReplay.ts +10 -9
- package/src/types/graph.ts +15 -9
- package/src/types/run.ts +5 -0
- package/src/types/stream.ts +26 -0
- package/src/types/tools.ts +2 -0
- package/src/utils/acceptedToolArguments.ts +204 -0
package/src/messages/prune.ts
CHANGED
|
@@ -1556,7 +1556,28 @@ function cloneAIMessageWithProjectedStreamContent(
|
|
|
1556
1556
|
) as AIMessage | AIMessageChunk;
|
|
1557
1557
|
}
|
|
1558
1558
|
|
|
1559
|
-
|
|
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(
|
|
1568
|
-
|
|
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
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
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
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
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
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
!
|
|
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
|
-
? {
|
|
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
|
-
|
|
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('"
|
|
1810
|
-
value.includes('"
|
|
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 =
|
|
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 (
|
|
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… [
|
|
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
|
-
|
|
2069
|
-
|
|
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
|
|
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
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
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;
|
package/src/openai/index.ts
CHANGED
|
@@ -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
|
+
}
|