@librechat/agents 3.4.0 → 3.4.2
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.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +165 -57
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +81 -33
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs +14 -0
- package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs.map +1 -0
- package/dist/cjs/graphs/createGraph.cjs +15 -0
- package/dist/cjs/graphs/createGraph.cjs.map +1 -0
- package/dist/cjs/graphs/index.cjs +1 -0
- package/dist/cjs/hooks/HookRegistry.cjs +83 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +1 -1
- package/dist/cjs/hooks/executeHooks.cjs +60 -14
- package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
- package/dist/cjs/hooks/index.cjs.map +1 -1
- package/dist/cjs/hooks/types.cjs +2 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/llm/init.cjs +3 -3
- package/dist/cjs/llm/invoke.cjs +2 -2
- package/dist/cjs/main.cjs +34 -24
- package/dist/cjs/messages/format.cjs +40 -23
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/run.cjs +130 -53
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/AgentSession.cjs +37 -2
- package/dist/cjs/session/AgentSession.cjs.map +1 -1
- package/dist/cjs/session/JsonlSessionStore.cjs +2 -2
- package/dist/cjs/stream.cjs +1 -1
- package/dist/cjs/summarization/node.cjs +6 -3
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/BashExecutor.cjs +1 -1
- package/dist/cjs/tools/CodeExecutor.cjs +1 -1
- package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -1
- package/dist/cjs/tools/SubagentTool.cjs +4 -4
- package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +321 -95
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/ToolSearch.cjs +1 -1
- package/dist/cjs/tools/eagerEventExecution.cjs +1 -0
- package/dist/cjs/tools/local/LocalExecutionEngine.cjs +3 -3
- package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +2 -2
- package/dist/cjs/tools/search/rerankers.cjs +154 -1
- package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +4 -1
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs +601 -0
- package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs.map +1 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1114 -233
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentReplay.cjs +177 -0
- package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -0
- package/dist/cjs/tools/subagent/childGraphConfig.cjs +278 -0
- package/dist/cjs/tools/subagent/childGraphConfig.cjs.map +1 -0
- package/dist/cjs/tools/subagent/index.cjs +1 -0
- package/dist/cjs/tools/subagent/runtimeLimits.cjs +5 -0
- package/dist/cjs/tools/subagent/runtimeLimits.cjs.map +1 -0
- package/dist/cjs/tools/toolOutputReferences.cjs +20 -0
- package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
- package/dist/cjs/utils/index.cjs +2 -2
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +165 -57
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +81 -33
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/graphs/applyGraphRuntimeConfig.mjs +14 -0
- package/dist/esm/graphs/applyGraphRuntimeConfig.mjs.map +1 -0
- package/dist/esm/graphs/createGraph.mjs +15 -0
- package/dist/esm/graphs/createGraph.mjs.map +1 -0
- package/dist/esm/graphs/index.mjs +1 -0
- package/dist/esm/hooks/HookRegistry.mjs +83 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
- package/dist/esm/hooks/executeHooks.mjs +60 -14
- package/dist/esm/hooks/executeHooks.mjs.map +1 -1
- package/dist/esm/hooks/index.mjs.map +1 -1
- package/dist/esm/hooks/types.mjs +2 -1
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/llm/init.mjs +1 -1
- package/dist/esm/llm/invoke.mjs +2 -2
- package/dist/esm/main.mjs +21 -19
- package/dist/esm/messages/format.mjs +40 -23
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/run.mjs +131 -54
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/AgentSession.mjs +37 -2
- package/dist/esm/session/AgentSession.mjs.map +1 -1
- package/dist/esm/session/JsonlSessionStore.mjs +2 -2
- package/dist/esm/stream.mjs +1 -1
- package/dist/esm/summarization/node.mjs +6 -3
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/BashExecutor.mjs +1 -1
- package/dist/esm/tools/CodeExecutor.mjs +1 -1
- package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -1
- package/dist/esm/tools/SubagentTool.mjs +4 -4
- package/dist/esm/tools/SubagentTool.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +322 -96
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/ToolSearch.mjs +1 -1
- package/dist/esm/tools/eagerEventExecution.mjs +1 -1
- package/dist/esm/tools/local/LocalExecutionEngine.mjs +3 -3
- package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +2 -2
- package/dist/esm/tools/search/rerankers.mjs +154 -1
- package/dist/esm/tools/search/rerankers.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +4 -1
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs +597 -0
- package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs.map +1 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +1114 -233
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentReplay.mjs +170 -0
- package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -0
- package/dist/esm/tools/subagent/childGraphConfig.mjs +272 -0
- package/dist/esm/tools/subagent/childGraphConfig.mjs.map +1 -0
- package/dist/esm/tools/subagent/index.mjs +1 -0
- package/dist/esm/tools/subagent/runtimeLimits.mjs +5 -0
- package/dist/esm/tools/subagent/runtimeLimits.mjs.map +1 -0
- package/dist/esm/tools/toolOutputReferences.mjs +20 -0
- package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
- package/dist/esm/utils/index.mjs +2 -2
- package/dist/types/agents/AgentContext.d.ts +1 -1
- package/dist/types/graphs/Graph.d.ts +22 -2
- package/dist/types/graphs/MultiAgentGraph.d.ts +7 -1
- package/dist/types/graphs/applyGraphRuntimeConfig.d.ts +3 -0
- package/dist/types/graphs/createGraph.d.ts +13 -0
- package/dist/types/graphs/graphFactory.d.ts +13 -0
- package/dist/types/graphs/index.d.ts +2 -0
- package/dist/types/hooks/HookRegistry.d.ts +10 -1
- package/dist/types/hooks/executeHooks.d.ts +5 -1
- package/dist/types/hooks/index.d.ts +2 -2
- package/dist/types/hooks/types.d.ts +12 -0
- package/dist/types/run.d.ts +6 -3
- package/dist/types/session/AgentSession.d.ts +1 -0
- package/dist/types/tools/SubagentTool.d.ts +1 -1
- package/dist/types/tools/ToolNode.d.ts +23 -15
- package/dist/types/tools/search/rerankers.d.ts +28 -0
- package/dist/types/tools/search/types.d.ts +38 -1
- package/dist/types/tools/subagent/SubagentExecutionRegistry.d.ts +171 -0
- package/dist/types/tools/subagent/SubagentExecutor.d.ts +70 -36
- package/dist/types/tools/subagent/SubagentReplay.d.ts +85 -0
- package/dist/types/tools/subagent/childGraphConfig.d.ts +37 -0
- package/dist/types/tools/subagent/index.d.ts +1 -1
- package/dist/types/tools/subagent/runtimeLimits.d.ts +4 -0
- package/dist/types/tools/toolOutputReferences.d.ts +12 -0
- package/dist/types/types/graph.d.ts +189 -24
- package/dist/types/types/hitl.d.ts +15 -0
- package/package.json +8 -2
- package/src/agents/AgentContext.ts +1 -1
- package/src/graphs/Graph.ts +349 -104
- package/src/graphs/MultiAgentGraph.ts +149 -47
- package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +48 -9
- package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1 -1
- package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +80 -0
- package/src/graphs/__tests__/MultiAgentGraph.test.ts +86 -0
- package/src/graphs/__tests__/composition.smoke.test.ts +152 -0
- package/src/graphs/__tests__/createGraph.test.ts +206 -0
- package/src/graphs/applyGraphRuntimeConfig.ts +25 -0
- package/src/graphs/createGraph.ts +32 -0
- package/src/graphs/graphFactory.ts +12 -0
- package/src/graphs/index.ts +2 -0
- package/src/hooks/HookRegistry.ts +151 -1
- package/src/hooks/__tests__/HookRegistry.test.ts +96 -0
- package/src/hooks/__tests__/executeHooks.test.ts +160 -0
- package/src/hooks/executeHooks.ts +133 -15
- package/src/hooks/index.ts +3 -1
- package/src/hooks/types.ts +16 -0
- package/src/messages/format.ts +54 -26
- package/src/messages/formatAgentMessages.reducer.test.ts +162 -0
- package/src/messages/formatAgentMessages.steer.test.ts +16 -11
- package/src/messages/formatAgentMessages.test.ts +7 -2
- package/src/run.ts +214 -56
- package/src/session/AgentSession.ts +52 -2
- package/src/session/__tests__/JsonlSessionStore.test.ts +53 -0
- package/src/specs/graph-subagent.live.test.ts +242 -0
- package/src/specs/graph-subagent.test.ts +322 -0
- package/src/specs/langfuse-routing.integration.test.ts +120 -1
- package/src/specs/subagent.test.ts +130 -4
- package/src/summarization/__tests__/node.test.ts +32 -14
- package/src/summarization/node.ts +15 -11
- package/src/tools/SubagentTool.ts +4 -4
- package/src/tools/ToolNode.ts +643 -157
- package/src/tools/__tests__/SubagentExecutor.test.ts +740 -15
- package/src/tools/__tests__/SubagentReplay.test.ts +329 -0
- package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +208 -2
- package/src/tools/__tests__/directToolHITLResumeScope.test.ts +512 -2
- package/src/tools/__tests__/hitl.test.ts +234 -5
- package/src/tools/__tests__/subagentHooks.test.ts +1126 -61
- package/src/tools/__tests__/toolOutputReferences.test.ts +19 -2
- package/src/tools/search/rag-api-reranker.test.ts +891 -0
- package/src/tools/search/rerankers.ts +260 -0
- package/src/tools/search/tool.ts +6 -0
- package/src/tools/search/types.ts +45 -1
- package/src/tools/subagent/SubagentExecutionRegistry.ts +1159 -0
- package/src/tools/subagent/SubagentExecutor.ts +2232 -312
- package/src/tools/subagent/SubagentReplay.ts +584 -0
- package/src/tools/subagent/__tests__/SubagentExecutionRegistry.test.ts +637 -0
- package/src/tools/subagent/__tests__/SubagentExecutor.lazy.test.ts +3054 -0
- package/src/tools/subagent/__tests__/childGraphConfig.test.ts +521 -0
- package/src/tools/subagent/childGraphConfig.ts +694 -0
- package/src/tools/subagent/index.ts +6 -0
- package/src/tools/subagent/runtimeLimits.ts +7 -0
- package/src/tools/toolOutputReferences.ts +40 -1
- package/src/types/graph.ts +222 -24
- package/src/types/hitl.ts +16 -0
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from '@jest/globals';
|
|
2
|
+
import { GraphInterrupt, MemorySaver } from '@langchain/langgraph';
|
|
2
3
|
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
|
3
4
|
import type { BaseMessage } from '@langchain/core/messages';
|
|
4
5
|
import type {
|
|
5
6
|
AgentInputs,
|
|
7
|
+
GraphSubagentConfig,
|
|
6
8
|
ResolvedSubagentConfig,
|
|
9
|
+
ResolvedSingleAgentSubagentConfig,
|
|
7
10
|
StandardGraphInput,
|
|
8
11
|
SubagentUpdateEvent,
|
|
9
12
|
SubagentUsageEvent,
|
|
@@ -13,12 +16,14 @@ import type {
|
|
|
13
16
|
import type { StandardGraph } from '@/graphs/Graph';
|
|
14
17
|
import {
|
|
15
18
|
SubagentExecutor,
|
|
19
|
+
filterGraphSubagentResult,
|
|
16
20
|
filterSubagentResult,
|
|
17
21
|
resolveSubagentConfigs,
|
|
18
22
|
buildChildInputs,
|
|
19
23
|
summarizeEvent,
|
|
20
24
|
} from '../subagent';
|
|
21
25
|
import { sanitizeForwardedSubagentUpdateData } from '../subagent/SubagentExecutor';
|
|
26
|
+
import { SUBAGENT_PARENT_BATCH_CONFIG_KEY } from '../subagent/SubagentReplay';
|
|
22
27
|
import { Constants, Providers, GraphEvents, StepTypes } from '@/common';
|
|
23
28
|
import { StreamLimitExceededError } from '@/llm/streamLimits';
|
|
24
29
|
import { AgentContext } from '@/agents/AgentContext';
|
|
@@ -37,8 +42,8 @@ const makeChildInputs = (agentId = 'child-agent'): AgentInputs => ({
|
|
|
37
42
|
|
|
38
43
|
const makeConfig = (
|
|
39
44
|
type = 'researcher',
|
|
40
|
-
overrides: Partial<
|
|
41
|
-
):
|
|
45
|
+
overrides: Partial<ResolvedSingleAgentSubagentConfig> = {}
|
|
46
|
+
): ResolvedSingleAgentSubagentConfig => ({
|
|
42
47
|
type,
|
|
43
48
|
name: 'Test Researcher',
|
|
44
49
|
description: 'Researches things',
|
|
@@ -47,6 +52,18 @@ const makeConfig = (
|
|
|
47
52
|
});
|
|
48
53
|
|
|
49
54
|
describe('filterSubagentResult', () => {
|
|
55
|
+
it('treats a result member with no new AI message as textless completion', () => {
|
|
56
|
+
expect(
|
|
57
|
+
filterGraphSubagentResult(
|
|
58
|
+
{
|
|
59
|
+
messages: [new AIMessage('worker output')],
|
|
60
|
+
subagentResult: { agentId: 'result' },
|
|
61
|
+
},
|
|
62
|
+
'result'
|
|
63
|
+
)
|
|
64
|
+
).toBe('Task completed');
|
|
65
|
+
});
|
|
66
|
+
|
|
50
67
|
it('extracts text from last AIMessage string content', () => {
|
|
51
68
|
const messages: BaseMessage[] = [
|
|
52
69
|
new HumanMessage('task'),
|
|
@@ -218,7 +235,8 @@ describe('resolveSubagentConfigs', () => {
|
|
|
218
235
|
const parentContext = AgentContext.fromConfig(parentInputs);
|
|
219
236
|
const resolved = resolveSubagentConfigs([config], parentContext);
|
|
220
237
|
expect(resolved).toHaveLength(1);
|
|
221
|
-
|
|
238
|
+
const resolvedConfig = resolved[0];
|
|
239
|
+
expect(resolvedConfig.agentInputs.agentId).toBe('child-agent');
|
|
222
240
|
});
|
|
223
241
|
|
|
224
242
|
it('resolves self-spawn from parent _sourceInputs', () => {
|
|
@@ -231,8 +249,9 @@ describe('resolveSubagentConfigs', () => {
|
|
|
231
249
|
const parentContext = AgentContext.fromConfig(parentInputs);
|
|
232
250
|
const resolved = resolveSubagentConfigs([selfConfig], parentContext);
|
|
233
251
|
expect(resolved).toHaveLength(1);
|
|
234
|
-
|
|
235
|
-
expect(
|
|
252
|
+
const resolvedConfig = resolved[0];
|
|
253
|
+
expect(resolvedConfig.agentInputs.provider).toBe(Providers.OPENAI);
|
|
254
|
+
expect(resolvedConfig.agentInputs.instructions).toBe(
|
|
236
255
|
'You are a parent agent.'
|
|
237
256
|
);
|
|
238
257
|
});
|
|
@@ -349,8 +368,7 @@ describe('buildChildInputs', () => {
|
|
|
349
368
|
/**
|
|
350
369
|
* Self-spawn: `resolveSubagentConfigs` fills `agentInputs` as a shallow
|
|
351
370
|
* spread of the parent's `_sourceInputs`, so the parent-scoped direct
|
|
352
|
-
* tool would leak into
|
|
353
|
-
* `No checkpointer set` — must be scrubbed.
|
|
371
|
+
* tool would leak into the child implicitly — it must be scrubbed.
|
|
354
372
|
*/
|
|
355
373
|
const selfConfig: ResolvedSubagentConfig = {
|
|
356
374
|
type: 'self',
|
|
@@ -365,9 +383,8 @@ describe('buildChildInputs', () => {
|
|
|
365
383
|
|
|
366
384
|
/**
|
|
367
385
|
* Explicit child config: a host that deliberately attaches its own
|
|
368
|
-
* in-process direct tools to a child keeps them (Codex #289 P2
|
|
369
|
-
* interrupt-capable tools
|
|
370
|
-
* direct tools are legitimate there).
|
|
386
|
+
* in-process direct tools to a child keeps them (Codex #289 P2). With
|
|
387
|
+
* HITL enabled, interrupt-capable child tools use the shared checkpointer.
|
|
371
388
|
*/
|
|
372
389
|
const explicitConfig: ResolvedSubagentConfig = {
|
|
373
390
|
type: 'researcher',
|
|
@@ -496,6 +513,281 @@ describe('SubagentExecutor', () => {
|
|
|
496
513
|
expect(result.messages).toEqual([]);
|
|
497
514
|
});
|
|
498
515
|
|
|
516
|
+
it('fails graph HITL before factories, hooks, events, or usage can run', async () => {
|
|
517
|
+
const graphConfig: GraphSubagentConfig = {
|
|
518
|
+
kind: 'graph',
|
|
519
|
+
type: 'graph-team',
|
|
520
|
+
name: 'Graph Team',
|
|
521
|
+
description: 'Runs a graph.',
|
|
522
|
+
agents: [makeChildInputs('graph-member')],
|
|
523
|
+
edges: [],
|
|
524
|
+
entryAgentId: 'graph-member',
|
|
525
|
+
resultAgentId: 'graph-member',
|
|
526
|
+
};
|
|
527
|
+
const createChildGraph = jest.fn(makeNoopGraphFactory());
|
|
528
|
+
const createChildGraphByKind = jest.fn(
|
|
529
|
+
(): StandardGraph => makeNoopGraphFactory()()
|
|
530
|
+
);
|
|
531
|
+
const hook = jest.fn(async (): Promise<Record<string, never>> => ({}));
|
|
532
|
+
const hookRegistry = new HookRegistry();
|
|
533
|
+
hookRegistry.register('SubagentStart', { hooks: [hook] });
|
|
534
|
+
const update = jest.fn();
|
|
535
|
+
const handlerRegistry = new HandlerRegistry();
|
|
536
|
+
handlerRegistry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
|
|
537
|
+
handle: update,
|
|
538
|
+
});
|
|
539
|
+
const usageSink = jest.fn();
|
|
540
|
+
const executor = createExecutor({
|
|
541
|
+
configs: new Map([[graphConfig.type, graphConfig]]),
|
|
542
|
+
humanInTheLoop: { enabled: true },
|
|
543
|
+
createChildGraph,
|
|
544
|
+
createChildGraphByKind,
|
|
545
|
+
hookRegistry,
|
|
546
|
+
parentHandlerRegistry: handlerRegistry,
|
|
547
|
+
usageSink,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
const result = await executor.execute({
|
|
551
|
+
description: 'Do not run.',
|
|
552
|
+
subagentType: graphConfig.type,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
expect(result.content).toBe(
|
|
556
|
+
'Error: Human-in-the-loop execution is not yet supported for graph subagents.'
|
|
557
|
+
);
|
|
558
|
+
expect(createChildGraph).not.toHaveBeenCalled();
|
|
559
|
+
expect(createChildGraphByKind).not.toHaveBeenCalled();
|
|
560
|
+
expect(hook).not.toHaveBeenCalled();
|
|
561
|
+
expect(update).not.toHaveBeenCalled();
|
|
562
|
+
expect(usageSink).not.toHaveBeenCalled();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('fails closed when resumable execution has no parent tool call ID', async () => {
|
|
566
|
+
const createChildGraph = jest.fn(makeNoopGraphFactory());
|
|
567
|
+
const executor = createExecutor({
|
|
568
|
+
humanInTheLoop: { enabled: true },
|
|
569
|
+
createChildGraph,
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
const result = await executor.execute({
|
|
573
|
+
description: 'Do something',
|
|
574
|
+
subagentType: 'researcher',
|
|
575
|
+
threadId: 'durable-thread',
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
expect(result.content).toContain('requires a parent tool call ID');
|
|
579
|
+
expect(result.messages).toEqual([]);
|
|
580
|
+
expect(createChildGraph).not.toHaveBeenCalled();
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it('persists terminal replay outcomes without an active child workflow', async () => {
|
|
584
|
+
const checkpointer = new MemorySaver();
|
|
585
|
+
const hookRegistry = new HookRegistry();
|
|
586
|
+
hookRegistry.registerSession('original-run', 'PreToolUse', {
|
|
587
|
+
hooks: [async (): Promise<Record<string, never>> => ({})],
|
|
588
|
+
});
|
|
589
|
+
const initialExecutor = createExecutor({
|
|
590
|
+
checkpointer,
|
|
591
|
+
hookRegistry,
|
|
592
|
+
humanInTheLoop: { enabled: true },
|
|
593
|
+
parentRunId: 'original-run',
|
|
594
|
+
});
|
|
595
|
+
const call = {
|
|
596
|
+
id: 'call_terminal',
|
|
597
|
+
name: Constants.SUBAGENT,
|
|
598
|
+
args: {
|
|
599
|
+
description: 'blocked before execution',
|
|
600
|
+
subagent_type: 'researcher',
|
|
601
|
+
},
|
|
602
|
+
type: 'tool_call' as const,
|
|
603
|
+
};
|
|
604
|
+
const originalConfig = {
|
|
605
|
+
configurable: {
|
|
606
|
+
thread_id: 'durable-parent',
|
|
607
|
+
checkpoint_id: 'parent-fork',
|
|
608
|
+
run_id: 'original-run',
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
const settled = {
|
|
612
|
+
output: new ToolMessage({
|
|
613
|
+
content: 'Blocked: policy',
|
|
614
|
+
status: 'error' as const,
|
|
615
|
+
name: Constants.SUBAGENT,
|
|
616
|
+
tool_call_id: call.id,
|
|
617
|
+
}),
|
|
618
|
+
additionalContexts: ['persisted context'],
|
|
619
|
+
resolvedArgs: { description: 'rewritten' },
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
await initialExecutor.persistSettledToolOutput(
|
|
623
|
+
call,
|
|
624
|
+
originalConfig,
|
|
625
|
+
settled
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
const rebuiltExecutor = createExecutor({
|
|
629
|
+
checkpointer,
|
|
630
|
+
hookRegistry,
|
|
631
|
+
humanInTheLoop: { enabled: true },
|
|
632
|
+
parentRunId: 'rebuilt-run',
|
|
633
|
+
});
|
|
634
|
+
const restored = await rebuiltExecutor.getSettledToolOutput(call, {
|
|
635
|
+
configurable: {
|
|
636
|
+
...originalConfig.configurable,
|
|
637
|
+
run_id: 'rebuilt-run',
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
expect(restored?.output.content).toBe('Blocked: policy');
|
|
642
|
+
expect(restored?.additionalContexts).toEqual(['persisted context']);
|
|
643
|
+
expect(restored?.resolvedArgs).toEqual({ description: 'rewritten' });
|
|
644
|
+
expect(hookRegistry.hasHookFor('PreToolUse', 'original-run')).toBe(true);
|
|
645
|
+
expect(hookRegistry.hasHookFor('PreToolUse', 'rebuilt-run')).toBe(true);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('persists an ordinary child error after the active workflow is released', async () => {
|
|
649
|
+
const checkpointer = new MemorySaver();
|
|
650
|
+
const invoke = jest.fn().mockRejectedValue(new Error('child failed'));
|
|
651
|
+
const executor = createExecutor({
|
|
652
|
+
checkpointer,
|
|
653
|
+
humanInTheLoop: { enabled: true },
|
|
654
|
+
createChildGraph: (): StandardGraph =>
|
|
655
|
+
({
|
|
656
|
+
createWorkflow: () => ({
|
|
657
|
+
getState: jest.fn().mockResolvedValue({
|
|
658
|
+
values: {},
|
|
659
|
+
next: ['child-agent'],
|
|
660
|
+
tasks: [],
|
|
661
|
+
}),
|
|
662
|
+
invoke,
|
|
663
|
+
}),
|
|
664
|
+
clearHeavyState: jest.fn(),
|
|
665
|
+
}) as unknown as StandardGraph,
|
|
666
|
+
});
|
|
667
|
+
const call = {
|
|
668
|
+
id: 'call_error',
|
|
669
|
+
name: Constants.SUBAGENT,
|
|
670
|
+
args: { description: 'fail', subagent_type: 'researcher' },
|
|
671
|
+
type: 'tool_call' as const,
|
|
672
|
+
};
|
|
673
|
+
const runnableConfig = {
|
|
674
|
+
configurable: {
|
|
675
|
+
thread_id: 'durable-parent',
|
|
676
|
+
checkpoint_id: 'parent-fork',
|
|
677
|
+
run_id: 'original-run',
|
|
678
|
+
},
|
|
679
|
+
};
|
|
680
|
+
const result = await executor.execute({
|
|
681
|
+
description: 'fail',
|
|
682
|
+
subagentType: 'researcher',
|
|
683
|
+
threadId: 'durable-parent',
|
|
684
|
+
parentToolCallId: call.id,
|
|
685
|
+
parentConfigurable: runnableConfig.configurable,
|
|
686
|
+
});
|
|
687
|
+
expect(result.content).toBe('Subagent error: child failed');
|
|
688
|
+
|
|
689
|
+
await executor.persistSettledToolOutput(call, runnableConfig, {
|
|
690
|
+
output: new ToolMessage({
|
|
691
|
+
content: result.content,
|
|
692
|
+
name: Constants.SUBAGENT,
|
|
693
|
+
tool_call_id: call.id,
|
|
694
|
+
}),
|
|
695
|
+
additionalContexts: [],
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
const restored = await createExecutor({
|
|
699
|
+
checkpointer,
|
|
700
|
+
humanInTheLoop: { enabled: true },
|
|
701
|
+
}).getSettledToolOutput(call, runnableConfig);
|
|
702
|
+
expect(invoke).toHaveBeenCalledTimes(1);
|
|
703
|
+
expect(restored?.output.content).toBe('Subagent error: child failed');
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('continues a recovered in-progress child without reseeding its task', async () => {
|
|
707
|
+
const invoke = jest.fn().mockResolvedValue({
|
|
708
|
+
messages: [new AIMessage('continued')],
|
|
709
|
+
});
|
|
710
|
+
const executor = createExecutor({
|
|
711
|
+
checkpointer: new MemorySaver(),
|
|
712
|
+
humanInTheLoop: { enabled: true },
|
|
713
|
+
createChildGraph: (): StandardGraph =>
|
|
714
|
+
({
|
|
715
|
+
createWorkflow: () => ({
|
|
716
|
+
getState: jest.fn().mockResolvedValue({
|
|
717
|
+
values: {},
|
|
718
|
+
next: ['child-agent'],
|
|
719
|
+
tasks: [],
|
|
720
|
+
}),
|
|
721
|
+
invoke,
|
|
722
|
+
}),
|
|
723
|
+
clearHeavyState: jest.fn(),
|
|
724
|
+
}) as unknown as StandardGraph,
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
const result = await executor.execute({
|
|
728
|
+
description: 'Do not duplicate this task',
|
|
729
|
+
subagentType: 'researcher',
|
|
730
|
+
threadId: 'durable-parent',
|
|
731
|
+
parentToolCallId: 'call_in_progress',
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
expect(result.content).toBe('continued');
|
|
735
|
+
expect(invoke).toHaveBeenCalledWith(null, expect.any(Object));
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('preserves an existing nested approval scope', async () => {
|
|
739
|
+
const nestedScope = {
|
|
740
|
+
run_id: 'grandchild-run',
|
|
741
|
+
agent_id: 'grandchild-agent',
|
|
742
|
+
subagent_type: 'grandchild',
|
|
743
|
+
parent_tool_call_id: 'call_grandchild',
|
|
744
|
+
};
|
|
745
|
+
const nestedInterrupt = new GraphInterrupt([
|
|
746
|
+
{
|
|
747
|
+
id: 'nested-interrupt',
|
|
748
|
+
value: {
|
|
749
|
+
type: 'tool_approval',
|
|
750
|
+
action_requests: [],
|
|
751
|
+
review_configs: [],
|
|
752
|
+
subagent: nestedScope,
|
|
753
|
+
},
|
|
754
|
+
},
|
|
755
|
+
]);
|
|
756
|
+
const executor = createExecutor({
|
|
757
|
+
humanInTheLoop: { enabled: true },
|
|
758
|
+
createChildGraph: (): StandardGraph =>
|
|
759
|
+
({
|
|
760
|
+
createWorkflow: () => ({
|
|
761
|
+
getState: jest.fn().mockResolvedValue({
|
|
762
|
+
values: {},
|
|
763
|
+
next: ['agent'],
|
|
764
|
+
tasks: [],
|
|
765
|
+
}),
|
|
766
|
+
invoke: jest.fn().mockRejectedValue(nestedInterrupt),
|
|
767
|
+
}),
|
|
768
|
+
clearHeavyState: jest.fn(),
|
|
769
|
+
}) as unknown as StandardGraph,
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
let received: GraphInterrupt | undefined;
|
|
773
|
+
try {
|
|
774
|
+
await executor.execute({
|
|
775
|
+
description: 'Delegate again',
|
|
776
|
+
subagentType: 'researcher',
|
|
777
|
+
threadId: 'durable-thread',
|
|
778
|
+
parentToolCallId: 'call_child',
|
|
779
|
+
});
|
|
780
|
+
} catch (error) {
|
|
781
|
+
if (error instanceof GraphInterrupt) {
|
|
782
|
+
received = error;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
expect(received?.interrupts[0].value).toMatchObject({
|
|
787
|
+
subagent: nestedScope,
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
|
|
499
791
|
it('rethrows a child stream-limit trip instead of converting it to a tool result', async () => {
|
|
500
792
|
const executor = createExecutor({
|
|
501
793
|
createChildGraph: (): StandardGraph =>
|
|
@@ -1393,6 +1685,7 @@ describe('SubagentExecutor', () => {
|
|
|
1393
1685
|
checkpoint_id: 'parent-checkpoint-id',
|
|
1394
1686
|
checkpoint_map: { parent: 'checkpoint' },
|
|
1395
1687
|
checkpoint_ns: 'parent-checkpoint-ns',
|
|
1688
|
+
[SUBAGENT_PARENT_BATCH_CONFIG_KEY]: 'assistant-batch',
|
|
1396
1689
|
requestBody: { messageId: 'msg-1' },
|
|
1397
1690
|
thread_id: 'parent-thread',
|
|
1398
1691
|
user: { id: 'user_abc' },
|
|
@@ -1409,11 +1702,260 @@ describe('SubagentExecutor', () => {
|
|
|
1409
1702
|
expect(configurable.checkpoint_id).toBeUndefined();
|
|
1410
1703
|
expect(configurable.checkpoint_map).toBeUndefined();
|
|
1411
1704
|
expect(configurable.checkpoint_ns).toBeUndefined();
|
|
1705
|
+
expect(configurable[SUBAGENT_PARENT_BATCH_CONFIG_KEY]).toBeUndefined();
|
|
1412
1706
|
expect(configurable.requestBody).toEqual({ messageId: 'msg-1' });
|
|
1413
1707
|
expect(configurable.thread_id).toBe('parent-thread');
|
|
1414
1708
|
expect(configurable.user).toEqual({ id: 'user_abc' });
|
|
1415
1709
|
});
|
|
1416
1710
|
|
|
1711
|
+
it('isolates child checkpoints created from different parent forks', async () => {
|
|
1712
|
+
const invokedConfigs: Record<string, unknown>[] = [];
|
|
1713
|
+
const invoke = jest
|
|
1714
|
+
.fn()
|
|
1715
|
+
.mockImplementation(
|
|
1716
|
+
async (
|
|
1717
|
+
_input: unknown,
|
|
1718
|
+
config: Record<string, unknown>
|
|
1719
|
+
): Promise<{ messages: BaseMessage[] }> => {
|
|
1720
|
+
invokedConfigs.push(config);
|
|
1721
|
+
return { messages: [new AIMessage('done')] };
|
|
1722
|
+
}
|
|
1723
|
+
);
|
|
1724
|
+
const executor = createExecutor({
|
|
1725
|
+
humanInTheLoop: { enabled: true },
|
|
1726
|
+
createChildGraph: (): StandardGraph =>
|
|
1727
|
+
({
|
|
1728
|
+
createWorkflow: () => ({
|
|
1729
|
+
getState: jest.fn().mockResolvedValue({
|
|
1730
|
+
values: {},
|
|
1731
|
+
next: [],
|
|
1732
|
+
tasks: [],
|
|
1733
|
+
}),
|
|
1734
|
+
invoke,
|
|
1735
|
+
}),
|
|
1736
|
+
clearHeavyState: jest.fn(),
|
|
1737
|
+
}) as unknown as StandardGraph,
|
|
1738
|
+
});
|
|
1739
|
+
const common = {
|
|
1740
|
+
description: 'task',
|
|
1741
|
+
subagentType: 'researcher',
|
|
1742
|
+
threadId: 'parent-thread',
|
|
1743
|
+
parentToolCallId: 'call_shared',
|
|
1744
|
+
};
|
|
1745
|
+
|
|
1746
|
+
await executor.execute({
|
|
1747
|
+
...common,
|
|
1748
|
+
parentConfigurable: {
|
|
1749
|
+
thread_id: 'parent-thread',
|
|
1750
|
+
checkpoint_id: 'fork-a',
|
|
1751
|
+
},
|
|
1752
|
+
});
|
|
1753
|
+
await executor.execute({
|
|
1754
|
+
...common,
|
|
1755
|
+
parentConfigurable: {
|
|
1756
|
+
thread_id: 'parent-thread',
|
|
1757
|
+
checkpoint_id: 'fork-b',
|
|
1758
|
+
},
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
expect(invoke).toHaveBeenCalledTimes(2);
|
|
1762
|
+
const childThreadIds = invokedConfigs.map(
|
|
1763
|
+
(config) =>
|
|
1764
|
+
(config.configurable as Record<string, unknown>).thread_id as string
|
|
1765
|
+
);
|
|
1766
|
+
expect(childThreadIds[0]).toMatch(/^subagent:/);
|
|
1767
|
+
expect(childThreadIds[1]).toMatch(/^subagent:/);
|
|
1768
|
+
expect(childThreadIds[0]).not.toBe(childThreadIds[1]);
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
it('isolates reused tool-call IDs across assistant batches', async () => {
|
|
1772
|
+
const invokedThreadIds: string[] = [];
|
|
1773
|
+
const executor = createExecutor({
|
|
1774
|
+
humanInTheLoop: { enabled: true },
|
|
1775
|
+
createChildGraph: (): StandardGraph =>
|
|
1776
|
+
({
|
|
1777
|
+
createWorkflow: () => ({
|
|
1778
|
+
getState: jest.fn().mockResolvedValue({
|
|
1779
|
+
values: {},
|
|
1780
|
+
next: [],
|
|
1781
|
+
tasks: [],
|
|
1782
|
+
}),
|
|
1783
|
+
invoke: jest
|
|
1784
|
+
.fn()
|
|
1785
|
+
.mockImplementation(
|
|
1786
|
+
async (
|
|
1787
|
+
_input: unknown,
|
|
1788
|
+
invokeConfig: Record<string, unknown>
|
|
1789
|
+
): Promise<{ messages: BaseMessage[] }> => {
|
|
1790
|
+
invokedThreadIds.push(
|
|
1791
|
+
(invokeConfig.configurable as Record<string, unknown>)
|
|
1792
|
+
.thread_id as string
|
|
1793
|
+
);
|
|
1794
|
+
return { messages: [new AIMessage('done')] };
|
|
1795
|
+
}
|
|
1796
|
+
),
|
|
1797
|
+
}),
|
|
1798
|
+
clearHeavyState: jest.fn(),
|
|
1799
|
+
}) as unknown as StandardGraph,
|
|
1800
|
+
});
|
|
1801
|
+
const common = {
|
|
1802
|
+
description: 'task',
|
|
1803
|
+
subagentType: 'researcher',
|
|
1804
|
+
threadId: 'parent-thread',
|
|
1805
|
+
parentToolCallId: 'call_reused',
|
|
1806
|
+
};
|
|
1807
|
+
|
|
1808
|
+
await executor.execute({
|
|
1809
|
+
...common,
|
|
1810
|
+
parentConfigurable: {
|
|
1811
|
+
thread_id: 'parent-thread',
|
|
1812
|
+
[SUBAGENT_PARENT_BATCH_CONFIG_KEY]: 'assistant-batch-1',
|
|
1813
|
+
},
|
|
1814
|
+
});
|
|
1815
|
+
await executor.execute({
|
|
1816
|
+
...common,
|
|
1817
|
+
parentConfigurable: {
|
|
1818
|
+
thread_id: 'parent-thread',
|
|
1819
|
+
[SUBAGENT_PARENT_BATCH_CONFIG_KEY]: 'assistant-batch-2',
|
|
1820
|
+
},
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1823
|
+
expect(invokedThreadIds).toHaveLength(2);
|
|
1824
|
+
expect(invokedThreadIds[0]).not.toBe(invokedThreadIds[1]);
|
|
1825
|
+
});
|
|
1826
|
+
|
|
1827
|
+
it('keeps ambiguous parent agent and tool-call components collision-free', async () => {
|
|
1828
|
+
const invokedThreadIds: string[] = [];
|
|
1829
|
+
const createChildGraph = (): StandardGraph =>
|
|
1830
|
+
({
|
|
1831
|
+
createWorkflow: () => ({
|
|
1832
|
+
getState: jest.fn().mockResolvedValue({
|
|
1833
|
+
values: {},
|
|
1834
|
+
next: [],
|
|
1835
|
+
tasks: [],
|
|
1836
|
+
}),
|
|
1837
|
+
invoke: jest
|
|
1838
|
+
.fn()
|
|
1839
|
+
.mockImplementation(
|
|
1840
|
+
async (
|
|
1841
|
+
_input: unknown,
|
|
1842
|
+
invokeConfig: Record<string, unknown>
|
|
1843
|
+
): Promise<{ messages: BaseMessage[] }> => {
|
|
1844
|
+
invokedThreadIds.push(
|
|
1845
|
+
(invokeConfig.configurable as Record<string, unknown>)
|
|
1846
|
+
.thread_id as string
|
|
1847
|
+
);
|
|
1848
|
+
return { messages: [new AIMessage('done')] };
|
|
1849
|
+
}
|
|
1850
|
+
),
|
|
1851
|
+
}),
|
|
1852
|
+
clearHeavyState: jest.fn(),
|
|
1853
|
+
}) as unknown as StandardGraph;
|
|
1854
|
+
const common = {
|
|
1855
|
+
description: 'task',
|
|
1856
|
+
subagentType: 'researcher',
|
|
1857
|
+
threadId: 'parent',
|
|
1858
|
+
parentConfigurable: { thread_id: 'parent' },
|
|
1859
|
+
};
|
|
1860
|
+
|
|
1861
|
+
await createExecutor({
|
|
1862
|
+
checkpointer: new MemorySaver(),
|
|
1863
|
+
humanInTheLoop: { enabled: true },
|
|
1864
|
+
parentAgentId: 'a_b',
|
|
1865
|
+
createChildGraph,
|
|
1866
|
+
}).execute({ ...common, parentToolCallId: 'c' });
|
|
1867
|
+
await createExecutor({
|
|
1868
|
+
checkpointer: new MemorySaver(),
|
|
1869
|
+
humanInTheLoop: { enabled: true },
|
|
1870
|
+
parentAgentId: 'a',
|
|
1871
|
+
createChildGraph,
|
|
1872
|
+
}).execute({ ...common, parentToolCallId: 'b_c' });
|
|
1873
|
+
|
|
1874
|
+
expect(invokedThreadIds).toHaveLength(2);
|
|
1875
|
+
expect(invokedThreadIds[0]).not.toBe(invokedThreadIds[1]);
|
|
1876
|
+
});
|
|
1877
|
+
|
|
1878
|
+
it('retains owned child checkpoint threads after heavy-state cleanup', async () => {
|
|
1879
|
+
const executor = createExecutor({
|
|
1880
|
+
checkpointer: new MemorySaver(),
|
|
1881
|
+
humanInTheLoop: { enabled: true },
|
|
1882
|
+
createChildGraph: (): StandardGraph =>
|
|
1883
|
+
({
|
|
1884
|
+
createWorkflow: () => ({
|
|
1885
|
+
getState: jest.fn().mockResolvedValue({
|
|
1886
|
+
values: {},
|
|
1887
|
+
next: [],
|
|
1888
|
+
tasks: [],
|
|
1889
|
+
}),
|
|
1890
|
+
invoke: jest.fn().mockResolvedValue({
|
|
1891
|
+
messages: [new AIMessage('done')],
|
|
1892
|
+
}),
|
|
1893
|
+
}),
|
|
1894
|
+
clearHeavyState: jest.fn(),
|
|
1895
|
+
}) as unknown as StandardGraph,
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
await executor.execute({
|
|
1899
|
+
description: 'task',
|
|
1900
|
+
subagentType: 'researcher',
|
|
1901
|
+
threadId: 'parent',
|
|
1902
|
+
parentToolCallId: 'call_owned',
|
|
1903
|
+
});
|
|
1904
|
+
const beforeCleanup = executor.getChildCheckpointThreadIds();
|
|
1905
|
+
executor.clearHeavyState();
|
|
1906
|
+
|
|
1907
|
+
expect(beforeCleanup).toHaveLength(1);
|
|
1908
|
+
expect(executor.getChildCheckpointThreadIds()).toEqual(beforeCleanup);
|
|
1909
|
+
|
|
1910
|
+
executor.resetCheckpointThreadIds();
|
|
1911
|
+
expect(executor.getChildCheckpointThreadIds()).toEqual([]);
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
it('includes checkpoint threads from active descendant graphs', async () => {
|
|
1915
|
+
const grandchildThreadId = 'subagent:grandchild';
|
|
1916
|
+
const executor = createExecutor({
|
|
1917
|
+
checkpointer: new MemorySaver(),
|
|
1918
|
+
humanInTheLoop: { enabled: true },
|
|
1919
|
+
createChildGraph: (): StandardGraph =>
|
|
1920
|
+
({
|
|
1921
|
+
createWorkflow: () => ({
|
|
1922
|
+
getState: jest.fn().mockResolvedValue({
|
|
1923
|
+
values: {},
|
|
1924
|
+
next: ['child-agent'],
|
|
1925
|
+
tasks: [],
|
|
1926
|
+
}),
|
|
1927
|
+
invoke: jest.fn().mockRejectedValue(
|
|
1928
|
+
new GraphInterrupt([
|
|
1929
|
+
{
|
|
1930
|
+
id: 'grandchild-interrupt',
|
|
1931
|
+
value: {
|
|
1932
|
+
type: 'tool_approval',
|
|
1933
|
+
action_requests: [],
|
|
1934
|
+
review_configs: [],
|
|
1935
|
+
},
|
|
1936
|
+
},
|
|
1937
|
+
])
|
|
1938
|
+
),
|
|
1939
|
+
}),
|
|
1940
|
+
getChildCheckpointThreadIds: jest.fn(() => [grandchildThreadId]),
|
|
1941
|
+
clearHeavyState: jest.fn(),
|
|
1942
|
+
}) as unknown as StandardGraph,
|
|
1943
|
+
});
|
|
1944
|
+
|
|
1945
|
+
await expect(
|
|
1946
|
+
executor.execute({
|
|
1947
|
+
description: 'nested task',
|
|
1948
|
+
subagentType: 'researcher',
|
|
1949
|
+
threadId: 'parent',
|
|
1950
|
+
parentToolCallId: 'call_child',
|
|
1951
|
+
})
|
|
1952
|
+
).rejects.toBeInstanceOf(GraphInterrupt);
|
|
1953
|
+
|
|
1954
|
+
expect(executor.getChildCheckpointThreadIds()).toEqual(
|
|
1955
|
+
expect.arrayContaining([grandchildThreadId])
|
|
1956
|
+
);
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1417
1959
|
it('does not require parentConfigurable (back-compat with hosts that omit it)', async () => {
|
|
1418
1960
|
const { factory, getInvokeConfig } = makeCapturingGraphFactory();
|
|
1419
1961
|
const executor = createExecutor({ createChildGraph: factory });
|
|
@@ -1427,7 +1969,6 @@ describe('SubagentExecutor', () => {
|
|
|
1427
1969
|
string,
|
|
1428
1970
|
unknown
|
|
1429
1971
|
>;
|
|
1430
|
-
// Only thread_id (childRunId fallback) is set when no parent context is supplied.
|
|
1431
1972
|
expect(Object.keys(configurable)).toEqual(['thread_id']);
|
|
1432
1973
|
});
|
|
1433
1974
|
});
|
|
@@ -1502,6 +2043,67 @@ describe('SubagentExecutor', () => {
|
|
|
1502
2043
|
expect(input.agentType).toBe('researcher');
|
|
1503
2044
|
});
|
|
1504
2045
|
|
|
2046
|
+
it('finishes missing child lifecycle after recovering a completed checkpoint', async () => {
|
|
2047
|
+
const stopHook = jest.fn().mockResolvedValue({});
|
|
2048
|
+
const hookRegistry = new HookRegistry();
|
|
2049
|
+
hookRegistry.register('SubagentStop', { hooks: [stopHook] });
|
|
2050
|
+
const updates: SubagentUpdateEvent[] = [];
|
|
2051
|
+
const handlerRegistry = new HandlerRegistry();
|
|
2052
|
+
handlerRegistry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
|
|
2053
|
+
handle: (_event, data): void => {
|
|
2054
|
+
updates.push(data as SubagentUpdateEvent);
|
|
2055
|
+
},
|
|
2056
|
+
});
|
|
2057
|
+
const invoke = jest.fn();
|
|
2058
|
+
const updateState = jest.fn().mockResolvedValue({});
|
|
2059
|
+
const executor = createExecutor({
|
|
2060
|
+
humanInTheLoop: { enabled: true },
|
|
2061
|
+
hookRegistry,
|
|
2062
|
+
parentHandlerRegistry: handlerRegistry,
|
|
2063
|
+
createChildGraph: (): StandardGraph =>
|
|
2064
|
+
({
|
|
2065
|
+
createWorkflow: () => ({
|
|
2066
|
+
getState: jest.fn().mockResolvedValue({
|
|
2067
|
+
values: { messages: [new AIMessage('persisted result')] },
|
|
2068
|
+
next: [],
|
|
2069
|
+
tasks: [],
|
|
2070
|
+
}),
|
|
2071
|
+
invoke,
|
|
2072
|
+
updateState,
|
|
2073
|
+
}),
|
|
2074
|
+
clearHeavyState: jest.fn(),
|
|
2075
|
+
}) as unknown as StandardGraph,
|
|
2076
|
+
});
|
|
2077
|
+
|
|
2078
|
+
const result = await executor.execute({
|
|
2079
|
+
description: 'recover me',
|
|
2080
|
+
subagentType: 'researcher',
|
|
2081
|
+
threadId: 'durable-thread',
|
|
2082
|
+
parentToolCallId: 'call_recovered',
|
|
2083
|
+
});
|
|
2084
|
+
|
|
2085
|
+
expect(result.content).toBe('persisted result');
|
|
2086
|
+
expect(invoke).not.toHaveBeenCalled();
|
|
2087
|
+
expect(stopHook).toHaveBeenCalledTimes(1);
|
|
2088
|
+
expect(updates.filter((update) => update.phase === 'stop')).toHaveLength(
|
|
2089
|
+
1
|
|
2090
|
+
);
|
|
2091
|
+
expect(updateState).toHaveBeenCalledTimes(1);
|
|
2092
|
+
expect(updateState.mock.calls[0][1]).toMatchObject({
|
|
2093
|
+
messages: [
|
|
2094
|
+
{
|
|
2095
|
+
additional_kwargs: {
|
|
2096
|
+
__librechat_subagent_checkpoint: {
|
|
2097
|
+
version: 1,
|
|
2098
|
+
parentToolCallId: 'call_recovered',
|
|
2099
|
+
lifecycleComplete: true,
|
|
2100
|
+
},
|
|
2101
|
+
},
|
|
2102
|
+
},
|
|
2103
|
+
],
|
|
2104
|
+
});
|
|
2105
|
+
});
|
|
2106
|
+
|
|
1505
2107
|
it('SubagentStart deny blocks execution', async () => {
|
|
1506
2108
|
const registry = new HookRegistry();
|
|
1507
2109
|
registry.register('SubagentStart', {
|
|
@@ -2022,6 +2624,107 @@ describe('SubagentExecutor', () => {
|
|
|
2022
2624
|
expect(phases[phases.length - 1]).toBe('stop');
|
|
2023
2625
|
});
|
|
2024
2626
|
|
|
2627
|
+
it('does not let a stalled observational update handler block child completion', async () => {
|
|
2628
|
+
jest.useFakeTimers();
|
|
2629
|
+
try {
|
|
2630
|
+
const registry = new HandlerRegistry();
|
|
2631
|
+
const updateHandler = jest.fn(
|
|
2632
|
+
(): Promise<void> => new Promise<void>(() => {})
|
|
2633
|
+
);
|
|
2634
|
+
registry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
|
|
2635
|
+
handle: updateHandler,
|
|
2636
|
+
});
|
|
2637
|
+
const { factory } = makeStubGraphFactory({
|
|
2638
|
+
messages: [new AIMessage('ok')],
|
|
2639
|
+
});
|
|
2640
|
+
const executor = createExecutor({
|
|
2641
|
+
createChildGraph: factory,
|
|
2642
|
+
parentHandlerRegistry: registry,
|
|
2643
|
+
});
|
|
2644
|
+
|
|
2645
|
+
const execution = executor.execute({
|
|
2646
|
+
description: 'Task',
|
|
2647
|
+
subagentType: 'researcher',
|
|
2648
|
+
});
|
|
2649
|
+
const outcome = Promise.race([
|
|
2650
|
+
execution.then(({ content }) => content),
|
|
2651
|
+
new Promise<string>((resolve) =>
|
|
2652
|
+
setTimeout(() => resolve('stalled'), 20_000)
|
|
2653
|
+
),
|
|
2654
|
+
]);
|
|
2655
|
+
|
|
2656
|
+
await jest.advanceTimersByTimeAsync(20_000);
|
|
2657
|
+
|
|
2658
|
+
await expect(outcome).resolves.toBe('ok');
|
|
2659
|
+
expect(updateHandler).toHaveBeenCalledTimes(2);
|
|
2660
|
+
} finally {
|
|
2661
|
+
jest.useRealTimers();
|
|
2662
|
+
}
|
|
2663
|
+
});
|
|
2664
|
+
|
|
2665
|
+
it('does not let a stalled queued update block the terminal stop envelope', async () => {
|
|
2666
|
+
jest.useFakeTimers();
|
|
2667
|
+
try {
|
|
2668
|
+
const phases: SubagentUpdateEvent['phase'][] = [];
|
|
2669
|
+
const registry = new HandlerRegistry();
|
|
2670
|
+
registry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
|
|
2671
|
+
handle: (_event, rawData): void | Promise<void> => {
|
|
2672
|
+
const update = rawData as SubagentUpdateEvent;
|
|
2673
|
+
phases.push(update.phase);
|
|
2674
|
+
if (update.phase === 'run_step') {
|
|
2675
|
+
return new Promise<void>(() => {});
|
|
2676
|
+
}
|
|
2677
|
+
},
|
|
2678
|
+
});
|
|
2679
|
+
const factory: () => StandardGraph = (): StandardGraph =>
|
|
2680
|
+
({
|
|
2681
|
+
createWorkflow: (): { invoke: jest.Mock } => ({
|
|
2682
|
+
invoke: jest.fn().mockImplementation(async (_state, options) => {
|
|
2683
|
+
const opts = options as { callbacks?: unknown[] };
|
|
2684
|
+
const forwarder = (opts.callbacks ?? [])[0] as {
|
|
2685
|
+
handleCustomEvent?: (
|
|
2686
|
+
eventName: string,
|
|
2687
|
+
data: unknown
|
|
2688
|
+
) => Promise<void> | void;
|
|
2689
|
+
};
|
|
2690
|
+
for (let index = 0; index < 80; index++) {
|
|
2691
|
+
await forwarder.handleCustomEvent?.(GraphEvents.ON_RUN_STEP, {
|
|
2692
|
+
id: `step_${index}`,
|
|
2693
|
+
type: StepTypes.MESSAGE_CREATION,
|
|
2694
|
+
agentId: 'researcher',
|
|
2695
|
+
index,
|
|
2696
|
+
});
|
|
2697
|
+
}
|
|
2698
|
+
return { messages: [new AIMessage('ok')] };
|
|
2699
|
+
}),
|
|
2700
|
+
}),
|
|
2701
|
+
clearHeavyState: jest.fn(),
|
|
2702
|
+
}) as unknown as StandardGraph;
|
|
2703
|
+
const executor = createExecutor({
|
|
2704
|
+
createChildGraph: factory,
|
|
2705
|
+
parentHandlerRegistry: registry,
|
|
2706
|
+
});
|
|
2707
|
+
|
|
2708
|
+
const execution = executor.execute({
|
|
2709
|
+
description: 'Task',
|
|
2710
|
+
subagentType: 'researcher',
|
|
2711
|
+
});
|
|
2712
|
+
const outcome = Promise.race([
|
|
2713
|
+
execution.then(({ content }) => content),
|
|
2714
|
+
new Promise<string>((resolve) =>
|
|
2715
|
+
setTimeout(() => resolve('stalled'), 20_000)
|
|
2716
|
+
),
|
|
2717
|
+
]);
|
|
2718
|
+
|
|
2719
|
+
await jest.advanceTimersByTimeAsync(20_000);
|
|
2720
|
+
|
|
2721
|
+
await expect(outcome).resolves.toBe('ok');
|
|
2722
|
+
expect(phases).toEqual(['start', 'run_step', 'stop']);
|
|
2723
|
+
} finally {
|
|
2724
|
+
jest.useRealTimers();
|
|
2725
|
+
}
|
|
2726
|
+
});
|
|
2727
|
+
|
|
2025
2728
|
it('allowlists forwarded run step payloads before wrapping them in ON_SUBAGENT_UPDATE', async () => {
|
|
2026
2729
|
const subagentUpdates: SubagentUpdateEvent[] = [];
|
|
2027
2730
|
const registry = new HandlerRegistry();
|
|
@@ -2149,18 +2852,32 @@ describe('SubagentExecutor', () => {
|
|
|
2149
2852
|
expect(serialized).not.toContain('nested-completed-secret');
|
|
2150
2853
|
});
|
|
2151
2854
|
|
|
2152
|
-
it('
|
|
2855
|
+
it('bounds queued updates under overload while preserving lifecycle envelopes', async () => {
|
|
2856
|
+
const phases: SubagentUpdateEvent['phase'][] = [];
|
|
2153
2857
|
const completedIds: string[] = [];
|
|
2858
|
+
let releaseFirstCompleted!: () => void;
|
|
2859
|
+
const firstCompletedBlocked = new Promise<void>((resolve) => {
|
|
2860
|
+
releaseFirstCompleted = resolve;
|
|
2861
|
+
});
|
|
2862
|
+
let markAllEmitted!: () => void;
|
|
2863
|
+
const allEmitted = new Promise<void>((resolve) => {
|
|
2864
|
+
markAllEmitted = resolve;
|
|
2865
|
+
});
|
|
2866
|
+
let blockedFirstCompleted = false;
|
|
2154
2867
|
const registry = new HandlerRegistry();
|
|
2155
2868
|
registry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
|
|
2156
2869
|
handle: async (_event, rawData): Promise<void> => {
|
|
2157
2870
|
const update = rawData as SubagentUpdateEvent;
|
|
2871
|
+
phases.push(update.phase);
|
|
2158
2872
|
if (update.phase === 'run_step_completed') {
|
|
2159
2873
|
const data = update.data as { result?: { id?: string } };
|
|
2160
2874
|
if (data.result?.id != null) {
|
|
2161
2875
|
completedIds.push(data.result.id);
|
|
2162
2876
|
}
|
|
2163
|
-
|
|
2877
|
+
if (!blockedFirstCompleted) {
|
|
2878
|
+
blockedFirstCompleted = true;
|
|
2879
|
+
await firstCompletedBlocked;
|
|
2880
|
+
}
|
|
2164
2881
|
}
|
|
2165
2882
|
},
|
|
2166
2883
|
});
|
|
@@ -2195,6 +2912,7 @@ describe('SubagentExecutor', () => {
|
|
|
2195
2912
|
}
|
|
2196
2913
|
);
|
|
2197
2914
|
}
|
|
2915
|
+
markAllEmitted();
|
|
2198
2916
|
return { messages: [new AIMessage('ok')] };
|
|
2199
2917
|
}),
|
|
2200
2918
|
}),
|
|
@@ -2206,14 +2924,21 @@ describe('SubagentExecutor', () => {
|
|
|
2206
2924
|
parentHandlerRegistry: registry,
|
|
2207
2925
|
});
|
|
2208
2926
|
|
|
2209
|
-
|
|
2927
|
+
const execution = executor.execute({
|
|
2210
2928
|
description: 'Task',
|
|
2211
2929
|
subagentType: 'researcher',
|
|
2212
2930
|
});
|
|
2931
|
+
await allEmitted;
|
|
2932
|
+
releaseFirstCompleted();
|
|
2933
|
+
await execution;
|
|
2213
2934
|
|
|
2214
|
-
expect(completedIds).toHaveLength(
|
|
2935
|
+
expect(completedIds).toHaveLength(65);
|
|
2215
2936
|
expect(completedIds[0]).toBe('step_0');
|
|
2937
|
+
expect(completedIds).not.toContain('step_1');
|
|
2938
|
+
expect(completedIds).toContain('step_16');
|
|
2216
2939
|
expect(completedIds[completedIds.length - 1]).toBe('step_79');
|
|
2940
|
+
expect(phases[0]).toBe('start');
|
|
2941
|
+
expect(phases[phases.length - 1]).toBe('stop');
|
|
2217
2942
|
});
|
|
2218
2943
|
|
|
2219
2944
|
it('does NOT forward ON_TOOL_EXECUTE when the parent registry has no handler (safe fallback)', async () => {
|