@librechat/agents 3.7.0 → 3.7.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 (87) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +11 -2
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +7 -2
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/llm/invoke.cjs +56 -9
  6. package/dist/cjs/llm/invoke.cjs.map +1 -1
  7. package/dist/cjs/main.cjs +11 -1
  8. package/dist/cjs/messages/content.cjs +8 -5
  9. package/dist/cjs/messages/content.cjs.map +1 -1
  10. package/dist/cjs/messages/format.cjs +15 -5
  11. package/dist/cjs/messages/format.cjs.map +1 -1
  12. package/dist/cjs/messages/index.cjs +2 -1
  13. package/dist/cjs/messages/projectionInvariant.cjs +74 -0
  14. package/dist/cjs/messages/projectionInvariant.cjs.map +1 -0
  15. package/dist/cjs/messages/provenance.cjs +30 -8
  16. package/dist/cjs/messages/provenance.cjs.map +1 -1
  17. package/dist/cjs/messages/recency.cjs +202 -7
  18. package/dist/cjs/messages/recency.cjs.map +1 -1
  19. package/dist/cjs/messages/toolResultTypes.cjs +1 -0
  20. package/dist/cjs/session/AgentSession.cjs +4 -23
  21. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  22. package/dist/cjs/session/deriveMessages.cjs +25 -0
  23. package/dist/cjs/session/deriveMessages.cjs.map +1 -0
  24. package/dist/cjs/session/index.cjs +1 -0
  25. package/dist/cjs/summarization/node.cjs +13 -6
  26. package/dist/cjs/summarization/node.cjs.map +1 -1
  27. package/dist/cjs/tools/subagent/InMemorySubagentTaskStore.cjs +65 -8
  28. package/dist/cjs/tools/subagent/InMemorySubagentTaskStore.cjs.map +1 -1
  29. package/dist/esm/agents/AgentContext.mjs +11 -2
  30. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  31. package/dist/esm/graphs/Graph.mjs +8 -3
  32. package/dist/esm/graphs/Graph.mjs.map +1 -1
  33. package/dist/esm/llm/invoke.mjs +56 -9
  34. package/dist/esm/llm/invoke.mjs.map +1 -1
  35. package/dist/esm/main.mjs +6 -4
  36. package/dist/esm/messages/content.mjs +8 -6
  37. package/dist/esm/messages/content.mjs.map +1 -1
  38. package/dist/esm/messages/format.mjs +16 -6
  39. package/dist/esm/messages/format.mjs.map +1 -1
  40. package/dist/esm/messages/index.mjs +2 -1
  41. package/dist/esm/messages/projectionInvariant.mjs +72 -0
  42. package/dist/esm/messages/projectionInvariant.mjs.map +1 -0
  43. package/dist/esm/messages/provenance.mjs +30 -9
  44. package/dist/esm/messages/provenance.mjs.map +1 -1
  45. package/dist/esm/messages/recency.mjs +201 -8
  46. package/dist/esm/messages/recency.mjs.map +1 -1
  47. package/dist/esm/messages/toolResultTypes.mjs +1 -1
  48. package/dist/esm/session/AgentSession.mjs +4 -23
  49. package/dist/esm/session/AgentSession.mjs.map +1 -1
  50. package/dist/esm/session/deriveMessages.mjs +25 -0
  51. package/dist/esm/session/deriveMessages.mjs.map +1 -0
  52. package/dist/esm/session/index.mjs +1 -0
  53. package/dist/esm/summarization/node.mjs +14 -7
  54. package/dist/esm/summarization/node.mjs.map +1 -1
  55. package/dist/esm/tools/subagent/InMemorySubagentTaskStore.mjs +65 -8
  56. package/dist/esm/tools/subagent/InMemorySubagentTaskStore.mjs.map +1 -1
  57. package/dist/types/agents/AgentContext.d.ts +6 -1
  58. package/dist/types/messages/content.d.ts +4 -1
  59. package/dist/types/messages/format.d.ts +6 -0
  60. package/dist/types/messages/index.d.ts +1 -0
  61. package/dist/types/messages/projectionInvariant.d.ts +25 -0
  62. package/dist/types/messages/provenance.d.ts +10 -0
  63. package/dist/types/messages/recency.d.ts +30 -18
  64. package/dist/types/session/deriveMessages.d.ts +11 -0
  65. package/dist/types/session/index.d.ts +2 -0
  66. package/dist/types/tools/subagent/InMemorySubagentTaskStore.d.ts +13 -1
  67. package/dist/types/types/graph.d.ts +1 -1
  68. package/dist/types/types/subagentTasks.d.ts +22 -0
  69. package/dist/types/types/summarize.d.ts +15 -13
  70. package/package.json +3 -1
  71. package/src/agents/AgentContext.ts +19 -5
  72. package/src/graphs/Graph.ts +9 -1
  73. package/src/llm/invoke.ts +102 -23
  74. package/src/messages/content.ts +20 -10
  75. package/src/messages/format.ts +29 -5
  76. package/src/messages/index.ts +1 -0
  77. package/src/messages/projectionInvariant.ts +134 -0
  78. package/src/messages/provenance.ts +60 -18
  79. package/src/messages/recency.ts +429 -27
  80. package/src/session/AgentSession.ts +4 -30
  81. package/src/session/deriveMessages.ts +37 -0
  82. package/src/session/index.ts +2 -0
  83. package/src/summarization/node.ts +37 -15
  84. package/src/tools/subagent/InMemorySubagentTaskStore.ts +139 -6
  85. package/src/types/graph.ts +1 -0
  86. package/src/types/subagentTasks.ts +28 -0
  87. package/src/types/summarize.ts +15 -13
@@ -1,6 +1,27 @@
1
- import type { BaseMessage } from '@langchain/core/messages';
1
+ import type {
2
+ AIMessage,
3
+ BaseMessage,
4
+ ToolMessage,
5
+ } from '@langchain/core/messages';
6
+ import type {
7
+ ProviderToolCallIndex,
8
+ ProviderToolCallPartDescriptor,
9
+ ProviderToolResultPartDescriptor,
10
+ } from './toolResultTypes';
11
+ import { getProviderSourceMessageIds } from './provenance';
12
+ import {
13
+ appendProviderToolCallDescriptor,
14
+ consumeProviderToolResultPair,
15
+ getBoundedProviderPairingArray,
16
+ getBoundedProviderPairingArrayProperty,
17
+ getProviderAIMessageToolCallDescriptor,
18
+ getProviderToolCallPartDescriptor,
19
+ getProviderToolResultPartDescriptor,
20
+ PROVIDER_TOOL_PAIRING_MAX_IDENTIFIER_CHARS,
21
+ } from './toolResultTypes';
2
22
 
3
23
  export const DEFAULT_RETAIN_RECENT_TURNS = 2;
24
+ export const DEFAULT_INTRA_TURN_RETAIN_RATIO = 0.16;
4
25
 
5
26
  /**
6
27
  * Configuration for splitting a message list into a head (to be summarized)
@@ -8,15 +29,15 @@ export const DEFAULT_RETAIN_RECENT_TURNS = 2;
8
29
  */
9
30
  export interface RecencyWindowOptions {
10
31
  /**
11
- * Maximum number of recent user-led turns to keep in the tail. A "turn"
12
- * begins at a HumanMessage and includes every following AIMessage and
13
- * ToolMessage up to (but not including) the next HumanMessage. Cutting
14
- * at turn boundaries guarantees that tool_use / tool_result pairs are
15
- * never split across the head/tail divide.
32
+ * Maximum number of recent user-led turns to keep in the tail. A "turn"
33
+ * begins at a user-authored HumanMessage and includes every following
34
+ * AIMessage and tool result up to the next user-authored HumanMessage.
35
+ * Provider-native HumanMessages containing only tool results remain in the
36
+ * current turn, so the boundary cannot split them from their calls.
16
37
  *
17
- * The most recent turn is always preserved regardless of this value or
18
- * the token cap, so that a single oversized first message is never
19
- * destroyed by summarization.
38
+ * The most recent turn is preserved unless `intraTurnTokens` enables the
39
+ * pairing-balanced fallback for a tool-heavy history. A lone oversized user
40
+ * message is never eligible for that fallback.
20
41
  *
21
42
  * Defaults to `2`. A value of `0` disables the recency window (head =
22
43
  * everything, tail = empty), restoring the pre-recency-window behavior.
@@ -34,31 +55,378 @@ export interface RecencyWindowOptions {
34
55
  tokens?: number;
35
56
  /** Token-counter used to evaluate the optional `tokens` cap. */
36
57
  tokenCounter?: (m: BaseMessage) => number;
58
+ /**
59
+ * Minimum token budget to retain when the turn window would otherwise make
60
+ * the whole history indivisible. When set with `tokenCounter`, older closed
61
+ * tool-call/result units may be summarized from within the earliest retained
62
+ * turn. A lone user payload and open tool units remain indivisible.
63
+ */
64
+ intraTurnTokens?: number;
37
65
  }
38
66
 
39
67
  export interface RecencySplit {
40
68
  /** Older messages eligible for summarization. Empty when nothing to summarize. */
41
69
  head: BaseMessage[];
42
- /** Recent messages preserved verbatim. Always contains the most recent turn when any HumanMessage exists. */
70
+ /** Recent messages preserved verbatim, beginning at a pairing-balanced boundary. */
43
71
  tail: BaseMessage[];
44
72
  /** Number of user-led turns retained in the tail (0 if no HumanMessage exists). */
45
73
  tailTurnCount: number;
46
74
  /** Index in the original `messages` array where the tail begins. */
47
75
  tailStartIndex: number;
76
+ /** True when the pairing-balanced fallback selected the boundary. */
77
+ usedIntraTurnFallback: boolean;
78
+ }
79
+
80
+ export function resolveIntraTurnRetainTokens({
81
+ tokens,
82
+ maxContextTokens,
83
+ }: {
84
+ tokens?: number;
85
+ maxContextTokens?: number;
86
+ }): number | undefined {
87
+ if (tokens != null) {
88
+ return Number.isFinite(tokens) && tokens > 0
89
+ ? Math.floor(tokens)
90
+ : undefined;
91
+ }
92
+ if (
93
+ maxContextTokens == null ||
94
+ !Number.isFinite(maxContextTokens) ||
95
+ maxContextTokens <= 0
96
+ ) {
97
+ return undefined;
98
+ }
99
+ return Math.max(
100
+ 1,
101
+ Math.floor(maxContextTokens * DEFAULT_INTRA_TURN_RETAIN_RATIO)
102
+ );
103
+ }
104
+
105
+ function readOwnString(value: unknown, key: string): string | undefined {
106
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) {
107
+ return undefined;
108
+ }
109
+ try {
110
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
111
+ return descriptor != null &&
112
+ 'value' in descriptor &&
113
+ typeof descriptor.value === 'string' &&
114
+ descriptor.value !== '' &&
115
+ descriptor.value.length <= PROVIDER_TOOL_PAIRING_MAX_IDENTIFIER_CHARS
116
+ ? descriptor.value
117
+ : undefined;
118
+ } catch {
119
+ return undefined;
120
+ }
121
+ }
122
+
123
+ function readOwnValue(value: unknown, key: string): unknown {
124
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) {
125
+ return undefined;
126
+ }
127
+ try {
128
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
129
+ return descriptor != null && 'value' in descriptor
130
+ ? descriptor.value
131
+ : undefined;
132
+ } catch {
133
+ return undefined;
134
+ }
135
+ }
136
+
137
+ const LEGACY_FUNCTION_CALL_PREFIX = 'legacy_function:';
138
+
139
+ function getLegacyFunctionCallId(name: string): string | undefined {
140
+ const callId = `${LEGACY_FUNCTION_CALL_PREFIX}${name}`;
141
+ return callId.length <= PROVIDER_TOOL_PAIRING_MAX_IDENTIFIER_CHARS
142
+ ? callId
143
+ : undefined;
144
+ }
145
+
146
+ function getRawToolCallDescriptor(
147
+ toolCall: unknown
148
+ ): ProviderToolCallPartDescriptor | undefined {
149
+ const callId = readOwnString(toolCall, 'id');
150
+ if (callId == null) {
151
+ return undefined;
152
+ }
153
+ let name: string | undefined;
154
+ if (toolCall != null && typeof toolCall === 'object') {
155
+ try {
156
+ const property = Object.getOwnPropertyDescriptor(toolCall, 'function');
157
+ name =
158
+ property != null && 'value' in property
159
+ ? readOwnString(property.value, 'name')
160
+ : undefined;
161
+ } catch {
162
+ return undefined;
163
+ }
164
+ }
165
+ return {
166
+ callId,
167
+ kind: 'tool',
168
+ sourceType: 'raw_tool_calls',
169
+ ...(name == null ? {} : { name }),
170
+ };
171
+ }
172
+
173
+ function appendMessageToolCalls(
174
+ message: BaseMessage,
175
+ calls: ProviderToolCallIndex
176
+ ): void {
177
+ const messageRole = (message as BaseMessage & { role?: unknown }).role;
178
+ if (message.getType() !== 'ai' && messageRole !== 'assistant') {
179
+ return;
180
+ }
181
+
182
+ for (const toolCall of (message as AIMessage).tool_calls ?? []) {
183
+ const descriptor = getProviderAIMessageToolCallDescriptor(toolCall);
184
+ if (descriptor != null) {
185
+ appendProviderToolCallDescriptor(calls, descriptor);
186
+ }
187
+ }
188
+
189
+ const rawToolCalls = getBoundedProviderPairingArrayProperty(
190
+ message.additional_kwargs,
191
+ 'tool_calls'
192
+ );
193
+ if (rawToolCalls != null) {
194
+ for (const toolCall of rawToolCalls) {
195
+ const descriptor = getRawToolCallDescriptor(toolCall);
196
+ if (descriptor != null) {
197
+ appendProviderToolCallDescriptor(calls, descriptor);
198
+ }
199
+ }
200
+ }
201
+
202
+ const legacyFunctionCall = readOwnValue(
203
+ message.additional_kwargs,
204
+ 'function_call'
205
+ );
206
+ const legacyFunctionName = readOwnString(legacyFunctionCall, 'name');
207
+ const legacyFunctionCallId =
208
+ legacyFunctionName == null
209
+ ? undefined
210
+ : getLegacyFunctionCallId(legacyFunctionName);
211
+ if (legacyFunctionCallId != null) {
212
+ appendProviderToolCallDescriptor(calls, {
213
+ callId: legacyFunctionCallId,
214
+ kind: 'tool',
215
+ name: legacyFunctionName,
216
+ sourceType: 'legacy_function_call',
217
+ });
218
+ }
219
+ }
220
+
221
+ function getToolMessageResultDescriptor(
222
+ message: BaseMessage
223
+ ): ProviderToolResultPartDescriptor | undefined {
224
+ if (message.getType() === 'function' && message.name != null) {
225
+ const toolCallId = getLegacyFunctionCallId(message.name);
226
+ return toolCallId == null
227
+ ? undefined
228
+ : {
229
+ type: 'function_message',
230
+ toolCallId,
231
+ compatibleCallKinds: ['tool'],
232
+ expectedToolNames: [message.name],
233
+ };
234
+ }
235
+ if (message.getType() !== 'tool') {
236
+ return undefined;
237
+ }
238
+ const toolMessage = message as ToolMessage & { toolCallId?: unknown };
239
+ const toolCallId =
240
+ typeof toolMessage.tool_call_id === 'string'
241
+ ? toolMessage.tool_call_id
242
+ : toolMessage.toolCallId;
243
+ return typeof toolCallId === 'string' &&
244
+ toolCallId !== '' &&
245
+ toolCallId.length <= PROVIDER_TOOL_PAIRING_MAX_IDENTIFIER_CHARS
246
+ ? {
247
+ type: 'tool_message',
248
+ toolCallId,
249
+ compatibleCallKinds: ['tool'],
250
+ }
251
+ : undefined;
252
+ }
253
+
254
+ interface MessagePairingResult {
255
+ completedToolCalls: number;
256
+ trustedHumanToolResult: boolean;
257
+ }
258
+
259
+ function inspectMessagePairing(
260
+ message: BaseMessage,
261
+ calls: ProviderToolCallIndex
262
+ ): MessagePairingResult {
263
+ const isHuman = message.getType() === 'human';
264
+ const candidateCalls = isHuman ? new Map(calls) : calls;
265
+ if (!isHuman) {
266
+ appendMessageToolCalls(message, candidateCalls);
267
+ }
268
+ const content = getBoundedProviderPairingArray(message.content);
269
+ let completedToolCalls = 0;
270
+ let resultPartCount = 0;
271
+ let everyPartIsTrustedHumanResult = isHuman && content != null;
272
+ if (content != null) {
273
+ let previousPart: unknown;
274
+ for (const part of content) {
275
+ const callDescriptor = getProviderToolCallPartDescriptor(part);
276
+ if (callDescriptor != null && !isHuman) {
277
+ appendProviderToolCallDescriptor(candidateCalls, callDescriptor);
278
+ }
279
+ const resultDescriptor = getProviderToolResultPartDescriptor(part);
280
+ if (resultDescriptor == null) {
281
+ everyPartIsTrustedHumanResult = false;
282
+ } else {
283
+ resultPartCount += 1;
284
+ const canPairHumanResult =
285
+ !isHuman || resultDescriptor.allowHumanMessagePairing === true;
286
+ if (
287
+ canPairHumanResult &&
288
+ consumeProviderToolResultPair(
289
+ resultDescriptor,
290
+ candidateCalls,
291
+ previousPart
292
+ )
293
+ ) {
294
+ completedToolCalls += 1;
295
+ } else {
296
+ everyPartIsTrustedHumanResult = false;
297
+ }
298
+ }
299
+ previousPart = part;
300
+ }
301
+ }
302
+
303
+ const toolMessageResult = getToolMessageResultDescriptor(message);
304
+ if (
305
+ toolMessageResult != null &&
306
+ consumeProviderToolResultPair(toolMessageResult, candidateCalls)
307
+ ) {
308
+ completedToolCalls += 1;
309
+ }
310
+
311
+ const trustedHumanToolResult =
312
+ everyPartIsTrustedHumanResult &&
313
+ resultPartCount > 0 &&
314
+ resultPartCount === content?.length;
315
+ if (trustedHumanToolResult) {
316
+ calls.clear();
317
+ for (const [callId, entry] of candidateCalls) {
318
+ calls.set(callId, entry);
319
+ }
320
+ }
321
+ return {
322
+ completedToolCalls: isHuman && !trustedHumanToolResult
323
+ ? 0
324
+ : completedToolCalls,
325
+ trustedHumanToolResult,
326
+ };
327
+ }
328
+
329
+ function findTurnStarts(messages: BaseMessage[]): number[] {
330
+ const turnStarts: number[] = [];
331
+ const calls: ProviderToolCallIndex = new Map();
332
+ for (let i = 0; i < messages.length; i++) {
333
+ const message = messages[i] as BaseMessage;
334
+ const pairing = inspectMessagePairing(message, calls);
335
+ if (message.getType() !== 'human' || pairing.trustedHumanToolResult) {
336
+ continue;
337
+ }
338
+ turnStarts.push(i);
339
+ calls.clear();
340
+ }
341
+ return turnStarts;
342
+ }
343
+
344
+ function getCoverageSourceIds(message: BaseMessage): string[] {
345
+ const sourceIds = getProviderSourceMessageIds(message);
346
+ if (sourceIds.length > 0) {
347
+ return sourceIds;
348
+ }
349
+ const messageId = message.id?.trim();
350
+ return messageId != null && messageId !== '' ? [messageId] : [];
351
+ }
352
+
353
+ function findIntraTurnBoundary(
354
+ messages: BaseMessage[],
355
+ countMessageAt: (index: number) => number,
356
+ retainTokens: number,
357
+ earliestRetainedTurnEndIndex: number
358
+ ): number | undefined {
359
+ let remainingTokens = 0;
360
+ for (let i = 0; i < messages.length; i++) {
361
+ remainingTokens += countMessageAt(i);
362
+ }
363
+ if (remainingTokens <= retainTokens) {
364
+ return undefined;
365
+ }
366
+
367
+ const sourceIdsByIndex = new Array<string[]>(messages.length);
368
+ const lastIndexBySourceId = new Map<string, number>();
369
+ for (let i = 0; i < messages.length; i++) {
370
+ const sourceIds = getCoverageSourceIds(messages[i] as BaseMessage);
371
+ sourceIdsByIndex[i] = sourceIds;
372
+ for (const sourceId of sourceIds) {
373
+ lastIndexBySourceId.set(sourceId, i);
374
+ }
375
+ }
376
+
377
+ const pendingToolCalls: ProviderToolCallIndex = new Map();
378
+ const straddlingSourceIds = new Set<string>();
379
+ let completedToolCalls = 0;
380
+ let boundary: number | undefined;
381
+
382
+ const boundarySearchEnd = Math.min(
383
+ messages.length - 1,
384
+ earliestRetainedTurnEndIndex - 1
385
+ );
386
+ for (let i = 0; i <= boundarySearchEnd; i++) {
387
+ const message = messages[i] as BaseMessage;
388
+ remainingTokens -= countMessageAt(i);
389
+
390
+ const pairing = inspectMessagePairing(message, pendingToolCalls);
391
+ completedToolCalls += pairing.completedToolCalls;
392
+ if (message.getType() === 'human' && !pairing.trustedHumanToolResult) {
393
+ pendingToolCalls.clear();
394
+ }
395
+
396
+ for (const sourceId of sourceIdsByIndex[i] as string[]) {
397
+ if (lastIndexBySourceId.get(sourceId) === i) {
398
+ straddlingSourceIds.delete(sourceId);
399
+ } else {
400
+ straddlingSourceIds.add(sourceId);
401
+ }
402
+ }
403
+
404
+ if (remainingTokens < retainTokens) {
405
+ break;
406
+ }
407
+ if (
408
+ completedToolCalls > 0 &&
409
+ pendingToolCalls.size === 0 &&
410
+ straddlingSourceIds.size === 0 &&
411
+ (message.getType() !== 'human' || pairing.trustedHumanToolResult)
412
+ ) {
413
+ boundary = i + 1;
414
+ }
415
+ }
416
+
417
+ return boundary;
48
418
  }
49
419
 
50
420
  /**
51
421
  * Splits `messages` into a head (older, to summarize) and a tail (recent,
52
- * to preserve verbatim) at user-message boundaries. The most recent
53
- * user-led turn is always included in the tail; additional older turns
54
- * are added subject to `turns` and `tokens` caps.
422
+ * to preserve verbatim), preferring user-message boundaries. The most recent
423
+ * user-led turn is normally included in the tail; additional older turns are
424
+ * added subject to `turns` and `tokens` caps.
55
425
  *
56
- * Cutting strictly at HumanMessage boundaries ensures that:
57
- * - tool_use tool_result pairs are never split (they always live within
58
- * the same turn);
59
- * - the first user message is never replaced by a summary, addressing
60
- * the "first turn destruction" failure mode where a single large
61
- * user-pasted payload would otherwise be replaced by a generic summary.
426
+ * When that policy exposes no compactable head, `intraTurnTokens` may select
427
+ * a boundary after older closed tool-call/result units. This keeps runaway
428
+ * first-turn tool loops compactable without splitting parallel calls from
429
+ * their results. A user payload without a completed tool unit stays intact.
62
430
  *
63
431
  * When `messages` contains no HumanMessage (degenerate state — e.g. system
64
432
  * + assistant messages from a programmatic preamble), everything is
@@ -78,15 +446,11 @@ export function splitAtRecencyBoundary(
78
446
  tail: [],
79
447
  tailTurnCount: 0,
80
448
  tailStartIndex: messages.length,
449
+ usedIntraTurnFallback: false,
81
450
  };
82
451
  }
83
452
 
84
- const turnStarts: number[] = [];
85
- for (let i = 0; i < messages.length; i++) {
86
- if (messages[i].getType() === 'human') {
87
- turnStarts.push(i);
88
- }
89
- }
453
+ const turnStarts = findTurnStarts(messages);
90
454
 
91
455
  if (turnStarts.length === 0) {
92
456
  return {
@@ -94,6 +458,7 @@ export function splitAtRecencyBoundary(
94
458
  tail: [],
95
459
  tailTurnCount: 0,
96
460
  tailStartIndex: messages.length,
461
+ usedIntraTurnFallback: false,
97
462
  };
98
463
  }
99
464
 
@@ -105,6 +470,16 @@ export function splitAtRecencyBoundary(
105
470
  const tokenCounter = options.tokenCounter;
106
471
  const trackTokens =
107
472
  tokensCap != null && Number.isFinite(tokensCap) && tokenCounter != null;
473
+ const messageTokens: Array<number | undefined> = new Array(messages.length);
474
+ const countMessageAt = (index: number): number => {
475
+ const cached = messageTokens[index];
476
+ if (cached != null) {
477
+ return cached;
478
+ }
479
+ const count = tokenCounter?.(messages[index] as BaseMessage) ?? 0;
480
+ messageTokens[index] = count;
481
+ return count;
482
+ };
108
483
 
109
484
  /**
110
485
  * Token-counting strategy: each candidate turn `t` spans the half-open
@@ -122,7 +497,7 @@ export function splitAtRecencyBoundary(
122
497
  let tailTokens = 0;
123
498
  if (trackTokens) {
124
499
  for (let i = lastTurnStart; i < messages.length; i++) {
125
- tailTokens += tokenCounter(messages[i] as BaseMessage);
500
+ tailTokens += countMessageAt(i);
126
501
  }
127
502
  }
128
503
 
@@ -136,7 +511,7 @@ export function splitAtRecencyBoundary(
136
511
  if (trackTokens) {
137
512
  let turnTokens = 0;
138
513
  for (let i = turnStart; i < turnEnd; i++) {
139
- turnTokens += tokenCounter(messages[i] as BaseMessage);
514
+ turnTokens += countMessageAt(i);
140
515
  }
141
516
  if (tailTokens + turnTokens > (tokensCap as number)) {
142
517
  break;
@@ -148,10 +523,37 @@ export function splitAtRecencyBoundary(
148
523
  tailTurnCount += 1;
149
524
  }
150
525
 
526
+ if (tailStartIndex === turnStarts[0]) {
527
+ const intraTurnTokens = options.intraTurnTokens;
528
+ if (
529
+ intraTurnTokens != null &&
530
+ Number.isFinite(intraTurnTokens) &&
531
+ intraTurnTokens > 0 &&
532
+ tokenCounter != null
533
+ ) {
534
+ const intraTurnBoundary = findIntraTurnBoundary(
535
+ messages,
536
+ countMessageAt,
537
+ intraTurnTokens,
538
+ turnStarts[1] ?? messages.length
539
+ );
540
+ if (intraTurnBoundary != null) {
541
+ return {
542
+ head: messages.slice(0, intraTurnBoundary),
543
+ tail: messages.slice(intraTurnBoundary),
544
+ tailTurnCount,
545
+ tailStartIndex: intraTurnBoundary,
546
+ usedIntraTurnFallback: true,
547
+ };
548
+ }
549
+ }
550
+ }
551
+
151
552
  return {
152
553
  head: messages.slice(0, tailStartIndex),
153
554
  tail: messages.slice(tailStartIndex),
154
555
  tailTurnCount,
155
556
  tailStartIndex,
557
+ usedIntraTurnFallback: false,
156
558
  };
157
559
  }
@@ -23,7 +23,7 @@ import type {
23
23
  } from './types';
24
24
  import type { HookRegistry } from '@/hooks';
25
25
  import type * as t from '@/types';
26
- import { deserializeMessage } from './messageSerialization';
26
+ import { deriveMessages } from './deriveMessages';
27
27
  import { createSummarizeNode } from '@/summarization/node';
28
28
  import { resolveStreamLimits } from '@/llm/streamLimits';
29
29
  import { JsonlSessionStore } from './JsonlSessionStore';
@@ -493,32 +493,6 @@ function createCheckpointReference(params: {
493
493
  };
494
494
  }
495
495
 
496
- function createSessionRunState(entries: SessionEntry[]): {
497
- messages: BaseMessage[];
498
- initialSummary?: InitialSummary;
499
- } {
500
- const messages: BaseMessage[] = [];
501
- let initialSummary: InitialSummary | undefined;
502
- for (const entry of entries) {
503
- if (entry.type === 'summary') {
504
- initialSummary = {
505
- text: entry.data.text,
506
- tokenCount:
507
- typeof entry.data.tokenCount === 'number' &&
508
- Number.isFinite(entry.data.tokenCount)
509
- ? entry.data.tokenCount
510
- : 0,
511
- };
512
- messages.length = 0;
513
- continue;
514
- }
515
- if (entry.type === 'message') {
516
- messages.push(deserializeMessage(entry.data.message));
517
- }
518
- }
519
- return { messages, initialSummary };
520
- }
521
-
522
496
  function isMessageEntry(
523
497
  entry: SessionEntry
524
498
  ): entry is Extract<SessionEntry, { type: 'message' }> {
@@ -1103,7 +1077,7 @@ export class AgentSession {
1103
1077
  handlerResult.events.push(streamEvent);
1104
1078
  onEvent?.(streamEvent);
1105
1079
  };
1106
- const sessionState = createSessionRunState(
1080
+ const sessionState = deriveMessages(
1107
1081
  isSessionThread ? (this.store?.getPath() ?? []) : []
1108
1082
  );
1109
1083
  let run: Run<t.IState> | undefined;
@@ -1349,7 +1323,7 @@ export class AgentSession {
1349
1323
  throw new Error('Cannot compact an ephemeral session');
1350
1324
  }
1351
1325
  const activePath = path ?? store.getPath();
1352
- const sessionState = createSessionRunState(activePath);
1326
+ const sessionState = deriveMessages(activePath);
1353
1327
  const messageEntries = activePath.filter(isMessageEntry);
1354
1328
  if (sessionState.messages.length === 0) {
1355
1329
  return undefined;
@@ -1474,7 +1448,7 @@ export class AgentSession {
1474
1448
  threadId,
1475
1449
  userHandlers: this.runConfig.customHandlers,
1476
1450
  });
1477
- const sessionState = createSessionRunState(
1451
+ const sessionState = deriveMessages(
1478
1452
  isSessionThread ? (this.store?.getPath() ?? []) : []
1479
1453
  );
1480
1454
  let run: Run<t.IState> | undefined;
@@ -0,0 +1,37 @@
1
+ import type { BaseMessage } from '@langchain/core/messages';
2
+ import type { SessionEntry } from './types';
3
+ import { deserializeMessage } from './messageSerialization';
4
+
5
+ export interface DerivedSessionMessages {
6
+ initialSummary?: {
7
+ text: string;
8
+ tokenCount: number;
9
+ };
10
+ messages: BaseMessage[];
11
+ }
12
+
13
+ /** Derives the active model context from one append-only session-log path. */
14
+ export function deriveMessages(
15
+ entries: readonly SessionEntry[]
16
+ ): DerivedSessionMessages {
17
+ const messages: BaseMessage[] = [];
18
+ let initialSummary: DerivedSessionMessages['initialSummary'];
19
+ for (const entry of entries) {
20
+ if (entry.type === 'summary') {
21
+ initialSummary = {
22
+ text: entry.data.text,
23
+ tokenCount:
24
+ typeof entry.data.tokenCount === 'number' &&
25
+ Number.isFinite(entry.data.tokenCount)
26
+ ? entry.data.tokenCount
27
+ : 0,
28
+ };
29
+ messages.length = 0;
30
+ continue;
31
+ }
32
+ if (entry.type === 'message') {
33
+ messages.push(deserializeMessage(entry.data.message));
34
+ }
35
+ }
36
+ return { messages, ...(initialSummary != null && { initialSummary }) };
37
+ }
@@ -1,4 +1,5 @@
1
1
  export { AgentSession, createAgentSession } from './AgentSession';
2
+ export { deriveMessages } from './deriveMessages';
2
3
  export { JsonlSessionStore, SessionManager } from './JsonlSessionStore';
3
4
  export { createRunHandlers } from './handlers';
4
5
  export {
@@ -42,3 +43,4 @@ export type {
42
43
  SessionSummaryEntry,
43
44
  SessionTreeNode,
44
45
  } from './types';
46
+ export type { DerivedSessionMessages } from './deriveMessages';