@librechat/agents 3.3.9 → 3.3.10

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.
@@ -643,6 +643,10 @@ export abstract class Graph<
643
643
  currentToolMap?: t.ToolMap;
644
644
  }): CustomToolNode<T> | ToolNode<T>;
645
645
  abstract getRunMessages(): BaseMessage[] | undefined;
646
+ /** Returns a snapshot of deferred tools discovered by this graph. */
647
+ getDiscoveredTools(_agentId?: string): string[] {
648
+ return [];
649
+ }
646
650
  abstract getContentParts(): t.MessageContentComplex[] | undefined;
647
651
  abstract generateStepId(stepKey: string): [string, number];
648
652
  abstract getKeyList(
@@ -1006,6 +1010,8 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1006
1010
  messages: BaseMessage[] = [];
1007
1011
  /** Cached run messages preserved before clearHeavyState() so getRunMessages() works after cleanup. */
1008
1012
  private cachedRunMessages?: BaseMessage[];
1013
+ /** Per-agent discovery snapshots preserved before contexts are reset on cleanup. */
1014
+ private cachedDiscoveredTools?: Map<string, string[]>;
1009
1015
  /** Ids of AI turns the agent node returned THIS run; see isRunProducedMessage. */
1010
1016
  protected runProducedAiMessageIds = new Set<string>();
1011
1017
  /** Checkpoint scope whose messages match index-keyed tool snapshots. */
@@ -1137,6 +1143,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1137
1143
  resetValues(keepContent?: boolean, checkpointScope?: string): void {
1138
1144
  this.messages = [];
1139
1145
  this.cachedRunMessages = undefined;
1146
+ this.cachedDiscoveredTools = undefined;
1140
1147
  this.config = resetIfNotEmpty(this.config, undefined);
1141
1148
  if (keepContent !== true) {
1142
1149
  this.contentData = resetIfNotEmpty(this.contentData, []);
@@ -1203,6 +1210,12 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1203
1210
 
1204
1211
  override clearHeavyState(): void {
1205
1212
  this.cachedRunMessages = this.messages.slice(this.startIndex);
1213
+ this.cachedDiscoveredTools = new Map(
1214
+ Array.from(this.agentContexts, ([agentId, context]) => [
1215
+ agentId,
1216
+ context.getDiscoveredTools(),
1217
+ ])
1218
+ );
1206
1219
  super.clearHeavyState();
1207
1220
  this.messages = [];
1208
1221
  this.overrideModel = undefined;
@@ -1497,6 +1510,32 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1497
1510
  return this.messages.slice(this.startIndex);
1498
1511
  }
1499
1512
 
1513
+ override getDiscoveredTools(agentId?: string): string[] {
1514
+ if (agentId != null) {
1515
+ const current =
1516
+ this.agentContexts.get(agentId)?.getDiscoveredTools() ?? [];
1517
+ if (current.length > 0 || this.cachedDiscoveredTools == null) {
1518
+ return current;
1519
+ }
1520
+ return [...(this.cachedDiscoveredTools.get(agentId) ?? [])];
1521
+ }
1522
+
1523
+ const discoveredTools = new Set<string>();
1524
+ for (const context of this.agentContexts.values()) {
1525
+ for (const toolName of context.getDiscoveredTools()) {
1526
+ discoveredTools.add(toolName);
1527
+ }
1528
+ }
1529
+ if (discoveredTools.size === 0 && this.cachedDiscoveredTools != null) {
1530
+ for (const snapshot of this.cachedDiscoveredTools.values()) {
1531
+ for (const toolName of snapshot) {
1532
+ discoveredTools.add(toolName);
1533
+ }
1534
+ }
1535
+ }
1536
+ return Array.from(discoveredTools);
1537
+ }
1538
+
1500
1539
  /**
1501
1540
  * True when THIS RUN produced `message` — the provenance the handoff cue
1502
1541
  * gate needs. Tracked as an id set rather than inferred from `startIndex`
package/src/run.ts CHANGED
@@ -538,6 +538,21 @@ export class Run<_T extends t.BaseGraphState> {
538
538
  return this.Graph.getRunMessages();
539
539
  }
540
540
 
541
+ /**
542
+ * Returns a defensive snapshot of tools discovered by the current run.
543
+ * Pass an agent id for that context, or omit it for the ordered union across
544
+ * contexts. Interrupted state is available immediately for host persistence;
545
+ * completed runs retain their final snapshot through graph cleanup.
546
+ */
547
+ getDiscoveredTools(agentId?: string): string[] {
548
+ if (!this.Graph) {
549
+ throw new Error(
550
+ 'Graph not initialized. Make sure to use Run.create() to instantiate the Run.'
551
+ );
552
+ }
553
+ return this.Graph.getDiscoveredTools(agentId);
554
+ }
555
+
541
556
  /**
542
557
  * Returns the current calibration ratio (EMA of provider-vs-estimate token ratios).
543
558
  * Hosts should persist this value and pass it back as `RunConfig.calibrationRatio`
@@ -0,0 +1,217 @@
1
+ import { z } from 'zod';
2
+ import { tool } from '@langchain/core/tools';
3
+ import { MemorySaver } from '@langchain/langgraph';
4
+ import { HumanMessage } from '@langchain/core/messages';
5
+ import { ChatGenerationChunk } from '@langchain/core/outputs';
6
+ import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
7
+ import type { ToolCall, ToolCallChunk } from '@langchain/core/messages/tool';
8
+ import type { BaseMessage } from '@langchain/core/messages';
9
+ import type * as t from '@/types';
10
+ import { FakeChatModel } from '@/llm/fake';
11
+ import { StandardGraph } from '@/graphs';
12
+ import { askUserQuestion } from '@/hitl';
13
+ import { Providers } from '@/common';
14
+ import { Run } from '@/run';
15
+
16
+ const DISCOVERED_TOOL = 'save_issue_mcp_linear';
17
+
18
+ const searchTool = tool(
19
+ async ({ query }) => [
20
+ JSON.stringify({
21
+ found: 1,
22
+ tools: [{ name: DISCOVERED_TOOL }],
23
+ query,
24
+ }),
25
+ { tool_references: [{ tool_name: DISCOVERED_TOOL }] },
26
+ ],
27
+ {
28
+ name: 'tool_search',
29
+ description: 'Find a deferred tool.',
30
+ schema: z.object({ query: z.string() }),
31
+ responseFormat: 'content_and_artifact',
32
+ }
33
+ );
34
+
35
+ const askTool = tool(
36
+ async (input) => {
37
+ const { answer } = askUserQuestion(input);
38
+ return answer;
39
+ },
40
+ {
41
+ name: 'ask_user_question',
42
+ description: 'Pause until the user answers a question.',
43
+ schema: z.object({ question: z.string() }),
44
+ }
45
+ );
46
+
47
+ class DiscoveryThenAskModel extends FakeChatModel {
48
+ private turn = 0;
49
+
50
+ constructor() {
51
+ super({ responses: ['', ''] });
52
+ }
53
+
54
+ async *_streamResponseChunks(
55
+ _messages: BaseMessage[],
56
+ _options: this['ParsedCallOptions'],
57
+ _runManager?: CallbackManagerForLLMRun
58
+ ): AsyncGenerator<ChatGenerationChunk> {
59
+ const calls: ToolCall[][] = [
60
+ [
61
+ {
62
+ name: 'tool_search',
63
+ args: { query: 'save_issue' },
64
+ id: 'search-call',
65
+ type: 'tool_call',
66
+ },
67
+ ],
68
+ [
69
+ {
70
+ name: 'ask_user_question',
71
+ args: { question: 'Proceed?' },
72
+ id: 'ask-call',
73
+ type: 'tool_call',
74
+ },
75
+ ],
76
+ ];
77
+ const toolCallChunks = calls[this.turn++].map(
78
+ (call, index): ToolCallChunk => ({
79
+ name: call.name,
80
+ args: JSON.stringify(call.args),
81
+ id: call.id,
82
+ index,
83
+ type: 'tool_call_chunk',
84
+ })
85
+ );
86
+ yield this._createResponseChunk('', toolCallChunks);
87
+ }
88
+ }
89
+
90
+ describe('Run discovered tools', () => {
91
+ it('exposes discoveries made before an ask-user pause', async () => {
92
+ const run = await Run.create<t.IState>({
93
+ runId: 'discovery-pause-run',
94
+ graphConfig: {
95
+ type: 'standard',
96
+ llmConfig: {
97
+ provider: Providers.OPENAI,
98
+ model: 'gpt-4o-mini',
99
+ streaming: true,
100
+ streamUsage: false,
101
+ },
102
+ instructions: 'Search first, then ask the user.',
103
+ tools: [searchTool, askTool],
104
+ compileOptions: { checkpointer: new MemorySaver() },
105
+ },
106
+ returnContent: true,
107
+ customHandlers: {},
108
+ });
109
+ run.Graph!.overrideModel = new DiscoveryThenAskModel();
110
+
111
+ await run.processStream(
112
+ { messages: [new HumanMessage('Create a Linear issue')] },
113
+ {
114
+ configurable: { thread_id: 'discovery-pause-thread' },
115
+ version: 'v2',
116
+ }
117
+ );
118
+
119
+ expect(run.getInterrupt()?.payload.type).toBe('ask_user_question');
120
+ // The interrupted inner subgraph has not returned its messages to the outer
121
+ // reducer, which is why reconstructing discoveries from run history fails.
122
+ expect(run.getRunMessages()).toEqual([]);
123
+ expect(run.getDiscoveredTools()).toEqual([DISCOVERED_TOOL]);
124
+
125
+ const snapshot = run.getDiscoveredTools();
126
+ snapshot.push('caller-mutation');
127
+ expect(run.getDiscoveredTools()).toEqual([DISCOVERED_TOOL]);
128
+ });
129
+
130
+ it('retains the last snapshot when normal cleanup resets agent contexts', async () => {
131
+ const run = await Run.create<t.IState>({
132
+ runId: 'discovery-cleanup-run',
133
+ graphConfig: {
134
+ type: 'standard',
135
+ llmConfig: {
136
+ provider: Providers.OPENAI,
137
+ model: 'gpt-4o-mini',
138
+ },
139
+ instructions: 'Test discovery cleanup.',
140
+ },
141
+ });
142
+ const graph = run.Graph as StandardGraph;
143
+ graph.agentContexts
144
+ .get(graph.defaultAgentId)
145
+ ?.markToolsAsDiscovered([DISCOVERED_TOOL]);
146
+
147
+ graph.clearHeavyState();
148
+
149
+ expect(run.getDiscoveredTools()).toEqual([DISCOVERED_TOOL]);
150
+ });
151
+
152
+ it('supports per-agent snapshots while returning a deduplicated union by default', async () => {
153
+ const run = await Run.create<t.IState>({
154
+ runId: 'multi-agent-discovery-run',
155
+ graphConfig: {
156
+ type: 'multi-agent',
157
+ agents: [
158
+ {
159
+ agentId: 'researcher',
160
+ provider: Providers.ANTHROPIC,
161
+ clientOptions: {
162
+ modelName: 'claude-haiku-4-5',
163
+ apiKey: 'test-key',
164
+ },
165
+ instructions: 'Research.',
166
+ },
167
+ {
168
+ agentId: 'writer',
169
+ provider: Providers.ANTHROPIC,
170
+ clientOptions: {
171
+ modelName: 'claude-haiku-4-5',
172
+ apiKey: 'test-key',
173
+ },
174
+ instructions: 'Write.',
175
+ },
176
+ ],
177
+ edges: [{ from: 'researcher', to: 'writer', edgeType: 'direct' }],
178
+ },
179
+ });
180
+ const graph = run.Graph as StandardGraph;
181
+ graph.agentContexts
182
+ .get('researcher')
183
+ ?.markToolsAsDiscovered(['shared_tool', 'research_tool']);
184
+ graph.agentContexts
185
+ .get('writer')
186
+ ?.markToolsAsDiscovered(['shared_tool', 'writing_tool']);
187
+
188
+ expect(run.getDiscoveredTools('researcher')).toEqual([
189
+ 'shared_tool',
190
+ 'research_tool',
191
+ ]);
192
+ expect(run.getDiscoveredTools('writer')).toEqual([
193
+ 'shared_tool',
194
+ 'writing_tool',
195
+ ]);
196
+ expect(run.getDiscoveredTools()).toEqual([
197
+ 'shared_tool',
198
+ 'research_tool',
199
+ 'writing_tool',
200
+ ]);
201
+
202
+ graph.clearHeavyState();
203
+ expect(run.getDiscoveredTools('researcher')).toEqual([
204
+ 'shared_tool',
205
+ 'research_tool',
206
+ ]);
207
+ expect(run.getDiscoveredTools('writer')).toEqual([
208
+ 'shared_tool',
209
+ 'writing_tool',
210
+ ]);
211
+ expect(run.getDiscoveredTools()).toEqual([
212
+ 'shared_tool',
213
+ 'research_tool',
214
+ 'writing_tool',
215
+ ]);
216
+ });
217
+ });