@librechat/agents 3.7.0 → 3.7.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 (101) 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/eventActor/EventActorExecutor.cjs +797 -0
  4. package/dist/cjs/eventActor/EventActorExecutor.cjs.map +1 -0
  5. package/dist/cjs/eventActor/index.cjs +1 -0
  6. package/dist/cjs/graphs/Graph.cjs +7 -2
  7. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  8. package/dist/cjs/llm/invoke.cjs +56 -9
  9. package/dist/cjs/llm/invoke.cjs.map +1 -1
  10. package/dist/cjs/main.cjs +15 -1
  11. package/dist/cjs/messages/content.cjs +8 -5
  12. package/dist/cjs/messages/content.cjs.map +1 -1
  13. package/dist/cjs/messages/format.cjs +15 -5
  14. package/dist/cjs/messages/format.cjs.map +1 -1
  15. package/dist/cjs/messages/index.cjs +2 -1
  16. package/dist/cjs/messages/projectionInvariant.cjs +74 -0
  17. package/dist/cjs/messages/projectionInvariant.cjs.map +1 -0
  18. package/dist/cjs/messages/provenance.cjs +30 -8
  19. package/dist/cjs/messages/provenance.cjs.map +1 -1
  20. package/dist/cjs/messages/recency.cjs +202 -7
  21. package/dist/cjs/messages/recency.cjs.map +1 -1
  22. package/dist/cjs/messages/toolResultTypes.cjs +1 -0
  23. package/dist/cjs/session/AgentSession.cjs +4 -23
  24. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  25. package/dist/cjs/session/deriveMessages.cjs +25 -0
  26. package/dist/cjs/session/deriveMessages.cjs.map +1 -0
  27. package/dist/cjs/session/index.cjs +1 -0
  28. package/dist/cjs/summarization/node.cjs +13 -6
  29. package/dist/cjs/summarization/node.cjs.map +1 -1
  30. package/dist/cjs/tools/subagent/InMemorySubagentTaskStore.cjs +65 -8
  31. package/dist/cjs/tools/subagent/InMemorySubagentTaskStore.cjs.map +1 -1
  32. package/dist/esm/agents/AgentContext.mjs +11 -2
  33. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  34. package/dist/esm/eventActor/EventActorExecutor.mjs +796 -0
  35. package/dist/esm/eventActor/EventActorExecutor.mjs.map +1 -0
  36. package/dist/esm/eventActor/index.mjs +2 -0
  37. package/dist/esm/graphs/Graph.mjs +8 -3
  38. package/dist/esm/graphs/Graph.mjs.map +1 -1
  39. package/dist/esm/llm/invoke.mjs +56 -9
  40. package/dist/esm/llm/invoke.mjs.map +1 -1
  41. package/dist/esm/main.mjs +8 -4
  42. package/dist/esm/messages/content.mjs +8 -6
  43. package/dist/esm/messages/content.mjs.map +1 -1
  44. package/dist/esm/messages/format.mjs +16 -6
  45. package/dist/esm/messages/format.mjs.map +1 -1
  46. package/dist/esm/messages/index.mjs +2 -1
  47. package/dist/esm/messages/projectionInvariant.mjs +72 -0
  48. package/dist/esm/messages/projectionInvariant.mjs.map +1 -0
  49. package/dist/esm/messages/provenance.mjs +30 -9
  50. package/dist/esm/messages/provenance.mjs.map +1 -1
  51. package/dist/esm/messages/recency.mjs +201 -8
  52. package/dist/esm/messages/recency.mjs.map +1 -1
  53. package/dist/esm/messages/toolResultTypes.mjs +1 -1
  54. package/dist/esm/session/AgentSession.mjs +4 -23
  55. package/dist/esm/session/AgentSession.mjs.map +1 -1
  56. package/dist/esm/session/deriveMessages.mjs +25 -0
  57. package/dist/esm/session/deriveMessages.mjs.map +1 -0
  58. package/dist/esm/session/index.mjs +1 -0
  59. package/dist/esm/summarization/node.mjs +14 -7
  60. package/dist/esm/summarization/node.mjs.map +1 -1
  61. package/dist/esm/tools/subagent/InMemorySubagentTaskStore.mjs +65 -8
  62. package/dist/esm/tools/subagent/InMemorySubagentTaskStore.mjs.map +1 -1
  63. package/dist/types/agents/AgentContext.d.ts +6 -1
  64. package/dist/types/eventActor/EventActorExecutor.d.ts +18 -0
  65. package/dist/types/eventActor/index.d.ts +2 -0
  66. package/dist/types/eventActor/types.d.ts +193 -0
  67. package/dist/types/index.d.ts +1 -0
  68. package/dist/types/messages/content.d.ts +4 -1
  69. package/dist/types/messages/format.d.ts +6 -0
  70. package/dist/types/messages/index.d.ts +1 -0
  71. package/dist/types/messages/projectionInvariant.d.ts +25 -0
  72. package/dist/types/messages/provenance.d.ts +10 -0
  73. package/dist/types/messages/recency.d.ts +30 -18
  74. package/dist/types/session/deriveMessages.d.ts +11 -0
  75. package/dist/types/session/index.d.ts +2 -0
  76. package/dist/types/tools/subagent/InMemorySubagentTaskStore.d.ts +13 -1
  77. package/dist/types/types/graph.d.ts +1 -1
  78. package/dist/types/types/subagentTasks.d.ts +22 -0
  79. package/dist/types/types/summarize.d.ts +15 -13
  80. package/package.json +68 -77
  81. package/src/agents/AgentContext.ts +19 -5
  82. package/src/eventActor/EventActorExecutor.ts +1499 -0
  83. package/src/eventActor/index.ts +32 -0
  84. package/src/eventActor/types.ts +250 -0
  85. package/src/graphs/Graph.ts +9 -1
  86. package/src/index.ts +3 -0
  87. package/src/llm/invoke.ts +102 -23
  88. package/src/messages/content.ts +20 -10
  89. package/src/messages/format.ts +29 -5
  90. package/src/messages/index.ts +1 -0
  91. package/src/messages/projectionInvariant.ts +134 -0
  92. package/src/messages/provenance.ts +60 -18
  93. package/src/messages/recency.ts +429 -27
  94. package/src/session/AgentSession.ts +4 -30
  95. package/src/session/deriveMessages.ts +37 -0
  96. package/src/session/index.ts +2 -0
  97. package/src/summarization/node.ts +37 -15
  98. package/src/tools/subagent/InMemorySubagentTaskStore.ts +139 -6
  99. package/src/types/graph.ts +1 -0
  100. package/src/types/subagentTasks.ts +28 -0
  101. package/src/types/summarize.ts +15 -13
@@ -0,0 +1,32 @@
1
+ export {
2
+ EventActorExecutor,
3
+ createEventActorExecutor,
4
+ } from './EventActorExecutor';
5
+ export type {
6
+ EventActorAdapterPrepareRequest,
7
+ EventActorAdapterPreparation,
8
+ EventActorAppliedResult,
9
+ EventActorCheckpointFork,
10
+ EventActorCheckpointReference,
11
+ EventActorCommitRequest,
12
+ EventActorCommitResult,
13
+ EventActorDiscardReason,
14
+ EventActorDiscardRequest,
15
+ EventActorEvent,
16
+ EventActorExecutionRequest,
17
+ EventActorExecutionResult,
18
+ EventActorExecutorOptions,
19
+ EventActorHead,
20
+ EventActorHostAdapter,
21
+ EventActorInvocation,
22
+ EventActorInvocationContext,
23
+ EventActorIndeterminateResult,
24
+ EventActorInvocationReference,
25
+ EventActorInvocationResult,
26
+ EventActorPreparation,
27
+ EventActorPreparationContext,
28
+ EventActorPreparedInvocation,
29
+ EventActorPrepareRequest,
30
+ EventActorSettlementResult,
31
+ EventActorTerminalResult,
32
+ } from './types';
@@ -0,0 +1,250 @@
1
+ import type { RunnableConfig } from '@langchain/core/runnables';
2
+
3
+ /** Durable event payload accepted by the actor lifecycle. */
4
+ export type EventActorEvent =
5
+ | null
6
+ | boolean
7
+ | number
8
+ | string
9
+ | readonly EventActorEvent[]
10
+ | { readonly [key: string]: EventActorEvent };
11
+
12
+ /** Stable reference to one persisted LangGraph checkpoint. */
13
+ export interface EventActorCheckpointReference {
14
+ threadId: string;
15
+ checkpointId?: string;
16
+ checkpointNs: string;
17
+ }
18
+
19
+ /** Committed logical head read before an event invocation is prepared. */
20
+ export interface EventActorHead {
21
+ actorThreadId: string;
22
+ generation: number;
23
+ checkpoint?: EventActorCheckpointReference;
24
+ }
25
+
26
+ /** Invocation-owned checkpoint fork that cannot become authoritative in place. */
27
+ export interface EventActorCheckpointFork
28
+ extends EventActorCheckpointReference {
29
+ invocationId: string;
30
+ }
31
+
32
+ export interface EventActorInvocationReference {
33
+ actorThreadId: string;
34
+ invocationId: string;
35
+ depth: number;
36
+ continuation: 'warm' | 'cold';
37
+ base: EventActorHead;
38
+ fork: EventActorCheckpointFork;
39
+ }
40
+
41
+ export interface EventActorInvocation<TEvent extends EventActorEvent>
42
+ extends EventActorInvocationReference {
43
+ event: TEvent;
44
+ }
45
+
46
+ export interface EventActorPreparedInvocation<TEvent extends EventActorEvent>
47
+ extends EventActorInvocation<TEvent> {
48
+ /**
49
+ * Executor-authenticated, time-bounded binding over the complete prepared
50
+ * invocation. Its wire representation is opaque to callers.
51
+ */
52
+ preparationDigest: string;
53
+ }
54
+
55
+ export type EventActorAdapterPreparation<TEvent extends EventActorEvent> =
56
+ | { status: 'ready'; invocation: EventActorInvocation<TEvent> }
57
+ | { status: 'checkpoint_unavailable'; head: EventActorHead };
58
+
59
+ export type EventActorPreparation<TEvent extends EventActorEvent> =
60
+ | { status: 'ready'; invocation: EventActorPreparedInvocation<TEvent> }
61
+ | {
62
+ status: 'checkpoint_unavailable';
63
+ request: EventActorPrepareRequest<TEvent>;
64
+ head: EventActorHead;
65
+ /** Executor-authenticated binding over this exact request/head pair. */
66
+ preparationDigest: string;
67
+ };
68
+
69
+ export type EventActorTerminalResult<TResult extends EventActorEvent> =
70
+ | {
71
+ status: 'applied';
72
+ result: TResult;
73
+ checkpoint: EventActorCheckpointFork;
74
+ }
75
+ | { status: 'completed_no_action'; result?: TResult };
76
+
77
+ export type EventActorAppliedResult<TResult extends EventActorEvent> = Extract<
78
+ EventActorTerminalResult<TResult>,
79
+ { status: 'applied' }
80
+ > & {
81
+ /** Executor-issued one-shot settlement for the invocation that produced this action. */
82
+ invocation: EventActorInvocationReference;
83
+ };
84
+
85
+ export interface EventActorIndeterminateResult<
86
+ TResult extends EventActorEvent,
87
+ > {
88
+ /** Applied handling cannot be proven safe to retry; retain its fork. */
89
+ status: 'commit_indeterminate';
90
+ result?: TResult;
91
+ checkpoint: EventActorCheckpointFork;
92
+ error: Error;
93
+ }
94
+
95
+ export type EventActorInvocationResult<TResult extends EventActorEvent> =
96
+ | EventActorAppliedResult<TResult>
97
+ | EventActorIndeterminateResult<TResult>
98
+ | Extract<
99
+ EventActorTerminalResult<TResult>,
100
+ { status: 'completed_no_action' }
101
+ >;
102
+
103
+ export interface EventActorInvocationContext {
104
+ signal: AbortSignal;
105
+ config: RunnableConfig;
106
+ }
107
+
108
+ export interface EventActorPreparationContext {
109
+ /** Explicit task-owned cancellation; parent-run ambient signals are excluded. */
110
+ signal: AbortSignal;
111
+ }
112
+
113
+ export interface EventActorPrepareRequest<TEvent extends EventActorEvent> {
114
+ actorThreadId: string;
115
+ invocationId: string;
116
+ depth: number;
117
+ event: TEvent;
118
+ }
119
+
120
+ export interface EventActorAdapterPrepareRequest<TEvent extends EventActorEvent>
121
+ extends EventActorPrepareRequest<TEvent> {
122
+ /** Unique execution-attempt namespace; invocationId remains the logical idempotency key. */
123
+ checkpointNs: string;
124
+ }
125
+
126
+ export interface EventActorCommitRequest<TResult extends EventActorEvent> {
127
+ invocation: EventActorInvocationReference;
128
+ expectedHead: EventActorHead;
129
+ checkpoint: EventActorCheckpointFork;
130
+ result: TResult;
131
+ retention: {
132
+ committedCheckpoints: 2;
133
+ dormantCheckpointTtlMs: number;
134
+ };
135
+ }
136
+
137
+ export type EventActorCommitResult =
138
+ | { status: 'committed'; head: EventActorHead }
139
+ | { status: 'stale'; head?: EventActorHead };
140
+
141
+ /** Public settlement outcome after an action has already been applied. */
142
+ export type EventActorSettlementResult<TResult extends EventActorEvent> =
143
+ | EventActorCommitResult
144
+ | EventActorIndeterminateResult<TResult>;
145
+
146
+ export type EventActorDiscardReason =
147
+ | 'cancelled'
148
+ | 'completed_no_action'
149
+ | 'failed';
150
+
151
+ export interface EventActorDiscardRequest {
152
+ invocation: EventActorInvocationReference;
153
+ reason: EventActorDiscardReason;
154
+ }
155
+
156
+ /**
157
+ * Host adapter for durable actor state and the concrete agent invocation.
158
+ * `commit` must compare both the expected generation and checkpoint identity
159
+ * atomically before advancing the logical actor head. The host mailbox
160
+ * deduplicates the logical `invocationId` before entering this seam, while each
161
+ * SDK execution attempt receives a distinct checkpoint namespace. Preparation
162
+ * methods own rollback until they return a ready invocation and must treat the
163
+ * request event as immutable. On cancellation they roll back and reject with
164
+ * `context.signal.reason`; cleanup failures reject with their own error so they
165
+ * remain observable. `invoke` returns only after its provider, stream, timer,
166
+ * and executor resources have been released. Once qualifying action evidence
167
+ * exists, `invoke` must return `applied` even if a later abort or provider
168
+ * failure occurs; a thrown error is therefore a definite no-action failure
169
+ * whose fork is safe to discard. `commit` must not reclaim an applied stale
170
+ * fork: the SDK retains and surfaces it as `commit_conflict` for host
171
+ * reconciliation. `discard` must be idempotent for the same invocation because
172
+ * an ambiguous cleanup failure can be retried through the public lifecycle.
173
+ */
174
+ export interface EventActorHostAdapter<
175
+ TEvent extends EventActorEvent,
176
+ TResult extends EventActorEvent,
177
+ > {
178
+ prepare(
179
+ request: EventActorAdapterPrepareRequest<TEvent>,
180
+ context: EventActorPreparationContext
181
+ ): Promise<EventActorAdapterPreparation<TEvent>>;
182
+ coldContinue(
183
+ request: EventActorAdapterPrepareRequest<TEvent>,
184
+ head: EventActorHead,
185
+ context: EventActorPreparationContext
186
+ ): Promise<EventActorInvocation<TEvent>>;
187
+ invoke(
188
+ invocation: EventActorInvocation<TEvent>,
189
+ context: EventActorInvocationContext
190
+ ): Promise<EventActorTerminalResult<TResult>>;
191
+ commit(
192
+ request: EventActorCommitRequest<TResult>
193
+ ): Promise<EventActorCommitResult>;
194
+ discard(request: EventActorDiscardRequest): Promise<void>;
195
+ }
196
+
197
+ export interface EventActorExecutionRequest<TEvent extends EventActorEvent> {
198
+ actorThreadId: string;
199
+ invocationId: string;
200
+ event: TEvent;
201
+ depth?: number;
202
+ /** Explicit task-owned signal. Ambient parent-run signals are ignored. */
203
+ signal?: AbortSignal;
204
+ }
205
+
206
+ export type EventActorExecutionResult<TResult extends EventActorEvent> =
207
+ | {
208
+ status: 'applied';
209
+ result: TResult;
210
+ head: EventActorHead;
211
+ continuation: 'warm' | 'cold';
212
+ }
213
+ | {
214
+ status: 'completed_no_action';
215
+ result?: TResult;
216
+ continuation: 'warm' | 'cold';
217
+ }
218
+ | {
219
+ status: 'cancelled';
220
+ continuation: 'warm' | 'cold';
221
+ }
222
+ | {
223
+ /** The action happened, but another head won the CAS. Reconcile; do not retry. */
224
+ status: 'commit_conflict';
225
+ result: TResult;
226
+ checkpoint: EventActorCheckpointFork;
227
+ head?: EventActorHead;
228
+ continuation: 'warm' | 'cold';
229
+ }
230
+ | {
231
+ /** Applied handling cannot be proven safe to retry; retain its fork. */
232
+ status: 'commit_indeterminate';
233
+ result?: TResult;
234
+ checkpoint: EventActorCheckpointFork;
235
+ error: Error;
236
+ continuation: 'warm' | 'cold';
237
+ }
238
+ | {
239
+ status: 'failed';
240
+ error: Error;
241
+ continuation: 'warm' | 'cold';
242
+ };
243
+
244
+ export interface EventActorExecutorOptions {
245
+ maxDepth?: number;
246
+ /** Also bounds signed preparation authority and local terminal fences. */
247
+ dormantCheckpointTtlMs?: number;
248
+ /** Stable private key of at least 32 bytes for cross-lifetime handoffs. */
249
+ preparationSigningKey?: string | Uint8Array;
250
+ }
@@ -68,6 +68,7 @@ import {
68
68
  makeIsDeferred,
69
69
  partitionAndMarkAnthropicToolCache,
70
70
  DEFAULT_RETAIN_RECENT_TURNS,
71
+ resolveIntraTurnRetainTokens,
71
72
  splitAtRecencyBoundary,
72
73
  convertInjectedMessages,
73
74
  coalesceAdjacentUserTurns,
@@ -3936,6 +3937,9 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
3936
3937
  */
3937
3938
  const estimatedPromptTokens = getEstimatedPromptTokens(contextUsage);
3938
3939
 
3940
+ const recencyTokenCounter =
3941
+ agentContext.contextPressureTokenCounts?.count ??
3942
+ agentContext.tokenCounter;
3939
3943
  const canSummarizeOverflow =
3940
3944
  agentContext.summarizationEnabled === true &&
3941
3945
  splitAtRecencyBoundary(messages, {
@@ -3943,7 +3947,11 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
3943
3947
  agentContext.summarizationConfig?.retainRecent?.turns ??
3944
3948
  DEFAULT_RETAIN_RECENT_TURNS,
3945
3949
  tokens: agentContext.summarizationConfig?.retainRecent?.tokens,
3946
- tokenCounter: agentContext.tokenCounter,
3950
+ tokenCounter: recencyTokenCounter,
3951
+ intraTurnTokens: resolveIntraTurnRetainTokens({
3952
+ tokens: agentContext.summarizationConfig?.retainRecent?.tokens,
3953
+ maxContextTokens: agentContext.maxContextTokens,
3954
+ }),
3947
3955
  }).head.length > 0;
3948
3956
 
3949
3957
  const getLocalProviderOverflowMeasurement = (
package/src/index.ts CHANGED
@@ -43,6 +43,9 @@ export * from './hooks';
43
43
  /* Programmatic sessions */
44
44
  export * from './session';
45
45
 
46
+ /* Event actors */
47
+ export * from './eventActor';
48
+
46
49
  /* HITL helpers */
47
50
  export * from './hitl';
48
51
 
package/src/llm/invoke.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { concat } from '@langchain/core/utils/stream';
2
2
  import { AIMessageChunk } from '@langchain/core/messages';
3
+ import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
3
4
  import { getCallbackManagerForConfig } from '@langchain/core/runnables';
4
5
  import {
5
6
  CallbackManager,
@@ -40,7 +41,12 @@ import { assertNotTruncatedToolCall } from '@/llm/truncation';
40
41
  import { safeDispatchCustomEvent } from '@/utils/events';
41
42
  import { getContextOverflowInfo } from '@/utils/errors';
42
43
  import { appendCallbacks } from '@/utils/callbacks';
43
- import { modifyDeltaProperties } from '@/messages';
44
+ import {
45
+ inspectProviderMessageProjection,
46
+ ProviderMessageProjectionInvariantError,
47
+ resolveProviderMessageProjectionInvariantMode,
48
+ modifyDeltaProperties,
49
+ } from '@/messages';
44
50
  import { canSealPreempt } from '@/llm/preempt';
45
51
  import { initializeModel } from '@/llm/init';
46
52
 
@@ -106,6 +112,79 @@ export type OnChunk = (
106
112
  /** Unique per-model-attempt sequence; see the stamp in `attemptInvoke`. */
107
113
  let streamLimitAttemptSeq = 0;
108
114
 
115
+ function createModelStartHandler({
116
+ config,
117
+ mode,
118
+ provider,
119
+ captureRunId,
120
+ }: {
121
+ config: RunnableConfig;
122
+ mode: ReturnType<typeof resolveProviderMessageProjectionInvariantMode>;
123
+ provider: t.ProviderName;
124
+ captureRunId?: (runId: string) => void;
125
+ }): BaseCallbackHandler {
126
+ let inspected = false;
127
+ const handler = BaseCallbackHandler.fromMethods({
128
+ handleChatModelStart: async (
129
+ _llm: Serialized,
130
+ messageBatches: BaseMessage[][],
131
+ runId: string
132
+ ): Promise<void> => {
133
+ captureRunId?.(runId);
134
+ if (mode === 'off' || inspected) {
135
+ return;
136
+ }
137
+ inspected = true;
138
+ const report = inspectProviderMessageProjection(messageBatches[0] ?? []);
139
+ if (report.valid) {
140
+ return;
141
+ }
142
+ if (mode === 'assert') {
143
+ throw new ProviderMessageProjectionInvariantError(report);
144
+ }
145
+ try {
146
+ const callbackManager = await getCallbackManagerForConfig(config);
147
+ await callbackManager?.handleCustomEvent?.(
148
+ GraphEvents.ON_AGENT_LOG,
149
+ {
150
+ level: 'warn',
151
+ scope: 'projection',
152
+ message: 'Provider message projection has provenance gaps',
153
+ data: { provider, report },
154
+ runId,
155
+ } satisfies t.AgentLogEvent,
156
+ runId
157
+ );
158
+ } catch {
159
+ return;
160
+ }
161
+ },
162
+ });
163
+ handler.name = 'provider-message-projection-invariant';
164
+ handler.raiseError = mode === 'assert';
165
+ handler.awaitHandlers = true;
166
+ return handler;
167
+ }
168
+
169
+ function withModelStartHandler({
170
+ config,
171
+ mode,
172
+ provider,
173
+ captureRunId,
174
+ }: {
175
+ config: RunnableConfig;
176
+ mode: ReturnType<typeof resolveProviderMessageProjectionInvariantMode>;
177
+ provider: t.ProviderName;
178
+ captureRunId?: (runId: string) => void;
179
+ }): RunnableConfig {
180
+ return {
181
+ ...config,
182
+ callbacks: appendCallbacks(config.callbacks, [
183
+ createModelStartHandler({ config, mode, provider, captureRunId }),
184
+ ]),
185
+ };
186
+ }
187
+
109
188
  function getManualToolStreamNormalizationProvider(
110
189
  provider: t.ProviderName
111
190
  ): t.ProviderName {
@@ -632,6 +711,24 @@ async function attemptInvokeBody(
632
711
  config: RunnableConfig
633
712
  ): Promise<Partial<t.BaseGraphState>> {
634
713
  const { model, messages: messagesForProvider, provider } = request;
714
+ const projectionInvariantMode =
715
+ resolveProviderMessageProjectionInvariantMode();
716
+ let sealedRunId: string | undefined;
717
+ let invocationConfig = config;
718
+ const captureModelRunId =
719
+ model.stream != null && context?.preemption != null;
720
+ if (projectionInvariantMode !== 'off' || captureModelRunId) {
721
+ invocationConfig = withModelStartHandler({
722
+ config,
723
+ mode: projectionInvariantMode,
724
+ provider,
725
+ captureRunId: captureModelRunId
726
+ ? (runId: string): void => {
727
+ sealedRunId ??= runId;
728
+ }
729
+ : undefined,
730
+ });
731
+ }
635
732
 
636
733
  /**
637
734
  * Stamp the provider that is ACTUALLY serving this invocation onto the
@@ -647,28 +744,10 @@ async function attemptInvokeBody(
647
744
  * Observed, not dictated. `handleChatModelStart` fires with the chat
648
745
  * model's real run id before the first chunk, which is the only way to
649
746
  * name the run a seal has to close — pinning `config.runId` does not
650
- * survive the bound runnable. Installed only when preemption is
651
- * configured, so a run that cannot seal carries no extra handler.
747
+ * survive the bound runnable. The same handler owns the opt-in projection
748
+ * invariant so enabled diagnostics do not stack a second model callback.
652
749
  */
653
- let sealedRunId: string | undefined;
654
- const streamConfig =
655
- context?.preemption == null
656
- ? config
657
- : {
658
- ...config,
659
- callbacks: appendCallbacks(config.callbacks, [
660
- {
661
- handleChatModelStart: (
662
- _llm: Serialized,
663
- _messages: BaseMessage[][],
664
- runId: string
665
- ): void => {
666
- sealedRunId ??= runId;
667
- },
668
- },
669
- ]),
670
- };
671
- const stream = await model.stream(messagesForProvider, streamConfig);
750
+ const stream = await model.stream(messagesForProvider, invocationConfig);
672
751
  let finalChunk: AIMessageChunk | undefined;
673
752
  let preempted = false;
674
753
  const registeredStreamHandler =
@@ -855,7 +934,7 @@ async function attemptInvokeBody(
855
934
  return { messages: [finalChunk as AIMessageChunk] };
856
935
  }
857
936
 
858
- const finalMessage = await model.invoke(messagesForProvider, config);
937
+ const finalMessage = await model.invoke(messagesForProvider, invocationConfig);
859
938
  if ((finalMessage.tool_calls?.length ?? 0) > 0) {
860
939
  finalMessage.tool_calls = finalMessage.tool_calls?.filter(
861
940
  (tool_call: ToolCall) => !!tool_call.name
@@ -22,6 +22,20 @@ export const isLegacyConvertible = (message: BaseMessage): boolean => {
22
22
  return message.content.every((block) => block.type === ContentTypes.TEXT);
23
23
  };
24
24
 
25
+ /** Joins the text of {@link isLegacyConvertible} content blocks into the exact
26
+ * string {@link formatContentStrings} has always produced for them. */
27
+ export const flattenLegacyContent = (
28
+ blocks: MessageContentComplex[]
29
+ ): string => {
30
+ const content = blocks.reduce((acc, curr) => {
31
+ if (curr.type === ContentTypes.TEXT) {
32
+ return `${acc}${curr[ContentTypes.TEXT] ?? ''}\n`;
33
+ }
34
+ return acc;
35
+ }, '');
36
+ return content.trim();
37
+ };
38
+
25
39
  /**
26
40
  * Formats an array of messages for LangChain, making sure all content fields are strings
27
41
  * @param {Array<HumanMessage | AIMessage | SystemMessage | ToolMessage>} payload - The array of messages to format.
@@ -39,16 +53,12 @@ export const formatContentStrings = (
39
53
  continue;
40
54
  }
41
55
 
42
- // Reduce text types to a single string
43
- const blocks = message.content as MessageContentComplex[];
44
- const content = blocks.reduce((acc, curr) => {
45
- if (curr.type === ContentTypes.TEXT) {
46
- return `${acc}${curr[ContentTypes.TEXT] ?? ''}\n`;
47
- }
48
- return acc;
49
- }, '');
50
-
51
- result.push(cloneMessage(message, content.trim()));
56
+ result.push(
57
+ cloneMessage(
58
+ message,
59
+ flattenLegacyContent(message.content as MessageContentComplex[])
60
+ )
61
+ );
52
62
  }
53
63
 
54
64
  return result;
@@ -46,6 +46,7 @@ import {
46
46
  hasBijectiveProviderContentPartMapping,
47
47
  inspectProviderMessageProvenance,
48
48
  inspectProviderSourceMessageIds,
49
+ setFreshProviderMessageProvenance,
49
50
  setInvalidProviderMessageProvenance,
50
51
  setProviderMessageProvenance,
51
52
  } from './provenance';
@@ -57,6 +58,7 @@ import {
57
58
  } from '@/utils/toolContent';
58
59
  import { normalizeAnthropicToolCallId } from '@/llm/anthropic/utils/message_inputs';
59
60
  import { toLangChainContent, toLangChainMessageFields } from './langchain';
61
+ import { flattenLegacyContent, isLegacyConvertible } from './content';
60
62
  import { HARD_MAX_TOOL_RESULT_CHARS } from '@/utils/truncation';
61
63
  import { Providers, ContentTypes, Constants } from '@/common';
62
64
  import { emitAgentLog } from '@/utils/events';
@@ -378,6 +380,12 @@ type SourceContentPartIndices = number | readonly number[];
378
380
 
379
381
  interface FormatAgentMessagesOptions {
380
382
  provider?: ProviderName;
383
+ /** Emit flattenable text content as the joined string the legacy-content
384
+ * projection would produce, so the per-request `formatContentStrings` pass
385
+ * finds nothing to convert and every history message keeps its identity —
386
+ * which is what lets exact-count reuse skip re-tokenizing it. Set this if
387
+ * and only if the run's provider uses legacy string content. */
388
+ legacyContent?: boolean;
381
389
  /** Reconstruct hidden `reasoning_content` from `THINK` parts onto prior
382
390
  * tool-call messages. Explicit opt-in for OpenAI-compatible endpoints that
383
391
  * replay reasoning across turns; defaults to on for DeepSeek thinking-mode. */
@@ -1380,7 +1388,7 @@ function stampSourceMessageIdentity(
1380
1388
  },
1381
1389
  ];
1382
1390
  }
1383
- setProviderMessageProvenance(message, partsToStamp);
1391
+ setFreshProviderMessageProvenance(message, partsToStamp);
1384
1392
  if (sourceMessageId == null || derivedIndex !== 0) {
1385
1393
  return;
1386
1394
  }
@@ -1996,6 +2004,22 @@ export const formatAgentMessages = (
1996
2004
  * assistant turn. Held rather than emitted so an entry that produces
1997
2005
  * nothing cannot leave the anchor stranded as the final turn.
1998
2006
  */
2007
+ const legacyContentEnabled = options?.legacyContent === true;
2008
+ /** Emission choke point: every formatted message enters the result here, so
2009
+ * the legacy flatten happens once per message with no closing rescan. The
2010
+ * summary boundary slices payload entries before formatting, and nothing
2011
+ * mutates an emitted message's content afterwards, so flattening at
2012
+ * emission and flattening at return are equivalent. */
2013
+ const emitFormattedMessage = (message: (typeof messages)[number]): void => {
2014
+ if (legacyContentEnabled && isLegacyConvertible(message)) {
2015
+ const flattened = flattenLegacyContent(
2016
+ message.content as MessageContentComplex[]
2017
+ );
2018
+ message.content = flattened;
2019
+ message.lc_kwargs.content = flattened;
2020
+ }
2021
+ messages.push(message);
2022
+ };
1999
2023
  let pendingSteerAnchor = false;
2000
2024
  /**
2001
2025
  * Emits the deferred anchor ahead of `next` — the message about to be
@@ -2018,7 +2042,7 @@ export const formatAgentMessages = (
2018
2042
  'assistant'
2019
2043
  );
2020
2044
  stampSourceMessageIdentity(anchor, undefined, 0, 'synthetic');
2021
- messages.push(anchor);
2045
+ emitFormattedMessage(anchor);
2022
2046
  };
2023
2047
  // If indexTokenCountMap is provided, create a new map to track the updated indices
2024
2048
  const updatedIndexTokenCountMap: Record<number, number> = {};
@@ -2098,7 +2122,7 @@ export const formatAgentMessages = (
2098
2122
  provenanceParts
2099
2123
  );
2100
2124
  flushSteerAnchor(formattedMessage);
2101
- messages.push(formattedMessage);
2125
+ emitFormattedMessage(formattedMessage);
2102
2126
 
2103
2127
  // Update the index mapping for this message
2104
2128
  indexMapping[i] = [messages.length - 1];
@@ -2359,7 +2383,7 @@ export const formatAgentMessages = (
2359
2383
  flushSteerAnchor(formattedMessages[0]);
2360
2384
  }
2361
2385
  for (const formattedMessage of formattedMessages) {
2362
- messages.push(formattedMessage);
2386
+ emitFormattedMessage(formattedMessage);
2363
2387
  }
2364
2388
  if (endsWithSteerMessage(formattedMessages)) {
2365
2389
  pendingSteerAnchor = true;
@@ -2390,7 +2414,7 @@ export const formatAgentMessages = (
2390
2414
  'user'
2391
2415
  );
2392
2416
  stampSourceMessageIdentity(skillMessage, undefined, 0, 'synthetic');
2393
- messages.push(skillMessage);
2417
+ emitFormattedMessage(skillMessage);
2394
2418
  }
2395
2419
  }
2396
2420
  }
@@ -16,3 +16,4 @@ export * from './reducer';
16
16
  export * from './recency';
17
17
  export * from './assistantPhase';
18
18
  export * from './provenance';
19
+ export * from './projectionInvariant';