@librechat/agents 3.4.5 → 3.4.7
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/common/enum.cjs +2 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +268 -14
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/langfuseTraceShaping.cjs +9 -6
- package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
- package/dist/cjs/llm/google/utils/common.cjs +10 -5
- package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
- package/dist/cjs/run.cjs +147 -14
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/AgentSession.cjs +93 -31
- package/dist/cjs/session/AgentSession.cjs.map +1 -1
- package/dist/cjs/session/handlers.cjs +10 -0
- package/dist/cjs/session/handlers.cjs.map +1 -1
- package/dist/cjs/stream.cjs +17 -3
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +1 -0
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +4 -2
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/handlers.cjs +1 -0
- package/dist/cjs/tools/handlers.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +28 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/utils/handlers.cjs +3 -0
- package/dist/cjs/utils/handlers.cjs.map +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 +268 -14
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/langfuseTraceShaping.mjs +9 -6
- package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
- package/dist/esm/llm/google/utils/common.mjs +10 -5
- package/dist/esm/llm/google/utils/common.mjs.map +1 -1
- package/dist/esm/run.mjs +147 -14
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/AgentSession.mjs +93 -31
- package/dist/esm/session/AgentSession.mjs.map +1 -1
- package/dist/esm/session/handlers.mjs +10 -0
- package/dist/esm/session/handlers.mjs.map +1 -1
- package/dist/esm/stream.mjs +17 -3
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +1 -0
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +4 -2
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/handlers.mjs +1 -0
- package/dist/esm/tools/handlers.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +28 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/utils/handlers.mjs +3 -0
- package/dist/esm/utils/handlers.mjs.map +1 -1
- package/dist/types/common/enum.d.ts +2 -0
- package/dist/types/graphs/Graph.d.ts +87 -0
- package/dist/types/langfuseTraceShaping.d.ts +4 -3
- package/dist/types/run.d.ts +9 -1
- package/dist/types/session/types.d.ts +1 -1
- package/dist/types/summarization/node.d.ts +6 -0
- package/dist/types/types/graph.d.ts +3 -3
- package/dist/types/types/stream.d.ts +57 -0
- package/dist/types/utils/handlers.d.ts +1 -0
- package/package.json +1 -1
- package/src/aggregator.test.ts +127 -0
- package/src/common/enum.ts +2 -0
- package/src/graphs/Graph.ts +365 -7
- package/src/graphs/__tests__/Graph.closeRunStep.test.ts +385 -0
- package/src/langfuseTraceShaping.ts +14 -7
- package/src/llm/google/utils/common.test.ts +11 -0
- package/src/llm/google/utils/common.ts +6 -4
- package/src/run.ts +219 -41
- package/src/session/AgentSession.ts +105 -13
- package/src/session/handlers.ts +14 -2
- package/src/session/types.ts +1 -0
- package/src/specs/activity-label-observability.live.test.ts +262 -0
- package/src/specs/activity-label-observability.test.ts +167 -0
- package/src/specs/langfuse-trace-shaping.test.ts +32 -0
- package/src/specs/run-step-timestamps.test.ts +414 -0
- package/src/stream.ts +29 -6
- package/src/summarization/node.ts +11 -0
- package/src/tools/ToolNode.ts +2 -0
- package/src/tools/__tests__/SubagentExecutor.test.ts +52 -10
- package/src/tools/__tests__/handlers.test.ts +2 -0
- package/src/tools/handlers.ts +1 -0
- package/src/tools/subagent/SubagentExecutor.ts +46 -4
- package/src/types/graph.ts +9 -6
- package/src/types/stream.ts +65 -12
- package/src/utils/handlers.ts +13 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { tool } from '@langchain/core/tools';
|
|
3
|
+
import { HumanMessage } from '@langchain/core/messages';
|
|
4
|
+
import { MemorySaver, Command } from '@langchain/langgraph';
|
|
5
|
+
import type { ToolCall } from '@langchain/core/messages/tool';
|
|
6
|
+
import type * as t from '@/types';
|
|
7
|
+
import { GraphEvents, StepTypes, Providers } from '@/common';
|
|
8
|
+
import { FakeChatModel } from '@/llm/fake';
|
|
9
|
+
import { askUserQuestion } from '@/hitl';
|
|
10
|
+
import { Run } from '@/run';
|
|
11
|
+
|
|
12
|
+
const askTool = tool(
|
|
13
|
+
async (input) => {
|
|
14
|
+
const { answer } = askUserQuestion(input as { question: string });
|
|
15
|
+
return answer;
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'ask_user_question',
|
|
19
|
+
description:
|
|
20
|
+
'Ask the user a clarifying question and wait for their answer.',
|
|
21
|
+
schema: z.object({ question: z.string() }),
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const llmConfig: t.LLMConfig = {
|
|
26
|
+
provider: Providers.OPENAI,
|
|
27
|
+
streaming: true,
|
|
28
|
+
streamUsage: false,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const echoTool = tool(
|
|
32
|
+
async ({ input }: { input: string }) => `echo: ${input}`,
|
|
33
|
+
{
|
|
34
|
+
name: 'echoTool',
|
|
35
|
+
description: 'Echoes the input back',
|
|
36
|
+
schema: z.object({ input: z.string() }),
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const toolCalls: ToolCall[] = [
|
|
41
|
+
{
|
|
42
|
+
name: 'echoTool',
|
|
43
|
+
args: { input: 'ping' },
|
|
44
|
+
id: 'call_timestamps_1',
|
|
45
|
+
type: 'tool_call',
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const createStreamConfig = (
|
|
50
|
+
threadId: string,
|
|
51
|
+
signal?: AbortSignal
|
|
52
|
+
): t.RunStreamConfig => ({
|
|
53
|
+
configurable: { thread_id: threadId },
|
|
54
|
+
version: 'v2',
|
|
55
|
+
...(signal != null ? { signal } : {}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
type RecordedEvent = {
|
|
59
|
+
event: string;
|
|
60
|
+
data: unknown;
|
|
61
|
+
/** Lifecycle fields captured at receipt — dispatched RunStep objects are
|
|
62
|
+
* shared references that later closes mutate in place. */
|
|
63
|
+
statusAtReceipt?: t.RunStepStatus;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function createRecorder(): {
|
|
67
|
+
sequence: RecordedEvent[];
|
|
68
|
+
handlers: Record<string, t.EventHandler>;
|
|
69
|
+
} {
|
|
70
|
+
const sequence: RecordedEvent[] = [];
|
|
71
|
+
const record = (event: string): t.EventHandler => ({
|
|
72
|
+
handle: (_event, data): void => {
|
|
73
|
+
sequence.push({
|
|
74
|
+
event,
|
|
75
|
+
data,
|
|
76
|
+
...(event === GraphEvents.ON_RUN_STEP
|
|
77
|
+
? { statusAtReceipt: (data as t.RunStep).status }
|
|
78
|
+
: {}),
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
sequence,
|
|
84
|
+
handlers: {
|
|
85
|
+
[GraphEvents.ON_RUN_STEP]: record(GraphEvents.ON_RUN_STEP),
|
|
86
|
+
[GraphEvents.ON_RUN_STEP_COMPLETED]: record(
|
|
87
|
+
GraphEvents.ON_RUN_STEP_COMPLETED
|
|
88
|
+
),
|
|
89
|
+
[GraphEvents.ON_RUN_STEP_CLOSED]: record(GraphEvents.ON_RUN_STEP_CLOSED),
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getSteps(sequence: RecordedEvent[]): t.RunStep[] {
|
|
95
|
+
return sequence
|
|
96
|
+
.filter((entry) => entry.event === GraphEvents.ON_RUN_STEP)
|
|
97
|
+
.map((entry) => entry.data as t.RunStep);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getClosed(sequence: RecordedEvent[]): t.RunStepClosedEvent[] {
|
|
101
|
+
return sequence
|
|
102
|
+
.filter((entry) => entry.event === GraphEvents.ON_RUN_STEP_CLOSED)
|
|
103
|
+
.map((entry) => entry.data as t.RunStepClosedEvent);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe('run step timestamps', () => {
|
|
107
|
+
jest.setTimeout(20000);
|
|
108
|
+
|
|
109
|
+
it('stamps starts and closes every step exactly once on a natural finish', async () => {
|
|
110
|
+
const { sequence, handlers } = createRecorder();
|
|
111
|
+
const run = await Run.create<t.IState>({
|
|
112
|
+
runId: 'test-run-step-timestamps',
|
|
113
|
+
graphConfig: {
|
|
114
|
+
type: 'standard',
|
|
115
|
+
llmConfig,
|
|
116
|
+
tools: [echoTool],
|
|
117
|
+
instructions: 'You are a helpful assistant.',
|
|
118
|
+
},
|
|
119
|
+
returnContent: true,
|
|
120
|
+
skipCleanup: true,
|
|
121
|
+
customHandlers: handlers,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
run.Graph?.overrideTestModel(
|
|
125
|
+
['Let me call the tool', 'The tool answered'],
|
|
126
|
+
2,
|
|
127
|
+
toolCalls
|
|
128
|
+
);
|
|
129
|
+
const started = Date.now();
|
|
130
|
+
await run.processStream(
|
|
131
|
+
{ messages: [new HumanMessage('hello')] },
|
|
132
|
+
createStreamConfig('run-step-timestamps')
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const steps = getSteps(sequence);
|
|
136
|
+
const closed = getClosed(sequence);
|
|
137
|
+
expect(steps.length).toBeGreaterThanOrEqual(3);
|
|
138
|
+
const stepReceipts = sequence.filter(
|
|
139
|
+
(entry) => entry.event === GraphEvents.ON_RUN_STEP
|
|
140
|
+
);
|
|
141
|
+
for (const receipt of stepReceipts) {
|
|
142
|
+
expect(receipt.statusAtReceipt).toBe('in_progress');
|
|
143
|
+
}
|
|
144
|
+
for (const step of steps) {
|
|
145
|
+
expect(typeof step.created_at).toBe('number');
|
|
146
|
+
expect(step.created_at as number).toBeGreaterThanOrEqual(started);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const stepIds = steps.map((step) => step.id);
|
|
150
|
+
expect(closed.map((event) => event.id).sort()).toEqual([...stepIds].sort());
|
|
151
|
+
for (const event of closed) {
|
|
152
|
+
expect(event.status).toBe('completed');
|
|
153
|
+
expect(event.closed_at).toBeGreaterThanOrEqual(
|
|
154
|
+
event.created_at as number
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const toolStep = steps.find((step) => step.type === StepTypes.TOOL_CALLS);
|
|
159
|
+
expect(toolStep).toBeDefined();
|
|
160
|
+
const toolStepId = (toolStep as t.RunStep).id;
|
|
161
|
+
const toolCompletedIndex = sequence.findIndex(
|
|
162
|
+
(entry) =>
|
|
163
|
+
entry.event === GraphEvents.ON_RUN_STEP_COMPLETED &&
|
|
164
|
+
(entry.data as { result?: { id?: string } }).result?.id === toolStepId
|
|
165
|
+
);
|
|
166
|
+
const toolClosedIndex = sequence.findIndex(
|
|
167
|
+
(entry) =>
|
|
168
|
+
entry.event === GraphEvents.ON_RUN_STEP_CLOSED &&
|
|
169
|
+
(entry.data as t.RunStepClosedEvent).id === toolStepId
|
|
170
|
+
);
|
|
171
|
+
expect(toolCompletedIndex).toBeGreaterThanOrEqual(0);
|
|
172
|
+
expect(toolClosedIndex).toBeGreaterThan(toolCompletedIndex);
|
|
173
|
+
|
|
174
|
+
const firstMessageStepId = steps[0].id;
|
|
175
|
+
const toolStepIndex = sequence.findIndex(
|
|
176
|
+
(entry) =>
|
|
177
|
+
entry.event === GraphEvents.ON_RUN_STEP &&
|
|
178
|
+
(entry.data as t.RunStep).id === toolStepId
|
|
179
|
+
);
|
|
180
|
+
const firstMessageClosedIndex = sequence.findIndex(
|
|
181
|
+
(entry) =>
|
|
182
|
+
entry.event === GraphEvents.ON_RUN_STEP_CLOSED &&
|
|
183
|
+
(entry.data as t.RunStepClosedEvent).id === firstMessageStepId
|
|
184
|
+
);
|
|
185
|
+
expect(firstMessageClosedIndex).toBeGreaterThanOrEqual(0);
|
|
186
|
+
expect(firstMessageClosedIndex).toBeLessThan(toolStepIndex);
|
|
187
|
+
|
|
188
|
+
const completedEvent = sequence
|
|
189
|
+
.filter((entry) => entry.event === GraphEvents.ON_RUN_STEP_COMPLETED)
|
|
190
|
+
.map((entry) => (entry.data as { result: t.ToolCompleteEvent }).result)
|
|
191
|
+
.find((result) => result.id === toolStepId);
|
|
192
|
+
expect(typeof completedEvent?.completed_at).toBe('number');
|
|
193
|
+
|
|
194
|
+
for (const step of run.Graph?.contentData ?? []) {
|
|
195
|
+
expect(step.status).toBe('completed');
|
|
196
|
+
expect(typeof step.completed_at).toBe('number');
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('changes nothing for hosts that only register the legacy handlers', async () => {
|
|
201
|
+
const received: string[] = [];
|
|
202
|
+
const legacyHandler = (event: string): t.EventHandler => ({
|
|
203
|
+
handle: (): void => {
|
|
204
|
+
received.push(event);
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
const run = await Run.create<t.IState>({
|
|
208
|
+
runId: 'test-run-step-timestamps-legacy',
|
|
209
|
+
graphConfig: {
|
|
210
|
+
type: 'standard',
|
|
211
|
+
llmConfig,
|
|
212
|
+
tools: [echoTool],
|
|
213
|
+
instructions: 'You are a helpful assistant.',
|
|
214
|
+
},
|
|
215
|
+
returnContent: true,
|
|
216
|
+
skipCleanup: true,
|
|
217
|
+
customHandlers: {
|
|
218
|
+
[GraphEvents.ON_RUN_STEP]: legacyHandler(GraphEvents.ON_RUN_STEP),
|
|
219
|
+
[GraphEvents.ON_RUN_STEP_COMPLETED]: legacyHandler(
|
|
220
|
+
GraphEvents.ON_RUN_STEP_COMPLETED
|
|
221
|
+
),
|
|
222
|
+
[GraphEvents.ON_MESSAGE_DELTA]: legacyHandler(
|
|
223
|
+
GraphEvents.ON_MESSAGE_DELTA
|
|
224
|
+
),
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
run.Graph?.overrideTestModel(
|
|
229
|
+
['Let me call the tool', 'The tool answered'],
|
|
230
|
+
2,
|
|
231
|
+
toolCalls
|
|
232
|
+
);
|
|
233
|
+
await run.processStream(
|
|
234
|
+
{ messages: [new HumanMessage('hello')] },
|
|
235
|
+
createStreamConfig('run-step-timestamps-legacy')
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect(new Set(received)).toEqual(
|
|
239
|
+
new Set([
|
|
240
|
+
GraphEvents.ON_RUN_STEP,
|
|
241
|
+
GraphEvents.ON_RUN_STEP_COMPLETED,
|
|
242
|
+
GraphEvents.ON_MESSAGE_DELTA,
|
|
243
|
+
])
|
|
244
|
+
);
|
|
245
|
+
for (const step of run.Graph?.contentData ?? []) {
|
|
246
|
+
expect(step.status).toBe('completed');
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('sweeps unfinished steps as cancelled when the caller aborts mid-stream', async () => {
|
|
251
|
+
const { sequence, handlers } = createRecorder();
|
|
252
|
+
const controller = new AbortController();
|
|
253
|
+
const run = await Run.create<t.IState>({
|
|
254
|
+
runId: 'test-run-step-timestamps-abort',
|
|
255
|
+
graphConfig: {
|
|
256
|
+
type: 'standard',
|
|
257
|
+
llmConfig,
|
|
258
|
+
instructions: 'You are a helpful assistant.',
|
|
259
|
+
},
|
|
260
|
+
returnContent: true,
|
|
261
|
+
skipCleanup: true,
|
|
262
|
+
customHandlers: {
|
|
263
|
+
...handlers,
|
|
264
|
+
[GraphEvents.ON_MESSAGE_DELTA]: {
|
|
265
|
+
handle: (): void => {
|
|
266
|
+
controller.abort();
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
run.Graph?.overrideTestModel(
|
|
273
|
+
['This response streams slowly enough to abort in flight'],
|
|
274
|
+
25
|
|
275
|
+
);
|
|
276
|
+
await expect(
|
|
277
|
+
run.processStream(
|
|
278
|
+
{ messages: [new HumanMessage('hello')] },
|
|
279
|
+
createStreamConfig('run-step-timestamps-abort', controller.signal)
|
|
280
|
+
)
|
|
281
|
+
).rejects.toThrow();
|
|
282
|
+
|
|
283
|
+
const closed = getClosed(sequence);
|
|
284
|
+
expect(closed.length).toBeGreaterThanOrEqual(1);
|
|
285
|
+
expect(closed.every((event) => event.status === 'cancelled')).toBe(true);
|
|
286
|
+
for (const step of run.Graph?.contentData ?? []) {
|
|
287
|
+
expect(step.status).toBe('cancelled');
|
|
288
|
+
expect(typeof step.cancelled_at).toBe('number');
|
|
289
|
+
expect(step.cancelled_at as number).toBeGreaterThanOrEqual(
|
|
290
|
+
step.created_at as number
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('sweeps as failed when an AbortError surfaces with no signal aborted', async () => {
|
|
296
|
+
const { sequence, handlers } = createRecorder();
|
|
297
|
+
const run = await Run.create<t.IState>({
|
|
298
|
+
runId: 'test-run-step-timestamps-uncorroborated-abort',
|
|
299
|
+
graphConfig: {
|
|
300
|
+
type: 'standard',
|
|
301
|
+
llmConfig,
|
|
302
|
+
instructions: 'You are a helpful assistant.',
|
|
303
|
+
},
|
|
304
|
+
returnContent: true,
|
|
305
|
+
skipCleanup: true,
|
|
306
|
+
customHandlers: {
|
|
307
|
+
...handlers,
|
|
308
|
+
[GraphEvents.ON_MESSAGE_DELTA]: {
|
|
309
|
+
handle: (): void => {
|
|
310
|
+
/** A provider/host rejection that merely borrows the name — no
|
|
311
|
+
* caller or construction signal was ever aborted. */
|
|
312
|
+
const error = new Error('provider stream aborted internally');
|
|
313
|
+
error.name = 'AbortError';
|
|
314
|
+
throw error;
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
run.Graph?.overrideTestModel(['some streamed content'], 2);
|
|
321
|
+
await expect(
|
|
322
|
+
run.processStream(
|
|
323
|
+
{ messages: [new HumanMessage('hello')] },
|
|
324
|
+
createStreamConfig('run-step-timestamps-uncorroborated-abort')
|
|
325
|
+
)
|
|
326
|
+
).rejects.toThrow();
|
|
327
|
+
|
|
328
|
+
const closed = getClosed(sequence);
|
|
329
|
+
expect(closed.length).toBeGreaterThanOrEqual(1);
|
|
330
|
+
expect(closed.every((event) => event.status === 'failed')).toBe(true);
|
|
331
|
+
for (const step of run.Graph?.contentData ?? []) {
|
|
332
|
+
expect(step.status).toBe('failed');
|
|
333
|
+
expect(typeof step.failed_at).toBe('number');
|
|
334
|
+
expect(step.cancelled_at).toBeUndefined();
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('keeps steps open across a HITL interrupt and closes them on resume with the original created_at', async () => {
|
|
339
|
+
jest.setTimeout(30000);
|
|
340
|
+
const { sequence, handlers } = createRecorder();
|
|
341
|
+
const saver = new MemorySaver();
|
|
342
|
+
const run = await Run.create<t.IState>({
|
|
343
|
+
runId: 'test-run-step-timestamps-hitl',
|
|
344
|
+
graphConfig: {
|
|
345
|
+
type: 'standard',
|
|
346
|
+
agents: [
|
|
347
|
+
{
|
|
348
|
+
agentId: 'timestamps-hitl-agent',
|
|
349
|
+
provider: Providers.OPENAI,
|
|
350
|
+
clientOptions: {
|
|
351
|
+
model: 'gpt-4o-mini',
|
|
352
|
+
streaming: true,
|
|
353
|
+
streamUsage: false,
|
|
354
|
+
},
|
|
355
|
+
instructions: 'You are a helpful assistant.',
|
|
356
|
+
maxContextTokens: 8000,
|
|
357
|
+
graphTools: [askTool],
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
compileOptions: { checkpointer: saver },
|
|
361
|
+
},
|
|
362
|
+
returnContent: true,
|
|
363
|
+
skipCleanup: true,
|
|
364
|
+
customHandlers: handlers,
|
|
365
|
+
});
|
|
366
|
+
run.Graph!.overrideModel = new FakeChatModel({
|
|
367
|
+
responses: ['asking the user', 'done after resume'],
|
|
368
|
+
toolCalls: [
|
|
369
|
+
{
|
|
370
|
+
name: 'ask_user_question',
|
|
371
|
+
args: { question: 'pick one' },
|
|
372
|
+
id: 'call_ask_timestamps',
|
|
373
|
+
type: 'tool_call',
|
|
374
|
+
},
|
|
375
|
+
],
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const streamConfig = createStreamConfig('run-step-timestamps-hitl');
|
|
379
|
+
await run.processStream(
|
|
380
|
+
{ messages: [new HumanMessage('go')] },
|
|
381
|
+
streamConfig
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
expect(run.getInterrupt()).toBeDefined();
|
|
385
|
+
const toolStep = run.Graph?.contentData.find(
|
|
386
|
+
(step) => step.type === StepTypes.TOOL_CALLS
|
|
387
|
+
) as t.RunStep;
|
|
388
|
+
expect(toolStep).toBeDefined();
|
|
389
|
+
expect(toolStep.status).toBe('in_progress');
|
|
390
|
+
const createdAt = toolStep.created_at;
|
|
391
|
+
expect(typeof createdAt).toBe('number');
|
|
392
|
+
expect(getClosed(sequence).map((event) => event.id)).not.toContain(
|
|
393
|
+
toolStep.id
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
await run.processStream(
|
|
397
|
+
new Command({ resume: { answer: 'blue' } }) as unknown as t.IState,
|
|
398
|
+
streamConfig
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
expect(toolStep.status).toBe('completed');
|
|
402
|
+
expect(toolStep.created_at).toBe(createdAt);
|
|
403
|
+
const closedForTool = getClosed(sequence).filter(
|
|
404
|
+
(event) => event.id === toolStep.id
|
|
405
|
+
);
|
|
406
|
+
expect(closedForTool).toHaveLength(1);
|
|
407
|
+
expect(closedForTool[0].closed_at).toBeGreaterThanOrEqual(
|
|
408
|
+
createdAt as number
|
|
409
|
+
);
|
|
410
|
+
for (const step of run.Graph?.contentData ?? []) {
|
|
411
|
+
expect(step.status).toBe('completed');
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
});
|
package/src/stream.ts
CHANGED
|
@@ -924,6 +924,7 @@ async function dispatchEagerToolCompletions(args: {
|
|
|
924
924
|
progress: 1,
|
|
925
925
|
...(outcome != null && { outcome }),
|
|
926
926
|
} as t.ProcessedToolCall,
|
|
927
|
+
completed_at: Date.now(),
|
|
927
928
|
},
|
|
928
929
|
},
|
|
929
930
|
graph.config
|
|
@@ -1836,9 +1837,7 @@ export class ChatModelStreamHandler implements t.EventHandler {
|
|
|
1836
1837
|
contentGroups[0]
|
|
1837
1838
|
).phase;
|
|
1838
1839
|
const phaseChanged =
|
|
1839
|
-
currentPhase != null &&
|
|
1840
|
-
nextPhase != null &&
|
|
1841
|
-
currentPhase !== nextPhase;
|
|
1840
|
+
currentPhase != null && nextPhase != null && currentPhase !== nextPhase;
|
|
1842
1841
|
if (contentGroups.length > 1 || phaseChanged) {
|
|
1843
1842
|
for (const contentGroup of contentGroups) {
|
|
1844
1843
|
const currentStepId = await dispatchMessageCreationStep({
|
|
@@ -2299,20 +2298,44 @@ export function createContentAggregator(): t.ContentAggregatorResult {
|
|
|
2299
2298
|
return;
|
|
2300
2299
|
}
|
|
2301
2300
|
|
|
2301
|
+
const incomingText =
|
|
2302
|
+
ContentTypes.TEXT in contentPart && typeof contentPart.text === 'string'
|
|
2303
|
+
? contentPart.text
|
|
2304
|
+
: undefined;
|
|
2305
|
+
/**
|
|
2306
|
+
* Anthropic emits a search turn's citations as their own `citations_delta`,
|
|
2307
|
+
* which arrives here as a text part carrying `citations` and no `text`.
|
|
2308
|
+
*/
|
|
2309
|
+
const { citations } = contentPart as {
|
|
2310
|
+
citations?: t.MessageDeltaUpdate['citations'];
|
|
2311
|
+
};
|
|
2312
|
+
const incomingCitations = Array.isArray(citations) ? citations : undefined;
|
|
2313
|
+
|
|
2302
2314
|
if (
|
|
2303
2315
|
partType.startsWith(ContentTypes.TEXT) &&
|
|
2304
|
-
|
|
2305
|
-
typeof contentPart.text === 'string'
|
|
2316
|
+
(incomingText !== undefined || incomingCitations !== undefined)
|
|
2306
2317
|
) {
|
|
2307
2318
|
// TODO: update this!!
|
|
2308
2319
|
const currentContent = contentParts[index] as t.MessageDeltaUpdate;
|
|
2309
2320
|
const update: t.MessageDeltaUpdate = {
|
|
2310
2321
|
type: ContentTypes.TEXT,
|
|
2311
|
-
text: (currentContent.text || '') +
|
|
2322
|
+
text: (currentContent.text || '') + (incomingText ?? ''),
|
|
2312
2323
|
};
|
|
2313
2324
|
|
|
2314
2325
|
if (contentPart.tool_call_ids) {
|
|
2315
2326
|
update.tool_call_ids = contentPart.tool_call_ids;
|
|
2327
|
+
} else if (incomingText === undefined && currentContent.tool_call_ids) {
|
|
2328
|
+
/** A citations-only delta must not drop ids already accumulated */
|
|
2329
|
+
update.tool_call_ids = currentContent.tool_call_ids;
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
if (incomingCitations !== undefined) {
|
|
2333
|
+
update.citations =
|
|
2334
|
+
currentContent.citations !== undefined
|
|
2335
|
+
? [...currentContent.citations, ...incomingCitations]
|
|
2336
|
+
: [...incomingCitations];
|
|
2337
|
+
} else if (currentContent.citations !== undefined) {
|
|
2338
|
+
update.citations = currentContent.citations;
|
|
2316
2339
|
}
|
|
2317
2340
|
contentParts[index] = update;
|
|
2318
2341
|
} else if (
|
|
@@ -908,6 +908,16 @@ interface CreateSummarizeNodeParams {
|
|
|
908
908
|
result: t.StepCompleted,
|
|
909
909
|
config?: RunnableConfig
|
|
910
910
|
) => Promise<void>;
|
|
911
|
+
/**
|
|
912
|
+
* Terminal close for a summary step that ends without a completion —
|
|
913
|
+
* an errored or empty summary would otherwise stay `in_progress` until
|
|
914
|
+
* the run-end sweep reported it as `completed`.
|
|
915
|
+
*/
|
|
916
|
+
closeRunStep?: (
|
|
917
|
+
stepId: string,
|
|
918
|
+
status: Exclude<t.RunStepStatus, 'in_progress'>,
|
|
919
|
+
config?: RunnableConfig
|
|
920
|
+
) => Promise<void>;
|
|
911
921
|
/** The run's shared breaker signal, composed into every summarization
|
|
912
922
|
* model attempt so a sibling branch tripping a stream limit also
|
|
913
923
|
* cancels in-flight summaries. */
|
|
@@ -1342,6 +1352,7 @@ export function createSummarizeNode({
|
|
|
1342
1352
|
runnableConfig
|
|
1343
1353
|
);
|
|
1344
1354
|
}
|
|
1355
|
+
await graph.closeRunStep?.(stepId, 'failed', runnableConfig);
|
|
1345
1356
|
return { summarizationRequest: undefined };
|
|
1346
1357
|
}
|
|
1347
1358
|
|
package/src/tools/ToolNode.ts
CHANGED
|
@@ -2561,6 +2561,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
2561
2561
|
index: turn,
|
|
2562
2562
|
type: 'tool_call' as const,
|
|
2563
2563
|
tool_call,
|
|
2564
|
+
completed_at: Date.now(),
|
|
2564
2565
|
},
|
|
2565
2566
|
},
|
|
2566
2567
|
config
|
|
@@ -3965,6 +3966,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
|
|
|
3965
3966
|
progress: 1,
|
|
3966
3967
|
...(outcome != null && { outcome }),
|
|
3967
3968
|
} as t.ProcessedToolCall,
|
|
3969
|
+
completed_at: Date.now(),
|
|
3968
3970
|
},
|
|
3969
3971
|
},
|
|
3970
3972
|
config
|
|
@@ -1563,17 +1563,13 @@ describe('SubagentExecutor', () => {
|
|
|
1563
1563
|
const selected = makeConfig('selected', {
|
|
1564
1564
|
agentInputs: {
|
|
1565
1565
|
...makeChildInputs('selected-agent'),
|
|
1566
|
-
initialSessions: new Map([
|
|
1567
|
-
[Constants.EXECUTE_CODE, selectedSession],
|
|
1568
|
-
]),
|
|
1566
|
+
initialSessions: new Map([[Constants.EXECUTE_CODE, selectedSession]]),
|
|
1569
1567
|
},
|
|
1570
1568
|
});
|
|
1571
1569
|
const sibling = makeConfig('sibling', {
|
|
1572
1570
|
agentInputs: {
|
|
1573
1571
|
...makeChildInputs('sibling-agent'),
|
|
1574
|
-
initialSessions: new Map([
|
|
1575
|
-
[Constants.EXECUTE_CODE, siblingSession],
|
|
1576
|
-
]),
|
|
1572
|
+
initialSessions: new Map([[Constants.EXECUTE_CODE, siblingSession]]),
|
|
1577
1573
|
},
|
|
1578
1574
|
});
|
|
1579
1575
|
const childSessions: ToolSessionMap = new Map();
|
|
@@ -1613,7 +1609,10 @@ describe('SubagentExecutor', () => {
|
|
|
1613
1609
|
});
|
|
1614
1610
|
|
|
1615
1611
|
it('combines member session seeds inside a selected graph child', async () => {
|
|
1616
|
-
const makeSession = (
|
|
1612
|
+
const makeSession = (
|
|
1613
|
+
storageSessionId: string,
|
|
1614
|
+
includeFileSession = true
|
|
1615
|
+
) => ({
|
|
1617
1616
|
session_id: storageSessionId,
|
|
1618
1617
|
files: [
|
|
1619
1618
|
{
|
|
@@ -1696,9 +1695,7 @@ describe('SubagentExecutor', () => {
|
|
|
1696
1695
|
const hitlConfig = makeConfig('hitl-child', {
|
|
1697
1696
|
agentInputs: {
|
|
1698
1697
|
...makeChildInputs('hitl-child'),
|
|
1699
|
-
initialSessions: new Map([
|
|
1700
|
-
[Constants.EXECUTE_CODE, seededSession],
|
|
1701
|
-
]),
|
|
1698
|
+
initialSessions: new Map([[Constants.EXECUTE_CODE, seededSession]]),
|
|
1702
1699
|
},
|
|
1703
1700
|
});
|
|
1704
1701
|
const childSessions: ToolSessionMap = new Map();
|
|
@@ -3625,6 +3622,51 @@ describe('sanitizeForwardedSubagentUpdateData', () => {
|
|
|
3625
3622
|
expect(serialized).not.toContain('refresh-secret');
|
|
3626
3623
|
});
|
|
3627
3624
|
|
|
3625
|
+
it('forwards run step lifecycle stamps and closed events via allowlists', () => {
|
|
3626
|
+
const sanitizedStep = sanitizeForwardedSubagentUpdateData(
|
|
3627
|
+
GraphEvents.ON_RUN_STEP,
|
|
3628
|
+
{
|
|
3629
|
+
id: 'step_1',
|
|
3630
|
+
type: StepTypes.MESSAGE_CREATION,
|
|
3631
|
+
index: 0,
|
|
3632
|
+
created_at: 1_000,
|
|
3633
|
+
status: 'in_progress',
|
|
3634
|
+
stepDetails: {
|
|
3635
|
+
type: StepTypes.MESSAGE_CREATION,
|
|
3636
|
+
message_creation: { message_id: 'message_1' },
|
|
3637
|
+
},
|
|
3638
|
+
}
|
|
3639
|
+
) as { created_at?: number; status?: string };
|
|
3640
|
+
expect(sanitizedStep.created_at).toBe(1_000);
|
|
3641
|
+
expect(sanitizedStep.status).toBe('in_progress');
|
|
3642
|
+
|
|
3643
|
+
const sanitizedClosed = sanitizeForwardedSubagentUpdateData(
|
|
3644
|
+
GraphEvents.ON_RUN_STEP_CLOSED,
|
|
3645
|
+
{
|
|
3646
|
+
id: 'step_1',
|
|
3647
|
+
index: 0,
|
|
3648
|
+
type: StepTypes.MESSAGE_CREATION,
|
|
3649
|
+
status: 'completed',
|
|
3650
|
+
created_at: 1_000,
|
|
3651
|
+
closed_at: 2_000,
|
|
3652
|
+
runId: 'run_1',
|
|
3653
|
+
agentId: 'researcher',
|
|
3654
|
+
futureSecret: 'top-level-secret',
|
|
3655
|
+
}
|
|
3656
|
+
);
|
|
3657
|
+
expect(sanitizedClosed).toEqual({
|
|
3658
|
+
id: 'step_1',
|
|
3659
|
+
index: 0,
|
|
3660
|
+
type: StepTypes.MESSAGE_CREATION,
|
|
3661
|
+
status: 'completed',
|
|
3662
|
+
created_at: 1_000,
|
|
3663
|
+
closed_at: 2_000,
|
|
3664
|
+
runId: 'run_1',
|
|
3665
|
+
agentId: 'researcher',
|
|
3666
|
+
});
|
|
3667
|
+
expect(JSON.stringify(sanitizedClosed)).not.toContain('top-level-secret');
|
|
3668
|
+
});
|
|
3669
|
+
|
|
3628
3670
|
it('keeps completed tool output while stripping operational fields', () => {
|
|
3629
3671
|
const output = 'x'.repeat(10_000);
|
|
3630
3672
|
const sanitized = sanitizeForwardedSubagentUpdateData(
|
|
@@ -16,6 +16,7 @@ type MockGraph = {
|
|
|
16
16
|
getRunStep: jest.Mock;
|
|
17
17
|
dispatchRunStep: jest.Mock;
|
|
18
18
|
dispatchRunStepDelta: jest.Mock;
|
|
19
|
+
registerPendingToolCall: jest.Mock;
|
|
19
20
|
toolCallStepIds: Map<string, string>;
|
|
20
21
|
messageStepHasToolCalls: Map<string, boolean>;
|
|
21
22
|
messageIdsByStepKey: Map<string, string>;
|
|
@@ -40,6 +41,7 @@ function createMockGraph(overrides?: Partial<MockGraph>): MockGraph {
|
|
|
40
41
|
dispatchRunStepDelta: jest
|
|
41
42
|
.fn<() => Promise<void>>()
|
|
42
43
|
.mockResolvedValue(undefined),
|
|
44
|
+
registerPendingToolCall: jest.fn<() => void>(),
|
|
43
45
|
toolCallStepIds: new Map(),
|
|
44
46
|
messageStepHasToolCalls: new Map(),
|
|
45
47
|
messageIdsByStepKey: new Map(),
|
package/src/tools/handlers.ts
CHANGED
|
@@ -181,6 +181,7 @@ export const handleToolCalls = async (
|
|
|
181
181
|
const isEmpty = !details.tool_calls || details.tool_calls.length === 0;
|
|
182
182
|
if (isEmpty && prevStepId !== reusedChunkStepId) {
|
|
183
183
|
graph.toolCallStepIds.set(toolCallId, prevStepId);
|
|
184
|
+
graph.registerPendingToolCall(toolCallId, prevStepId);
|
|
184
185
|
reusedChunkStepId = prevStepId;
|
|
185
186
|
continue;
|
|
186
187
|
}
|