@librechat/agents 3.9.0 → 3.9.2

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 (49) hide show
  1. package/dist/cjs/graphs/Graph.cjs +3 -1
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/graphs/MultiAgentGraph.cjs +4 -2
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  5. package/dist/cjs/llm/prepareProviderRequest.cjs +3 -2
  6. package/dist/cjs/llm/prepareProviderRequest.cjs.map +1 -1
  7. package/dist/cjs/main.cjs +5 -1
  8. package/dist/cjs/messages/contextPruning.cjs +2 -1
  9. package/dist/cjs/messages/contextPruning.cjs.map +1 -1
  10. package/dist/cjs/messages/handoffCue.cjs +27 -0
  11. package/dist/cjs/messages/handoffCue.cjs.map +1 -1
  12. package/dist/cjs/messages/index.cjs +1 -1
  13. package/dist/cjs/messages/prune.cjs +3 -3
  14. package/dist/cjs/messages/prune.cjs.map +1 -1
  15. package/dist/cjs/utils/toolContent.cjs +6 -6
  16. package/dist/cjs/utils/toolContent.cjs.map +1 -1
  17. package/dist/cjs/utils/truncation.cjs +14 -5
  18. package/dist/cjs/utils/truncation.cjs.map +1 -1
  19. package/dist/esm/graphs/Graph.mjs +3 -1
  20. package/dist/esm/graphs/Graph.mjs.map +1 -1
  21. package/dist/esm/graphs/MultiAgentGraph.mjs +4 -2
  22. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  23. package/dist/esm/llm/prepareProviderRequest.mjs +3 -2
  24. package/dist/esm/llm/prepareProviderRequest.mjs.map +1 -1
  25. package/dist/esm/main.mjs +3 -3
  26. package/dist/esm/messages/contextPruning.mjs +2 -1
  27. package/dist/esm/messages/contextPruning.mjs.map +1 -1
  28. package/dist/esm/messages/handoffCue.mjs +25 -1
  29. package/dist/esm/messages/handoffCue.mjs.map +1 -1
  30. package/dist/esm/messages/index.mjs +1 -1
  31. package/dist/esm/messages/prune.mjs +4 -4
  32. package/dist/esm/messages/prune.mjs.map +1 -1
  33. package/dist/esm/utils/toolContent.mjs +7 -7
  34. package/dist/esm/utils/toolContent.mjs.map +1 -1
  35. package/dist/esm/utils/truncation.mjs +14 -6
  36. package/dist/esm/utils/truncation.mjs.map +1 -1
  37. package/dist/types/llm/prepareProviderRequest.d.ts +1 -1
  38. package/dist/types/messages/handoffCue.d.ts +11 -0
  39. package/dist/types/utils/toolContent.d.ts +1 -1
  40. package/dist/types/utils/truncation.d.ts +2 -0
  41. package/package.json +1 -1
  42. package/src/graphs/Graph.ts +10 -4
  43. package/src/graphs/MultiAgentGraph.ts +23 -10
  44. package/src/llm/prepareProviderRequest.ts +6 -4
  45. package/src/messages/contextPruning.ts +8 -2
  46. package/src/messages/handoffCue.ts +51 -0
  47. package/src/messages/prune.ts +15 -10
  48. package/src/utils/toolContent.ts +23 -7
  49. package/src/utils/truncation.ts +31 -6
@@ -4,6 +4,8 @@
4
4
  * Prevents oversized tool outputs from entering the message array and
5
5
  * consuming the entire context window.
6
6
  */
7
+ /** Slices by UTF-16 code units, dropping orphaned surrogate halves at either edge. */
8
+ export declare function sliceWithoutSplittingSurrogates(value: string, start: number, end?: number): string;
7
9
  /**
8
10
  * Absolute hard cap on tool result length (characters).
9
11
  * Even if the model has a 1M-token context, a single tool result
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.9.0",
3
+ "version": "3.9.2",
4
4
  "reova": {
5
5
  "enabled": true,
6
6
  "endpoint": "https://telemetry.reo.dev/data"
@@ -70,6 +70,7 @@ import {
70
70
  convertInjectedMessages,
71
71
  coalesceAdjacentUserTurns,
72
72
  appendPredecessorHandoffCue,
73
+ appendInstructionlessHandoffCue,
73
74
  stampSyntheticProviderMessage,
74
75
  } from '@/messages';
75
76
  import {
@@ -3721,11 +3722,16 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
3721
3722
  * Applied HERE for the primary so the cue is part of the MEASURED
3722
3723
  * payload — the pre-invoke projection and overflow guard run on this
3723
3724
  * stage's output, and a post-measure append could push a just-fits
3724
- * prompt over budget unreported (#346 round 2). The attemptInvoke
3725
- * funnel re-keys per SERVING provider: it strips this cue for a
3726
- * tolerant fallback and adds it for a Claude fallback behind a
3727
- * tolerant primary.
3725
+ * prompt over budget unreported (#346 round 2). Instructionless tool
3726
+ * handoffs are grounded for every transport. Only the direct-edge
3727
+ * predecessor cue is re-keyed per SERVING provider: tolerant
3728
+ * fallbacks strip it, while Claude fallbacks add it.
3728
3729
  */
3730
+ const beforeHandoffCue = transformed;
3731
+ transformed = trackProviderMessageOrigins(
3732
+ beforeHandoffCue,
3733
+ appendInstructionlessHandoffCue(beforeHandoffCue, callConfig)
3734
+ );
3729
3735
  if (isAnthropicLike(provider, clientOptions as { model?: string })) {
3730
3736
  const before = transformed;
3731
3737
  transformed = trackProviderMessageOrigins(
@@ -28,12 +28,13 @@ import {
28
28
  setProviderMessageProvenance,
29
29
  stampSyntheticProviderMessage,
30
30
  } from '@/messages/provenance';
31
- import { serializeToolContentBounded } from '@/utils/toolContent';
32
- import { Constants, MULTI_AGENT_GRAPH_RUN_NAME } from '@/common';
33
31
  import {
34
32
  calculateMaxToolResultChars,
35
33
  HARD_MAX_TOOL_RESULT_CHARS,
36
34
  } from '@/utils/truncation';
35
+ import { withInstructionlessHandoffCue } from '@/messages/handoffCue';
36
+ import { serializeToolContentBounded } from '@/utils/toolContent';
37
+ import { Constants, MULTI_AGENT_GRAPH_RUN_NAME } from '@/common';
37
38
  import { StandardGraph } from './Graph';
38
39
 
39
40
  /** Pattern to extract instructions from transfer ToolMessage content */
@@ -1354,12 +1355,6 @@ export class MultiAgentGraph extends StandardGraph {
1354
1355
  this.memberRecursionLimit == null
1355
1356
  ? config
1356
1357
  : { ...config, recursionLimit: this.memberRecursionLimit };
1357
- const memberConfig = withActiveAgentMetadata(
1358
- recursionLimitedConfig,
1359
- agentId,
1360
- agentContext?.name
1361
- );
1362
-
1363
1358
  /**
1364
1359
  * Check if this agent is receiving a handoff.
1365
1360
  * If so, filter out the transfer messages and inject instructions as preamble.
@@ -1371,6 +1366,19 @@ export class MultiAgentGraph extends StandardGraph {
1371
1366
  agentId
1372
1367
  );
1373
1368
 
1369
+ const memberConfig = withInstructionlessHandoffCue(
1370
+ withActiveAgentMetadata(
1371
+ recursionLimitedConfig,
1372
+ agentId,
1373
+ agentContext?.name
1374
+ ),
1375
+ handoffContext != null &&
1376
+ (handoffContext.instructions == null ||
1377
+ handoffContext.instructions === '')
1378
+ ? handoffContext.filteredMessages.at(-1)
1379
+ : undefined
1380
+ );
1381
+
1374
1382
  if (
1375
1383
  handoffContext?.sourceAgentName != null &&
1376
1384
  handoffContext.sourceAgentName !== ''
@@ -1582,7 +1590,9 @@ export class MultiAgentGraph extends StandardGraph {
1582
1590
  }
1583
1591
 
1584
1592
  const startingNodes =
1585
- summarizeOnlyAgentId != null ? [summarizeOnlyAgentId] : this.startingNodes;
1593
+ summarizeOnlyAgentId != null
1594
+ ? [summarizeOnlyAgentId]
1595
+ : this.startingNodes;
1586
1596
  for (const startNode of startingNodes) {
1587
1597
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1588
1598
  /** @ts-ignore */
@@ -1634,7 +1644,10 @@ export class MultiAgentGraph extends StandardGraph {
1634
1644
  this.resolveMaxRoutingPromptChars(destination);
1635
1645
 
1636
1646
  if (typeof prompt === 'function') {
1637
- const resolvedPrompt = await prompt(state.messages, this.startIndex);
1647
+ const resolvedPrompt = await prompt(
1648
+ state.messages,
1649
+ this.startIndex
1650
+ );
1638
1651
  promptText =
1639
1652
  resolvedPrompt == null
1640
1653
  ? undefined
@@ -6,8 +6,8 @@ import type {
6
6
  } from '@langchain/core/messages';
7
7
  import type { RunnableConfig } from '@langchain/core/runnables';
8
8
  import type { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
9
- import type * as t from '@/types';
10
9
  import type { ToolHistoryPreparation } from '@/messages/toolHistoryProjection';
10
+ import type * as t from '@/types';
11
11
  import {
12
12
  projectCacheControlledToolOutputsToText,
13
13
  projectComputerCallOutputsToText,
@@ -21,6 +21,7 @@ import {
21
21
  import {
22
22
  coalesceAdjacentUserTurns,
23
23
  appendPredecessorHandoffCue,
24
+ appendInstructionlessHandoffCue,
24
25
  removePredecessorHandoffCue,
25
26
  } from '@/messages';
26
27
  import {
@@ -28,12 +29,12 @@ import {
28
29
  stripBedrockCacheControl,
29
30
  cloneMessage,
30
31
  } from '@/messages/cache';
32
+ import { createToolHistoryPreparation } from '@/messages/toolHistoryProjection';
31
33
  import { isAnthropicLike, isGoogleLike, isOpenAILike } from '@/utils/llm';
32
34
  import { annotateMessagesForLLM } from '@/tools/toolOutputReferences';
33
35
  import { providerRequiresStrictAlternation } from '@/llm/providers';
34
36
  import { getProviderFamily } from '@/llm/providerRegistry';
35
37
  import { Providers } from '@/common';
36
- import { createToolHistoryPreparation } from '@/messages/toolHistoryProjection';
37
38
 
38
39
  const preparedProviderRequestBrand = Symbol('PreparedProviderRequest');
39
40
  const OMITTED_ATTACHMENT_TEXT =
@@ -555,16 +556,17 @@ export function prepareProviderRequest({
555
556
  const annotated = annotateMessagesForLLM(projected, registry, runId);
556
557
  const isRunProduced = context?.isRunProducedMessage;
557
558
  const modelId = resolveServingModelId(model);
559
+ const handoffGrounded = appendInstructionlessHandoffCue(annotated, config);
558
560
  const cued = isAnthropicLike(provider, {
559
561
  model: modelId,
560
562
  })
561
563
  ? appendPredecessorHandoffCue(
562
- annotated,
564
+ handoffGrounded,
563
565
  isRunProduced == null
564
566
  ? undefined
565
567
  : (message): boolean => isRunProduced.call(context, message)
566
568
  )
567
- : removePredecessorHandoffCue(annotated);
569
+ : removePredecessorHandoffCue(handoffGrounded);
568
570
  const preparedMessages = providerRequiresStrictAlternation(provider)
569
571
  ? coalesceAdjacentUserTurns(cued)
570
572
  : cued;
@@ -26,6 +26,7 @@ import {
26
26
  serializeToolContentBounded,
27
27
  } from '@/utils/toolContent';
28
28
  import { resolveContextPruningSettings } from './contextPruningSettings';
29
+ import { sliceWithoutSplittingSurrogates } from '@/utils/truncation';
29
30
 
30
31
  /**
31
32
  * Applies head+tail soft-trim to tool result content.
@@ -36,7 +37,11 @@ function softTrimContent(
36
37
  ): string {
37
38
  const { headChars, tailChars } = settings;
38
39
  const indicator = `\n\n… [soft-trimmed: ${content.length} chars → ${headChars + tailChars} chars, middle removed] …\n\n`;
39
- return content.slice(0, headChars) + indicator + content.slice(-tailChars);
40
+ return (
41
+ sliceWithoutSplittingSurrogates(content, 0, headChars) +
42
+ indicator +
43
+ sliceWithoutSplittingSurrogates(content, -tailChars)
44
+ );
40
45
  }
41
46
 
42
47
  export interface ContextPruningResult {
@@ -140,7 +145,8 @@ export function applyContextPruning(params: {
140
145
  }
141
146
  const content = message.content;
142
147
  const contentLength = getToolContentCharLength(content);
143
- const eligibilityContent = params.canonicalMessages?.[i]?.content ?? content;
148
+ const eligibilityContent =
149
+ params.canonicalMessages?.[i]?.content ?? content;
144
150
  if (
145
151
  getToolContentCharLength(eligibilityContent) <
146
152
  settings.minPrunableToolChars
@@ -1,8 +1,59 @@
1
1
  // src/messages/handoffCue.ts
2
2
  import { HumanMessage } from '@langchain/core/messages';
3
+ import type { RunnableConfig } from '@langchain/core/runnables';
3
4
  import type { BaseMessage } from '@langchain/core/messages';
4
5
  import { stampSyntheticProviderMessage } from './provenance';
5
6
 
7
+ const HANDOFF_CUE_MESSAGE_ID = '__handoff_cue_message_id';
8
+ export const INSTRUCTIONLESS_HANDOFF_CUE =
9
+ 'Continue as the receiving agent using the preceding user request and context.';
10
+
11
+ /** Scope transport-only grounding to this recipient's incoming assistant turn.
12
+ * Every agent entry resets the marker, including direct edges and cycles. The
13
+ * reducer has already assigned the source message's id, including on replay. */
14
+ export function withInstructionlessHandoffCue(
15
+ config: RunnableConfig | undefined,
16
+ tail?: BaseMessage
17
+ ): RunnableConfig {
18
+ return {
19
+ ...config,
20
+ metadata: {
21
+ ...config?.metadata,
22
+ [HANDOFF_CUE_MESSAGE_ID]:
23
+ tail?.getType() === 'ai' ? (tail.id ?? null) : null,
24
+ },
25
+ };
26
+ }
27
+
28
+ /** Only provider projections get this cue. Matching the incoming turn's id
29
+ * prevents it leaking into later tool iterations or deliberate assistant
30
+ * prefill. It is independent of transport provider and run-produced ids, so
31
+ * gateways and checkpoint replays follow the same handoff contract. */
32
+ export function appendInstructionlessHandoffCue(
33
+ messages: BaseMessage[],
34
+ config?: RunnableConfig
35
+ ): BaseMessage[] {
36
+ const tail = messages.at(-1);
37
+ const sourceId = config?.metadata?.[HANDOFF_CUE_MESSAGE_ID];
38
+ if (
39
+ typeof sourceId !== 'string' ||
40
+ sourceId === '' ||
41
+ tail?.getType() !== 'ai' ||
42
+ tail.id !== sourceId
43
+ ) {
44
+ return messages;
45
+ }
46
+ return [
47
+ ...messages,
48
+ stampSyntheticProviderMessage(
49
+ new HumanMessage({
50
+ content: INSTRUCTIONLESS_HANDOFF_CUE,
51
+ additional_kwargs: { role: 'user', isMeta: true, source: 'routing' },
52
+ })
53
+ ),
54
+ ];
55
+ }
56
+
6
57
  /**
7
58
  * Bracketed-meta convention, like the handoff path's
8
59
  * `[Processed tool result and transferring to …]` bridge. The wording makes
@@ -19,6 +19,7 @@ import {
19
19
  HARD_MAX_TOOL_CALL_INPUT_CHARS,
20
20
  HARD_MAX_TOOL_RESULT_CHARS,
21
21
  MIN_JSON_VALUE_CHARS,
22
+ sliceWithoutSplittingSurrogates,
22
23
  calculateMaxToolCallInputChars,
23
24
  calculateMaxToolResultChars,
24
25
  } from '@/utils/truncation';
@@ -1591,7 +1592,8 @@ function createBoundedTruncationValue(
1591
1592
  const next = Math.ceil((low + high) / 2);
1592
1593
  const candidate = {
1593
1594
  _truncated:
1594
- TOOL_INPUT_TRUNCATION_MARKER + canonicalPrefix.slice(0, next),
1595
+ TOOL_INPUT_TRUNCATION_MARKER +
1596
+ sliceWithoutSplittingSurrogates(canonicalPrefix, 0, next),
1595
1597
  _originalChars: originalChars,
1596
1598
  };
1597
1599
  if (JSON.stringify(candidate).length <= normalizedMaxChars) {
@@ -1604,7 +1606,8 @@ function createBoundedTruncationValue(
1604
1606
  // Keep the marker separate from a pure canonical prefix so another,
1605
1607
  // slightly smaller cap can be derived without nesting the envelope.
1606
1608
  _truncated:
1607
- TOOL_INPUT_TRUNCATION_MARKER + canonicalPrefix.slice(0, low),
1609
+ TOOL_INPUT_TRUNCATION_MARKER +
1610
+ sliceWithoutSplittingSurrogates(canonicalPrefix, 0, low),
1608
1611
  _originalChars: originalChars,
1609
1612
  };
1610
1613
  }
@@ -1847,8 +1850,12 @@ function projectStringInputWithinLimit(
1847
1850
  return {
1848
1851
  value:
1849
1852
  marker.length >= normalizedMaxChars
1850
- ? prefix.slice(0, normalizedMaxChars)
1851
- : prefix.slice(0, normalizedMaxChars - marker.length) + marker,
1853
+ ? sliceWithoutSplittingSurrogates(prefix, 0, normalizedMaxChars)
1854
+ : sliceWithoutSplittingSurrogates(
1855
+ prefix,
1856
+ 0,
1857
+ normalizedMaxChars - marker.length
1858
+ ) + marker,
1852
1859
  changed: true,
1853
1860
  };
1854
1861
  }
@@ -2366,7 +2373,9 @@ function applyToolCallInputCaps(params: {
2366
2373
  additionalKwargsChanges
2367
2374
  );
2368
2375
  }
2369
- if (capped.response_metadata.output !== canonical.response_metadata.output) {
2376
+ if (
2377
+ capped.response_metadata.output !== canonical.response_metadata.output
2378
+ ) {
2370
2379
  changes.response_metadata = cloneWithProjectedProperties(
2371
2380
  current.response_metadata,
2372
2381
  { output: capped.response_metadata.output }
@@ -2523,11 +2532,7 @@ export function createPruneMessages(factoryParams: PruneMessagesFactoryParams) {
2523
2532
  originalToolContent.clear();
2524
2533
  originalToolContentSize = 0;
2525
2534
  }
2526
- for (
2527
- let i = toolExchangeWidthThrough;
2528
- i < canonicalMessages.length;
2529
- i++
2530
- ) {
2535
+ for (let i = toolExchangeWidthThrough; i < canonicalMessages.length; i++) {
2531
2536
  maxToolExchangeWidth = Math.max(
2532
2537
  maxToolExchangeWidth,
2533
2538
  getToolCallIds(canonicalMessages[i]).size
@@ -3,6 +3,7 @@ import { ToolMessage, type BaseMessage } from '@langchain/core/messages';
3
3
  import {
4
4
  HARD_MAX_TOTAL_TOOL_OUTPUT_SIZE,
5
5
  truncateToolResultContent,
6
+ sliceWithoutSplittingSurrogates,
6
7
  } from './truncation';
7
8
 
8
9
  type ToolContent = BaseMessage['content'];
@@ -421,7 +422,7 @@ type SegmentedStringBuffer = {
421
422
  export type BoundedStructuredSerialization = {
422
423
  /** Provider-facing head/tail preview, bounded by `maxChars`. */
423
424
  content: string;
424
- /** Exact serialized prefix up to the defensive traversal-work ceiling. */
425
+ /** Code-point-aligned serialized prefix up to the traversal-work ceiling. */
425
426
  prefix: string;
426
427
  /** Exact length, or `Number.MAX_SAFE_INTEGER` when traversal was capped. */
427
428
  originalChars: number;
@@ -505,6 +506,10 @@ function materializeSegmentedStringBuffer(
505
506
  return segments.join('');
506
507
  }
507
508
 
509
+ /**
510
+ * Retains exact code-unit prefixes internally. Trimming surrogate edges here
511
+ * would let later chunks fill the gap and corrupt the prefix; trim on exposure.
512
+ */
508
513
  function appendSerializedChunk(
509
514
  collector: StructuredSerializationCollector,
510
515
  chunk: string
@@ -953,7 +958,7 @@ function formatBoundedStructuredContent(
953
958
  suffix: string
954
959
  ): string {
955
960
  if (collector.totalChars <= maxChars) {
956
- return prefix.slice(0, collector.totalChars);
961
+ return sliceWithoutSplittingSurrogates(prefix, 0, collector.totalChars);
957
962
  }
958
963
 
959
964
  const indicator = collector.work.exceeded
@@ -962,10 +967,13 @@ function formatBoundedStructuredContent(
962
967
  `${maxChars} limit] …\n\n`;
963
968
  const available = maxChars - indicator.length;
964
969
  if (available <= 0) {
965
- return prefix.slice(0, maxChars);
970
+ return sliceWithoutSplittingSurrogates(prefix, 0, maxChars);
966
971
  }
967
972
  if (available < 200) {
968
- return prefix.slice(0, available) + indicator.trimEnd();
973
+ return (
974
+ sliceWithoutSplittingSurrogates(prefix, 0, available) +
975
+ indicator.trimEnd()
976
+ );
969
977
  }
970
978
 
971
979
  const headSize = Math.ceil(available * 0.7);
@@ -982,7 +990,11 @@ function formatBoundedStructuredContent(
982
990
  tailStart = tailNewline + 1;
983
991
  }
984
992
 
985
- return prefix.slice(0, headEnd) + indicator + suffix.slice(tailStart);
993
+ return (
994
+ sliceWithoutSplittingSurrogates(prefix, 0, headEnd) +
995
+ indicator +
996
+ sliceWithoutSplittingSurrogates(suffix, tailStart)
997
+ );
986
998
  }
987
999
 
988
1000
  /**
@@ -1036,7 +1048,7 @@ export function serializeStructuredValueBounded(
1036
1048
  prefix,
1037
1049
  suffix
1038
1050
  ),
1039
- prefix: prefix.slice(0, normalizedPrefixChars),
1051
+ prefix: sliceWithoutSplittingSurrogates(prefix, 0, normalizedPrefixChars),
1040
1052
  originalChars,
1041
1053
  truncated:
1042
1054
  collector.work.exceeded || collector.totalChars > normalizedMaxChars,
@@ -1931,7 +1943,11 @@ function serializeDenseTextBlocksWithinLimit(
1931
1943
  }
1932
1944
  preview += textBlocks[i].text.slice(0, available - preview.length);
1933
1945
  }
1934
- return (preview + indicator).slice(0, normalizedMaxChars);
1946
+ return sliceWithoutSplittingSurrogates(
1947
+ sliceWithoutSplittingSurrogates(preview, 0) + indicator,
1948
+ 0,
1949
+ normalizedMaxChars
1950
+ );
1935
1951
  }
1936
1952
 
1937
1953
  export type CompactToolContentResult = {
@@ -5,6 +5,22 @@
5
5
  * consuming the entire context window.
6
6
  */
7
7
 
8
+ /** Slices by UTF-16 code units, dropping orphaned surrogate halves at either edge. */
9
+ export function sliceWithoutSplittingSurrogates(
10
+ value: string,
11
+ start: number,
12
+ end?: number
13
+ ): string {
14
+ const sliced = value.slice(start, end);
15
+ const first = sliced.charCodeAt(0);
16
+ const last = sliced.charCodeAt(sliced.length - 1);
17
+ const trimStart = first >= 0xdc00 && first <= 0xdfff;
18
+ const trimEnd = last >= 0xd800 && last <= 0xdbff;
19
+ return trimStart || trimEnd
20
+ ? sliced.slice(trimStart ? 1 : 0, sliced.length - (trimEnd ? 1 : 0))
21
+ : sliced;
22
+ }
23
+
8
24
  /**
9
25
  * Absolute hard cap on tool result length (characters).
10
26
  * Even if the model has a 1M-token context, a single tool result
@@ -85,7 +101,9 @@ export function truncateToolInput(
85
101
 
86
102
  if (available < 100) {
87
103
  return {
88
- _truncated: serialized.slice(0, maxChars) + indicator.trimEnd(),
104
+ _truncated:
105
+ sliceWithoutSplittingSurrogates(serialized, 0, maxChars) +
106
+ indicator.trimEnd(),
89
107
  _originalChars: serialized.length,
90
108
  };
91
109
  }
@@ -95,9 +113,9 @@ export function truncateToolInput(
95
113
 
96
114
  return {
97
115
  _truncated:
98
- serialized.slice(0, headSize) +
116
+ sliceWithoutSplittingSurrogates(serialized, 0, headSize) +
99
117
  indicator +
100
- serialized.slice(serialized.length - tailSize),
118
+ sliceWithoutSplittingSurrogates(serialized, serialized.length - tailSize),
101
119
  _originalChars: serialized.length,
102
120
  };
103
121
  }
@@ -126,12 +144,15 @@ export function truncateToolResultContent(
126
144
  const indicator = `\n\n… [truncated: ${content.length} chars exceeded ${maxChars} limit] …\n\n`;
127
145
  const available = maxChars - indicator.length;
128
146
  if (available <= 0) {
129
- return content.slice(0, maxChars);
147
+ return sliceWithoutSplittingSurrogates(content, 0, maxChars);
130
148
  }
131
149
 
132
150
  // When budget is too small for a meaningful tail, fall back to head-only
133
151
  if (available < 200) {
134
- return content.slice(0, available) + indicator.trimEnd();
152
+ return (
153
+ sliceWithoutSplittingSurrogates(content, 0, available) +
154
+ indicator.trimEnd()
155
+ );
135
156
  }
136
157
 
137
158
  const headSize = Math.ceil(available * 0.7);
@@ -150,7 +171,11 @@ export function truncateToolResultContent(
150
171
  tailStart = tailNewline + 1;
151
172
  }
152
173
 
153
- return content.slice(0, headEnd) + indicator + content.slice(tailStart);
174
+ return (
175
+ sliceWithoutSplittingSurrogates(content, 0, headEnd) +
176
+ indicator +
177
+ sliceWithoutSplittingSurrogates(content, tailStart)
178
+ );
154
179
  }
155
180
 
156
181
  /** Absolute hard cap on a single tool-call input (characters). */