@librechat/agents 3.4.0 → 3.4.1

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 (114) hide show
  1. package/dist/cjs/graphs/Graph.cjs +111 -27
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/hooks/HookRegistry.cjs +83 -0
  4. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  5. package/dist/cjs/hooks/executeHooks.cjs +60 -14
  6. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  7. package/dist/cjs/hooks/index.cjs.map +1 -1
  8. package/dist/cjs/hooks/types.cjs +2 -0
  9. package/dist/cjs/hooks/types.cjs.map +1 -1
  10. package/dist/cjs/llm/init.cjs +3 -3
  11. package/dist/cjs/llm/invoke.cjs +2 -2
  12. package/dist/cjs/main.cjs +14 -13
  13. package/dist/cjs/messages/format.cjs +40 -23
  14. package/dist/cjs/messages/format.cjs.map +1 -1
  15. package/dist/cjs/run.cjs +82 -15
  16. package/dist/cjs/run.cjs.map +1 -1
  17. package/dist/cjs/session/AgentSession.cjs +37 -2
  18. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  19. package/dist/cjs/stream.cjs +1 -1
  20. package/dist/cjs/summarization/node.cjs +6 -3
  21. package/dist/cjs/summarization/node.cjs.map +1 -1
  22. package/dist/cjs/tools/BashExecutor.cjs +1 -1
  23. package/dist/cjs/tools/CodeExecutor.cjs +1 -1
  24. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +1 -1
  25. package/dist/cjs/tools/ToolNode.cjs +316 -94
  26. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  27. package/dist/cjs/tools/ToolSearch.cjs +1 -1
  28. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +630 -78
  29. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  30. package/dist/cjs/tools/subagent/SubagentReplay.cjs +175 -0
  31. package/dist/cjs/tools/subagent/SubagentReplay.cjs.map +1 -0
  32. package/dist/cjs/tools/toolOutputReferences.cjs +20 -0
  33. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  34. package/dist/cjs/utils/index.cjs +2 -2
  35. package/dist/esm/graphs/Graph.mjs +111 -27
  36. package/dist/esm/graphs/Graph.mjs.map +1 -1
  37. package/dist/esm/hooks/HookRegistry.mjs +83 -0
  38. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  39. package/dist/esm/hooks/executeHooks.mjs +60 -14
  40. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  41. package/dist/esm/hooks/index.mjs.map +1 -1
  42. package/dist/esm/hooks/types.mjs +2 -1
  43. package/dist/esm/hooks/types.mjs.map +1 -1
  44. package/dist/esm/llm/init.mjs +1 -1
  45. package/dist/esm/llm/invoke.mjs +2 -2
  46. package/dist/esm/main.mjs +11 -11
  47. package/dist/esm/messages/format.mjs +40 -23
  48. package/dist/esm/messages/format.mjs.map +1 -1
  49. package/dist/esm/run.mjs +83 -16
  50. package/dist/esm/run.mjs.map +1 -1
  51. package/dist/esm/session/AgentSession.mjs +37 -2
  52. package/dist/esm/session/AgentSession.mjs.map +1 -1
  53. package/dist/esm/stream.mjs +1 -1
  54. package/dist/esm/summarization/node.mjs +6 -3
  55. package/dist/esm/summarization/node.mjs.map +1 -1
  56. package/dist/esm/tools/BashExecutor.mjs +1 -1
  57. package/dist/esm/tools/CodeExecutor.mjs +1 -1
  58. package/dist/esm/tools/ProgrammaticToolCalling.mjs +1 -1
  59. package/dist/esm/tools/ToolNode.mjs +317 -95
  60. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  61. package/dist/esm/tools/ToolSearch.mjs +1 -1
  62. package/dist/esm/tools/subagent/SubagentExecutor.mjs +631 -79
  63. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  64. package/dist/esm/tools/subagent/SubagentReplay.mjs +168 -0
  65. package/dist/esm/tools/subagent/SubagentReplay.mjs.map +1 -0
  66. package/dist/esm/tools/toolOutputReferences.mjs +20 -0
  67. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  68. package/dist/esm/utils/index.mjs +2 -2
  69. package/dist/types/graphs/Graph.d.ts +16 -1
  70. package/dist/types/hooks/HookRegistry.d.ts +10 -1
  71. package/dist/types/hooks/executeHooks.d.ts +5 -1
  72. package/dist/types/hooks/index.d.ts +2 -2
  73. package/dist/types/hooks/types.d.ts +12 -0
  74. package/dist/types/run.d.ts +4 -1
  75. package/dist/types/session/AgentSession.d.ts +1 -0
  76. package/dist/types/tools/ToolNode.d.ts +23 -15
  77. package/dist/types/tools/subagent/SubagentExecutor.d.ts +46 -7
  78. package/dist/types/tools/subagent/SubagentReplay.d.ts +81 -0
  79. package/dist/types/tools/toolOutputReferences.d.ts +12 -0
  80. package/dist/types/types/graph.d.ts +5 -5
  81. package/dist/types/types/hitl.d.ts +15 -0
  82. package/package.json +6 -2
  83. package/src/graphs/Graph.ts +246 -52
  84. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +48 -9
  85. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1 -1
  86. package/src/graphs/__tests__/Graph.subagentResumeState.test.ts +80 -0
  87. package/src/hooks/HookRegistry.ts +151 -1
  88. package/src/hooks/__tests__/HookRegistry.test.ts +96 -0
  89. package/src/hooks/__tests__/executeHooks.test.ts +160 -0
  90. package/src/hooks/executeHooks.ts +133 -15
  91. package/src/hooks/index.ts +3 -1
  92. package/src/hooks/types.ts +16 -0
  93. package/src/messages/format.ts +54 -26
  94. package/src/messages/formatAgentMessages.reducer.test.ts +162 -0
  95. package/src/messages/formatAgentMessages.steer.test.ts +16 -11
  96. package/src/messages/formatAgentMessages.test.ts +7 -2
  97. package/src/run.ts +164 -18
  98. package/src/session/AgentSession.ts +52 -2
  99. package/src/session/__tests__/JsonlSessionStore.test.ts +53 -0
  100. package/src/summarization/__tests__/node.test.ts +32 -14
  101. package/src/summarization/node.ts +15 -11
  102. package/src/tools/ToolNode.ts +631 -157
  103. package/src/tools/__tests__/SubagentExecutor.test.ts +542 -6
  104. package/src/tools/__tests__/SubagentReplay.test.ts +300 -0
  105. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +208 -2
  106. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +486 -2
  107. package/src/tools/__tests__/hitl.test.ts +234 -5
  108. package/src/tools/__tests__/subagentHooks.test.ts +1040 -62
  109. package/src/tools/__tests__/toolOutputReferences.test.ts +19 -2
  110. package/src/tools/subagent/SubagentExecutor.ts +1369 -131
  111. package/src/tools/subagent/SubagentReplay.ts +575 -0
  112. package/src/tools/toolOutputReferences.ts +40 -1
  113. package/src/types/graph.ts +5 -5
  114. package/src/types/hitl.ts +16 -0
package/src/run.ts CHANGED
@@ -4,21 +4,28 @@ import { PromptTemplate } from '@langchain/core/prompts';
4
4
  import { RunnableLambda } from '@langchain/core/runnables';
5
5
  import { AzureChatOpenAI, ChatOpenAI } from '@langchain/openai';
6
6
  import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
7
- import { HumanMessage, SystemMessage } from '@langchain/core/messages';
7
+ import {
8
+ BaseMessage,
9
+ HumanMessage,
10
+ SystemMessage,
11
+ } from '@langchain/core/messages';
8
12
  import {
9
13
  Command,
10
14
  INTERRUPT,
11
15
  MemorySaver,
12
16
  isInterrupted,
13
17
  } from '@langchain/langgraph';
14
- import type {
15
- MessageContentComplex,
16
- BaseMessage,
17
- } from '@langchain/core/messages';
18
18
  import type { StringPromptValue } from '@langchain/core/prompt_values';
19
+ import type { MessageContentComplex } from '@langchain/core/messages';
19
20
  import type { RunnableConfig } from '@langchain/core/runnables';
20
21
  import type { HookRegistry } from '@/hooks';
21
22
  import type * as t from '@/types';
23
+ import {
24
+ requireValidSubagentResumeManifest,
25
+ stripSubagentResumeManifest,
26
+ SUBAGENT_RESUME_ATTEMPT_CONFIG_KEY,
27
+ SUBAGENT_RESUME_MANIFEST_CONFIG_KEY,
28
+ } from '@/tools/subagent/SubagentReplay';
22
29
  import {
23
30
  createLangfuseTraceMetadata,
24
31
  createLangfuseHandler,
@@ -121,19 +128,83 @@ function isLangGraphResumeMapForInterrupt(
121
128
  return Object.prototype.hasOwnProperty.call(value, interruptId);
122
129
  }
123
130
 
131
+ function getInterruptHookSessionId(payload: unknown): string | undefined {
132
+ const publicPayload = stripSubagentResumeManifest(payload);
133
+ if (
134
+ publicPayload == null ||
135
+ typeof publicPayload !== 'object' ||
136
+ (publicPayload as { type?: unknown }).type !== 'tool_approval'
137
+ ) {
138
+ return undefined;
139
+ }
140
+ const sessionId = (publicPayload as { hook_session_id?: unknown })
141
+ .hook_session_id;
142
+ return typeof sessionId === 'string' && sessionId.length > 0
143
+ ? sessionId
144
+ : undefined;
145
+ }
146
+
124
147
  type InterruptStateSnapshot = {
125
148
  config?: RunnableConfig;
149
+ values?: { messages?: BaseMessage[] };
126
150
  tasks?: Array<{
127
- interrupts?: Array<{ id?: string }>;
151
+ interrupts?: Array<{ id?: string; value?: unknown }>;
128
152
  }>;
129
153
  };
130
154
 
131
155
  type WorkflowWithStateHistory = {
156
+ getState?(config: RunnableConfig): Promise<InterruptStateSnapshot>;
132
157
  getStateHistory?(
133
158
  config: RunnableConfig
134
159
  ): AsyncIterableIterator<InterruptStateSnapshot>;
135
160
  };
136
161
 
162
+ function getFirstPersistedInterrupt(
163
+ snapshot: InterruptStateSnapshot
164
+ ): { id: string; value: unknown } | undefined {
165
+ for (const task of snapshot.tasks ?? []) {
166
+ for (const pendingInterrupt of task.interrupts ?? []) {
167
+ if (
168
+ typeof pendingInterrupt.id === 'string' &&
169
+ pendingInterrupt.id.length > 0
170
+ ) {
171
+ return { id: pendingInterrupt.id, value: pendingInterrupt.value };
172
+ }
173
+ }
174
+ }
175
+ return undefined;
176
+ }
177
+
178
+ function getPersistedMessages(
179
+ snapshot: InterruptStateSnapshot
180
+ ): BaseMessage[] | undefined {
181
+ const messages = snapshot.values?.messages;
182
+ if (!Array.isArray(messages) || !messages.every(BaseMessage.isInstance)) {
183
+ return undefined;
184
+ }
185
+ return messages;
186
+ }
187
+
188
+ type ResumeCommandUpdate = ConstructorParameters<typeof Command>[0]['update'];
189
+
190
+ function getResumeUpdateMessages(
191
+ update: ResumeCommandUpdate
192
+ ): BaseMessage[] | undefined {
193
+ if (update == null) {
194
+ return undefined;
195
+ }
196
+ const messages = Array.isArray(update)
197
+ ? update.find(([key]) => key === 'messages')?.[1]
198
+ : update.messages;
199
+ if (BaseMessage.isInstance(messages)) {
200
+ return [messages];
201
+ }
202
+ if (!Array.isArray(messages) || !messages.every(BaseMessage.isInstance)) {
203
+ return undefined;
204
+ }
205
+ return messages;
206
+ }
207
+
137
208
  export class Run<_T extends t.BaseGraphState> {
138
209
  id: string;
139
210
  private tokenCounter?: t.TokenCounter;
@@ -227,8 +298,6 @@ export class Run<_T extends t.BaseGraphState> {
227
298
  /** Default to legacy graph for 'standard' or undefined type */
228
299
  this.graphRunnable = this.createLegacyGraph(config.graphConfig);
229
300
  if (this.Graph) {
230
- this.Graph.compileOptions =
231
- config.graphConfig.compileOptions ?? this.Graph.compileOptions;
232
301
  this.Graph.handlerRegistry = handlerRegistry;
233
302
  }
234
303
  }
@@ -542,6 +611,10 @@ export class Run<_T extends t.BaseGraphState> {
542
611
  return this.Graph.getRunMessages();
543
612
  }
544
613
 
614
+ getChildCheckpointThreadIds(): string[] {
615
+ return this.Graph?.getChildCheckpointThreadIds() ?? [];
616
+ }
617
+
545
618
  /**
546
619
  * Returns a defensive snapshot of tools discovered by the current run.
547
620
  * Pass an agent id for that context, or omit it for the ordered union across
@@ -759,6 +832,10 @@ export class Run<_T extends t.BaseGraphState> {
759
832
  recursionLimit,
760
833
  configurable: { ...callerConfig.configurable },
761
834
  };
835
+ if (!isResume) {
836
+ delete config.configurable?.[SUBAGENT_RESUME_ATTEMPT_CONFIG_KEY];
837
+ delete config.configurable?.[SUBAGENT_RESUME_MANIFEST_CONFIG_KEY];
838
+ }
762
839
 
763
840
  /**
764
841
  * Cancellation can arrive either at graph construction or per-call through
@@ -1248,7 +1325,13 @@ export class Run<_T extends t.BaseGraphState> {
1248
1325
  getInterrupt<TPayload = t.HumanInterruptPayload>():
1249
1326
  | t.RunInterruptResult<TPayload>
1250
1327
  | undefined {
1251
- return this._interrupt as t.RunInterruptResult<TPayload> | undefined;
1328
+ if (this._interrupt == null) {
1329
+ return undefined;
1330
+ }
1331
+ return {
1332
+ ...this._interrupt,
1333
+ payload: stripSubagentResumeManifest(this._interrupt.payload),
1334
+ } as t.RunInterruptResult<TPayload>;
1252
1335
  }
1253
1336
 
1254
1337
  /**
@@ -1331,6 +1414,10 @@ export class Run<_T extends t.BaseGraphState> {
1331
1414
  'update' | 'goto'
1332
1415
  >
1333
1416
  ): Promise<MessageContentComplex[] | undefined> {
1417
+ const resumeConfig = await this.resolveInterruptResumeConfig(
1418
+ callerConfig,
1419
+ commandOptions?.update
1420
+ );
1334
1421
  const interruptId = this._interrupt?.interruptId;
1335
1422
  const scopedResume =
1336
1423
  typeof interruptId === 'string' &&
@@ -1338,7 +1425,6 @@ export class Run<_T extends t.BaseGraphState> {
1338
1425
  !isLangGraphResumeMapForInterrupt(resumeValue, interruptId)
1339
1426
  ? { [interruptId]: resumeValue }
1340
1427
  : resumeValue;
1341
- const resumeConfig = await this.resolveInterruptResumeConfig(callerConfig);
1342
1428
  // langgraph 1.4.5 applies resume + state update + reroute in one superstep
1343
1429
  // (single checkpoint). `update`/`goto` are omitted unless the caller sets them.
1344
1430
  return this.processStream(
@@ -1357,9 +1443,29 @@ export class Run<_T extends t.BaseGraphState> {
1357
1443
  }
1358
1444
 
1359
1445
  private async resolveInterruptResumeConfig(
1360
- callerConfig: t.RunStreamConfig
1446
+ callerConfig: t.RunStreamConfig,
1447
+ resumeUpdate?: ResumeCommandUpdate
1361
1448
  ): Promise<t.RunStreamConfig> {
1449
+ await this.restoreInterruptFromCheckpoint(callerConfig, resumeUpdate);
1362
1450
  const interrupt = this._interrupt;
1451
+ const resumeManifest = requireValidSubagentResumeManifest(
1452
+ interrupt?.payload
1453
+ );
1454
+ const resumeConfigurable = { ...callerConfig.configurable };
1455
+ delete resumeConfigurable[SUBAGENT_RESUME_ATTEMPT_CONFIG_KEY];
1456
+ delete resumeConfigurable[SUBAGENT_RESUME_MANIFEST_CONFIG_KEY];
1457
+ resumeConfigurable[SUBAGENT_RESUME_ATTEMPT_CONFIG_KEY] = nanoid();
1458
+ if (resumeManifest != null) {
1459
+ resumeConfigurable[SUBAGENT_RESUME_MANIFEST_CONFIG_KEY] = resumeManifest;
1460
+ }
1461
+ const manifestConfig = {
1462
+ ...callerConfig,
1463
+ configurable: resumeConfigurable,
1464
+ };
1465
+ const hookSessionId = getInterruptHookSessionId(interrupt?.payload);
1466
+ if (hookSessionId != null) {
1467
+ this.hookRegistry?.copySession(hookSessionId, this.id);
1468
+ }
1363
1469
  const interruptId = interrupt?.interruptId;
1364
1470
  const workflow = this.graphRunnable as
1365
1471
  | (t.CompiledStateWorkflow & WorkflowWithStateHistory)
@@ -1367,9 +1473,9 @@ export class Run<_T extends t.BaseGraphState> {
1367
1473
  const stateHistory = workflow?.getStateHistory;
1368
1474
  if (interrupt?.checkpointId != null && interrupt.checkpointId.length > 0) {
1369
1475
  return {
1370
- ...callerConfig,
1476
+ ...manifestConfig,
1371
1477
  configurable: {
1372
- ...callerConfig.configurable,
1478
+ ...manifestConfig.configurable,
1373
1479
  checkpoint_id: interrupt.checkpointId,
1374
1480
  ...(typeof interrupt.checkpointNs === 'string'
1375
1481
  ? { checkpoint_ns: interrupt.checkpointNs }
@@ -1383,12 +1489,12 @@ export class Run<_T extends t.BaseGraphState> {
1383
1489
  interruptId.length === 0 ||
1384
1490
  typeof stateHistory !== 'function'
1385
1491
  ) {
1386
- return callerConfig;
1492
+ return manifestConfig;
1387
1493
  }
1388
1494
 
1389
1495
  for await (const snapshot of stateHistory.call(
1390
1496
  this.graphRunnable,
1391
- callerConfig as RunnableConfig
1497
+ manifestConfig as RunnableConfig
1392
1498
  )) {
1393
1499
  const hasMatchingInterrupt =
1394
1500
  snapshot.tasks?.some(
@@ -1411,9 +1517,9 @@ export class Run<_T extends t.BaseGraphState> {
1411
1517
  ...(typeof checkpointNs === 'string' ? { checkpointNs } : {}),
1412
1518
  };
1413
1519
  return {
1414
- ...callerConfig,
1520
+ ...manifestConfig,
1415
1521
  configurable: {
1416
- ...callerConfig.configurable,
1522
+ ...manifestConfig.configurable,
1417
1523
  checkpoint_id: checkpointId,
1418
1524
  ...(typeof checkpointNs === 'string'
1419
1525
  ? { checkpoint_ns: checkpointNs }
@@ -1423,7 +1529,47 @@ export class Run<_T extends t.BaseGraphState> {
1423
1529
  }
1424
1530
  }
1425
1531
 
1426
- return callerConfig;
1532
+ return manifestConfig;
1533
+ }
1534
+
1535
+ private async restoreInterruptFromCheckpoint(
1536
+ callerConfig: t.RunStreamConfig,
1537
+ resumeUpdate?: ResumeCommandUpdate
1538
+ ): Promise<void> {
1539
+ if (this._interrupt != null || this.humanInTheLoop?.enabled !== true) {
1540
+ return;
1541
+ }
1542
+ const workflow = this.graphRunnable as
1543
+ | (t.CompiledStateWorkflow & WorkflowWithStateHistory)
1544
+ | undefined;
1545
+ if (typeof workflow?.getState !== 'function') {
1546
+ return;
1547
+ }
1548
+
1549
+ const snapshot = await workflow.getState(callerConfig as RunnableConfig);
1550
+ const persistedInterrupt = getFirstPersistedInterrupt(snapshot);
1551
+ if (persistedInterrupt == null) {
1552
+ return;
1553
+ }
1554
+ const persistedMessages = getPersistedMessages(snapshot);
1555
+ if (persistedMessages != null) {
1556
+ this.Graph?.restoreCheckpointMessages(
1557
+ persistedMessages,
1558
+ getResumeUpdateMessages(resumeUpdate)
1559
+ );
1560
+ }
1561
+
1562
+ const checkpointConfigurable = snapshot.config?.configurable;
1563
+ const checkpointId = checkpointConfigurable?.checkpoint_id;
1564
+ const checkpointNs = checkpointConfigurable?.checkpoint_ns;
1565
+ const threadId = callerConfig.configurable?.thread_id;
1566
+ this._interrupt = {
1567
+ interruptId: persistedInterrupt.id,
1568
+ payload: persistedInterrupt.value,
1569
+ ...(typeof threadId === 'string' ? { threadId } : {}),
1570
+ ...(typeof checkpointId === 'string' ? { checkpointId } : {}),
1571
+ ...(typeof checkpointNs === 'string' ? { checkpointNs } : {}),
1572
+ };
1427
1573
  }
1428
1574
 
1429
1575
  private createSystemCallback<K extends keyof t.ClientCallbacks>(
@@ -934,6 +934,30 @@ export class AgentSession {
934
934
  });
935
935
  }
936
936
 
937
+ private async recordChildCheckpointThreads(params: {
938
+ source: 'run' | 'resume';
939
+ runId: string;
940
+ run: Run<t.IState>;
941
+ }): Promise<void> {
942
+ if (!this.checkpointing.enabled || this.store == null) {
943
+ return;
944
+ }
945
+ const recordedThreadIds = new Set(
946
+ this.store.getCheckpoints().map((checkpoint) => checkpoint.data.threadId)
947
+ );
948
+ for (const threadId of params.run.getChildCheckpointThreadIds()) {
949
+ if (recordedThreadIds.has(threadId)) {
950
+ continue;
951
+ }
952
+ recordedThreadIds.add(threadId);
953
+ await this.store.appendCheckpoint({
954
+ source: params.source,
955
+ runId: params.runId,
956
+ threadId,
957
+ });
958
+ }
959
+ }
960
+
937
961
  private getCheckpointThreadIds(): string[] {
938
962
  const threadIds = new Set<string>([this.threadId]);
939
963
  for (const checkpoint of this.store?.getCheckpoints() ?? []) {
@@ -1006,6 +1030,7 @@ export class AgentSession {
1006
1030
  const sessionState = createSessionRunState(
1007
1031
  isSessionThread ? (this.store?.getPath() ?? []) : []
1008
1032
  );
1033
+ let run: Run<t.IState> | undefined;
1009
1034
  try {
1010
1035
  const runConfig: t.RunConfig = {
1011
1036
  ...this.runConfig,
@@ -1024,7 +1049,7 @@ export class AgentSession {
1024
1049
  ...handlerResult.handlers,
1025
1050
  },
1026
1051
  };
1027
- const run = await Run.create<t.IState>(runConfig);
1052
+ run = await Run.create<t.IState>(runConfig);
1028
1053
  let messages = inputMessages;
1029
1054
  if (!useCheckpointState && sessionState.messages.length > 0) {
1030
1055
  messages = sessionState.messages;
@@ -1070,6 +1095,11 @@ export class AgentSession {
1070
1095
  checkpointId: interrupt?.checkpointId,
1071
1096
  checkpointNs: interrupt?.checkpointNs,
1072
1097
  });
1098
+ await this.recordChildCheckpointThreads({
1099
+ source: 'run',
1100
+ runId,
1101
+ run,
1102
+ });
1073
1103
  const contentParts = (content ?? handlerResult.contentParts).filter(
1074
1104
  (part): part is t.MessageContentComplex => part != null
1075
1105
  );
@@ -1099,6 +1129,13 @@ export class AgentSession {
1099
1129
  threadId,
1100
1130
  config: callerConfig,
1101
1131
  });
1132
+ if (run != null) {
1133
+ await this.recordChildCheckpointThreads({
1134
+ source: 'run',
1135
+ runId,
1136
+ run,
1137
+ });
1138
+ }
1102
1139
  throw error;
1103
1140
  }
1104
1141
  }
@@ -1348,8 +1385,9 @@ export class AgentSession {
1348
1385
  const sessionState = createSessionRunState(
1349
1386
  isSessionThread ? (this.store?.getPath() ?? []) : []
1350
1387
  );
1388
+ let run: Run<t.IState> | undefined;
1351
1389
  try {
1352
- const run = await Run.create<t.IState>({
1390
+ run = await Run.create<t.IState>({
1353
1391
  ...this.runConfig,
1354
1392
  runId,
1355
1393
  graphConfig: applyCheckpointingToGraphConfig(
@@ -1400,6 +1438,11 @@ export class AgentSession {
1400
1438
  checkpointId: interrupt?.checkpointId,
1401
1439
  checkpointNs: interrupt?.checkpointNs,
1402
1440
  });
1441
+ await this.recordChildCheckpointThreads({
1442
+ source: 'resume',
1443
+ runId,
1444
+ run,
1445
+ });
1403
1446
  const contentParts = (content ?? handlerResult.contentParts).filter(
1404
1447
  (part): part is t.MessageContentComplex => part != null
1405
1448
  );
@@ -1425,6 +1468,13 @@ export class AgentSession {
1425
1468
  threadId,
1426
1469
  config: callerConfig,
1427
1470
  });
1471
+ if (run != null) {
1472
+ await this.recordChildCheckpointThreads({
1473
+ source: 'resume',
1474
+ runId,
1475
+ run,
1476
+ });
1477
+ }
1428
1478
  throw error;
1429
1479
  }
1430
1480
  }
@@ -25,6 +25,9 @@ type MockRun = {
25
25
  >;
26
26
  getInterrupt: jest.MockedFunction<Run<t.IState>['getInterrupt']>;
27
27
  getHaltReason: jest.MockedFunction<Run<t.IState>['getHaltReason']>;
28
+ getChildCheckpointThreadIds: jest.MockedFunction<
29
+ Run<t.IState>['getChildCheckpointThreadIds']
30
+ >;
28
31
  };
29
32
 
30
33
  function createMockRun(outputText = 'ok'): MockRun {
@@ -45,6 +48,7 @@ function createMockRun(outputText = 'ok'): MockRun {
45
48
  getCalibrationRatio: jest.fn(() => 1),
46
49
  getInterrupt: jest.fn(() => undefined),
47
50
  getHaltReason: jest.fn(() => undefined),
51
+ getChildCheckpointThreadIds: jest.fn(() => []),
48
52
  };
49
53
  }
50
54
 
@@ -1119,6 +1123,55 @@ describe('JsonlSessionStore', () => {
1119
1123
  });
1120
1124
  });
1121
1125
 
1126
+ it('records and resets child checkpoint threads owned by a run', async () => {
1127
+ const checkpointer = new MemorySaver();
1128
+ const childThreadId = 'subagent:owned-child';
1129
+ const mockRun = createMockRun('child result');
1130
+ mockRun.getChildCheckpointThreadIds.mockReturnValue([childThreadId]);
1131
+ mockRunCreate(mockRun);
1132
+ const session = await createAgentSession({
1133
+ cwd: dir,
1134
+ runId: 'template-run',
1135
+ checkpointing: { checkpointer },
1136
+ graphConfig: {
1137
+ type: 'standard',
1138
+ llmConfig: {
1139
+ provider: 'openAI' as never,
1140
+ model: 'test-model',
1141
+ },
1142
+ instructions: 'test',
1143
+ },
1144
+ });
1145
+ await putCheckpoint({
1146
+ checkpointer,
1147
+ threadId: childThreadId,
1148
+ id: 'checkpoint_child',
1149
+ });
1150
+
1151
+ await session.run('fresh turn', { runId: 'run_with_child' });
1152
+
1153
+ expect(
1154
+ session.getSessionStore()?.getCheckpoints(childThreadId).at(-1)?.data
1155
+ ).toMatchObject({
1156
+ source: 'run',
1157
+ runId: 'run_with_child',
1158
+ threadId: childThreadId,
1159
+ });
1160
+ const firstMessage = session
1161
+ .getSessionStore()
1162
+ ?.getPath()
1163
+ .find((entry) => entry.type === 'message');
1164
+ await session.branch(firstMessage?.id ?? '', { position: 'at' });
1165
+
1166
+ const tuple = await checkpointer.getTuple({
1167
+ configurable: { thread_id: childThreadId },
1168
+ });
1169
+ expect(tuple).toBeUndefined();
1170
+ expect(
1171
+ session.getSessionStore()?.getCheckpoints(childThreadId).at(-1)?.data
1172
+ ).toMatchObject({ source: 'reset', reason: 'branch' });
1173
+ });
1174
+
1122
1175
  it('keeps checkpoint state when branching to the active JSONL leaf', async () => {
1123
1176
  const checkpointer = new MemorySaver();
1124
1177
  const session = await createAgentSession({
@@ -1059,6 +1059,24 @@ describe('recency window — first-turn protection', () => {
1059
1059
  expect(summaryBlock?.coverage).toEqual({ retainedFromMessageId: 'm2' });
1060
1060
  });
1061
1061
 
1062
+ it('anchors a derived retained message on its persisted source id', async () => {
1063
+ const summaryBlock = await runCompaction([
1064
+ new HumanMessage({ content: 'turn 1 query', id: 'm1' }),
1065
+ new AIMessage({ content: 'pre-steer reply', id: 'm2' }),
1066
+ new HumanMessage({
1067
+ content: 'steer',
1068
+ id: 'reducer-uuid',
1069
+ additional_kwargs: {
1070
+ role: 'user',
1071
+ source: 'steer',
1072
+ sourceMessageId: 'm2',
1073
+ },
1074
+ }),
1075
+ ]);
1076
+
1077
+ expect(summaryBlock?.coverage).toEqual({ retainedFromMessageId: 'm2' });
1078
+ });
1079
+
1062
1080
  /** `formatAgentMessages` reconstructs skill bodies inside its payload loop
1063
1081
  * and keeps processing payload entries after, so this unstamped entry — a
1064
1082
  * reducer UUID by the time compaction sees it — precedes stamped messages.
@@ -1770,13 +1788,13 @@ describe('summarize node breaker capture', () => {
1770
1788
  const entryBreaker = new AbortController();
1771
1789
  /** The trip lands while ON_SUMMARIZE_START is awaited — after the
1772
1790
  * entry check already passed. */
1773
- jest.spyOn(eventUtils, 'safeDispatchCustomEvent').mockImplementation((async (
1774
- ...args: unknown[]
1775
- ) => {
1776
- if (args[0] === GraphEvents.ON_SUMMARIZE_START) {
1777
- entryBreaker.abort(trip);
1778
- }
1779
- }) as never);
1791
+ jest
1792
+ .spyOn(eventUtils, 'safeDispatchCustomEvent')
1793
+ .mockImplementation((async (...args: unknown[]) => {
1794
+ if (args[0] === GraphEvents.ON_SUMMARIZE_START) {
1795
+ entryBreaker.abort(trip);
1796
+ }
1797
+ }) as never);
1780
1798
  const modelClassSpy = jest
1781
1799
  .spyOn(providers, 'getChatModelClass')
1782
1800
  .mockReturnValue(
@@ -1915,13 +1933,13 @@ describe('summarize node breaker capture', () => {
1915
1933
  const entryBreaker = new AbortController();
1916
1934
  const lateBreaker = new AbortController();
1917
1935
  let started = false;
1918
- jest.spyOn(eventUtils, 'safeDispatchCustomEvent').mockImplementation((async (
1919
- ...args: unknown[]
1920
- ) => {
1921
- if (args[0] === GraphEvents.ON_SUMMARIZE_START) {
1922
- started = true;
1923
- }
1924
- }) as never);
1936
+ jest
1937
+ .spyOn(eventUtils, 'safeDispatchCustomEvent')
1938
+ .mockImplementation((async (...args: unknown[]) => {
1939
+ if (args[0] === GraphEvents.ON_SUMMARIZE_START) {
1940
+ started = true;
1941
+ }
1942
+ }) as never);
1925
1943
 
1926
1944
  const capturedSignals: Array<AbortSignal | undefined> = [];
1927
1945
  jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
@@ -17,6 +17,11 @@ import {
17
17
  isComputerCallOutputMessage,
18
18
  serializeToolContentBounded,
19
19
  } from '@/utils/toolContent';
20
+ import {
21
+ enforceStreamLimitsForWireChunk,
22
+ StreamLimitExceededError,
23
+ STREAM_LIMIT_EPOCH_KEY,
24
+ } from '@/llm/streamLimits';
20
25
  import {
21
26
  addTailCacheControl,
22
27
  resolvePromptCacheTtl,
@@ -34,11 +39,6 @@ import {
34
39
  Providers,
35
40
  } from '@/common';
36
41
  import { safeDispatchCustomEvent, emitAgentLog } from '@/utils/events';
37
- import {
38
- enforceStreamLimitsForWireChunk,
39
- StreamLimitExceededError,
40
- STREAM_LIMIT_EPOCH_KEY,
41
- } from '@/llm/streamLimits';
42
42
  import { attemptInvoke, tryFallbackProviders } from '@/llm/invoke';
43
43
  import { calculateMaxToolResultChars } from '@/utils/truncation';
44
44
  import { createRemoveAllMessage } from '@/messages/reducer';
@@ -418,8 +418,9 @@ function computeSummaryTokenCount(
418
418
  * in `ToolNode` and `StandardGraph`, handoff cues, reconstructed skill bodies.
419
419
  *
420
420
  * `steer` is exempt from the `source` check because a replayed steer *is*
421
- * stamped from its payload entry; rejecting every marked `source` once dropped
422
- * exactly those retained steers. Injected steers are still caught, by `injected`.
421
+ * correlated with its payload entry; rejecting every marked `source` once
422
+ * dropped exactly those retained steers. Injected steers are still caught, by
423
+ * `injected`.
423
424
  *
424
425
  * Known limitation: a payload entry that omits `messageId` is never stamped, so
425
426
  * the reducer's UUID is recorded and cannot resolve on the next run. There is no
@@ -443,7 +444,10 @@ function resolveSummaryCoverage(
443
444
  if (isSyntheticContext(message)) {
444
445
  continue;
445
446
  }
446
- const id = message.id?.trim();
447
+ const sourceMessageId = message.additional_kwargs.sourceMessageId;
448
+ const sourceId =
449
+ typeof sourceMessageId === 'string' ? sourceMessageId.trim() : '';
450
+ const id = sourceId !== '' ? sourceId : message.id?.trim();
447
451
  if (id != null && id !== '') {
448
452
  return { retainedFromMessageId: id };
449
453
  }
@@ -1244,9 +1248,9 @@ export function createSummarizeNode({
1244
1248
  ? { [Constants.INVOKED_MODEL]: clientConfig.modelName }
1245
1249
  : {}),
1246
1250
  /** Entry-captured breaker epoch: the wire consumer epoch-gates
1247
- * old-run summary chunks exactly like model-attempt chunks —
1248
- * without the stamp, a straggling summary from a failed run
1249
- * reads as current and could abort the next run's controller. */
1251
+ * old-run summary chunks exactly like model-attempt chunks —
1252
+ * without the stamp, a straggling summary from a failed run
1253
+ * reads as current and could abort the next run's controller. */
1250
1254
  ...(entryBreakerEpoch != null
1251
1255
  ? { [STREAM_LIMIT_EPOCH_KEY]: entryBreakerEpoch }
1252
1256
  : {}),