@librechat/agents 3.4.1 → 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.
Files changed (128) hide show
  1. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  2. package/dist/cjs/graphs/Graph.cjs +57 -33
  3. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs +81 -33
  5. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  6. package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs +14 -0
  7. package/dist/cjs/graphs/applyGraphRuntimeConfig.cjs.map +1 -0
  8. package/dist/cjs/graphs/createGraph.cjs +15 -0
  9. package/dist/cjs/graphs/createGraph.cjs.map +1 -0
  10. package/dist/cjs/graphs/index.cjs +1 -0
  11. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +1 -1
  12. package/dist/cjs/main.cjs +20 -11
  13. package/dist/cjs/run.cjs +48 -38
  14. package/dist/cjs/run.cjs.map +1 -1
  15. package/dist/cjs/session/JsonlSessionStore.cjs +2 -2
  16. package/dist/cjs/tools/SubagentTool.cjs +4 -4
  17. package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
  18. package/dist/cjs/tools/ToolNode.cjs +13 -9
  19. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  20. package/dist/cjs/tools/eagerEventExecution.cjs +1 -0
  21. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +3 -3
  22. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +2 -2
  23. package/dist/cjs/tools/search/rerankers.cjs +154 -1
  24. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  25. package/dist/cjs/tools/search/tool.cjs +4 -1
  26. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  27. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs +601 -0
  28. package/dist/cjs/tools/subagent/SubagentExecutionRegistry.cjs.map +1 -0
  29. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +672 -343
  30. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  31. package/dist/cjs/tools/subagent/SubagentReplay.cjs +3 -1
  32. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -1
  33. package/dist/cjs/tools/subagent/childGraphConfig.cjs +278 -0
  34. package/dist/cjs/tools/subagent/childGraphConfig.cjs.map +1 -0
  35. package/dist/cjs/tools/subagent/index.cjs +1 -0
  36. package/dist/cjs/tools/subagent/runtimeLimits.cjs +5 -0
  37. package/dist/cjs/tools/subagent/runtimeLimits.cjs.map +1 -0
  38. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  39. package/dist/esm/graphs/Graph.mjs +57 -33
  40. package/dist/esm/graphs/Graph.mjs.map +1 -1
  41. package/dist/esm/graphs/MultiAgentGraph.mjs +81 -33
  42. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  43. package/dist/esm/graphs/applyGraphRuntimeConfig.mjs +14 -0
  44. package/dist/esm/graphs/applyGraphRuntimeConfig.mjs.map +1 -0
  45. package/dist/esm/graphs/createGraph.mjs +15 -0
  46. package/dist/esm/graphs/createGraph.mjs.map +1 -0
  47. package/dist/esm/graphs/index.mjs +1 -0
  48. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +1 -1
  49. package/dist/esm/main.mjs +12 -10
  50. package/dist/esm/run.mjs +48 -38
  51. package/dist/esm/run.mjs.map +1 -1
  52. package/dist/esm/session/JsonlSessionStore.mjs +2 -2
  53. package/dist/esm/tools/SubagentTool.mjs +4 -4
  54. package/dist/esm/tools/SubagentTool.mjs.map +1 -1
  55. package/dist/esm/tools/ToolNode.mjs +13 -9
  56. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  57. package/dist/esm/tools/eagerEventExecution.mjs +1 -1
  58. package/dist/esm/tools/local/LocalExecutionEngine.mjs +3 -3
  59. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +2 -2
  60. package/dist/esm/tools/search/rerankers.mjs +154 -1
  61. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  62. package/dist/esm/tools/search/tool.mjs +4 -1
  63. package/dist/esm/tools/search/tool.mjs.map +1 -1
  64. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs +597 -0
  65. package/dist/esm/tools/subagent/SubagentExecutionRegistry.mjs.map +1 -0
  66. package/dist/esm/tools/subagent/SubagentExecutor.mjs +672 -343
  67. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  68. package/dist/esm/tools/subagent/SubagentReplay.mjs +3 -1
  69. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -1
  70. package/dist/esm/tools/subagent/childGraphConfig.mjs +272 -0
  71. package/dist/esm/tools/subagent/childGraphConfig.mjs.map +1 -0
  72. package/dist/esm/tools/subagent/index.mjs +1 -0
  73. package/dist/esm/tools/subagent/runtimeLimits.mjs +5 -0
  74. package/dist/esm/tools/subagent/runtimeLimits.mjs.map +1 -0
  75. package/dist/types/agents/AgentContext.d.ts +1 -1
  76. package/dist/types/graphs/Graph.d.ts +7 -2
  77. package/dist/types/graphs/MultiAgentGraph.d.ts +7 -1
  78. package/dist/types/graphs/applyGraphRuntimeConfig.d.ts +3 -0
  79. package/dist/types/graphs/createGraph.d.ts +13 -0
  80. package/dist/types/graphs/graphFactory.d.ts +13 -0
  81. package/dist/types/graphs/index.d.ts +2 -0
  82. package/dist/types/run.d.ts +2 -2
  83. package/dist/types/tools/SubagentTool.d.ts +1 -1
  84. package/dist/types/tools/search/rerankers.d.ts +28 -0
  85. package/dist/types/tools/search/types.d.ts +38 -1
  86. package/dist/types/tools/subagent/SubagentExecutionRegistry.d.ts +171 -0
  87. package/dist/types/tools/subagent/SubagentExecutor.d.ts +32 -37
  88. package/dist/types/tools/subagent/SubagentReplay.d.ts +5 -1
  89. package/dist/types/tools/subagent/childGraphConfig.d.ts +37 -0
  90. package/dist/types/tools/subagent/index.d.ts +1 -1
  91. package/dist/types/tools/subagent/runtimeLimits.d.ts +4 -0
  92. package/dist/types/types/graph.d.ts +184 -19
  93. package/package.json +3 -1
  94. package/src/agents/AgentContext.ts +1 -1
  95. package/src/graphs/Graph.ts +111 -60
  96. package/src/graphs/MultiAgentGraph.ts +149 -47
  97. package/src/graphs/__tests__/MultiAgentGraph.test.ts +86 -0
  98. package/src/graphs/__tests__/composition.smoke.test.ts +152 -0
  99. package/src/graphs/__tests__/createGraph.test.ts +206 -0
  100. package/src/graphs/applyGraphRuntimeConfig.ts +25 -0
  101. package/src/graphs/createGraph.ts +32 -0
  102. package/src/graphs/graphFactory.ts +12 -0
  103. package/src/graphs/index.ts +2 -0
  104. package/src/run.ts +50 -38
  105. package/src/specs/graph-subagent.live.test.ts +242 -0
  106. package/src/specs/graph-subagent.test.ts +322 -0
  107. package/src/specs/langfuse-routing.integration.test.ts +120 -1
  108. package/src/specs/subagent.test.ts +130 -4
  109. package/src/tools/SubagentTool.ts +4 -4
  110. package/src/tools/ToolNode.ts +22 -10
  111. package/src/tools/__tests__/SubagentExecutor.test.ts +198 -9
  112. package/src/tools/__tests__/SubagentReplay.test.ts +29 -0
  113. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +28 -2
  114. package/src/tools/__tests__/subagentHooks.test.ts +90 -3
  115. package/src/tools/search/rag-api-reranker.test.ts +891 -0
  116. package/src/tools/search/rerankers.ts +260 -0
  117. package/src/tools/search/tool.ts +6 -0
  118. package/src/tools/search/types.ts +45 -1
  119. package/src/tools/subagent/SubagentExecutionRegistry.ts +1159 -0
  120. package/src/tools/subagent/SubagentExecutor.ts +1245 -563
  121. package/src/tools/subagent/SubagentReplay.ts +10 -1
  122. package/src/tools/subagent/__tests__/SubagentExecutionRegistry.test.ts +637 -0
  123. package/src/tools/subagent/__tests__/SubagentExecutor.lazy.test.ts +3054 -0
  124. package/src/tools/subagent/__tests__/childGraphConfig.test.ts +521 -0
  125. package/src/tools/subagent/childGraphConfig.ts +694 -0
  126. package/src/tools/subagent/index.ts +6 -0
  127. package/src/tools/subagent/runtimeLimits.ts +7 -0
  128. package/src/types/graph.ts +217 -19
@@ -64,6 +64,10 @@ export interface SubagentGraphResumeState {
64
64
  export interface SubagentResumeExecution {
65
65
  parentToolCallId: string;
66
66
  childRunId: string;
67
+ /** Effective child type bound when this execution first ran. */
68
+ subagentType?: string;
69
+ /** Lazy child revision bound when this execution first resolved. */
70
+ configId?: string;
67
71
  approvalExecutionScope: string;
68
72
  checkpoints: SubagentCheckpointReference[];
69
73
  graphState: SubagentGraphResumeState;
@@ -322,6 +326,8 @@ function isSubagentResumeExecution(
322
326
  }
323
327
  const execution = value as Partial<SubagentResumeExecution>;
324
328
  const childRunId = execution.childRunId;
329
+ const subagentType = execution.subagentType;
330
+ const configId = execution.configId;
325
331
  const approvalExecutionScope = execution.approvalExecutionScope;
326
332
  const checkpoints = execution.checkpoints;
327
333
  const graphState = execution.graphState;
@@ -329,6 +335,8 @@ function isSubagentResumeExecution(
329
335
  if (
330
336
  !isString(execution.parentToolCallId) ||
331
337
  !isString(childRunId) ||
338
+ (subagentType != null && !isString(subagentType)) ||
339
+ (configId != null && !isString(configId)) ||
332
340
  !isString(approvalExecutionScope) ||
333
341
  !Array.isArray(checkpoints) ||
334
342
  checkpoints.length === 0 ||
@@ -557,7 +565,8 @@ export type SettledSubagentToolOutput = {
557
565
 
558
566
  export interface SubagentReplayController {
559
567
  getResumeManifest?(
560
- parentToolCallIds?: ReadonlySet<string>
568
+ parentToolCallIds?: ReadonlySet<string>,
569
+ config?: RunnableConfig
561
570
  ): Promise<SubagentResumeManifest | undefined>;
562
571
  getSettledOutput(
563
572
  call: ToolCall,
@@ -0,0 +1,637 @@
1
+ import { describe, expect, it, jest } from '@jest/globals';
2
+ import {
3
+ SubagentExecutionRegistry,
4
+ SubagentExecutionInvalidatedError,
5
+ } from '@/tools/subagent/SubagentExecutionRegistry';
6
+ import {
7
+ SUBAGENT_PARENT_BATCH_CONFIG_KEY,
8
+ SUBAGENT_RESUME_ATTEMPT_CONFIG_KEY,
9
+ } from '@/tools/subagent/SubagentReplay';
10
+
11
+ type Result = { content: string };
12
+ type ResolvedConfig = { agentId: string };
13
+ type ActiveRun = { graphId: string };
14
+ type SettledOutput = { content: string };
15
+
16
+ const createRegistry = (): SubagentExecutionRegistry<
17
+ Result,
18
+ ResolvedConfig,
19
+ ActiveRun,
20
+ SettledOutput
21
+ > =>
22
+ new SubagentExecutionRegistry({
23
+ parentRunId: 'parent-run',
24
+ parentAgentId: 'parent-agent',
25
+ durable: true,
26
+ });
27
+
28
+ const createInput = (
29
+ overrides: Record<string, unknown> = {}
30
+ ): {
31
+ threadId: string;
32
+ parentToolCallId: string;
33
+ parentConfigurable: Record<string, unknown>;
34
+ } => ({
35
+ threadId: 'durable-thread',
36
+ parentToolCallId: 'call_shared',
37
+ parentConfigurable: {
38
+ checkpoint_id: 'fork-a',
39
+ [SUBAGENT_PARENT_BATCH_CONFIG_KEY]: 'batch-a',
40
+ [SUBAGENT_RESUME_ATTEMPT_CONFIG_KEY]: 'attempt-a',
41
+ ...overrides,
42
+ },
43
+ });
44
+
45
+ describe('SubagentExecutionRegistry', () => {
46
+ it('derives one canonical address and isolates forks, batches, and attempts', () => {
47
+ const registry = createRegistry();
48
+ const first = registry.open(createInput());
49
+ const duplicate = registry.open(createInput());
50
+ const fork = registry.open(createInput({ checkpoint_id: 'fork-b' }));
51
+ const batch = registry.open(
52
+ createInput({ [SUBAGENT_PARENT_BATCH_CONFIG_KEY]: 'batch-b' })
53
+ );
54
+ const attempt = registry.open(
55
+ createInput({ [SUBAGENT_RESUME_ATTEMPT_CONFIG_KEY]: 'attempt-b' })
56
+ );
57
+
58
+ expect(duplicate).toBe(first);
59
+ expect(
60
+ new Set([
61
+ first.address.key,
62
+ fork.address.key,
63
+ batch.address.key,
64
+ attempt.address.key,
65
+ ]).size
66
+ ).toBe(4);
67
+ expect(attempt.address.baseChildThreadId).toBe(
68
+ first.address.baseChildThreadId
69
+ );
70
+ expect(attempt.address.branchChildThreadId).not.toBe(
71
+ first.address.branchChildThreadId
72
+ );
73
+ });
74
+
75
+ it('keeps identity immutable and protects an effective definition binding', () => {
76
+ const record = createRegistry().open(createInput());
77
+ const identity = {
78
+ childRunId: 'child-run',
79
+ childThreadId: 'child-thread',
80
+ approvalExecutionScope: 'approval-scope',
81
+ };
82
+
83
+ record.bindIdentity(identity);
84
+ record.bindDefinition(
85
+ { subagentType: 'researcher', configId: 'researcher@v1' },
86
+ 'provisional'
87
+ );
88
+ record.bindDefinition(
89
+ { subagentType: 'coder', configId: 'coder@v2' },
90
+ 'effective'
91
+ );
92
+ record.bindDefinition(
93
+ { subagentType: 'researcher', configId: 'researcher@v1' },
94
+ 'provisional'
95
+ );
96
+ expect(() =>
97
+ record.bindDefinition(
98
+ { subagentType: 'coder', configId: 'coder@v2' },
99
+ 'effective'
100
+ )
101
+ ).not.toThrow();
102
+
103
+ expect(record.snapshot).toMatchObject({
104
+ identity,
105
+ binding: { subagentType: 'coder', configId: 'coder@v2' },
106
+ });
107
+ expect(() =>
108
+ record.bindIdentity({
109
+ ...identity,
110
+ childThreadId: 'another-child-thread',
111
+ })
112
+ ).toThrow('Subagent execution identity cannot change after binding.');
113
+ expect(() =>
114
+ record.bindDefinition(
115
+ { subagentType: 'writer', configId: 'writer@v3' },
116
+ 'effective'
117
+ )
118
+ ).toThrow('Subagent effective definition binding cannot change.');
119
+ });
120
+
121
+ it('coalesces resolution and isolates a stale completion after invalidation', async () => {
122
+ const registry = createRegistry();
123
+ const staleRecord = registry.open(createInput());
124
+ let finishStale = (_config: ResolvedConfig): void => undefined;
125
+ let finishCurrent = (_config: ResolvedConfig): void => undefined;
126
+ let markStaleStarted = (): void => undefined;
127
+ const staleStarted = new Promise<void>((resolve) => {
128
+ markStaleStarted = resolve;
129
+ });
130
+ const staleValue = new Promise<ResolvedConfig>((resolve) => {
131
+ finishStale = resolve;
132
+ });
133
+ const currentValue = new Promise<ResolvedConfig>((resolve) => {
134
+ finishCurrent = resolve;
135
+ });
136
+ const signal = new AbortController().signal;
137
+ const resolveStale = jest.fn(() => {
138
+ markStaleStarted();
139
+ return staleValue;
140
+ });
141
+
142
+ const stale = staleRecord.resolveConfig(resolveStale, signal);
143
+ const duplicate = staleRecord.resolveConfig(resolveStale, signal);
144
+ expect(duplicate).toBe(stale);
145
+ await staleStarted;
146
+
147
+ registry.clear();
148
+ const currentRecord = registry.open(createInput());
149
+ const current = currentRecord.resolveConfig(() => currentValue, signal);
150
+
151
+ finishStale({ agentId: 'stale-agent' });
152
+ await expect(stale).rejects.toBeInstanceOf(
153
+ SubagentExecutionInvalidatedError
154
+ );
155
+ expect(currentRecord.snapshot).toMatchObject({ resolving: true });
156
+
157
+ finishCurrent({ agentId: 'current-agent' });
158
+ await expect(current).resolves.toEqual({ agentId: 'current-agent' });
159
+ expect(currentRecord.snapshot).toMatchObject({
160
+ resolving: false,
161
+ resolvedConfig: { agentId: 'current-agent' },
162
+ });
163
+ expect(resolveStale).toHaveBeenCalledTimes(1);
164
+ });
165
+
166
+ it('coalesces identity resolution before binding the resolved identity', async () => {
167
+ const registry = createRegistry();
168
+ const record = registry.open(createInput());
169
+ let finish = (_identity: {
170
+ childRunId: string;
171
+ childThreadId: string;
172
+ approvalExecutionScope: string;
173
+ }): void => undefined;
174
+ const identity = new Promise<{
175
+ childRunId: string;
176
+ childThreadId: string;
177
+ approvalExecutionScope: string;
178
+ }>((resolve) => {
179
+ finish = resolve;
180
+ });
181
+ const resolveIdentity = jest.fn(async () => {
182
+ const resolvedIdentity = await identity;
183
+ return {
184
+ identity: resolvedIdentity,
185
+ lease: registry.beginIdentityPreparation(record, resolvedIdentity),
186
+ };
187
+ });
188
+
189
+ const first = record.resolveIdentity(resolveIdentity);
190
+ const duplicate = record.resolveIdentity(resolveIdentity);
191
+
192
+ expect(duplicate).toBe(first);
193
+ expect(record.snapshot.resolvingIdentity).toBe(true);
194
+ finish({
195
+ childRunId: 'child-run',
196
+ childThreadId: 'child-thread',
197
+ approvalExecutionScope: 'approval-scope',
198
+ });
199
+
200
+ await expect(first).resolves.toMatchObject({ childRunId: 'child-run' });
201
+ expect(resolveIdentity).toHaveBeenCalledTimes(1);
202
+ expect(record.snapshot).toMatchObject({
203
+ resolvingIdentity: false,
204
+ identity: { childThreadId: 'child-thread' },
205
+ });
206
+ });
207
+
208
+ it('rolls back prepared identity side effects after invalidation', async () => {
209
+ const registry = createRegistry();
210
+ const record = registry.open(createInput());
211
+ let finishPreparation = (): void => undefined;
212
+ let markPreparationStarted = (): void => undefined;
213
+ const preparationStarted = new Promise<void>((resolve) => {
214
+ markPreparationStarted = resolve;
215
+ });
216
+ const preparationGate = new Promise<void>((resolve) => {
217
+ finishPreparation = resolve;
218
+ });
219
+ const commit = jest.fn();
220
+ const rollback = jest.fn(async (): Promise<void> => undefined);
221
+ const staleIdentity = {
222
+ childRunId: 'stale-run',
223
+ childThreadId: 'stale-thread',
224
+ approvalExecutionScope: 'stale-scope',
225
+ };
226
+ const lease = registry.beginIdentityPreparation(record, staleIdentity);
227
+ const pending = record.resolveIdentity(async () => {
228
+ markPreparationStarted();
229
+ await preparationGate;
230
+ return {
231
+ identity: staleIdentity,
232
+ lease,
233
+ commit: () => commit(),
234
+ rollback: async () => rollback(),
235
+ };
236
+ });
237
+
238
+ await preparationStarted;
239
+ registry.clear();
240
+ finishPreparation();
241
+
242
+ await expect(pending).rejects.toBeInstanceOf(
243
+ SubagentExecutionInvalidatedError
244
+ );
245
+ expect(commit).not.toHaveBeenCalled();
246
+ expect(rollback).toHaveBeenCalledTimes(1);
247
+ expect(record.snapshot.identity).toBeUndefined();
248
+ expect(registry.selectForResume()).toEqual([]);
249
+ });
250
+
251
+ it('keeps created-resource ownership exclusive through rollback', async () => {
252
+ const registry = createRegistry();
253
+ const staleRecord = registry.open(createInput());
254
+ const sharedIdentity = {
255
+ childRunId: 'shared-run',
256
+ childThreadId: 'shared-thread',
257
+ approvalExecutionScope: 'shared-scope',
258
+ };
259
+ const staleLease = registry.beginIdentityPreparation(
260
+ staleRecord,
261
+ sharedIdentity
262
+ );
263
+ const checkpointCleanup = jest.fn(async (): Promise<void> => undefined);
264
+ const approvalCleanup = jest.fn(async (): Promise<void> => undefined);
265
+ staleLease.markCheckpointCreated(checkpointCleanup);
266
+ staleLease.markApprovalScopeCreated(approvalCleanup);
267
+ registry.clear();
268
+ const currentRecord = registry.open(createInput());
269
+ const staleCause = new Error('stale preparation');
270
+
271
+ expect(() =>
272
+ registry.beginIdentityPreparation(currentRecord, sharedIdentity)
273
+ ).toThrow(SubagentExecutionInvalidatedError);
274
+
275
+ await expect(staleLease.rollback(staleCause)).rejects.toBe(staleCause);
276
+
277
+ expect(staleLease.ownsCheckpoint).toBe(false);
278
+ expect(staleLease.ownsApprovalScope).toBe(false);
279
+ expect(checkpointCleanup).toHaveBeenCalledTimes(1);
280
+ expect(approvalCleanup).toHaveBeenCalledTimes(1);
281
+ const currentLease = registry.beginIdentityPreparation(
282
+ currentRecord,
283
+ sharedIdentity
284
+ );
285
+ expect(currentLease.ownsCreatedCheckpoint).toBe(false);
286
+ expect(currentLease.ownsCreatedApprovalScope).toBe(false);
287
+ currentLease.release();
288
+ });
289
+
290
+ it('blocks a cleaning checkpoint without claiming an independent approval scope', async () => {
291
+ const registry = createRegistry();
292
+ const staleRecord = registry.open(createInput());
293
+ const staleIdentity = {
294
+ childRunId: 'stale-run',
295
+ childThreadId: 'shared-cleaning-thread',
296
+ approvalExecutionScope: 'stale-scope',
297
+ };
298
+ const staleLease = registry.beginIdentityPreparation(
299
+ staleRecord,
300
+ staleIdentity
301
+ );
302
+ let finishCleanup = (): void => undefined;
303
+ let markCleanupStarted = (): void => undefined;
304
+ const cleanupGate = new Promise<void>((resolve) => {
305
+ finishCleanup = resolve;
306
+ });
307
+ const cleanupStarted = new Promise<void>((resolve) => {
308
+ markCleanupStarted = resolve;
309
+ });
310
+ const cleanup = jest.fn(async (): Promise<void> => {
311
+ markCleanupStarted();
312
+ await cleanupGate;
313
+ });
314
+ staleLease.markCheckpointCreated(cleanup);
315
+ registry.clear();
316
+ const currentRecord = registry.open(createInput());
317
+ const replacementIdentity = {
318
+ childRunId: 'replacement-run',
319
+ childThreadId: staleIdentity.childThreadId,
320
+ approvalExecutionScope: 'replacement-scope',
321
+ };
322
+ const staleCause = new Error('stale preparation');
323
+ const rollback = staleLease.rollback(staleCause);
324
+ await cleanupStarted;
325
+
326
+ expect(() =>
327
+ registry.beginIdentityPreparation(currentRecord, replacementIdentity)
328
+ ).toThrow(SubagentExecutionInvalidatedError);
329
+ const independentRecord = registry.open(
330
+ createInput({ checkpoint_id: 'independent-fork' })
331
+ );
332
+ const independentLease = registry.beginIdentityPreparation(
333
+ independentRecord,
334
+ {
335
+ childRunId: 'independent-run',
336
+ childThreadId: 'independent-thread',
337
+ approvalExecutionScope: replacementIdentity.approvalExecutionScope,
338
+ }
339
+ );
340
+ expect(independentLease.ownsCheckpoint).toBe(true);
341
+ expect(independentLease.ownsApprovalScope).toBe(true);
342
+ independentLease.release();
343
+
344
+ finishCleanup();
345
+ await expect(rollback).rejects.toBe(staleCause);
346
+ expect(cleanup).toHaveBeenCalledTimes(1);
347
+ const retryLease = registry.beginIdentityPreparation(
348
+ currentRecord,
349
+ replacementIdentity
350
+ );
351
+ expect(retryLease.ownsCheckpoint).toBe(true);
352
+ expect(retryLease.ownsApprovalScope).toBe(true);
353
+ retryLease.release();
354
+ });
355
+
356
+ it('preserves the primary failure when preparation rollback also fails', async () => {
357
+ const registry = createRegistry();
358
+ const record = registry.open(createInput());
359
+ const lease = registry.beginIdentityPreparation(record, {
360
+ childRunId: 'failed-run',
361
+ childThreadId: 'failed-thread',
362
+ approvalExecutionScope: 'failed-scope',
363
+ });
364
+ const cause = new Error('checkpoint fork failed');
365
+ const rollbackError = new Error('checkpoint cleanup failed');
366
+
367
+ await expect(
368
+ lease.rollback(cause, async () => {
369
+ throw rollbackError;
370
+ })
371
+ ).rejects.toBe(cause);
372
+
373
+ expect((cause as Error & { rollbackError?: unknown }).rollbackError).toBe(
374
+ rollbackError
375
+ );
376
+ });
377
+
378
+ it('preserves a frozen primary failure and releases a failed lease', async () => {
379
+ const registry = createRegistry();
380
+ const record = registry.open(createInput());
381
+ const identity = {
382
+ childRunId: 'frozen-run',
383
+ childThreadId: 'frozen-thread',
384
+ approvalExecutionScope: 'frozen-scope',
385
+ };
386
+ const lease = registry.beginIdentityPreparation(record, identity);
387
+ const cause = Object.freeze(new Error('frozen preparation failure'));
388
+ lease.markCheckpointCreated(async () => {
389
+ throw new Error('cleanup failure');
390
+ });
391
+
392
+ await expect(lease.rollback(cause)).rejects.toBe(cause);
393
+
394
+ expect(lease.ownsCheckpoint).toBe(false);
395
+ expect(lease.ownsApprovalScope).toBe(false);
396
+ const retryLease = registry.beginIdentityPreparation(record, identity);
397
+ expect(retryLease.ownsCheckpoint).toBe(true);
398
+ expect(retryLease.ownsApprovalScope).toBe(true);
399
+ retryLease.release();
400
+ });
401
+
402
+ it('coalesces invocation and owns active, interrupted, and completed state', async () => {
403
+ const record = createRegistry().open(createInput());
404
+ const invocation = {
405
+ description: 'Run the child.',
406
+ subagentType: 'researcher',
407
+ configId: 'researcher@v1',
408
+ };
409
+ let finish = (_result: Result): void => undefined;
410
+ const result = new Promise<Result>((resolve) => {
411
+ finish = resolve;
412
+ });
413
+ const invoke = jest.fn(async (): Promise<Result> => {
414
+ record.activate({ graphId: 'graph-a' });
415
+ record.markStarted();
416
+ record.markInterrupted();
417
+ const completed = await result;
418
+ record.activate({ graphId: 'graph-a' });
419
+ record.markCompleted(completed);
420
+ return completed;
421
+ });
422
+
423
+ const first = record.execute(invocation, invoke);
424
+ const duplicate = record.execute(invocation, invoke);
425
+ expect(duplicate).toBe(first);
426
+ expect(() =>
427
+ record.execute({ ...invocation, description: 'Changed request.' }, invoke)
428
+ ).toThrow('Subagent invocation binding cannot change.');
429
+ expect(() =>
430
+ record.execute({ ...invocation, configId: 'researcher@v2' }, invoke)
431
+ ).toThrow('Subagent effective definition binding cannot change.');
432
+ finish({ content: 'done' });
433
+
434
+ await expect(first).resolves.toEqual({ content: 'done' });
435
+ expect(invoke).toHaveBeenCalledTimes(1);
436
+ expect(record.snapshot).toMatchObject({
437
+ phase: 'completed',
438
+ activeRun: { graphId: 'graph-a' },
439
+ completedResult: { content: 'done' },
440
+ started: true,
441
+ completed: true,
442
+ executing: false,
443
+ });
444
+ expect(record.releaseActiveRun()).toEqual({ graphId: 'graph-a' });
445
+ expect(record.snapshot.activeRun).toBeUndefined();
446
+ expect(() => record.activate({ graphId: 'graph-b' })).toThrow(
447
+ 'Cannot transition subagent execution from completed to active.'
448
+ );
449
+ expect(() => record.markFailed()).toThrow(
450
+ 'Cannot transition subagent execution from completed to failed.'
451
+ );
452
+
453
+ const retryable = createRegistry().open(createInput());
454
+ retryable.markFailed();
455
+ expect(retryable.snapshot.phase).toBe('failed');
456
+ expect(retryable.activate({ graphId: 'graph-retry' })).toEqual({
457
+ graphId: 'graph-retry',
458
+ });
459
+ });
460
+
461
+ it('coalesces settlement and retains only its minimal replay output', async () => {
462
+ const record = createRegistry().open(createInput());
463
+ const invocation = {
464
+ description: 'Run the child.',
465
+ subagentType: 'researcher',
466
+ configId: 'researcher@v1',
467
+ };
468
+ const settlement = {
469
+ definitionAuthority: 'effective' as const,
470
+ fingerprint: 'settlement-a',
471
+ invocation,
472
+ subagentType: invocation.subagentType,
473
+ configId: invocation.configId,
474
+ };
475
+ const settledOutput = { content: 'settled result' };
476
+ const persist = jest.fn(async (): Promise<void> => undefined);
477
+ await record.resolveConfig(
478
+ async () => ({ agentId: 'heavy-agent-config' }),
479
+ new AbortController().signal
480
+ );
481
+ record.activate({ graphId: 'heavy-graph' });
482
+ record.markStarted();
483
+ record.markCompleted({ content: 'heavy execution result' });
484
+
485
+ const first = record.settle(settlement, settledOutput, persist);
486
+ const duplicate = record.settle(settlement, settledOutput, persist);
487
+
488
+ expect(duplicate).toBe(first);
489
+ await first;
490
+ expect(persist).toHaveBeenCalledTimes(1);
491
+ expect(record.snapshot).toMatchObject({
492
+ phase: 'completed',
493
+ settled: true,
494
+ settling: false,
495
+ });
496
+ expect(record.settledOutput).toEqual(settledOutput);
497
+ expect(record.snapshot.activeRun).toBeUndefined();
498
+ expect(record.snapshot.completedResult).toBeUndefined();
499
+ expect(record.snapshot.resolvedConfig).toBeUndefined();
500
+ await record.settle(settlement, settledOutput, persist);
501
+ expect(persist).toHaveBeenCalledTimes(1);
502
+ expect(() =>
503
+ record.settle(
504
+ { ...settlement, fingerprint: 'settlement-b' },
505
+ settledOutput,
506
+ persist
507
+ )
508
+ ).toThrow('Subagent settlement binding cannot change.');
509
+ expect(() =>
510
+ record.settle(
511
+ {
512
+ ...settlement,
513
+ invocation: { ...invocation, description: 'Changed request.' },
514
+ },
515
+ settledOutput,
516
+ persist
517
+ )
518
+ ).toThrow('Subagent invocation binding cannot change.');
519
+ });
520
+
521
+ it('fails closed for ambiguous tool calls and selects an exact resume address', () => {
522
+ const registry = createRegistry();
523
+ const forkA = registry.open(createInput());
524
+ const forkB = registry.open(createInput({ checkpoint_id: 'fork-b' }));
525
+ const unrelated = registry.open({
526
+ ...createInput(),
527
+ parentToolCallId: 'call_unrelated',
528
+ });
529
+ forkA.bindIdentity({
530
+ childRunId: 'run-a',
531
+ childThreadId: 'thread-a',
532
+ approvalExecutionScope: 'scope-a',
533
+ });
534
+ forkB.bindIdentity({
535
+ childRunId: 'run-b',
536
+ childThreadId: 'thread-b',
537
+ approvalExecutionScope: 'scope-b',
538
+ });
539
+ unrelated.bindIdentity({
540
+ childRunId: 'run-c',
541
+ childThreadId: 'thread-c',
542
+ approvalExecutionScope: 'scope-c',
543
+ });
544
+
545
+ expect(
546
+ registry.selectForResume({
547
+ parentToolCallIds: new Set(['call_shared']),
548
+ })
549
+ ).toEqual([]);
550
+ expect(registry.selectForResume()).toEqual([unrelated]);
551
+ expect(
552
+ registry.selectForResume({
553
+ parentToolCallIds: new Set(['call_shared']),
554
+ config: {
555
+ configurable: {
556
+ ...createInput().parentConfigurable,
557
+ thread_id: 'durable-thread',
558
+ },
559
+ },
560
+ })
561
+ ).toEqual([forkA]);
562
+ });
563
+
564
+ it('retires heavy state from superseded resume sources', () => {
565
+ const registry = createRegistry();
566
+ const attemptA = registry.open(createInput());
567
+ const attemptB = registry.open(
568
+ createInput({
569
+ checkpoint_id: 'fork-b',
570
+ [SUBAGENT_RESUME_ATTEMPT_CONFIG_KEY]: 'attempt-b',
571
+ })
572
+ );
573
+ const attemptC = registry.open(
574
+ createInput({
575
+ checkpoint_id: 'fork-c',
576
+ [SUBAGENT_RESUME_ATTEMPT_CONFIG_KEY]: 'attempt-c',
577
+ })
578
+ );
579
+ attemptA.bindIdentity({
580
+ childRunId: 'child-run',
581
+ childThreadId: 'thread-a',
582
+ approvalExecutionScope: 'scope-a',
583
+ });
584
+ attemptB.bindIdentity({
585
+ childRunId: 'child-run',
586
+ childThreadId: 'thread-b',
587
+ approvalExecutionScope: 'scope-b',
588
+ });
589
+ attemptC.bindIdentity({
590
+ childRunId: 'child-run',
591
+ childThreadId: 'thread-c',
592
+ approvalExecutionScope: 'scope-c',
593
+ });
594
+ attemptA.activate({ graphId: 'graph-a' });
595
+ attemptB.activate({ graphId: 'graph-b' });
596
+ const retired: string[] = [];
597
+
598
+ registry.retireResumeSources(attemptB, new Set(['thread-a']), (source) =>
599
+ retired.push(source.activeRun?.graphId ?? 'missing')
600
+ );
601
+ attemptC.activate({ graphId: 'graph-c' });
602
+ registry.retireResumeSources(attemptC, new Set(['thread-b']), (source) =>
603
+ retired.push(source.activeRun?.graphId ?? 'missing')
604
+ );
605
+
606
+ expect(retired).toEqual(['graph-a', 'graph-b']);
607
+ expect(attemptA.snapshot.phase).toBe('invalidated');
608
+ expect(attemptB.snapshot.phase).toBe('invalidated');
609
+ expect(attemptA.snapshot.identity).toBeUndefined();
610
+ expect(attemptB.snapshot.identity).toBeUndefined();
611
+ expect(attemptC.snapshot.activeRun).toEqual({ graphId: 'graph-c' });
612
+ expect(registry.selectForResume()).toEqual([attemptC]);
613
+ });
614
+
615
+ it('invalidates records atomically while retaining checkpoint cleanup data', () => {
616
+ const registry = createRegistry();
617
+ const record = registry.open(createInput());
618
+ record.bindIdentity({
619
+ childRunId: 'child-run',
620
+ childThreadId: 'child-thread',
621
+ approvalExecutionScope: 'approval-scope',
622
+ });
623
+ record.activate({ graphId: 'graph-a' });
624
+ registry.rememberCheckpointThread('child-thread');
625
+ const disposed: string[] = [];
626
+
627
+ registry.clear((current) => {
628
+ disposed.push(current.activeRun?.graphId ?? 'missing');
629
+ });
630
+
631
+ expect(disposed).toEqual(['graph-a']);
632
+ expect(record.snapshot).toMatchObject({ phase: 'invalidated' });
633
+ expect(record.snapshot.identity).toBeUndefined();
634
+ expect(record.snapshot.activeRun).toBeUndefined();
635
+ expect(registry.getCheckpointThreadIds()).toEqual(['child-thread']);
636
+ });
637
+ });