@librechat/agents 3.2.67 → 3.3.0

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 (68) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +1 -1
  2. package/dist/cjs/common/enum.cjs +2 -0
  3. package/dist/cjs/common/enum.cjs.map +1 -1
  4. package/dist/cjs/graphs/Graph.cjs +14 -1
  5. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  6. package/dist/cjs/graphs/MultiAgentGraph.cjs +1 -1
  7. package/dist/cjs/langfuseToolOutputTracing.cjs +4 -0
  8. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  9. package/dist/cjs/llm/google/index.cjs +2 -0
  10. package/dist/cjs/llm/google/index.cjs.map +1 -1
  11. package/dist/cjs/llm/google/utils/common.cjs +28 -0
  12. package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
  13. package/dist/cjs/llm/openai/index.cjs +1 -1
  14. package/dist/cjs/main.cjs +1 -0
  15. package/dist/cjs/messages/format.cjs +136 -4
  16. package/dist/cjs/messages/format.cjs.map +1 -1
  17. package/dist/cjs/prompts/activityLabel.cjs +101 -0
  18. package/dist/cjs/prompts/activityLabel.cjs.map +1 -0
  19. package/dist/cjs/run.cjs +162 -1
  20. package/dist/cjs/run.cjs.map +1 -1
  21. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -1
  22. package/dist/esm/agents/AgentContext.mjs +1 -1
  23. package/dist/esm/common/enum.mjs +2 -0
  24. package/dist/esm/common/enum.mjs.map +1 -1
  25. package/dist/esm/graphs/Graph.mjs +15 -2
  26. package/dist/esm/graphs/Graph.mjs.map +1 -1
  27. package/dist/esm/graphs/MultiAgentGraph.mjs +1 -1
  28. package/dist/esm/langfuseToolOutputTracing.mjs +4 -1
  29. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  30. package/dist/esm/llm/google/index.mjs +3 -1
  31. package/dist/esm/llm/google/index.mjs.map +1 -1
  32. package/dist/esm/llm/google/utils/common.mjs +28 -1
  33. package/dist/esm/llm/google/utils/common.mjs.map +1 -1
  34. package/dist/esm/llm/openai/index.mjs +1 -1
  35. package/dist/esm/main.mjs +2 -2
  36. package/dist/esm/messages/format.mjs +136 -5
  37. package/dist/esm/messages/format.mjs.map +1 -1
  38. package/dist/esm/prompts/activityLabel.mjs +100 -0
  39. package/dist/esm/prompts/activityLabel.mjs.map +1 -0
  40. package/dist/esm/run.mjs +163 -2
  41. package/dist/esm/run.mjs.map +1 -1
  42. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -1
  43. package/dist/types/common/enum.d.ts +3 -1
  44. package/dist/types/langfuseToolOutputTracing.d.ts +4 -0
  45. package/dist/types/llm/google/utils/common.d.ts +9 -0
  46. package/dist/types/messages/format.d.ts +22 -0
  47. package/dist/types/prompts/activityLabel.d.ts +31 -0
  48. package/dist/types/run.d.ts +14 -0
  49. package/dist/types/types/activityLabel.d.ts +53 -0
  50. package/dist/types/types/index.d.ts +1 -0
  51. package/dist/types/types/stream.d.ts +2 -0
  52. package/package.json +1 -1
  53. package/src/common/enum.ts +2 -0
  54. package/src/graphs/Graph.ts +20 -0
  55. package/src/langfuseToolOutputTracing.ts +4 -1
  56. package/src/llm/google/index.ts +3 -0
  57. package/src/llm/google/utils/common.test.ts +57 -2
  58. package/src/llm/google/utils/common.ts +44 -0
  59. package/src/messages/foldToollessToolBlocks.test.ts +438 -0
  60. package/src/messages/format.ts +233 -5
  61. package/src/prompts/activityLabel.ts +177 -0
  62. package/src/run.ts +298 -2
  63. package/src/specs/activity-label-prompt.test.ts +128 -0
  64. package/src/specs/activity-label-trace-seed.test.ts +47 -0
  65. package/src/specs/bedrock-toolless.live.test.ts +123 -0
  66. package/src/types/activityLabel.ts +55 -0
  67. package/src/types/index.ts +1 -0
  68. package/src/types/stream.ts +2 -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)
@@ -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
+ }