@librechat/agents 3.7.6 → 3.7.8

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 (92) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +23 -3
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/constants.cjs +10 -0
  4. package/dist/cjs/common/constants.cjs.map +1 -1
  5. package/dist/cjs/eventActor/EventActorExecutor.cjs +305 -11
  6. package/dist/cjs/eventActor/EventActorExecutor.cjs.map +1 -1
  7. package/dist/cjs/graphs/Graph.cjs +16 -5
  8. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  9. package/dist/cjs/langfuseTraceShaping.cjs +75 -0
  10. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  11. package/dist/cjs/llm/bedrock/toolCache.cjs +1 -1
  12. package/dist/cjs/llm/fake.cjs +9 -5
  13. package/dist/cjs/llm/fake.cjs.map +1 -1
  14. package/dist/cjs/llm/invoke.cjs +1 -1
  15. package/dist/cjs/llm/openrouter/toolCache.cjs +1 -1
  16. package/dist/cjs/llm/prepareProviderRequest.cjs +2 -2
  17. package/dist/cjs/llm/providers.cjs +1 -1
  18. package/dist/cjs/llm/truncation.cjs +1 -0
  19. package/dist/cjs/main.cjs +3 -2
  20. package/dist/cjs/messages/format.cjs +298 -3
  21. package/dist/cjs/messages/format.cjs.map +1 -1
  22. package/dist/cjs/messages/prune.cjs +1 -1
  23. package/dist/cjs/run.cjs +25 -15
  24. package/dist/cjs/run.cjs.map +1 -1
  25. package/dist/cjs/stream.cjs +1 -1
  26. package/dist/cjs/summarization/node.cjs +43 -9
  27. package/dist/cjs/summarization/node.cjs.map +1 -1
  28. package/dist/cjs/summarization/semanticIndex.cjs +362 -0
  29. package/dist/cjs/summarization/semanticIndex.cjs.map +1 -0
  30. package/dist/cjs/tools/subagent/childGraphConfig.cjs +2 -1
  31. package/dist/cjs/tools/subagent/childGraphConfig.cjs.map +1 -1
  32. package/dist/cjs/utils/index.cjs +1 -1
  33. package/dist/esm/agents/AgentContext.mjs +23 -3
  34. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  35. package/dist/esm/common/constants.mjs +10 -1
  36. package/dist/esm/common/constants.mjs.map +1 -1
  37. package/dist/esm/eventActor/EventActorExecutor.mjs +305 -11
  38. package/dist/esm/eventActor/EventActorExecutor.mjs.map +1 -1
  39. package/dist/esm/graphs/Graph.mjs +16 -5
  40. package/dist/esm/graphs/Graph.mjs.map +1 -1
  41. package/dist/esm/langfuseTraceShaping.mjs +75 -0
  42. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  43. package/dist/esm/llm/bedrock/toolCache.mjs +1 -1
  44. package/dist/esm/llm/fake.mjs +9 -5
  45. package/dist/esm/llm/fake.mjs.map +1 -1
  46. package/dist/esm/llm/invoke.mjs +1 -1
  47. package/dist/esm/llm/openrouter/toolCache.mjs +1 -1
  48. package/dist/esm/llm/prepareProviderRequest.mjs +2 -2
  49. package/dist/esm/llm/providers.mjs +1 -1
  50. package/dist/esm/llm/truncation.mjs +1 -1
  51. package/dist/esm/main.mjs +4 -4
  52. package/dist/esm/messages/format.mjs +298 -3
  53. package/dist/esm/messages/format.mjs.map +1 -1
  54. package/dist/esm/messages/prune.mjs +1 -1
  55. package/dist/esm/run.mjs +25 -15
  56. package/dist/esm/run.mjs.map +1 -1
  57. package/dist/esm/stream.mjs +1 -1
  58. package/dist/esm/summarization/node.mjs +43 -9
  59. package/dist/esm/summarization/node.mjs.map +1 -1
  60. package/dist/esm/summarization/semanticIndex.mjs +360 -0
  61. package/dist/esm/summarization/semanticIndex.mjs.map +1 -0
  62. package/dist/esm/tools/subagent/childGraphConfig.mjs +2 -1
  63. package/dist/esm/tools/subagent/childGraphConfig.mjs.map +1 -1
  64. package/dist/esm/utils/index.mjs +1 -1
  65. package/dist/types/agents/AgentContext.d.ts +27 -1
  66. package/dist/types/common/constants.d.ts +10 -0
  67. package/dist/types/eventActor/EventActorExecutor.d.ts +3 -1
  68. package/dist/types/eventActor/index.d.ts +1 -1
  69. package/dist/types/eventActor/types.d.ts +149 -5
  70. package/dist/types/graphs/Graph.d.ts +15 -0
  71. package/dist/types/llm/fake.d.ts +12 -2
  72. package/dist/types/messages/format.d.ts +10 -1
  73. package/dist/types/run.d.ts +17 -5
  74. package/dist/types/summarization/semanticIndex.d.ts +19 -0
  75. package/dist/types/types/graph.d.ts +9 -1
  76. package/dist/types/types/summarize.d.ts +38 -0
  77. package/package.json +2 -1
  78. package/src/agents/AgentContext.ts +61 -1
  79. package/src/common/constants.ts +11 -0
  80. package/src/eventActor/EventActorExecutor.ts +545 -16
  81. package/src/eventActor/index.ts +17 -0
  82. package/src/eventActor/types.ts +182 -3
  83. package/src/graphs/Graph.ts +41 -8
  84. package/src/langfuseTraceShaping.ts +94 -0
  85. package/src/llm/fake.ts +35 -4
  86. package/src/messages/format.ts +652 -4
  87. package/src/run.ts +36 -10
  88. package/src/summarization/node.ts +90 -33
  89. package/src/summarization/semanticIndex.ts +653 -0
  90. package/src/tools/subagent/childGraphConfig.ts +3 -0
  91. package/src/types/graph.ts +9 -0
  92. package/src/types/summarize.ts +50 -0
@@ -5,7 +5,15 @@ export {
5
5
  export type {
6
6
  EventActorAdapterPrepareRequest,
7
7
  EventActorAdapterPreparation,
8
+ EventActorAdapterInvocationResult,
9
+ EventActorAdapterResumeRequest,
10
+ EventActorAdapterResumeResult,
11
+ EventActorAdapterSuspendedResult,
8
12
  EventActorAppliedResult,
13
+ EventActorAdapterCancelSuspensionRequest,
14
+ EventActorAdapterCancelSuspensionResult,
15
+ EventActorCancelSuspensionRequest,
16
+ EventActorCancelSuspensionResult,
9
17
  EventActorCheckpointFork,
10
18
  EventActorCheckpointReference,
11
19
  EventActorCommitRequest,
@@ -21,12 +29,21 @@ export type {
21
29
  EventActorInvocation,
22
30
  EventActorInvocationContext,
23
31
  EventActorIndeterminateResult,
32
+ EventActorInterrupt,
24
33
  EventActorInvocationReference,
25
34
  EventActorInvocationResult,
26
35
  EventActorPreparation,
27
36
  EventActorPreparationContext,
28
37
  EventActorPreparedInvocation,
29
38
  EventActorPrepareRequest,
39
+ EventActorResumeRequest,
40
+ EventActorSettleSuspensionRequest,
41
+ EventActorSettleSuspensionResult,
42
+ EventActorSettlementAuthority,
30
43
  EventActorSettlementResult,
44
+ EventActorSuspendRequest,
45
+ EventActorSuspendResult,
46
+ EventActorSuspendedResult,
47
+ EventActorSuspension,
31
48
  EventActorTerminalResult,
32
49
  } from './types';
@@ -74,14 +74,156 @@ export type EventActorTerminalResult<TResult extends EventActorEvent> =
74
74
  }
75
75
  | { status: 'completed_no_action'; result?: TResult };
76
76
 
77
+ /** JSON-safe interrupt descriptor retained with a suspended invocation fork. */
78
+ export interface EventActorInterrupt<
79
+ TPayload extends EventActorEvent = EventActorEvent,
80
+ > {
81
+ id: string;
82
+ payload: TPayload;
83
+ }
84
+
85
+ /**
86
+ * Nonterminal adapter outcome. The checkpoint must already contain the pause;
87
+ * the SDK publishes its authority through `suspend` before exposing it.
88
+ */
89
+ export interface EventActorAdapterSuspendedResult<
90
+ TPayload extends EventActorEvent = EventActorEvent,
91
+ > {
92
+ status: 'suspended';
93
+ checkpoint: EventActorCheckpointFork;
94
+ interrupt: EventActorInterrupt<TPayload>;
95
+ }
96
+
97
+ export type EventActorAdapterInvocationResult<
98
+ TResult extends EventActorEvent,
99
+ TPayload extends EventActorEvent = EventActorEvent,
100
+ > =
101
+ | EventActorTerminalResult<TResult>
102
+ | EventActorAdapterSuspendedResult<TPayload>;
103
+
104
+ /**
105
+ * Authenticated, versioned, JSON-safe evidence for one nonterminal invocation.
106
+ * Integrity does not make this evidence one-shot; the host's durable current
107
+ * suspension record is the replay and ownership fence. Hosts must capability-
108
+ * route each exact version during rolling deploys and drain or migrate current
109
+ * evidence before rotating away from its signing key.
110
+ */
111
+ export interface EventActorSuspension<
112
+ TPayload extends EventActorEvent = EventActorEvent,
113
+ > {
114
+ version: 1;
115
+ suspensionId: string;
116
+ attempt: number;
117
+ issuedAt: number;
118
+ expiresAt: number;
119
+ invocation: EventActorInvocationReference;
120
+ checkpoint: EventActorCheckpointFork;
121
+ interrupt: EventActorInterrupt<TPayload>;
122
+ suspensionDigest: string;
123
+ }
124
+
125
+ export interface EventActorSuspendedResult<
126
+ TPayload extends EventActorEvent = EventActorEvent,
127
+ > {
128
+ status: 'suspended';
129
+ suspension: EventActorSuspension<TPayload>;
130
+ }
131
+
132
+ export interface EventActorSuspendRequest {
133
+ suspension: EventActorSuspension;
134
+ /** CAS predecessor required when a claimed resume pauses again. */
135
+ previous?: {
136
+ suspensionId: string;
137
+ attempt: number;
138
+ resumeAttemptId: string;
139
+ };
140
+ }
141
+
142
+ export type EventActorSuspendResult =
143
+ | { status: 'stored' }
144
+ | { status: 'stale' };
145
+
146
+ export interface EventActorAdapterResumeRequest {
147
+ suspension: EventActorSuspension;
148
+ resumeAttemptId: string;
149
+ value: EventActorEvent;
150
+ }
151
+
152
+ export type EventActorAdapterResumeResult<TResult extends EventActorEvent> =
153
+ | {
154
+ status: 'claimed';
155
+ result: EventActorAdapterInvocationResult<TResult>;
156
+ }
157
+ | {
158
+ /** The host proved no action before returning this claimed failure. */
159
+ status: 'claimed_failed';
160
+ error: Error;
161
+ }
162
+ | { status: 'stale' };
163
+
164
+ export interface EventActorResumeRequest {
165
+ suspension: EventActorSuspension;
166
+ resumeAttemptId: string;
167
+ value: EventActorEvent;
168
+ signal?: AbortSignal;
169
+ }
170
+
171
+ export interface EventActorCancelSuspensionRequest {
172
+ suspension: EventActorSuspension;
173
+ cancelAttemptId: string;
174
+ reason?: 'cancelled' | 'expired';
175
+ signal?: AbortSignal;
176
+ }
177
+
178
+ export interface EventActorAdapterCancelSuspensionRequest {
179
+ suspension: EventActorSuspension;
180
+ cancelAttemptId: string;
181
+ reason: 'cancelled' | 'expired';
182
+ }
183
+
184
+ export type EventActorCancelSuspensionResult =
185
+ | { status: 'cancelled' }
186
+ | EventActorIndeterminateResult<EventActorEvent>;
187
+
188
+ export type EventActorAdapterCancelSuspensionResult =
189
+ | { status: 'cancelled' }
190
+ | { status: 'stale' };
191
+
192
+ export interface EventActorSettleSuspensionRequest {
193
+ suspensionId: string;
194
+ attempt: number;
195
+ resumeAttemptId: string;
196
+ status: 'completed_no_action' | 'failed';
197
+ }
198
+
199
+ export type EventActorSettleSuspensionResult =
200
+ | { status: 'settled' }
201
+ | { status: 'stale' };
202
+
77
203
  export type EventActorAppliedResult<TResult extends EventActorEvent> = Extract<
78
204
  EventActorTerminalResult<TResult>,
79
205
  { status: 'applied' }
80
206
  > & {
81
- /** Executor-issued one-shot settlement for the invocation that produced this action. */
207
+ /** Executor-issued settlement for the invocation that produced this action. */
82
208
  invocation: EventActorInvocationReference;
209
+ /** Authenticated cross-executor authority for a resumed terminal action. */
210
+ settlementAuthority?: EventActorSettlementAuthority;
83
211
  };
84
212
 
213
+ /**
214
+ * Authenticated fence that binds a resumed terminal result to its claimed
215
+ * suspension. The host must consume it atomically with the actor-head CAS.
216
+ */
217
+ export interface EventActorSettlementAuthority {
218
+ version: 1;
219
+ suspensionId: string;
220
+ attempt: number;
221
+ resumeAttemptId: string;
222
+ issuedAt: number;
223
+ expiresAt: number;
224
+ settlementDigest: string;
225
+ }
226
+
85
227
  export interface EventActorIndeterminateResult<
86
228
  TResult extends EventActorEvent,
87
229
  > {
@@ -95,6 +237,7 @@ export interface EventActorIndeterminateResult<
95
237
  export type EventActorInvocationResult<TResult extends EventActorEvent> =
96
238
  | EventActorAppliedResult<TResult>
97
239
  | EventActorIndeterminateResult<TResult>
240
+ | EventActorSuspendedResult
98
241
  | Extract<
99
242
  EventActorTerminalResult<TResult>,
100
243
  { status: 'completed_no_action' }
@@ -128,6 +271,8 @@ export interface EventActorCommitRequest<TResult extends EventActorEvent> {
128
271
  expectedHead: EventActorHead;
129
272
  checkpoint: EventActorCheckpointFork;
130
273
  result: TResult;
274
+ /** Host must consume this suspension fence atomically with the head CAS. */
275
+ settlementAuthority?: EventActorSettlementAuthority;
131
276
  retention: {
132
277
  committedCheckpoints: 2;
133
278
  dormantCheckpointTtlMs: number;
@@ -156,7 +301,11 @@ export interface EventActorDiscardRequest {
156
301
  /**
157
302
  * Host adapter for durable actor state and the concrete agent invocation.
158
303
  * `commit` must compare both the expected generation and checkpoint identity
159
- * atomically before advancing the logical actor head. The host mailbox
304
+ * atomically before advancing the logical actor head. When settlement authority
305
+ * is present, that same transaction must also verify and close the exact
306
+ * suspension/resume-attempt fence, including stale-head outcomes. Retrying an
307
+ * ambiguous acknowledgement must return the durable outcome rather than apply
308
+ * the same terminal transition again. The host mailbox
160
309
  * deduplicates the logical `invocationId` before entering this seam, while each
161
310
  * SDK execution attempt receives a distinct checkpoint namespace. Preparation
162
311
  * methods own rollback until they return a ready invocation and must treat the
@@ -170,6 +319,18 @@ export interface EventActorDiscardRequest {
170
319
  * fork: the SDK retains and surfaces it as `commit_conflict` for host
171
320
  * reconciliation. `discard` must be idempotent for the same invocation because
172
321
  * an ambiguous cleanup failure can be retried through the public lifecycle.
322
+ *
323
+ * Suspension-capable hosts implement all optional suspension methods. `suspend`
324
+ * publishes an initial suspension only while its logical invocation is current;
325
+ * with `previous`, it atomically replaces only the exact claimed predecessor.
326
+ * `resume` atomically claims the current suspension before applying its value to
327
+ * the declared interrupt and rejects duplicate or competing claims as `stale`.
328
+ * It must return `claimed_failed` only when it can prove no qualifying action;
329
+ * any failure after an action returns `applied`. `settleSuspension` atomically
330
+ * discards the claimed fork and closes its fence for definite no-action
331
+ * outcomes. `cancelSuspension`
332
+ * atomically claims, discards, and closes current state, including expired
333
+ * evidence; expiration alone never implies a safe action outcome.
173
334
  */
174
335
  export interface EventActorHostAdapter<
175
336
  TEvent extends EventActorEvent,
@@ -187,7 +348,20 @@ export interface EventActorHostAdapter<
187
348
  invoke(
188
349
  invocation: EventActorInvocation<TEvent>,
189
350
  context: EventActorInvocationContext
190
- ): Promise<EventActorTerminalResult<TResult>>;
351
+ ): Promise<EventActorAdapterInvocationResult<TResult>>;
352
+ suspend?(request: EventActorSuspendRequest): Promise<EventActorSuspendResult>;
353
+ resume?(
354
+ request: EventActorAdapterResumeRequest,
355
+ context: EventActorInvocationContext
356
+ ): Promise<EventActorAdapterResumeResult<TResult>>;
357
+ /** Atomically claims, discards, and closes the current suspension. */
358
+ cancelSuspension?(
359
+ request: EventActorAdapterCancelSuspensionRequest,
360
+ context: EventActorPreparationContext
361
+ ): Promise<EventActorAdapterCancelSuspensionResult>;
362
+ settleSuspension?(
363
+ request: EventActorSettleSuspensionRequest
364
+ ): Promise<EventActorSettleSuspensionResult>;
191
365
  commit(
192
366
  request: EventActorCommitRequest<TResult>
193
367
  ): Promise<EventActorCommitResult>;
@@ -219,6 +393,9 @@ export type EventActorExecutionResult<TResult extends EventActorEvent> =
219
393
  status: 'cancelled';
220
394
  continuation: 'warm' | 'cold';
221
395
  }
396
+ | (EventActorSuspendedResult & {
397
+ continuation: 'warm' | 'cold';
398
+ })
222
399
  | {
223
400
  /** The action happened, but another head won the CAS. Reconcile; do not retry. */
224
401
  status: 'commit_conflict';
@@ -247,4 +424,6 @@ export interface EventActorExecutorOptions {
247
424
  dormantCheckpointTtlMs?: number;
248
425
  /** Stable private key of at least 32 bytes for cross-lifetime handoffs. */
249
426
  preparationSigningKey?: string | Uint8Array;
427
+ /** Maximum UTF-8 byte size of canonical suspension evidence. */
428
+ maxSuspensionPayloadBytes?: number;
250
429
  }
@@ -153,10 +153,10 @@ import { createLocalCodingToolBundle } from '@/tools/local/LocalCodingTools';
153
153
  import { SUBAGENT_REPLAY_CONTROLLER } from '@/tools/subagent/SubagentReplay';
154
154
  import { applyGraphRuntimeConfig } from '@/graphs/applyGraphRuntimeConfig';
155
155
  import { createContextPressureMeter } from '@/llm/contextPressureMeter';
156
- import { prepareToolsForPromptCache } from '@/llm/promptCacheTools';
157
156
  import { safeDispatchCustomEvent, emitAgentLog } from '@/utils/events';
158
157
  import { prepareProviderRequest } from '@/llm/prepareProviderRequest';
159
158
  import { createCloudflareCodingToolBundle } from '@/tools/cloudflare';
159
+ import { prepareToolsForPromptCache } from '@/llm/promptCacheTools';
160
160
  import { providerRequiresStrictAlternation } from '@/llm/providers';
161
161
  import { buildSubagentToolParams } from '@/tools/SubagentTool';
162
162
  import { initializeLangfuseTracing } from '@/instrumentation';
@@ -164,6 +164,7 @@ import { shouldTriggerSummarization } from '@/summarization';
164
164
  import { isRunStepResumeState } from '@/tools/runStepResume';
165
165
  import { resolveLocalToolsForBinding } from '@/tools/local';
166
166
  import { createSummarizeNode } from '@/summarization/node';
167
+ import { getTruncationStopReason } from '@/llm/truncation';
167
168
  import { messagesStateReducer } from '@/messages/reducer';
168
169
  import { createSchemaOnlyTools } from '@/tools/schema';
169
170
  import { AgentContext } from '@/agents/AgentContext';
@@ -1425,6 +1426,21 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1425
1426
  * hosts persist it as unfinished rather than complete.
1426
1427
  */
1427
1428
  preemptIncomplete = false;
1429
+ /**
1430
+ * True when `routeMessage` sent a turn to `END` because the last AI
1431
+ * message carries no tool call, AND the provider reports it stopped for
1432
+ * hitting the output token ceiling (`getTruncationStopReason`). Plain-text
1433
+ * and reasoning turns cut off this way carry no tool call for
1434
+ * `assertNotTruncatedToolCall` to catch, so `toolsCondition` reads them as
1435
+ * an ordinary finished turn otherwise — hosts read this flag to persist
1436
+ * the turn as unfinished instead of a silently truncated "complete" answer.
1437
+ *
1438
+ * Deliberately separate from `preemptIncomplete`/`preemptHaltReason`: this
1439
+ * has no interaction with the preempt/seal machinery (in particular the
1440
+ * `preemptHaltReason` check at each model node's entry), so setting it
1441
+ * cannot suppress an unrelated agent's turn in a multi-agent graph.
1442
+ */
1443
+ outputTruncatedIncomplete = false;
1428
1444
  /**
1429
1445
  * `stopReason` from a `PreemptBoundary` hook that halted the turn.
1430
1446
  *
@@ -1702,6 +1718,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1702
1718
  this.preemptEmptyBoundaries = 0;
1703
1719
  this.preemptIncomplete = false;
1704
1720
  this.preemptHaltReason = undefined;
1721
+ this.outputTruncatedIncomplete = false;
1705
1722
  }
1706
1723
 
1707
1724
  /**
@@ -2801,9 +2818,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
2801
2818
  provider,
2802
2819
  clientOptions,
2803
2820
  tools,
2804
- isDeferred: makeIsDeferred(
2805
- agentContext.getEffectiveToolDefinitions()
2806
- ),
2821
+ isDeferred: makeIsDeferred(agentContext.getEffectiveToolDefinitions()),
2807
2822
  });
2808
2823
  }
2809
2824
 
@@ -3030,9 +3045,9 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
3030
3045
  messagesToRefine.length > 0;
3031
3046
 
3032
3047
  if (hasPrunedMessages) {
3033
- const shouldSkip = agentContext.shouldSkipSummarization(
3034
- messages.length
3035
- );
3048
+ const shouldSkip =
3049
+ agentContext.summarizationExhausted ||
3050
+ agentContext.shouldSkipSummarization(messages.length);
3036
3051
  const triggerResult =
3037
3052
  !shouldSkip &&
3038
3053
  shouldTriggerSummarization({
@@ -3089,6 +3104,8 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
3089
3104
  messageCount: messages.length,
3090
3105
  messagesToRefineCount: messagesToRefine.length,
3091
3106
  contextLength: context.length,
3107
+ summarizationFailures: agentContext.summarizationFailures,
3108
+ summarizationExhausted: agentContext.summarizationExhausted,
3092
3109
  },
3093
3110
  { runId: this.runId, agentId }
3094
3111
  );
@@ -4818,11 +4835,27 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
4818
4835
  if (state.summarizationRequest != null) {
4819
4836
  return summarizeNode;
4820
4837
  }
4821
- return toolsCondition(
4838
+ const decision = toolsCondition(
4822
4839
  state as t.BaseGraphState,
4823
4840
  toolNode,
4824
4841
  this.invokedToolIds
4825
4842
  );
4843
+ /**
4844
+ * `toolsCondition` only looks at `tool_calls` — a plain-text/reasoning
4845
+ * turn cut off by the output token ceiling has none, so it reads as an
4846
+ * ordinary finished turn and routes here to END. Flag it so hosts can
4847
+ * tell a genuinely finished answer from one the model never got to
4848
+ * complete. See `outputTruncatedIncomplete` for why this stays clear
4849
+ * of the preempt/seal halt fields.
4850
+ */
4851
+ if (decision === END) {
4852
+ const { messages } = state as t.BaseGraphState;
4853
+ const lastMessage = messages[messages.length - 1];
4854
+ if (getTruncationStopReason(lastMessage) != null) {
4855
+ this.outputTruncatedIncomplete = true;
4856
+ }
4857
+ }
4858
+ return decision;
4826
4859
  };
4827
4860
 
4828
4861
  const StateAnnotation = Annotation.Root({
@@ -35,6 +35,11 @@ const DEPRECATED_TRACE_INPUT_ATTRIBUTE = 'langfuse.trace.input';
35
35
  const DEPRECATED_TRACE_OUTPUT_ATTRIBUTE = 'langfuse.trace.output';
36
36
  const OBSERVATION_METADATA_LANGGRAPH_NODE = `${LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.langgraph_node`;
37
37
  const OBSERVATION_METADATA_OPERATION = `${LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.${LANGFUSE_OPERATION_METADATA_KEY}`;
38
+ const OBSERVATION_METADATA_COMPACTION_SEMANTIC_INDEX_ENTRIES = `${LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.compaction_semantic_index_entries`;
39
+ const COMPACTION_SEMANTIC_INDEX_OPEN = '<compaction-semantic-index>';
40
+ const COMPACTION_SEMANTIC_INDEX_CLOSE = '</compaction-semantic-index>';
41
+ const REDACTED_COMPACTION_SEMANTIC_INDEX =
42
+ '<compaction-semantic-index redacted="true" />\n\n';
38
43
 
39
44
  type MutableSpan = ReadableSpan & {
40
45
  name: string;
@@ -66,6 +71,94 @@ function parseAttributeValue(value: unknown): unknown {
66
71
  }
67
72
  }
68
73
 
74
+ type CompactionSemanticIndexRedaction = {
75
+ value: unknown;
76
+ redacted: boolean;
77
+ };
78
+
79
+ function redactCompactionSemanticIndexText(
80
+ value: string
81
+ ): CompactionSemanticIndexRedaction {
82
+ if (!value.startsWith(COMPACTION_SEMANTIC_INDEX_OPEN)) {
83
+ return { value, redacted: false };
84
+ }
85
+ const start = 0;
86
+ const close = value.indexOf(COMPACTION_SEMANTIC_INDEX_CLOSE, start);
87
+ if (close < 0) {
88
+ return { value, redacted: false };
89
+ }
90
+ let end = close + COMPACTION_SEMANTIC_INDEX_CLOSE.length;
91
+ while (end < value.length && /\s/.test(value[end])) {
92
+ end++;
93
+ }
94
+ return {
95
+ value:
96
+ value.slice(0, start) +
97
+ REDACTED_COMPACTION_SEMANTIC_INDEX +
98
+ value.slice(end),
99
+ redacted: true,
100
+ };
101
+ }
102
+
103
+ function redactCompactionSemanticIndexValue(
104
+ value: unknown
105
+ ): CompactionSemanticIndexRedaction {
106
+ if (typeof value === 'string') {
107
+ return redactCompactionSemanticIndexText(value);
108
+ }
109
+ if (Array.isArray(value)) {
110
+ for (let index = value.length - 1; index >= 0; index--) {
111
+ const nested = redactCompactionSemanticIndexValue(value[index]);
112
+ if (nested.redacted) {
113
+ const result = [...value];
114
+ result[index] = nested.value;
115
+ return { value: result, redacted: true };
116
+ }
117
+ }
118
+ return { value, redacted: false };
119
+ }
120
+ if (!isRecord(value)) {
121
+ return { value, redacted: false };
122
+ }
123
+ const entries = Object.entries(value);
124
+ for (let index = entries.length - 1; index >= 0; index--) {
125
+ const [key, nestedValue] = entries[index];
126
+ const nested = redactCompactionSemanticIndexValue(nestedValue);
127
+ if (nested.redacted) {
128
+ return {
129
+ value: { ...value, [key]: nested.value },
130
+ redacted: true,
131
+ };
132
+ }
133
+ }
134
+ return { value, redacted: false };
135
+ }
136
+
137
+ function redactCompactionSemanticIndexInput(span: MutableSpan): void {
138
+ const entryCount =
139
+ span.attributes[
140
+ OBSERVATION_METADATA_COMPACTION_SEMANTIC_INDEX_ENTRIES
141
+ ];
142
+ const numericEntryCount = Number(entryCount);
143
+ if (!Number.isFinite(numericEntryCount) || numericEntryCount <= 0) {
144
+ return;
145
+ }
146
+ const inputKey = LangfuseOtelSpanAttributes.OBSERVATION_INPUT;
147
+ const input = span.attributes[inputKey];
148
+ if (input == null) {
149
+ return;
150
+ }
151
+ const parsed = parseAttributeValue(input);
152
+ const redaction = redactCompactionSemanticIndexValue(parsed);
153
+ if (!redaction.redacted) {
154
+ return;
155
+ }
156
+ span.attributes[inputKey] =
157
+ typeof redaction.value === 'string' && parsed === input
158
+ ? redaction.value
159
+ : JSON.stringify(redaction.value);
160
+ }
161
+
69
162
  function getMessageArray(
70
163
  value: unknown
71
164
  ): Record<string, unknown>[] | undefined {
@@ -594,6 +687,7 @@ export function shapeLangfuseSpan(span: ReadableSpan): void {
594
687
  const mutable = span as MutableSpan;
595
688
  delete mutable.attributes[DEPRECATED_TRACE_INPUT_ATTRIBUTE];
596
689
  delete mutable.attributes[DEPRECATED_TRACE_OUTPUT_ATTRIBUTE];
690
+ redactCompactionSemanticIndexInput(mutable);
597
691
  const isGraphObservation = isGraphSpan(mutable);
598
692
  if (mutable.name.startsWith(LANGGRAPH_AGENT_NODE_PREFIX)) {
599
693
  shapeAgentNodeSpan(mutable);
package/src/llm/fake.ts CHANGED
@@ -14,6 +14,15 @@ export class FakeChatModel extends FakeListChatModel {
14
14
  private splitStrategy: SplitStrategy;
15
15
  private toolCalls: ToolCall[] = [];
16
16
  private addedToolCalls: boolean = false;
17
+ /**
18
+ * Attached to the last streamed chunk of every response, mirroring a real
19
+ * provider's terminal `finish_reason`/`stop_reason` on `generationInfo`.
20
+ * Lets truncation-path tests (`getTruncationStopReason`) drive a genuine
21
+ * `Run`/`StandardGraph` without a live provider. `undefined` (the
22
+ * default) reproduces the previous behavior exactly: no metadata on any
23
+ * chunk.
24
+ */
25
+ private finalChunkGenerationInfo?: Record<string, unknown>;
17
26
 
18
27
  constructor({
19
28
  responses,
@@ -21,16 +30,19 @@ export class FakeChatModel extends FakeListChatModel {
21
30
  emitCustomEvent,
22
31
  splitStrategy = { type: 'regex', value: /(?<=\s+)|(?=\s+)/ },
23
32
  toolCalls = [],
33
+ finalChunkGenerationInfo,
24
34
  }: {
25
35
  responses: string[];
26
36
  sleep?: number;
27
37
  emitCustomEvent?: boolean;
28
38
  splitStrategy?: SplitStrategy;
29
39
  toolCalls?: ToolCall[];
40
+ finalChunkGenerationInfo?: Record<string, unknown>;
30
41
  }) {
31
42
  super({ responses, sleep, emitCustomEvent });
32
43
  this.splitStrategy = splitStrategy;
33
44
  this.toolCalls = toolCalls;
45
+ this.finalChunkGenerationInfo = finalChunkGenerationInfo;
34
46
  }
35
47
 
36
48
  private splitText(text: string): string[] {
@@ -47,14 +59,16 @@ export class FakeChatModel extends FakeListChatModel {
47
59
  }
48
60
  _createResponseChunk(
49
61
  text: string,
50
- tool_call_chunks?: ToolCallChunk[]
62
+ tool_call_chunks?: ToolCallChunk[],
63
+ responseMetadata?: Record<string, unknown>
51
64
  ): ChatGenerationChunk {
52
65
  return new ChatGenerationChunk({
53
66
  text,
54
- generationInfo: {},
67
+ generationInfo: responseMetadata ?? {},
55
68
  message: new AIMessageChunk({
56
69
  content: text,
57
70
  tool_call_chunks,
71
+ response_metadata: responseMetadata,
58
72
  additional_kwargs: tool_call_chunks
59
73
  ? {
60
74
  tool_calls: tool_call_chunks.map((toolCall) => ({
@@ -87,14 +101,31 @@ export class FakeChatModel extends FakeListChatModel {
87
101
  }
88
102
 
89
103
  const chunks = this.splitText(response);
90
- for await (const chunk of chunks) {
104
+ for (let i = 0; i < chunks.length; i++) {
105
+ const chunk = chunks[i];
91
106
  await this._sleepIfRequested();
92
107
 
93
108
  if (options.thrownErrorString != null && options.thrownErrorString) {
94
109
  throw new Error(options.thrownErrorString);
95
110
  }
96
111
 
97
- const responseChunk = super._createResponseChunk(chunk);
112
+ /**
113
+ * Only the true terminal chunk of the response gets the metadata: when
114
+ * this response also appends a trailing tool-call chunk below, THAT
115
+ * chunk is terminal instead (not currently exercised by any test —
116
+ * a caller wanting truncated-tool-call metadata should attach it
117
+ * there).
118
+ */
119
+ const isTerminalChunk =
120
+ i === chunks.length - 1 && this.toolCalls.length === 0;
121
+ const responseChunk =
122
+ isTerminalChunk && this.finalChunkGenerationInfo != null
123
+ ? this._createResponseChunk(
124
+ chunk,
125
+ undefined,
126
+ this.finalChunkGenerationInfo
127
+ )
128
+ : super._createResponseChunk(chunk);
98
129
  yield responseChunk;
99
130
  void runManager?.handleLLMNewToken(chunk);
100
131
  }