@librechat/agents 3.2.68 → 3.3.1

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 (159) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +117 -3
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/enum.cjs +4 -0
  4. package/dist/cjs/common/enum.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +243 -49
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/graphs/MultiAgentGraph.cjs +103 -28
  8. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  9. package/dist/cjs/langfuseToolOutputTracing.cjs +4 -0
  10. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  11. package/dist/cjs/llm/bedrock/index.cjs +11 -2
  12. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  13. package/dist/cjs/llm/contextOverflowRecovery.cjs +132 -0
  14. package/dist/cjs/llm/contextOverflowRecovery.cjs.map +1 -0
  15. package/dist/cjs/llm/google/index.cjs +1 -1
  16. package/dist/cjs/llm/invoke.cjs +60 -3
  17. package/dist/cjs/llm/invoke.cjs.map +1 -1
  18. package/dist/cjs/llm/openai/index.cjs +1 -1
  19. package/dist/cjs/main.cjs +21 -7
  20. package/dist/cjs/messages/format.cjs +136 -4
  21. package/dist/cjs/messages/format.cjs.map +1 -1
  22. package/dist/cjs/messages/prune.cjs +16 -5
  23. package/dist/cjs/messages/prune.cjs.map +1 -1
  24. package/dist/cjs/messages/recency.cjs +2 -0
  25. package/dist/cjs/messages/recency.cjs.map +1 -1
  26. package/dist/cjs/prompts/activityLabel.cjs +101 -0
  27. package/dist/cjs/prompts/activityLabel.cjs.map +1 -0
  28. package/dist/cjs/run.cjs +177 -3
  29. package/dist/cjs/run.cjs.map +1 -1
  30. package/dist/cjs/stream.cjs +205 -49
  31. package/dist/cjs/stream.cjs.map +1 -1
  32. package/dist/cjs/summarization/node.cjs +55 -7
  33. package/dist/cjs/summarization/node.cjs.map +1 -1
  34. package/dist/cjs/tools/BashExecutor.cjs +2 -2
  35. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  36. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +2 -2
  37. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  38. package/dist/cjs/tools/CodeExecutor.cjs +69 -8
  39. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  40. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +19 -15
  41. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  42. package/dist/cjs/tools/ToolNode.cjs +32 -3
  43. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  44. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -1
  45. package/dist/cjs/utils/errors.cjs +317 -52
  46. package/dist/cjs/utils/errors.cjs.map +1 -1
  47. package/dist/esm/agents/AgentContext.mjs +118 -4
  48. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  49. package/dist/esm/common/enum.mjs +4 -0
  50. package/dist/esm/common/enum.mjs.map +1 -1
  51. package/dist/esm/graphs/Graph.mjs +242 -48
  52. package/dist/esm/graphs/Graph.mjs.map +1 -1
  53. package/dist/esm/graphs/MultiAgentGraph.mjs +103 -28
  54. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  55. package/dist/esm/langfuseToolOutputTracing.mjs +4 -1
  56. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  57. package/dist/esm/llm/bedrock/index.mjs +11 -2
  58. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  59. package/dist/esm/llm/contextOverflowRecovery.mjs +130 -0
  60. package/dist/esm/llm/contextOverflowRecovery.mjs.map +1 -0
  61. package/dist/esm/llm/google/index.mjs +1 -1
  62. package/dist/esm/llm/invoke.mjs +59 -4
  63. package/dist/esm/llm/invoke.mjs.map +1 -1
  64. package/dist/esm/llm/openai/index.mjs +1 -1
  65. package/dist/esm/main.mjs +10 -10
  66. package/dist/esm/messages/format.mjs +136 -5
  67. package/dist/esm/messages/format.mjs.map +1 -1
  68. package/dist/esm/messages/prune.mjs +14 -6
  69. package/dist/esm/messages/prune.mjs.map +1 -1
  70. package/dist/esm/messages/recency.mjs +2 -1
  71. package/dist/esm/messages/recency.mjs.map +1 -1
  72. package/dist/esm/prompts/activityLabel.mjs +100 -0
  73. package/dist/esm/prompts/activityLabel.mjs.map +1 -0
  74. package/dist/esm/run.mjs +178 -4
  75. package/dist/esm/run.mjs.map +1 -1
  76. package/dist/esm/stream.mjs +205 -49
  77. package/dist/esm/stream.mjs.map +1 -1
  78. package/dist/esm/summarization/node.mjs +55 -7
  79. package/dist/esm/summarization/node.mjs.map +1 -1
  80. package/dist/esm/tools/BashExecutor.mjs +3 -3
  81. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  82. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +3 -3
  83. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  84. package/dist/esm/tools/CodeExecutor.mjs +62 -9
  85. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  86. package/dist/esm/tools/ProgrammaticToolCalling.mjs +20 -16
  87. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  88. package/dist/esm/tools/ToolNode.mjs +32 -3
  89. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  90. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -1
  91. package/dist/esm/utils/errors.mjs +317 -53
  92. package/dist/esm/utils/errors.mjs.map +1 -1
  93. package/dist/types/agents/AgentContext.d.ts +62 -3
  94. package/dist/types/common/enum.d.ts +5 -1
  95. package/dist/types/graphs/Graph.d.ts +16 -2
  96. package/dist/types/graphs/MultiAgentGraph.d.ts +1 -0
  97. package/dist/types/langfuseToolOutputTracing.d.ts +4 -0
  98. package/dist/types/llm/contextOverflowRecovery.d.ts +85 -0
  99. package/dist/types/llm/invoke.d.ts +35 -6
  100. package/dist/types/messages/format.d.ts +22 -0
  101. package/dist/types/messages/prune.d.ts +10 -2
  102. package/dist/types/messages/recency.d.ts +1 -0
  103. package/dist/types/prompts/activityLabel.d.ts +31 -0
  104. package/dist/types/run.d.ts +16 -0
  105. package/dist/types/tools/CodeExecutor.d.ts +14 -1
  106. package/dist/types/types/activityLabel.d.ts +53 -0
  107. package/dist/types/types/index.d.ts +1 -0
  108. package/dist/types/types/llm.d.ts +7 -4
  109. package/dist/types/types/stream.d.ts +7 -4
  110. package/dist/types/types/summarize.d.ts +22 -0
  111. package/dist/types/utils/__tests__/fixtures/contextOverflowSignatures.d.ts +40 -0
  112. package/dist/types/utils/errors.d.ts +65 -16
  113. package/dist/types/utils/redactSecrets.d.ts +3 -0
  114. package/package.json +7 -8
  115. package/src/agents/AgentContext.ts +188 -7
  116. package/src/agents/__tests__/AgentContext.overflow.test.ts +205 -0
  117. package/src/common/enum.ts +4 -0
  118. package/src/graphs/Graph.ts +409 -58
  119. package/src/graphs/MultiAgentGraph.ts +184 -46
  120. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +631 -0
  121. package/src/langfuseToolOutputTracing.ts +4 -1
  122. package/src/llm/__tests__/contextOverflowRecovery.test.ts +401 -0
  123. package/src/llm/__tests__/fallbackOverflow.test.ts +287 -0
  124. package/src/llm/bedrock/index.ts +25 -12
  125. package/src/llm/contextOverflowRecovery.ts +292 -0
  126. package/src/llm/invoke.ts +119 -4
  127. package/src/messages/foldToollessToolBlocks.test.ts +438 -0
  128. package/src/messages/format.ts +233 -5
  129. package/src/messages/prune.ts +24 -11
  130. package/src/messages/recency.ts +3 -1
  131. package/src/prompts/activityLabel.ts +177 -0
  132. package/src/run.ts +322 -3
  133. package/src/scripts/context-overflow-probe.ts +997 -0
  134. package/src/specs/activity-label-prompt.test.ts +128 -0
  135. package/src/specs/activity-label-trace-seed.test.ts +47 -0
  136. package/src/specs/agent-handoffs.test.ts +903 -1
  137. package/src/specs/bedrock-toolless.live.test.ts +123 -0
  138. package/src/specs/context-overflow-recovery.live.test.ts +213 -0
  139. package/src/splitStream.test.ts +882 -0
  140. package/src/stream.ts +315 -51
  141. package/src/summarization/__tests__/aggregator.test.ts +83 -0
  142. package/src/summarization/__tests__/node.test.ts +139 -0
  143. package/src/summarization/node.ts +99 -14
  144. package/src/tools/BashExecutor.ts +4 -2
  145. package/src/tools/BashProgrammaticToolCalling.ts +4 -7
  146. package/src/tools/CodeExecutor.ts +119 -8
  147. package/src/tools/ProgrammaticToolCalling.ts +29 -27
  148. package/src/tools/ToolNode.ts +50 -8
  149. package/src/tools/__tests__/CodeApiAuthHeaders.test.ts +297 -3
  150. package/src/types/activityLabel.ts +55 -0
  151. package/src/types/index.ts +1 -0
  152. package/src/types/llm.ts +8 -1
  153. package/src/types/stream.ts +7 -4
  154. package/src/types/summarize.ts +22 -0
  155. package/src/utils/__tests__/errors.test.ts +270 -0
  156. package/src/utils/__tests__/fixtures/contextOverflowSignatures.ts +336 -0
  157. package/src/utils/__tests__/redactSecrets.test.ts +56 -0
  158. package/src/utils/errors.ts +484 -66
  159. package/src/utils/redactSecrets.ts +61 -0
@@ -22,6 +22,7 @@ import type {
22
22
  SummaryContentBlock,
23
23
  ThinkingContentText,
24
24
  ToolCallContent,
25
+ ToolResultContent,
25
26
  ToolCallPart,
26
27
  TPayload,
27
28
  TMessage,
@@ -400,7 +401,8 @@ function hasMeaningfulAssistantContent(part: MessageContentComplex): boolean {
400
401
  part.type === ContentTypes.TOOL_CALL ||
401
402
  part.type === ContentTypes.ERROR ||
402
403
  part.type === ContentTypes.AGENT_UPDATE ||
403
- part.type === ContentTypes.SUMMARY
404
+ part.type === ContentTypes.SUMMARY ||
405
+ part.type === ContentTypes.ACTIVITY_LABEL
404
406
  ) {
405
407
  return false;
406
408
  }
@@ -798,7 +800,8 @@ function formatAssistantMessage(
798
800
  } else if (
799
801
  part.type === ContentTypes.ERROR ||
800
802
  part.type === ContentTypes.AGENT_UPDATE ||
801
- part.type === ContentTypes.SUMMARY
803
+ part.type === ContentTypes.SUMMARY ||
804
+ part.type === ContentTypes.ACTIVITY_LABEL
802
805
  ) {
803
806
  continue;
804
807
  } else {
@@ -914,6 +917,14 @@ function labelAllAgentContent(
914
917
 
915
918
  for (let i = 0; i < contentParts.length; i++) {
916
919
  const part = contentParts[i];
920
+ /** UI-only progress headers are not agent content and must not disturb
921
+ * agent state: a label with no `agentIdMap` entry would otherwise read
922
+ * as an agent change and flush the buffer mid-agent, splitting one
923
+ * agent's contiguous content into two labeled blocks. Skipped before
924
+ * any state transition below (mirrors the transfer path). */
925
+ if (part.type === ContentTypes.ACTIVITY_LABEL) {
926
+ continue;
927
+ }
917
928
  const agentId = agentIdMap[i];
918
929
 
919
930
  // If agent changed, flush previous buffer
@@ -1037,6 +1048,14 @@ export const labelContentByAgent = (
1037
1048
 
1038
1049
  for (let i = 0; i < contentParts.length; i++) {
1039
1050
  const part = contentParts[i];
1051
+ /** UI-only progress headers are not agent content and must not disturb
1052
+ * agent state: a label with no `agentIdMap` entry would otherwise look
1053
+ * like an agent change, flushing the buffer and resetting an open
1054
+ * transfer capture so the transferred agent's following chunks lose
1055
+ * their frame. Skipped before any state transition below. */
1056
+ if (part.type === ContentTypes.ACTIVITY_LABEL) {
1057
+ continue;
1058
+ }
1040
1059
  const agentId = agentIdMap[i];
1041
1060
 
1042
1061
  // Check if this is a transfer tool call
@@ -1806,6 +1825,65 @@ function appendMessageContent(
1806
1825
  continue;
1807
1826
  }
1808
1827
 
1828
+ // A `tool_call` content block appears either as the v1 standard shape
1829
+ // (`{ name, args }` at top level, which `@langchain/aws` maps to a Converse
1830
+ // toolUse) or this repo's `ToolCallContent` (`{ tool_call: { name, args,
1831
+ // output } }`, from `convertMessagesToContent` / persisted history). Handle
1832
+ // both, and emit any embedded output, so the name/args/result survive.
1833
+ if (block.type === 'tool_call') {
1834
+ hasToolUseBlock = true;
1835
+ const nested = (block as { tool_call?: ToolCallPart }).tool_call;
1836
+ const name = String(nested?.name ?? block.name ?? '');
1837
+ const rawArgs = nested?.args ?? block.args ?? {};
1838
+ const argsText =
1839
+ typeof rawArgs === 'string' ? rawArgs : JSON.stringify(rawArgs);
1840
+ textChunks.push(`${role}: [tool_use] ${name} ${argsText}`.trimEnd());
1841
+ const output = nested?.output;
1842
+ if (output != null && output !== '') {
1843
+ textChunks.push(`Tool: ${String(output)}`);
1844
+ }
1845
+ continue;
1846
+ }
1847
+
1848
+ // A `tool_result` content block (e.g. an AIMessage(tool_call) followed by a
1849
+ // user message carrying the result). Preserve nested image blocks as-is
1850
+ // instead of JSON-stringifying them through the generic fallback.
1851
+ if (block.type === 'tool_result') {
1852
+ hasToolUseBlock = true;
1853
+ const inner = (block as { content?: ToolResultContent['content'] })
1854
+ .content;
1855
+ if (typeof inner === 'string') {
1856
+ if (inner) {
1857
+ textChunks.push(`${role}: [tool_result] ${inner}`);
1858
+ }
1859
+ } else if (Array.isArray(inner)) {
1860
+ for (const innerBlock of inner as Array<
1861
+ string | ExtendedMessageContent
1862
+ >) {
1863
+ if (typeof innerBlock === 'string') {
1864
+ if (innerBlock) {
1865
+ textChunks.push(`${role}: [tool_result] ${innerBlock}`);
1866
+ }
1867
+ } else if (IMAGE_BLOCK_TYPES.has(innerBlock.type ?? '')) {
1868
+ flushTextChunks(textChunks, parts);
1869
+ parts.push({ ...innerBlock } as MessageContentComplex);
1870
+ } else {
1871
+ const innerText = innerBlock.text ?? innerBlock.input;
1872
+ textChunks.push(
1873
+ `${role}: [tool_result] ${
1874
+ typeof innerText === 'string' && innerText
1875
+ ? innerText
1876
+ : JSON.stringify(innerBlock)
1877
+ }`
1878
+ );
1879
+ }
1880
+ }
1881
+ } else if (inner != null) {
1882
+ textChunks.push(`${role}: [tool_result] ${JSON.stringify(inner)}`);
1883
+ }
1884
+ continue;
1885
+ }
1886
+
1809
1887
  const text = block.text ?? block.input;
1810
1888
  if (typeof text === 'string' && text) {
1811
1889
  textChunks.push(`${role}: ${text}`);
@@ -1834,11 +1912,26 @@ function appendToolCalls(
1834
1912
  return;
1835
1913
  }
1836
1914
  const aiMsg = msg as AIMessage;
1837
- if (!aiMsg.tool_calls || aiMsg.tool_calls.length === 0) {
1915
+ if (aiMsg.tool_calls && aiMsg.tool_calls.length > 0) {
1916
+ for (const tc of aiMsg.tool_calls) {
1917
+ textChunks.push(`AI: [tool_call] ${tc.name}(${JSON.stringify(tc.args)})`);
1918
+ }
1838
1919
  return;
1839
1920
  }
1840
- for (const tc of aiMsg.tool_calls) {
1841
- textChunks.push(`AI: [tool_call] ${tc.name}(${JSON.stringify(tc.args)})`);
1921
+ // Fall back to raw provider tool calls kept only in additional_kwargs.
1922
+ const rawToolCalls = aiMsg.additional_kwargs.tool_calls;
1923
+ if (!Array.isArray(rawToolCalls)) {
1924
+ return;
1925
+ }
1926
+ for (const tc of rawToolCalls) {
1927
+ const fn = (tc as { function?: { name?: string; arguments?: string } })
1928
+ .function;
1929
+ if (fn == null) {
1930
+ continue;
1931
+ }
1932
+ textChunks.push(
1933
+ `AI: [tool_call] ${String(fn.name ?? '')}(${String(fn.arguments ?? '')})`
1934
+ );
1842
1935
  }
1843
1936
  }
1844
1937
 
@@ -2024,6 +2117,141 @@ export function ensureThinkingBlockInMessages(
2024
2117
  return result;
2025
2118
  }
2026
2119
 
2120
+ /** Whether a message carries tool content a tool-less agent cannot legally
2121
+ * send. Covers every representation a provider converter will serialize back
2122
+ * into a request: a ToolMessage, parsed `AIMessage.tool_calls`, raw
2123
+ * `additional_kwargs.tool_calls` (OpenAI keeps calls here when the parsed
2124
+ * array is empty), and `tool_use` / `tool_call` / `tool_result` content
2125
+ * blocks (`@langchain/aws` and the Anthropic converter map these to Converse
2126
+ * `toolUse` / `toolResult`). Missing the parent AI message is not just a
2127
+ * passthrough: folding its ToolMessage alone would leave an orphan
2128
+ * `assistant(tool_calls) -> user(...)` sequence. */
2129
+ function messageHasToolContent(msg: BaseMessage): boolean {
2130
+ if (isToolMessage(msg)) {
2131
+ return true;
2132
+ }
2133
+ const aiMsg = msg as AIMessage;
2134
+ if (aiMsg.tool_calls != null && aiMsg.tool_calls.length > 0) {
2135
+ return true;
2136
+ }
2137
+ const rawToolCalls = aiMsg.additional_kwargs.tool_calls;
2138
+ if (Array.isArray(rawToolCalls) && rawToolCalls.length > 0) {
2139
+ return true;
2140
+ }
2141
+ if (Array.isArray(msg.content)) {
2142
+ for (const block of msg.content as ExtendedMessageContent[]) {
2143
+ if (
2144
+ typeof block === 'object' &&
2145
+ (block.type === 'tool_use' ||
2146
+ block.type === 'tool_call' ||
2147
+ block.type === 'tool_result')
2148
+ ) {
2149
+ return true;
2150
+ }
2151
+ }
2152
+ }
2153
+ return false;
2154
+ }
2155
+
2156
+ /** Whether a message carries a tool RESULT: a ToolMessage, or a message whose
2157
+ * content includes a `tool_result` block (the shape when a call/result pair is
2158
+ * split as `AIMessage(tool_call)` + `HumanMessage(tool_result)`). Such a result
2159
+ * belongs with the preceding tool call, so it is absorbed into the same fold
2160
+ * and labelled as tool output. */
2161
+ function isToolResultMessage(msg: BaseMessage): boolean {
2162
+ if (isToolMessage(msg)) {
2163
+ return true;
2164
+ }
2165
+ if (Array.isArray(msg.content)) {
2166
+ return (msg.content as ExtendedMessageContent[]).some(
2167
+ (block) => typeof block === 'object' && block.type === 'tool_result'
2168
+ );
2169
+ }
2170
+ return false;
2171
+ }
2172
+
2173
+ /**
2174
+ * Folds tool_use / tool_result content into plain text for an agent that binds
2175
+ * no tools.
2176
+ *
2177
+ * In a multi-agent graph, a tool-less destination still inherits the prior
2178
+ * agent's conversation history, which can contain toolUse/toolResult blocks.
2179
+ * Because it binds no tools, the model is invoked with no tool schema — and
2180
+ * Bedrock's Converse API rejects any request that carries toolUse/toolResult
2181
+ * blocks without a top-level toolConfig ("The toolConfig field must be defined
2182
+ * when using toolUse and toolResult content blocks"). Adding a dummy toolConfig
2183
+ * is not an option: AWS requires at least one tool, and it would expose a
2184
+ * capability the destination was intentionally denied.
2185
+ *
2186
+ * Each tool-call turn plus its trailing tool results (ToolMessages or
2187
+ * `tool_result` content blocks) is collapsed into a single `[Previous tool
2188
+ * interaction]` HumanMessage that preserves the tool name, arguments and result
2189
+ * as text (image blocks are kept as-is). Runs in a single pass: non-tool
2190
+ * messages pass through, `result` is allocated lazily on the first fold, and the
2191
+ * original array is returned unchanged when it holds no tool content (the common
2192
+ * fresh-tool-less-agent case).
2193
+ */
2194
+ export function foldToolBlocksForToollessAgent(
2195
+ messages: BaseMessage[],
2196
+ config?: RunnableConfig
2197
+ ): BaseMessage[] {
2198
+ let result: BaseMessage[] | null = null;
2199
+ let foldedCount = 0;
2200
+ let i = 0;
2201
+ while (i < messages.length) {
2202
+ const msg = messages[i];
2203
+ if (!messageHasToolContent(msg)) {
2204
+ result?.push(msg);
2205
+ i++;
2206
+ continue;
2207
+ }
2208
+
2209
+ /** First fold — copy the untouched prefix once, then append from here. */
2210
+ if (result === null) {
2211
+ result = messages.slice(0, i);
2212
+ }
2213
+
2214
+ const parts: MessageContentComplex[] = [];
2215
+ const textChunks: string[] = ['[Previous tool interaction]'];
2216
+ appendMessageContent(
2217
+ msg,
2218
+ isToolResultMessage(msg) ? 'Tool' : 'AI',
2219
+ textChunks,
2220
+ parts
2221
+ );
2222
+ foldedCount++;
2223
+
2224
+ let j = i + 1;
2225
+ while (j < messages.length && isToolResultMessage(messages[j])) {
2226
+ appendMessageContent(messages[j], 'Tool', textChunks, parts);
2227
+ foldedCount++;
2228
+ j++;
2229
+ }
2230
+
2231
+ flushTextChunks(textChunks, parts);
2232
+ result.push(
2233
+ withMessageRole(
2234
+ new HumanMessage({ content: toLangChainContent(parts) }),
2235
+ 'user'
2236
+ )
2237
+ );
2238
+ i = j;
2239
+ }
2240
+
2241
+ if (result === null) {
2242
+ return messages;
2243
+ }
2244
+
2245
+ emitAgentLog(
2246
+ config,
2247
+ 'warn',
2248
+ 'format',
2249
+ `foldToolBlocksForToollessAgent: folded ${foldedCount} tool message(s) into text for a tool-less agent`
2250
+ );
2251
+
2252
+ return result;
2253
+ }
2254
+
2027
2255
  /**
2028
2256
  * Walks backwards from `currentIndex` through the message array to check
2029
2257
  * whether an earlier AI message in the same "chain" (no HumanMessage boundary)
@@ -80,10 +80,18 @@ export function enforceOriginalContentCap(map: Map<number, string>): void {
80
80
 
81
81
  /** Minimum cumulative calibration ratio — provider can't count fewer tokens
82
82
  * than our raw estimate (within reason). Prevents divide-by-zero edge cases. */
83
- const CALIBRATION_RATIO_MIN = 0.5;
83
+ export const CALIBRATION_RATIO_MIN = 0.5;
84
84
 
85
85
  /** Maximum cumulative calibration ratio — sanity cap for the running ratio. */
86
- const CALIBRATION_RATIO_MAX = 5;
86
+ export const CALIBRATION_RATIO_MAX = 5;
87
+
88
+ /** Keeps provider/local token calibration within the shared safe range. */
89
+ export function clampCalibrationRatio(ratio: number): number {
90
+ return Math.max(
91
+ CALIBRATION_RATIO_MIN,
92
+ Math.min(CALIBRATION_RATIO_MAX, ratio)
93
+ );
94
+ }
87
95
 
88
96
  export type PruneMessagesFactoryParams = {
89
97
  provider?: Providers;
@@ -1002,8 +1010,8 @@ export function maskConsumedToolResults(params: {
1002
1010
  /** When provided, original (pre-masking) content is stored here keyed by
1003
1011
  * message index — only for entries that actually get truncated. */
1004
1012
  originalContentStore?: Map<number, string>;
1005
- /** Called after storing content with the char length of the stored entry. */
1006
- onContentStored?: (charLength: number) => void;
1013
+ /** Called after storing a newly captured entry. */
1014
+ onContentStored?: (index: number, content: string) => void;
1007
1015
  }): number {
1008
1016
  const { messages, indexTokenCountMap, tokenCounter } = params;
1009
1017
  let maskedCount = 0;
@@ -1079,7 +1087,7 @@ export function maskConsumedToolResults(params: {
1079
1087
  if (params.originalContentStore && !params.originalContentStore.has(i)) {
1080
1088
  params.originalContentStore.set(i, content);
1081
1089
  if (params.onContentStored) {
1082
- params.onContentStored(content.length);
1090
+ params.onContentStored(i, content);
1083
1091
  }
1084
1092
  }
1085
1093
 
@@ -1311,6 +1319,7 @@ export function createPruneMessages(factoryParams: PruneMessagesFactoryParams) {
1311
1319
  remainingContextTokens?: number;
1312
1320
  contextPressure?: number;
1313
1321
  originalToolContent?: Map<number, string>;
1322
+ newOriginalToolContent?: Map<number, string>;
1314
1323
  calibrationRatio?: number;
1315
1324
  resolvedInstructionOverhead?: number;
1316
1325
  /** Usable budget this call: maxTokens minus output reserve */
@@ -1318,6 +1327,7 @@ export function createPruneMessages(factoryParams: PruneMessagesFactoryParams) {
1318
1327
  /** Calibrated instruction overhead actually applied this call */
1319
1328
  effectiveInstructionTokens?: number;
1320
1329
  } {
1330
+ let newOriginalToolContent: Map<number, string> | undefined;
1321
1331
  if (params.messages.length === 0) {
1322
1332
  /** Post-compaction calls still invoke the model — report the same
1323
1333
  * reserve-adjusted budget fields as the populated paths */
@@ -1479,10 +1489,7 @@ export function createPruneMessages(factoryParams: PruneMessagesFactoryParams) {
1479
1489
  cumulativeRawSent += rawSentThisTurn;
1480
1490
  cumulativeProviderReported += providerMessageTokens;
1481
1491
  const newRatio = cumulativeProviderReported / cumulativeRawSent;
1482
- calibrationRatio = Math.max(
1483
- CALIBRATION_RATIO_MIN,
1484
- Math.min(CALIBRATION_RATIO_MAX, newRatio)
1485
- );
1492
+ calibrationRatio = clampCalibrationRatio(newRatio);
1486
1493
 
1487
1494
  const calibratedOurTotal =
1488
1495
  instructionOverhead + rawSentThisTurn * calibrationRatio;
@@ -1661,8 +1668,12 @@ export function createPruneMessages(factoryParams: PruneMessagesFactoryParams) {
1661
1668
  : undefined,
1662
1669
  onContentStored:
1663
1670
  factoryParams.summarizationEnabled === true
1664
- ? (charLen: number): void => {
1665
- originalToolContentSize += charLen;
1671
+ ? (index: number, content: string): void => {
1672
+ originalToolContentSize += content.length;
1673
+ if (newOriginalToolContent == null) {
1674
+ newOriginalToolContent = new Map();
1675
+ }
1676
+ newOriginalToolContent.set(index, content);
1666
1677
  while (
1667
1678
  originalToolContentSize > ORIGINAL_CONTENT_MAX_CHARS &&
1668
1679
  originalToolContent.size > 0
@@ -1798,6 +1809,7 @@ export function createPruneMessages(factoryParams: PruneMessagesFactoryParams) {
1798
1809
  contextPressure,
1799
1810
  originalToolContent:
1800
1811
  originalToolContent.size > 0 ? originalToolContent : undefined,
1812
+ newOriginalToolContent,
1801
1813
  calibrationRatio,
1802
1814
  resolvedInstructionOverhead: bestInstructionOverhead,
1803
1815
  contextBudget: pruningBudget,
@@ -2182,6 +2194,7 @@ export function createPruneMessages(factoryParams: PruneMessagesFactoryParams) {
2182
2194
  contextPressure,
2183
2195
  originalToolContent:
2184
2196
  originalToolContent.size > 0 ? originalToolContent : undefined,
2197
+ newOriginalToolContent,
2185
2198
  calibrationRatio,
2186
2199
  resolvedInstructionOverhead: bestInstructionOverhead,
2187
2200
  contextBudget: pruningBudget,
@@ -1,5 +1,7 @@
1
1
  import type { BaseMessage } from '@langchain/core/messages';
2
2
 
3
+ export const DEFAULT_RETAIN_RECENT_TURNS = 2;
4
+
3
5
  /**
4
6
  * Configuration for splitting a message list into a head (to be summarized)
5
7
  * and a tail (to be preserved verbatim).
@@ -68,7 +70,7 @@ export function splitAtRecencyBoundary(
68
70
  messages: BaseMessage[],
69
71
  options: RecencyWindowOptions = {}
70
72
  ): RecencySplit {
71
- const turnsCap = options.turns ?? 2;
73
+ const turnsCap = options.turns ?? DEFAULT_RETAIN_RECENT_TURNS;
72
74
 
73
75
  if (messages.length === 0 || turnsCap <= 0) {
74
76
  return {
@@ -0,0 +1,177 @@
1
+ import type { ResolvedLangfuseToolOutputTracingConfig } from '@/langfuseRuntimeContext';
2
+ import type { ActivityLabelToolEntry } from '@/types/activityLabel';
3
+ import { shouldRedactTool } from '@/langfuseToolOutputTracing';
4
+
5
+ /**
6
+ * Default system prompt for fast-model activity labeling.
7
+ *
8
+ * Style synthesized from Claude Code's tool-use summary prompt (git-subject
9
+ * register, past tense, distinctive nouns) and claude.ai's observed group
10
+ * headers (5–9 words describing a mixed reasoning + tool block, e.g.
11
+ * "Synthesized version data and curated comparative framework").
12
+ */
13
+ export const ACTIVITY_LABEL_PROMPT = `Write a short label describing what this block of agent activity accomplished. It appears as the header of a collapsed activity group in a chat UI.
14
+
15
+ Rules:
16
+ - 5 to 9 words, past-tense verb first
17
+ - Name the most distinctive subject (file, API, topic); drop articles and filler
18
+ - Describe outcomes, not mechanics; if something failed, say so plainly
19
+ - Output only the label — no quotes, no punctuation at the end, no preamble
20
+
21
+ Examples:
22
+ - Searched Node.js release notes and changelogs
23
+ - Compared runtime versions across official sources
24
+ - Fixed failing auth middleware tests
25
+ - Read project config and dependency manifests
26
+ - Attempted database migration, hit permission errors`;
27
+
28
+ /** Truncates a serialized value for the label prompt. */
29
+ export function truncateForLabel(value: string, maxLength: number): string {
30
+ if (value.length <= maxLength) {
31
+ return value;
32
+ }
33
+ return value.slice(0, Math.max(0, maxLength - 1)) + '…';
34
+ }
35
+
36
+ const ABORT_SERIALIZATION = Symbol('abort-label-serialization');
37
+
38
+ /**
39
+ * Serializes a tool value for the prompt WITHOUT materializing huge JSON:
40
+ * the output is clipped to a few hundred characters anyway, so a multi-
41
+ * megabyte tool result must not be stringified in full on the label path.
42
+ * Strings clip immediately; structured values serialize under a character
43
+ * budget and degrade to a shape summary once it is exhausted.
44
+ */
45
+ function serializeForLabel(value: unknown, limit: number): string {
46
+ if (value == null) {
47
+ return '';
48
+ }
49
+ if (typeof value === 'string') {
50
+ return value.length > limit ? value.slice(0, limit + 1) : value;
51
+ }
52
+ let budget = limit * 4;
53
+ try {
54
+ return (
55
+ JSON.stringify(value, (_key, nested: unknown) => {
56
+ if (budget <= 0) {
57
+ throw ABORT_SERIALIZATION;
58
+ }
59
+ if (typeof nested === 'string') {
60
+ const clipped =
61
+ nested.length > limit ? nested.slice(0, limit) : nested;
62
+ budget -= clipped.length;
63
+ return clipped;
64
+ }
65
+ budget -= 8;
66
+ return nested;
67
+ }) ?? ''
68
+ );
69
+ } catch (error) {
70
+ if (error === ABORT_SERIALIZATION) {
71
+ return Array.isArray(value) ? `[Array(${value.length})]` : '[Object]';
72
+ }
73
+ return String(value);
74
+ }
75
+ }
76
+
77
+ const INPUT_CONTEXT_LIMIT = 200;
78
+ const MAX_THINKING_EXCERPTS = 4;
79
+ /** A label is 5-9 words; no batch needs more than this many entries to
80
+ * produce one, and the cap keeps a 200-call programmatic batch from
81
+ * building an enormous prompt out of per-field-bounded pieces. */
82
+ const MAX_PROMPT_ENTRIES = 12;
83
+
84
+ export type BuildActivityLabelPromptParams = {
85
+ entries: ActivityLabelToolEntry[];
86
+ charLimit: number;
87
+ thinkingExcerpts?: string[];
88
+ lastAssistantText?: string;
89
+ /**
90
+ * Resolved tool-output tracing policy. The label prompt becomes Langfuse
91
+ * generation input, so outputs/errors excluded from tracing (global
92
+ * disable or `redactedToolNames`) must never appear in it — the same
93
+ * redaction the span processor applies to structured tool observations.
94
+ */
95
+ redaction?: ResolvedLangfuseToolOutputTracingConfig;
96
+ };
97
+
98
+ /**
99
+ * Builds the user prompt for a fast-model activity label. Pure — exported
100
+ * for direct testing of redaction and truncation behavior.
101
+ */
102
+ export function buildActivityLabelPrompt({
103
+ entries,
104
+ charLimit,
105
+ thinkingExcerpts,
106
+ lastAssistantText,
107
+ redaction,
108
+ }: BuildActivityLabelPromptParams): string {
109
+ const clip = truncateForLabel;
110
+ /** Reasoning and intent text can quote tool output verbatim — including
111
+ * output from EARLIER calls to a redacted tool that this batch does not
112
+ * contain — so any active policy (global disable or a configured
113
+ * redacted-name list) drops both wholesale. There is no reliable way to
114
+ * scrub a quoted fragment out of free-form model prose. */
115
+ const excerptsRedacted =
116
+ redaction != null &&
117
+ (redaction.enabled === false || redaction.redactedToolNames.size > 0);
118
+ const sections: string[] = [];
119
+ /** Intent text is free-form assistant prose that can quote a redacted
120
+ * tool result just as reasoning can, so it shares the excerpts' fate. */
121
+ if (
122
+ !excerptsRedacted &&
123
+ lastAssistantText != null &&
124
+ lastAssistantText.length > 0
125
+ ) {
126
+ sections.push(
127
+ `Intent (assistant's last message): ${clip(lastAssistantText, INPUT_CONTEXT_LIMIT)}`
128
+ );
129
+ }
130
+ if (
131
+ !excerptsRedacted &&
132
+ thinkingExcerpts != null &&
133
+ thinkingExcerpts.length > 0
134
+ ) {
135
+ sections.push(
136
+ 'Reasoning excerpts:\n' +
137
+ thinkingExcerpts
138
+ .slice(0, MAX_THINKING_EXCERPTS)
139
+ .map((excerpt) => `- ${clip(excerpt, charLimit)}`)
140
+ .join('\n')
141
+ );
142
+ }
143
+ if (entries.length > 0) {
144
+ const shown = entries.slice(0, MAX_PROMPT_ENTRIES);
145
+ const omitted = entries.length - shown.length;
146
+ sections.push(
147
+ 'Tool calls:\n' +
148
+ shown
149
+ .map((entry) => {
150
+ const input = clip(
151
+ serializeForLabel(entry.toolInput, charLimit),
152
+ charLimit
153
+ );
154
+ const redacted =
155
+ redaction != null && shouldRedactTool(entry.toolName, redaction);
156
+ let outcome: string;
157
+ if (redacted) {
158
+ outcome = redaction.redactionText;
159
+ } else if (entry.status === 'error') {
160
+ outcome = `ERROR: ${clip(entry.error ?? 'unknown error', charLimit)}`;
161
+ } else {
162
+ outcome = clip(
163
+ serializeForLabel(entry.toolOutput, charLimit),
164
+ charLimit
165
+ );
166
+ }
167
+ return `- ${entry.toolName}(${input}) → ${outcome}`;
168
+ })
169
+ .join('\n') +
170
+ (omitted > 0
171
+ ? `\n- …and ${omitted} more tool ${omitted === 1 ? 'call' : 'calls'}`
172
+ : '')
173
+ );
174
+ }
175
+ sections.push('Label:');
176
+ return sections.join('\n\n');
177
+ }