@librechat/agents 3.7.7 → 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 (82) 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/graphs/Graph.cjs +16 -5
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/langfuseTraceShaping.cjs +75 -0
  8. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  9. package/dist/cjs/llm/bedrock/toolCache.cjs +1 -1
  10. package/dist/cjs/llm/fake.cjs +9 -5
  11. package/dist/cjs/llm/fake.cjs.map +1 -1
  12. package/dist/cjs/llm/invoke.cjs +1 -1
  13. package/dist/cjs/llm/openrouter/toolCache.cjs +1 -1
  14. package/dist/cjs/llm/prepareProviderRequest.cjs +2 -2
  15. package/dist/cjs/llm/providers.cjs +1 -1
  16. package/dist/cjs/llm/truncation.cjs +1 -0
  17. package/dist/cjs/main.cjs +3 -2
  18. package/dist/cjs/messages/format.cjs +298 -3
  19. package/dist/cjs/messages/format.cjs.map +1 -1
  20. package/dist/cjs/messages/prune.cjs +1 -1
  21. package/dist/cjs/run.cjs +25 -15
  22. package/dist/cjs/run.cjs.map +1 -1
  23. package/dist/cjs/stream.cjs +1 -1
  24. package/dist/cjs/summarization/node.cjs +43 -9
  25. package/dist/cjs/summarization/node.cjs.map +1 -1
  26. package/dist/cjs/summarization/semanticIndex.cjs +362 -0
  27. package/dist/cjs/summarization/semanticIndex.cjs.map +1 -0
  28. package/dist/cjs/tools/subagent/childGraphConfig.cjs +2 -1
  29. package/dist/cjs/tools/subagent/childGraphConfig.cjs.map +1 -1
  30. package/dist/cjs/utils/index.cjs +1 -1
  31. package/dist/esm/agents/AgentContext.mjs +23 -3
  32. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  33. package/dist/esm/common/constants.mjs +10 -1
  34. package/dist/esm/common/constants.mjs.map +1 -1
  35. package/dist/esm/graphs/Graph.mjs +16 -5
  36. package/dist/esm/graphs/Graph.mjs.map +1 -1
  37. package/dist/esm/langfuseTraceShaping.mjs +75 -0
  38. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  39. package/dist/esm/llm/bedrock/toolCache.mjs +1 -1
  40. package/dist/esm/llm/fake.mjs +9 -5
  41. package/dist/esm/llm/fake.mjs.map +1 -1
  42. package/dist/esm/llm/invoke.mjs +1 -1
  43. package/dist/esm/llm/openrouter/toolCache.mjs +1 -1
  44. package/dist/esm/llm/prepareProviderRequest.mjs +2 -2
  45. package/dist/esm/llm/providers.mjs +1 -1
  46. package/dist/esm/llm/truncation.mjs +1 -1
  47. package/dist/esm/main.mjs +4 -4
  48. package/dist/esm/messages/format.mjs +298 -3
  49. package/dist/esm/messages/format.mjs.map +1 -1
  50. package/dist/esm/messages/prune.mjs +1 -1
  51. package/dist/esm/run.mjs +25 -15
  52. package/dist/esm/run.mjs.map +1 -1
  53. package/dist/esm/stream.mjs +1 -1
  54. package/dist/esm/summarization/node.mjs +43 -9
  55. package/dist/esm/summarization/node.mjs.map +1 -1
  56. package/dist/esm/summarization/semanticIndex.mjs +360 -0
  57. package/dist/esm/summarization/semanticIndex.mjs.map +1 -0
  58. package/dist/esm/tools/subagent/childGraphConfig.mjs +2 -1
  59. package/dist/esm/tools/subagent/childGraphConfig.mjs.map +1 -1
  60. package/dist/esm/utils/index.mjs +1 -1
  61. package/dist/types/agents/AgentContext.d.ts +27 -1
  62. package/dist/types/common/constants.d.ts +10 -0
  63. package/dist/types/graphs/Graph.d.ts +15 -0
  64. package/dist/types/llm/fake.d.ts +12 -2
  65. package/dist/types/messages/format.d.ts +10 -1
  66. package/dist/types/run.d.ts +17 -5
  67. package/dist/types/summarization/semanticIndex.d.ts +19 -0
  68. package/dist/types/types/graph.d.ts +9 -1
  69. package/dist/types/types/summarize.d.ts +38 -0
  70. package/package.json +2 -1
  71. package/src/agents/AgentContext.ts +61 -1
  72. package/src/common/constants.ts +11 -0
  73. package/src/graphs/Graph.ts +41 -8
  74. package/src/langfuseTraceShaping.ts +94 -0
  75. package/src/llm/fake.ts +35 -4
  76. package/src/messages/format.ts +652 -4
  77. package/src/run.ts +36 -10
  78. package/src/summarization/node.ts +90 -33
  79. package/src/summarization/semanticIndex.ts +653 -0
  80. package/src/tools/subagent/childGraphConfig.ts +3 -0
  81. package/src/types/graph.ts +9 -0
  82. package/src/types/summarize.ts +50 -0
@@ -1,7 +1,7 @@
1
1
  import { AIMessage, ToolMessage, BaseMessage, HumanMessage, SystemMessage } from '@langchain/core/messages';
2
2
  import type { MessageContentImageUrl } from '@langchain/core/messages';
3
3
  import type { RunnableConfig } from '@langchain/core/runnables';
4
- import type { MessageContentComplex, TPayload, ProviderName } from '@/types';
4
+ import type { MessageContentComplex, TPayload, ProviderName, CompactionSemanticIndex } from '@/types';
5
5
  import { Providers } from '@/common';
6
6
  interface MediaMessageParams {
7
7
  message: {
@@ -105,6 +105,13 @@ interface FormatAgentMessagesOptions {
105
105
  * historical `skill` tool_calls are not reconstructed into a HumanMessage,
106
106
  * so the same SKILL.md body is not injected twice in one request. */
107
107
  skipSkillBodyNames?: Set<string>;
108
+ /** Derive bounded compaction guidance during the formatter's existing
109
+ * persisted-content analysis. Tool intents are accepted only for names
110
+ * the host identifies as semantic-label fields; business `intent`
111
+ * parameters must remain ordinary tool input. */
112
+ compactionSemanticIndex?: {
113
+ intentToolNames?: ReadonlySet<string>;
114
+ };
108
115
  }
109
116
  /**
110
117
  * Groups content parts by agent and formats them with agent labels
@@ -148,6 +155,8 @@ export declare const formatAgentMessages: (payload: TPayload, indexTokenCountMap
148
155
  /** When a positional summary boundary sliced content from a message, the token
149
156
  * count was proportionally reduced. Returned so the caller can log it. */
150
157
  boundaryTokenAdjustment?: SummaryTokenAdjustment;
158
+ /** Bounded semantic guidance derived during persisted-content analysis. */
159
+ compactionSemanticIndex?: CompactionSemanticIndex;
151
160
  };
152
161
  /**
153
162
  * Adds a value at key 0 for system messages and shifts all key indices by one in an indexTokenCountMap.
@@ -134,6 +134,15 @@ export declare class Run<_T extends t.BaseGraphState> {
134
134
  */
135
135
  getPreemptStats(): t.PreemptStats;
136
136
  getToolCount(): number;
137
+ /**
138
+ * True when the run's last turn ended at `END` because the provider hit
139
+ * its output token ceiling while producing plain text/reasoning — no tool
140
+ * call, so `assertNotTruncatedToolCall` never sees it and the graph reads
141
+ * the turn as an ordinary completion. Hosts check this alongside
142
+ * `getPreemptStats()` / `getHaltReason()` to decide whether to persist the
143
+ * response as unfinished instead of a silently truncated "complete" one.
144
+ */
145
+ getOutputTruncated(): boolean;
137
146
  /**
138
147
  * Creates a custom event callback handler that intercepts custom events
139
148
  * and processes them through our handler registry instead of EventStreamCallbackHandler
@@ -175,13 +184,16 @@ export declare class Run<_T extends t.BaseGraphState> {
175
184
  */
176
185
  getInterrupt<TPayload = t.HumanInterruptPayload>(): t.RunInterruptResult<TPayload> | undefined;
177
186
  /**
178
- * Returns the reason a hook halted the run via
179
- * `preventContinuation: true`, or `undefined` if no hook halted.
187
+ * Returns why the run ended without a natural completion, or `undefined`
188
+ * when it completed normally. Reasons include hook- and prompt-driven
189
+ * halts, `preempt_incomplete` when a cooperative seal ended the turn
190
+ * without continuation content, and `output_truncated` when the provider
191
+ * stopped a plain-text/reasoning response at its output-token ceiling.
180
192
  *
181
193
  * Hosts inspect this after `processStream` returns to distinguish a
182
- * natural completion (`undefined`) from a hook-driven halt (a
183
- * truthy string). Independent from `getInterrupt()` — a halted run
184
- * has no interrupt; an interrupted run has no halt reason.
194
+ * natural completion from a terminal partial response. Independent from
195
+ * `getInterrupt()` — a halted run has no interrupt; an interrupted run has
196
+ * no halt reason.
185
197
  */
186
198
  getHaltReason(): string | undefined;
187
199
  /**
@@ -0,0 +1,19 @@
1
+ import type { BaseMessage } from '@langchain/core/messages';
2
+ import type { CompactionSemanticIndex } from '@/types';
3
+ export { COMPACTION_SEMANTIC_INDEX_LIMITS } from '@/common';
4
+ /** Records producer-side omissions without retaining the discarded entries. */
5
+ export declare function setCompactionSemanticIndexProvidedEntryCount(index: CompactionSemanticIndex, providedEntryCount: number): void;
6
+ export type RenderedCompactionSemanticIndex = {
7
+ appendix: string;
8
+ providedEntryCount: number;
9
+ entryCount: number;
10
+ charCount: number;
11
+ omittedEntryCount: number;
12
+ };
13
+ /** Captures caller-owned data before graph execution can cross an await. */
14
+ export declare function snapshotCompactionSemanticIndex(index: CompactionSemanticIndex | undefined): CompactionSemanticIndex | undefined;
15
+ /**
16
+ * Produces a deterministic, bounded compaction appendix. Invalid, stale,
17
+ * pending, redacted, conflicting, and out-of-range entries fail closed.
18
+ */
19
+ export declare function renderCompactionSemanticIndex(index: CompactionSemanticIndex | undefined, messagesToRefine: BaseMessage[]): RenderedCompactionSemanticIndex;
@@ -4,7 +4,7 @@ import type { START, StateGraph, StateGraphArgs } from '@langchain/langgraph';
4
4
  import type { RunnableConfig, Runnable } from '@langchain/core/runnables';
5
5
  import type { ChatGenerationChunk } from '@langchain/core/outputs';
6
6
  import type { GoogleAIToolType } from '@langchain/google-common';
7
- import type { SummarizationNodeInput, SummarizeCompleteEvent, SummarizationConfig, SummarizeStartEvent, SummarizeDeltaEvent } from '@/types/summarize';
7
+ import type { SummarizationNodeInput, SummarizeCompleteEvent, CompactionSemanticIndex, SummarizationConfig, SummarizeStartEvent, SummarizeDeltaEvent } from '@/types/summarize';
8
8
  import type { RunStep, RunStepDeltaEvent, RunStepResumeState, RunStepClosedEvent, MessageDeltaEvent, ReasoningDeltaEvent } from '@/types/stream';
9
9
  import type { ToolMap, ToolSessionMap, ToolEndEvent, GenericTool, LCTool, ToolExecutionConfig, ToolExecuteBatchRequest } from '@/types/tools';
10
10
  import type { TokenCounter, StreamLimits, StreamPreemption, TokenBudgetBreakdown } from '@/types/run';
@@ -693,6 +693,14 @@ interface AgentInputFields {
693
693
  discoveredTools?: string[];
694
694
  summarizationEnabled?: boolean;
695
695
  summarizationConfig?: SummarizationConfig;
696
+ /**
697
+ * Optional host-supplied, user-visible guidance for compaction. The SDK
698
+ * validates, bounds, and scopes entries to the messages being compacted;
699
+ * raw conversation messages remain authoritative. Captured when the
700
+ * AgentContext is constructed; labels committed later in the same run are
701
+ * outside this construction-time interface.
702
+ */
703
+ compactionSemanticIndex?: CompactionSemanticIndex;
696
704
  /** Cross-run summary from a previous run, forwarded from formatAgentMessages.
697
705
  * Injected into the dynamic system tail via AgentContext. */
698
706
  initialSummary?: {
@@ -29,6 +29,39 @@ export type RetainRecentConfig = {
29
29
  */
30
30
  tokens?: number;
31
31
  };
32
+ export type CompactionSemanticIndexStatus = 'committed' | 'pending';
33
+ type CompactionSemanticIndexEntryBase = {
34
+ /** Persisted message that owns the indexed content. */
35
+ sourceMessageId: string;
36
+ /** Zero-based content-part index within the persisted source message. */
37
+ sourceContentIndex: number;
38
+ /** Monotonic host revision for this logical entry. */
39
+ revision: number;
40
+ /** Only committed entries may guide compaction. */
41
+ status: CompactionSemanticIndexStatus;
42
+ /** User-visible semantic guidance. Hidden reasoning must never be supplied. */
43
+ text: string;
44
+ /** Omits the entry entirely when host policy redacts its source. */
45
+ redacted?: boolean;
46
+ };
47
+ export type CompactionToolSemanticIndexEntry = CompactionSemanticIndexEntryBase & {
48
+ type: 'tool_intent' | 'tool_outcome';
49
+ toolCallId: string;
50
+ };
51
+ export type CompactionActivitySemanticIndexEntry = CompactionSemanticIndexEntryBase & {
52
+ type: 'activity_phase';
53
+ };
54
+ export type CompactionReasoningSemanticIndexEntry = CompactionSemanticIndexEntryBase & {
55
+ type: 'reasoning_label';
56
+ /** Stable identity shared by every user-visible label revision. */
57
+ reasoningStepId: string;
58
+ };
59
+ /**
60
+ * Source-addressed navigation hints for the compaction model. Entries remain
61
+ * advisory: raw messages are always sent and remain authoritative.
62
+ */
63
+ export type CompactionSemanticIndexEntry = CompactionToolSemanticIndexEntry | CompactionActivitySemanticIndexEntry | CompactionReasoningSemanticIndexEntry;
64
+ export type CompactionSemanticIndex = readonly CompactionSemanticIndexEntry[];
32
65
  export type SummarizationConfig = {
33
66
  provider?: ProviderName;
34
67
  model?: string;
@@ -86,6 +119,10 @@ export interface SummarizeStartEvent {
86
119
  messagesToRefineCount: number;
87
120
  /** Which summarization cycle this is (1-based, increments each time summarization fires) */
88
121
  summaryVersion: number;
122
+ /** Committed, source-valid semantic hints included in the request. */
123
+ semanticIndexEntryCount?: number;
124
+ /** Serialized semantic-index characters included in the request. */
125
+ semanticIndexCharCount?: number;
89
126
  }
90
127
  export interface SummarizeDeltaEvent {
91
128
  id: string;
@@ -99,3 +136,4 @@ export interface SummarizeCompleteEvent {
99
136
  summary?: SummaryContentBlock;
100
137
  error?: string;
101
138
  }
139
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.7.7",
3
+ "version": "3.7.8",
4
4
  "reova": {
5
5
  "enabled": true,
6
6
  "endpoint": "https://telemetry.reo.dev/data"
@@ -221,6 +221,7 @@
221
221
  "bench:provider-derivation": "tsx ./src/scripts/bench-provider-derivation.ts",
222
222
  "bench:provider-projection": "tsx ./src/scripts/bench-provider-request-projection.ts",
223
223
  "bench:execution-world": "tsx ./src/scripts/bench-execution-world.ts",
224
+ "bench:compaction-semantic-index": "tsx ./src/scripts/bench-compaction-semantic-index.ts",
224
225
  "probe:overflow": "tsx -r dotenv/config ./src/scripts/context-overflow-probe.ts",
225
226
  "subagent": "tsx -r dotenv/config ./src/scripts/multi-agent-subagent.ts",
226
227
  "subagent:events": "tsx -r dotenv/config ./src/scripts/subagent-event-driven-debug.ts",
@@ -50,6 +50,7 @@ import {
50
50
  Providers,
51
51
  } from '@/common';
52
52
  import { isTokenCounterCacheCompatible } from '@/llm/tokenCounterCacheCompatibility';
53
+ import { snapshotCompactionSemanticIndex } from '@/summarization/semanticIndex';
53
54
  import { createExactTokenCountCache } from '@/llm/contextPressureMeter';
54
55
  import { createSchemaOnlyTools } from '@/tools/schema';
55
56
  import { apportionTokenCounts } from '@/utils/tokens';
@@ -74,6 +75,14 @@ type ProgrammaticToolInstructionTarget = {
74
75
  executesDirectly: boolean;
75
76
  };
76
77
 
78
+ /**
79
+ * Consecutive summarization attempts that may return no usable summary before
80
+ * the run stops asking. Every such attempt spends a full model call over the
81
+ * whole history and leaves the message set exactly as it was, so the cap
82
+ * bounds spend as much as it bounds the compaction loop.
83
+ */
84
+ const MAX_SUMMARIZATION_FAILURES = 3;
85
+
77
86
  /**
78
87
  * Encapsulates agent-specific state that can vary between agents in a multi-agent system
79
88
  */
@@ -108,6 +117,7 @@ export class AgentContext {
108
117
  discoveredTools,
109
118
  summarizationEnabled,
110
119
  summarizationConfig,
120
+ compactionSemanticIndex,
111
121
  initialSummary,
112
122
  contextPruningConfig,
113
123
  maxToolResultChars,
@@ -141,11 +151,18 @@ export class AgentContext {
141
151
  discoveredTools,
142
152
  summarizationEnabled,
143
153
  summarizationConfig,
154
+ compactionSemanticIndex,
144
155
  contextPruningConfig,
145
156
  maxToolResultChars,
146
157
  });
147
158
 
148
- agentContext._sourceInputs = agentConfig;
159
+ agentContext._sourceInputs =
160
+ compactionSemanticIndex == null
161
+ ? agentConfig
162
+ : {
163
+ ...agentConfig,
164
+ compactionSemanticIndex: agentContext.compactionSemanticIndex,
165
+ };
149
166
  agentContext.subagentConfigs = subagentConfigs;
150
167
  agentContext.maxSubagentDepth = maxSubagentDepth;
151
168
  /**
@@ -346,6 +363,8 @@ export class AgentContext {
346
363
  summarizationEnabled?: boolean;
347
364
  /** Summarization runtime settings used by graph pruning hooks */
348
365
  summarizationConfig?: t.SummarizationConfig;
366
+ /** Host-supplied advisory guidance consumed only when compaction runs. */
367
+ compactionSemanticIndex?: t.CompactionSemanticIndex;
349
368
  /** Current summary text produced by the summarize node, integrated into system message */
350
369
  private summaryText?: string;
351
370
  /** Token count of the current summary (tracked for token accounting) */
@@ -376,6 +395,13 @@ export class AgentContext {
376
395
  * Summarization is allowed to fire again only when new messages appear.
377
396
  */
378
397
  private _lastSummarizationMsgCount: number = 0;
398
+ /**
399
+ * Consecutive summarization attempts that produced no usable summary.
400
+ * An empty or failed summary leaves the message set exactly as it was, so
401
+ * the next prune cycle would ask again on identical state. Cleared by
402
+ * `setSummary` and by `reset()`.
403
+ */
404
+ private _summarizationFailures: number = 0;
379
405
  /**
380
406
  * Forced compactions performed after a provider rejected a prompt as too
381
407
  * large. Bounds the recovery loop so a model that keeps refusing cannot
@@ -430,6 +456,7 @@ export class AgentContext {
430
456
  discoveredTools,
431
457
  summarizationEnabled,
432
458
  summarizationConfig,
459
+ compactionSemanticIndex,
433
460
  contextPruningConfig,
434
461
  maxToolResultChars,
435
462
  }: {
@@ -456,6 +483,7 @@ export class AgentContext {
456
483
  discoveredTools?: string[];
457
484
  summarizationEnabled?: boolean;
458
485
  summarizationConfig?: t.SummarizationConfig;
486
+ compactionSemanticIndex?: t.CompactionSemanticIndex;
459
487
  contextPruningConfig?: t.ContextPruningConfig;
460
488
  maxToolResultChars?: number;
461
489
  }) {
@@ -495,6 +523,11 @@ export class AgentContext {
495
523
  this.useLegacyContent = useLegacyContent ?? false;
496
524
  this.summarizationEnabled = summarizationEnabled;
497
525
  this.summarizationConfig = summarizationConfig;
526
+ if (compactionSemanticIndex != null) {
527
+ this.compactionSemanticIndex = snapshotCompactionSemanticIndex(
528
+ compactionSemanticIndex
529
+ );
530
+ }
498
531
  this.contextPruningConfig = contextPruningConfig;
499
532
  this.maxToolResultChars = maxToolResultChars;
500
533
 
@@ -1209,6 +1242,7 @@ export class AgentContext {
1209
1242
  this.summaryTokenCount = this._durableSummaryTokenCount;
1210
1243
  this.summaryPrecedesMessages = this.durableSummaryPrecedesMessages;
1211
1244
  this._lastSummarizationMsgCount = 0;
1245
+ this._summarizationFailures = 0;
1212
1246
  this.lastCallUsage = undefined;
1213
1247
  this.totalTokensFresh = false;
1214
1248
  this.restoreContextBudgetAfterOverflow();
@@ -1478,6 +1512,7 @@ export class AgentContext {
1478
1512
  this._durableSummaryTokenCount = tokenCount;
1479
1513
  this.durableSummaryPrecedesMessages = this.summaryPrecedesMessages;
1480
1514
  this._summaryVersion += 1;
1515
+ this._summarizationFailures = 0;
1481
1516
  this.systemRunnableStale = true;
1482
1517
  this.pruneMessages = undefined;
1483
1518
  }
@@ -1547,6 +1582,31 @@ export class AgentContext {
1547
1582
  this._lastSummarizationMsgCount = msgCount;
1548
1583
  }
1549
1584
 
1585
+ /**
1586
+ * Records a summarization attempt that produced no usable summary — an
1587
+ * empty model response, or a provider failure the run declined to paper
1588
+ * over with a metadata stub. Cleared by the next successful summary.
1589
+ */
1590
+ recordSummarizationFailure(): void {
1591
+ this._summarizationFailures += 1;
1592
+ }
1593
+
1594
+ get summarizationFailures(): number {
1595
+ return this._summarizationFailures;
1596
+ }
1597
+
1598
+ /**
1599
+ * True once consecutive no-progress attempts reach {@link MAX_SUMMARIZATION_FAILURES}.
1600
+ * A summarizer that has returned nothing this many times in a row will keep
1601
+ * returning nothing: each empty result leaves the history unchanged, so the
1602
+ * next prune cycle re-triggers on the same state and the run burns its
1603
+ * recursion budget on empty summary steps. Summarization stays off for the
1604
+ * remainder of the run; `reset()` restores it for the next one.
1605
+ */
1606
+ get summarizationExhausted(): boolean {
1607
+ return this._summarizationFailures >= MAX_SUMMARIZATION_FAILURES;
1608
+ }
1609
+
1550
1610
  get overflowRecoveryAttempts(): number {
1551
1611
  return this._overflowRecoveryAttempts;
1552
1612
  }
@@ -42,3 +42,14 @@ export const ACTIVITY_LABEL_RUN_NAME = 'StepLabel';
42
42
  export const REASONING_LABEL_RUN_NAME = 'ReasoningLabel';
43
43
  export const ACTIVITY_PHASE_RUN_NAME = 'MultiStepLabel';
44
44
  export const ACTIVITY_PHASE_LABEL_RUN_NAME = 'MultiStepLabelGeneration';
45
+
46
+ /** Shared admission and rendering bounds for compaction navigation hints. */
47
+ export const COMPACTION_SEMANTIC_INDEX_LIMITS = Object.freeze({
48
+ maxInputEntries: 256,
49
+ maxEntries: 64,
50
+ maxEntryChars: 512,
51
+ maxTotalChars: 4_096,
52
+ maxInputTextChars: 4_096,
53
+ maxIdentityChars: 512,
54
+ maxSourceContentIndex: 4_095,
55
+ } as const);
@@ -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
  }