@librechat/agents 3.2.68 → 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 (56) 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/openai/index.cjs +1 -1
  10. package/dist/cjs/main.cjs +1 -0
  11. package/dist/cjs/messages/format.cjs +136 -4
  12. package/dist/cjs/messages/format.cjs.map +1 -1
  13. package/dist/cjs/prompts/activityLabel.cjs +101 -0
  14. package/dist/cjs/prompts/activityLabel.cjs.map +1 -0
  15. package/dist/cjs/run.cjs +162 -1
  16. package/dist/cjs/run.cjs.map +1 -1
  17. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -1
  18. package/dist/esm/agents/AgentContext.mjs +1 -1
  19. package/dist/esm/common/enum.mjs +2 -0
  20. package/dist/esm/common/enum.mjs.map +1 -1
  21. package/dist/esm/graphs/Graph.mjs +15 -2
  22. package/dist/esm/graphs/Graph.mjs.map +1 -1
  23. package/dist/esm/graphs/MultiAgentGraph.mjs +1 -1
  24. package/dist/esm/langfuseToolOutputTracing.mjs +4 -1
  25. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  26. package/dist/esm/llm/openai/index.mjs +1 -1
  27. package/dist/esm/main.mjs +2 -2
  28. package/dist/esm/messages/format.mjs +136 -5
  29. package/dist/esm/messages/format.mjs.map +1 -1
  30. package/dist/esm/prompts/activityLabel.mjs +100 -0
  31. package/dist/esm/prompts/activityLabel.mjs.map +1 -0
  32. package/dist/esm/run.mjs +163 -2
  33. package/dist/esm/run.mjs.map +1 -1
  34. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -1
  35. package/dist/types/common/enum.d.ts +3 -1
  36. package/dist/types/langfuseToolOutputTracing.d.ts +4 -0
  37. package/dist/types/messages/format.d.ts +22 -0
  38. package/dist/types/prompts/activityLabel.d.ts +31 -0
  39. package/dist/types/run.d.ts +14 -0
  40. package/dist/types/types/activityLabel.d.ts +53 -0
  41. package/dist/types/types/index.d.ts +1 -0
  42. package/dist/types/types/stream.d.ts +2 -0
  43. package/package.json +1 -1
  44. package/src/common/enum.ts +2 -0
  45. package/src/graphs/Graph.ts +20 -0
  46. package/src/langfuseToolOutputTracing.ts +4 -1
  47. package/src/messages/foldToollessToolBlocks.test.ts +438 -0
  48. package/src/messages/format.ts +233 -5
  49. package/src/prompts/activityLabel.ts +177 -0
  50. package/src/run.ts +298 -2
  51. package/src/specs/activity-label-prompt.test.ts +128 -0
  52. package/src/specs/activity-label-trace-seed.test.ts +47 -0
  53. package/src/specs/bedrock-toolless.live.test.ts +123 -0
  54. package/src/types/activityLabel.ts +55 -0
  55. package/src/types/index.ts +1 -0
  56. package/src/types/stream.ts +2 -0
@@ -178,4 +178,26 @@ export declare function shiftIndexTokenCountMap(indexTokenCountMap: Record<numbe
178
178
  * @returns The messages array with tool sequences converted to buffer strings if necessary
179
179
  */
180
180
  export declare function ensureThinkingBlockInMessages(messages: BaseMessage[], _provider: Providers, config?: RunnableConfig, runStartIndex?: number): BaseMessage[];
181
+ /**
182
+ * Folds tool_use / tool_result content into plain text for an agent that binds
183
+ * no tools.
184
+ *
185
+ * In a multi-agent graph, a tool-less destination still inherits the prior
186
+ * agent's conversation history, which can contain toolUse/toolResult blocks.
187
+ * Because it binds no tools, the model is invoked with no tool schema — and
188
+ * Bedrock's Converse API rejects any request that carries toolUse/toolResult
189
+ * blocks without a top-level toolConfig ("The toolConfig field must be defined
190
+ * when using toolUse and toolResult content blocks"). Adding a dummy toolConfig
191
+ * is not an option: AWS requires at least one tool, and it would expose a
192
+ * capability the destination was intentionally denied.
193
+ *
194
+ * Each tool-call turn plus its trailing tool results (ToolMessages or
195
+ * `tool_result` content blocks) is collapsed into a single `[Previous tool
196
+ * interaction]` HumanMessage that preserves the tool name, arguments and result
197
+ * as text (image blocks are kept as-is). Runs in a single pass: non-tool
198
+ * messages pass through, `result` is allocated lazily on the first fold, and the
199
+ * original array is returned unchanged when it holds no tool content (the common
200
+ * fresh-tool-less-agent case).
201
+ */
202
+ export declare function foldToolBlocksForToollessAgent(messages: BaseMessage[], config?: RunnableConfig): BaseMessage[];
181
203
  export {};
@@ -0,0 +1,31 @@
1
+ import type { ResolvedLangfuseToolOutputTracingConfig } from '@/langfuseRuntimeContext';
2
+ import type { ActivityLabelToolEntry } from '@/types/activityLabel';
3
+ /**
4
+ * Default system prompt for fast-model activity labeling.
5
+ *
6
+ * Style synthesized from Claude Code's tool-use summary prompt (git-subject
7
+ * register, past tense, distinctive nouns) and claude.ai's observed group
8
+ * headers (5–9 words describing a mixed reasoning + tool block, e.g.
9
+ * "Synthesized version data and curated comparative framework").
10
+ */
11
+ export declare 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.\n\nRules:\n- 5 to 9 words, past-tense verb first\n- Name the most distinctive subject (file, API, topic); drop articles and filler\n- Describe outcomes, not mechanics; if something failed, say so plainly\n- Output only the label \u2014 no quotes, no punctuation at the end, no preamble\n\nExamples:\n- Searched Node.js release notes and changelogs\n- Compared runtime versions across official sources\n- Fixed failing auth middleware tests\n- Read project config and dependency manifests\n- Attempted database migration, hit permission errors";
12
+ /** Truncates a serialized value for the label prompt. */
13
+ export declare function truncateForLabel(value: string, maxLength: number): string;
14
+ export type BuildActivityLabelPromptParams = {
15
+ entries: ActivityLabelToolEntry[];
16
+ charLimit: number;
17
+ thinkingExcerpts?: string[];
18
+ lastAssistantText?: string;
19
+ /**
20
+ * Resolved tool-output tracing policy. The label prompt becomes Langfuse
21
+ * generation input, so outputs/errors excluded from tracing (global
22
+ * disable or `redactedToolNames`) must never appear in it — the same
23
+ * redaction the span processor applies to structured tool observations.
24
+ */
25
+ redaction?: ResolvedLangfuseToolOutputTracingConfig;
26
+ };
27
+ /**
28
+ * Builds the user prompt for a fast-model activity label. Pure — exported
29
+ * for direct testing of redaction and truncation behavior.
30
+ */
31
+ export declare function buildActivityLabelPrompt({ entries, charLimit, thinkingExcerpts, lastAssistantText, redaction, }: BuildActivityLabelPromptParams): string;
@@ -39,6 +39,8 @@ export declare class Run<_T extends t.BaseGraphState> {
39
39
  * lets callers assert the type they expect.
40
40
  */
41
41
  private _interrupt;
42
+ /** Per-run sequence for batch-unique activity-label trace-seed fallbacks. */
43
+ private activityLabelSeq;
42
44
  private _haltedReason;
43
45
  private constructor();
44
46
  private createLegacyGraph;
@@ -207,4 +209,16 @@ export declare class Run<_T extends t.BaseGraphState> {
207
209
  language?: string;
208
210
  title?: string;
209
211
  }>;
212
+ /**
213
+ * Generates a short activity label for a completed tool/reasoning block
214
+ * using a fast model. Mirrors `generateTitle`'s Langfuse wiring so the
215
+ * call is traced under the conversation's session (sessionId from
216
+ * `chainOptions.configurable.thread_id`) with its own tags — never as an
217
+ * orphan trace. The payload contains no human messages by design: intent
218
+ * comes from `lastAssistantText`, content from reasoning excerpts and
219
+ * tool entries.
220
+ */
221
+ generateActivityLabel({ provider, clientOptions, entries, thinkingExcerpts, lastAssistantText, prompt, charLimit, chainOptions, traceSeed, agentId, }: t.RunActivityLabelOptions): Promise<{
222
+ label?: string;
223
+ }>;
210
224
  }
@@ -0,0 +1,53 @@
1
+ import type { RunnableConfig } from '@langchain/core/runnables';
2
+ import type { ClientOptions } from '@/types/llm';
3
+ import type { Providers } from '@/common';
4
+ /** One tool call's contribution to the label payload (host-assembled). */
5
+ export type ActivityLabelToolEntry = {
6
+ toolName: string;
7
+ toolInput: unknown;
8
+ toolOutput?: unknown;
9
+ error?: string;
10
+ status: 'success' | 'error';
11
+ };
12
+ /**
13
+ * Options for `Run.generateActivityLabel`. The payload deliberately contains
14
+ * NO human messages: intent context comes from the assistant's own last text
15
+ * (Claude Code's pattern) and the block's reasoning excerpts (claude.ai's
16
+ * pattern) — user text stays out of this low-scrutiny pathway entirely.
17
+ *
18
+ * This SDK defines NO activity-label graph event and never dispatches one.
19
+ * Label lifecycle streaming is entirely host-owned: a host claims its own
20
+ * content slots and emits on its own transport, with a payload shape only
21
+ * it defines. The SDK surface here is exactly this method plus the
22
+ * `activity_label` content type's formatter exclusions.
23
+ */
24
+ export type RunActivityLabelOptions = {
25
+ provider: Providers;
26
+ clientOptions?: ClientOptions;
27
+ /**
28
+ * Agent that executed the labeled batch. Selects that agent's Langfuse
29
+ * overlay (trace metadata AND tool-output redaction policy) instead of
30
+ * the graph default — a stricter per-agent policy must not be bypassed
31
+ * by labeling work the default agent never performed.
32
+ */
33
+ agentId?: string;
34
+ entries: ActivityLabelToolEntry[];
35
+ /** Truncated reasoning excerpts from the block being labeled. */
36
+ thinkingExcerpts?: string[];
37
+ /** Assistant's last text before the block (~200 chars), as intent context. */
38
+ lastAssistantText?: string;
39
+ /** Override for the default label system prompt. */
40
+ prompt?: string;
41
+ /** Per-entry serialization cap for the prompt. Default 600. */
42
+ charLimit?: number;
43
+ /** LangChain runnable config carrier (signal, callbacks, thread/user ids). */
44
+ chainOptions?: Partial<RunnableConfig> & {
45
+ configurable?: Record<string, unknown>;
46
+ };
47
+ /**
48
+ * Seed for deterministic Langfuse trace ids (e.g. `${runId}-${slotIndex}`)
49
+ * so each batch's label gets a distinct, reproducible trace. When omitted,
50
+ * a per-run sequence keeps batches from collapsing into one trace.
51
+ */
52
+ traceSeed?: string;
53
+ };
@@ -7,3 +7,4 @@ export * from './skill';
7
7
  export * from './stream';
8
8
  export * from './tools';
9
9
  export * from './summarize';
10
+ export * from './activityLabel';
@@ -138,6 +138,8 @@ export interface ExtendedMessageContent {
138
138
  type?: string;
139
139
  text?: string;
140
140
  input?: string;
141
+ /** Tool-call arguments on a v1 standard-content `tool_call` block. */
142
+ args?: ToolCallPart['args'];
141
143
  index?: string | number;
142
144
  id?: string;
143
145
  name?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.2.68",
3
+ "version": "3.3.0",
4
4
  "main": "./dist/cjs/main.cjs",
5
5
  "module": "./dist/esm/main.mjs",
6
6
  "types": "./dist/types/index.d.ts",
@@ -142,6 +142,8 @@ export enum ContentTypes {
142
142
  REASONING_CONTENT = 'reasoning_content',
143
143
  /** Mid-run user steer persisted inline in an assistant message; replayed as a user turn */
144
144
  STEER = 'steer',
145
+ /** Fast-model activity label for a tool/reasoning block; UI-only, never model input */
146
+ ACTIVITY_LABEL = 'activity_label',
145
147
  }
146
148
 
147
149
  export enum ToolCallTypes {
@@ -16,6 +16,7 @@ import type * as t from '@/types';
16
16
  import {
17
17
  formatAnthropicArtifactContent,
18
18
  ensureThinkingBlockInMessages,
19
+ foldToolBlocksForToollessAgent,
19
20
  convertMessagesToContent,
20
21
  sanitizeOrphanToolBlocks,
21
22
  extractToolDiscoveries,
@@ -1864,6 +1865,25 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1864
1865
  );
1865
1866
  }
1866
1867
 
1868
+ /**
1869
+ * A destination that binds no tools is invoked without a tool schema, but
1870
+ * in a multi-agent graph it can still inherit a prior agent's toolUse/
1871
+ * toolResult history. Bedrock's Converse API (and other tool-schema-strict
1872
+ * providers) reject such a request when no top-level toolConfig is sent.
1873
+ * Fold that historical tool content into plain text so the tool-less agent
1874
+ * receives valid, context-preserving messages. Handoff tools count as
1875
+ * bound tools, so a tool-less router mid-handoff is not affected.
1876
+ */
1877
+ if (toolsForBinding == null || toolsForBinding.length === 0) {
1878
+ finalMessages = foldToolBlocksForToollessAgent(finalMessages, config);
1879
+ // The fold emits structured (array) content; re-flatten for agents that
1880
+ // opted into string-only messages (`useLegacyContent`, run earlier at
1881
+ // the top of this block) so the folded turn isn't the lone exception.
1882
+ if (agentContext.useLegacyContent) {
1883
+ finalMessages = formatContentStrings(finalMessages);
1884
+ }
1885
+ }
1886
+
1867
1887
  // Determine the prompt-cache strategy up front. Two distinct facts:
1868
1888
  //
1869
1889
  // `providerPromptCacheEnabled` — prompt caching is on for this provider
@@ -84,7 +84,10 @@ function toolNameMatches(
84
84
  return config.redactedToolNames.has(normalizedToolName);
85
85
  }
86
86
 
87
- function shouldRedactTool(
87
+ /** Whether a tool's outputs are excluded from tracing (global disable or
88
+ * `redactedToolNames` match). Exported for the activity-label prompt
89
+ * builder, whose prompt becomes Langfuse generation input. */
90
+ export function shouldRedactTool(
88
91
  toolName: string | undefined,
89
92
  config: ResolvedLangfuseToolOutputTracingConfig
90
93
  ): boolean {
@@ -0,0 +1,438 @@
1
+ import {
2
+ AIMessage,
3
+ BaseMessage,
4
+ HumanMessage,
5
+ SystemMessage,
6
+ ToolMessage,
7
+ } from '@langchain/core/messages';
8
+ import type { ExtendedMessageContent } from '@/types';
9
+ import { foldToolBlocksForToollessAgent } from './format';
10
+
11
+ /** Concatenated text across a message's content (string or structured array). */
12
+ function getTextContent(msg: {
13
+ content: string | ExtendedMessageContent[];
14
+ }): string {
15
+ if (typeof msg.content === 'string') {
16
+ return msg.content;
17
+ }
18
+ if (Array.isArray(msg.content)) {
19
+ return (msg.content as ExtendedMessageContent[])
20
+ .filter((b) => b.type === 'text')
21
+ .map((b) => String(b.text ?? ''))
22
+ .join('\n');
23
+ }
24
+ return '';
25
+ }
26
+
27
+ /** Any residual tool content that a tool-less agent cannot legally send. */
28
+ function hasResidualToolContent(messages: BaseMessage[]): boolean {
29
+ return messages.some((m) => {
30
+ if (m instanceof ToolMessage) {
31
+ return true;
32
+ }
33
+ const ai = m as AIMessage;
34
+ if (ai.tool_calls != null && ai.tool_calls.length > 0) {
35
+ return true;
36
+ }
37
+ const rawToolCalls = ai.additional_kwargs.tool_calls;
38
+ if (Array.isArray(rawToolCalls) && rawToolCalls.length > 0) {
39
+ return true;
40
+ }
41
+ if (Array.isArray(m.content)) {
42
+ return (m.content as ExtendedMessageContent[]).some(
43
+ (b) =>
44
+ typeof b === 'object' &&
45
+ (b.type === 'tool_use' ||
46
+ b.type === 'tool_call' ||
47
+ b.type === 'tool_result')
48
+ );
49
+ }
50
+ return false;
51
+ });
52
+ }
53
+
54
+ describe('foldToolBlocksForToollessAgent', () => {
55
+ test('returns the same array reference when there is no tool content', () => {
56
+ const messages = [
57
+ new SystemMessage('You are helpful.'),
58
+ new HumanMessage('Hi'),
59
+ new AIMessage('Hello!'),
60
+ ];
61
+
62
+ const result = foldToolBlocksForToollessAgent(messages);
63
+
64
+ expect(result).toBe(messages);
65
+ });
66
+
67
+ test('folds an AI tool call plus its ToolMessage into one HumanMessage', () => {
68
+ const messages = [
69
+ new HumanMessage('Search my files for "roadmap"'),
70
+ new AIMessage({
71
+ content: '',
72
+ tool_calls: [
73
+ {
74
+ id: 'call_1',
75
+ name: 'file_search',
76
+ args: { query: 'roadmap' },
77
+ type: 'tool_call',
78
+ },
79
+ ],
80
+ }),
81
+ new ToolMessage({
82
+ content: 'Found roadmap.md',
83
+ tool_call_id: 'call_1',
84
+ name: 'file_search',
85
+ }),
86
+ ];
87
+
88
+ const result = foldToolBlocksForToollessAgent(messages);
89
+
90
+ expect(hasResidualToolContent(result)).toBe(false);
91
+ // Human prompt kept, AI+Tool collapsed into a single HumanMessage.
92
+ expect(result).toHaveLength(2);
93
+ expect(result[0]).toBeInstanceOf(HumanMessage);
94
+ const folded = getTextContent(result[1]);
95
+ expect(folded).toContain('[Previous tool interaction]');
96
+ expect(folded).toContain('file_search');
97
+ expect(folded).toContain('roadmap');
98
+ expect(folded).toContain('Found roadmap.md');
99
+ });
100
+
101
+ test('folds historical tool content that precedes the last human turn (the reported bug)', () => {
102
+ const messages = [
103
+ new HumanMessage('Search my files for "roadmap"'),
104
+ new AIMessage({
105
+ content: '',
106
+ tool_calls: [
107
+ {
108
+ id: 'call_1',
109
+ name: 'file_search',
110
+ args: { query: 'roadmap' },
111
+ type: 'tool_call',
112
+ },
113
+ ],
114
+ }),
115
+ new ToolMessage({
116
+ content: 'Found roadmap.md',
117
+ tool_call_id: 'call_1',
118
+ name: 'file_search',
119
+ }),
120
+ new AIMessage('Here is what I found in roadmap.md.'),
121
+ new HumanMessage('thanks'),
122
+ ];
123
+
124
+ const result = foldToolBlocksForToollessAgent(messages);
125
+
126
+ expect(hasResidualToolContent(result)).toBe(false);
127
+ // The trailing plain-text turns survive untouched.
128
+ const last = result[result.length - 1];
129
+ expect(last).toBeInstanceOf(HumanMessage);
130
+ expect(getTextContent(last)).toBe('thanks');
131
+ expect(
132
+ result.some((m) => getTextContent(m).includes('Here is what I found'))
133
+ ).toBe(true);
134
+ });
135
+
136
+ test('folds parallel tool calls and all their results together', () => {
137
+ const messages = [
138
+ new HumanMessage('Look up A and B'),
139
+ new AIMessage({
140
+ content: '',
141
+ tool_calls: [
142
+ { id: 'a', name: 'lookup', args: { key: 'A' }, type: 'tool_call' },
143
+ { id: 'b', name: 'lookup', args: { key: 'B' }, type: 'tool_call' },
144
+ ],
145
+ }),
146
+ new ToolMessage({ content: 'A=1', tool_call_id: 'a', name: 'lookup' }),
147
+ new ToolMessage({ content: 'B=2', tool_call_id: 'b', name: 'lookup' }),
148
+ ];
149
+
150
+ const result = foldToolBlocksForToollessAgent(messages);
151
+
152
+ expect(hasResidualToolContent(result)).toBe(false);
153
+ expect(result).toHaveLength(2);
154
+ const folded = getTextContent(result[1]);
155
+ expect(folded).toContain('A=1');
156
+ expect(folded).toContain('B=2');
157
+ });
158
+
159
+ test('detects Anthropic-style tool_use content blocks', () => {
160
+ const messages = [
161
+ new HumanMessage('Search'),
162
+ new AIMessage({
163
+ content: [
164
+ { type: 'text', text: 'Let me search.' },
165
+ {
166
+ type: 'tool_use',
167
+ id: 'call_1',
168
+ name: 'file_search',
169
+ input: { query: 'roadmap' },
170
+ },
171
+ ],
172
+ }),
173
+ new ToolMessage({
174
+ content: 'Found roadmap.md',
175
+ tool_call_id: 'call_1',
176
+ name: 'file_search',
177
+ }),
178
+ ];
179
+
180
+ const result = foldToolBlocksForToollessAgent(messages);
181
+
182
+ expect(hasResidualToolContent(result)).toBe(false);
183
+ const folded = getTextContent(result[result.length - 1]);
184
+ expect(folded).toContain('Let me search.');
185
+ expect(folded).toContain('file_search');
186
+ });
187
+
188
+ test('folds an AI message whose tool call is only in additional_kwargs', () => {
189
+ const messages = [
190
+ new HumanMessage('Search my files'),
191
+ // Parsed `tool_calls` is empty; the call survives only in the raw
192
+ // additional_kwargs. The OpenAI converter still serializes it, so the
193
+ // parent AI message must fold with its ToolMessage — otherwise the fold
194
+ // would leave an orphan assistant(tool_calls) -> user(...) sequence.
195
+ new AIMessage({
196
+ content: '',
197
+ additional_kwargs: {
198
+ tool_calls: [
199
+ {
200
+ id: 'call_1',
201
+ type: 'function',
202
+ function: {
203
+ name: 'file_search',
204
+ arguments: '{"query":"roadmap"}',
205
+ },
206
+ },
207
+ ],
208
+ },
209
+ }),
210
+ new ToolMessage({
211
+ content: 'Found roadmap.md',
212
+ tool_call_id: 'call_1',
213
+ name: 'file_search',
214
+ }),
215
+ ];
216
+
217
+ const result = foldToolBlocksForToollessAgent(messages);
218
+
219
+ expect(hasResidualToolContent(result)).toBe(false);
220
+ expect(result).toHaveLength(2);
221
+ const folded = getTextContent(result[1]);
222
+ expect(folded).toContain('file_search');
223
+ expect(folded).toContain('roadmap');
224
+ expect(folded).toContain('Found roadmap.md');
225
+ });
226
+
227
+ test('folds a standard tool_result content block on a user message', () => {
228
+ const messages = [
229
+ new HumanMessage('Search'),
230
+ new AIMessage({
231
+ content: '',
232
+ tool_calls: [
233
+ {
234
+ id: 'call_1',
235
+ name: 'file_search',
236
+ args: { query: 'roadmap' },
237
+ type: 'tool_call',
238
+ },
239
+ ],
240
+ }),
241
+ // Tool result stored as a content block on a user message (the shape the
242
+ // Anthropic converter produces/accepts) rather than a ToolMessage.
243
+ new HumanMessage({
244
+ content: [
245
+ {
246
+ type: 'tool_result',
247
+ tool_use_id: 'call_1',
248
+ content: 'Found roadmap.md',
249
+ },
250
+ ],
251
+ }),
252
+ new HumanMessage('thanks'),
253
+ ];
254
+
255
+ const result = foldToolBlocksForToollessAgent(messages);
256
+
257
+ expect(hasResidualToolContent(result)).toBe(false);
258
+ expect(result.map(getTextContent).join('\n')).toContain('Found roadmap.md');
259
+ });
260
+
261
+ test('detects v1 standard-content tool_call blocks (no AIMessage.tool_calls)', () => {
262
+ const messages = [
263
+ new HumanMessage('Search'),
264
+ // LangChain v1 standard content: the tool call lives only as a
265
+ // `tool_call` content block; @langchain/aws still serializes it to a
266
+ // Converse toolUse, so a tool-less destination must fold it too.
267
+ new AIMessage({
268
+ content: [
269
+ { type: 'text', text: 'Searching.' },
270
+ {
271
+ type: 'tool_call',
272
+ id: 'call_1',
273
+ name: 'file_search',
274
+ args: { query: 'roadmap' },
275
+ },
276
+ ],
277
+ response_metadata: { output_version: 'v1' },
278
+ }),
279
+ new ToolMessage({
280
+ content: 'Found roadmap.md',
281
+ tool_call_id: 'call_1',
282
+ name: 'file_search',
283
+ }),
284
+ ];
285
+
286
+ const result = foldToolBlocksForToollessAgent(messages);
287
+
288
+ expect(hasResidualToolContent(result)).toBe(false);
289
+ const folded = getTextContent(result[result.length - 1]);
290
+ expect(folded).toContain('file_search');
291
+ expect(folded).toContain('roadmap');
292
+ expect(folded).toContain('Found roadmap.md');
293
+ });
294
+
295
+ test('preserves name/args/output of the nested ToolCallContent shape', () => {
296
+ const messages = [
297
+ new HumanMessage('Search'),
298
+ // Shape produced by convertMessagesToContent / persisted LibreChat history:
299
+ // the call (and its output) are nested under `tool_call`, not top level.
300
+ new AIMessage({
301
+ content: [
302
+ {
303
+ type: 'tool_call',
304
+ tool_call: {
305
+ type: 'tool_call',
306
+ name: 'file_search',
307
+ args: { query: 'roadmap' },
308
+ output: 'Found roadmap.md',
309
+ },
310
+ },
311
+ ],
312
+ }),
313
+ ];
314
+
315
+ const result = foldToolBlocksForToollessAgent(messages);
316
+
317
+ expect(hasResidualToolContent(result)).toBe(false);
318
+ const folded = result.map(getTextContent).join('\n');
319
+ expect(folded).toContain('file_search');
320
+ expect(folded).toContain('roadmap');
321
+ // The embedded tool output is preserved, not dropped.
322
+ expect(folded).toContain('Found roadmap.md');
323
+ });
324
+
325
+ test('folds a split AIMessage(tool_call) + tool_result user message as one turn', () => {
326
+ const messages = [
327
+ new HumanMessage('Search'),
328
+ new AIMessage({
329
+ content: [
330
+ { type: 'text', text: 'Let me search.' },
331
+ {
332
+ type: 'tool_call',
333
+ id: 'c1',
334
+ name: 'file_search',
335
+ args: { query: 'roadmap' },
336
+ },
337
+ ],
338
+ }),
339
+ new HumanMessage({
340
+ content: [
341
+ { type: 'tool_result', tool_use_id: 'c1', content: 'Found roadmap.md' },
342
+ ],
343
+ }),
344
+ new HumanMessage('thanks'),
345
+ ];
346
+
347
+ const result = foldToolBlocksForToollessAgent(messages);
348
+
349
+ expect(hasResidualToolContent(result)).toBe(false);
350
+ // Call + result collapse into ONE folded turn (not split/mislabelled).
351
+ expect(result).toHaveLength(3);
352
+ const folded = getTextContent(result[1]);
353
+ expect(folded).toContain('file_search');
354
+ expect(folded).toContain('Found roadmap.md');
355
+ expect(getTextContent(result[2])).toBe('thanks');
356
+ });
357
+
358
+ test('preserves image blocks nested inside a tool_result content block', () => {
359
+ const messages = [
360
+ new HumanMessage('Chart'),
361
+ new AIMessage({
362
+ content: [{ type: 'tool_call', id: 'c1', name: 'chart', args: {} }],
363
+ }),
364
+ new HumanMessage({
365
+ content: [
366
+ {
367
+ type: 'tool_result',
368
+ tool_use_id: 'c1',
369
+ content: [
370
+ { type: 'text', text: 'chart:' },
371
+ {
372
+ type: 'image_url',
373
+ image_url: { url: 'data:image/png;base64,AAAA' },
374
+ },
375
+ ],
376
+ },
377
+ ],
378
+ }),
379
+ ];
380
+
381
+ const result = foldToolBlocksForToollessAgent(messages);
382
+
383
+ expect(hasResidualToolContent(result)).toBe(false);
384
+ const folded = result[result.length - 1].content;
385
+ expect(Array.isArray(folded)).toBe(true);
386
+ expect(
387
+ (folded as ExtendedMessageContent[]).some((b) => b.type === 'image_url')
388
+ ).toBe(true);
389
+ });
390
+
391
+ test('preserves image blocks in a tool result instead of stringifying them', () => {
392
+ const messages = [
393
+ new HumanMessage('Render a chart'),
394
+ new AIMessage({
395
+ content: '',
396
+ tool_calls: [
397
+ { id: 'c', name: 'chart', args: {}, type: 'tool_call' },
398
+ ],
399
+ }),
400
+ new ToolMessage({
401
+ content: [
402
+ { type: 'text', text: 'chart:' },
403
+ {
404
+ type: 'image_url',
405
+ image_url: { url: 'data:image/png;base64,AAAA' },
406
+ },
407
+ ],
408
+ tool_call_id: 'c',
409
+ name: 'chart',
410
+ }),
411
+ ];
412
+
413
+ const result = foldToolBlocksForToollessAgent(messages);
414
+
415
+ expect(hasResidualToolContent(result)).toBe(false);
416
+ const foldedContent = result[result.length - 1].content;
417
+ expect(Array.isArray(foldedContent)).toBe(true);
418
+ expect(
419
+ (foldedContent as ExtendedMessageContent[]).some(
420
+ (b) => b.type === 'image_url'
421
+ )
422
+ ).toBe(true);
423
+ });
424
+
425
+ test('leaves non-tool conversations untouched', () => {
426
+ const messages = [
427
+ new SystemMessage('sys'),
428
+ new HumanMessage('hi'),
429
+ new AIMessage('hello'),
430
+ new HumanMessage('bye'),
431
+ ];
432
+
433
+ const result = foldToolBlocksForToollessAgent(messages);
434
+
435
+ expect(result).toBe(messages);
436
+ expect(hasResidualToolContent(result)).toBe(false);
437
+ });
438
+ });