@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.
- package/dist/cjs/agents/AgentContext.cjs +1 -1
- package/dist/cjs/common/enum.cjs +2 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +14 -1
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +1 -1
- package/dist/cjs/langfuseToolOutputTracing.cjs +4 -0
- package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +1 -1
- package/dist/cjs/main.cjs +1 -0
- package/dist/cjs/messages/format.cjs +136 -4
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/prompts/activityLabel.cjs +101 -0
- package/dist/cjs/prompts/activityLabel.cjs.map +1 -0
- package/dist/cjs/run.cjs +162 -1
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -1
- package/dist/esm/agents/AgentContext.mjs +1 -1
- package/dist/esm/common/enum.mjs +2 -0
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +15 -2
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +1 -1
- package/dist/esm/langfuseToolOutputTracing.mjs +4 -1
- package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +1 -1
- package/dist/esm/main.mjs +2 -2
- package/dist/esm/messages/format.mjs +136 -5
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/prompts/activityLabel.mjs +100 -0
- package/dist/esm/prompts/activityLabel.mjs.map +1 -0
- package/dist/esm/run.mjs +163 -2
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -1
- package/dist/types/common/enum.d.ts +3 -1
- package/dist/types/langfuseToolOutputTracing.d.ts +4 -0
- package/dist/types/messages/format.d.ts +22 -0
- package/dist/types/prompts/activityLabel.d.ts +31 -0
- package/dist/types/run.d.ts +14 -0
- package/dist/types/types/activityLabel.d.ts +53 -0
- package/dist/types/types/index.d.ts +1 -0
- package/dist/types/types/stream.d.ts +2 -0
- package/package.json +1 -1
- package/src/common/enum.ts +2 -0
- package/src/graphs/Graph.ts +20 -0
- package/src/langfuseToolOutputTracing.ts +4 -1
- package/src/messages/foldToollessToolBlocks.test.ts +438 -0
- package/src/messages/format.ts +233 -5
- package/src/prompts/activityLabel.ts +177 -0
- package/src/run.ts +298 -2
- package/src/specs/activity-label-prompt.test.ts +128 -0
- package/src/specs/activity-label-trace-seed.test.ts +47 -0
- package/src/specs/bedrock-toolless.live.test.ts +123 -0
- package/src/types/activityLabel.ts +55 -0
- package/src/types/index.ts +1 -0
- 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;
|
package/dist/types/run.d.ts
CHANGED
|
@@ -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
|
+
};
|
|
@@ -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
package/src/common/enum.ts
CHANGED
|
@@ -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 {
|
package/src/graphs/Graph.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|