@librechat/agents 3.7.0 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +11 -2
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +7 -2
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/llm/invoke.cjs +56 -9
  6. package/dist/cjs/llm/invoke.cjs.map +1 -1
  7. package/dist/cjs/main.cjs +11 -1
  8. package/dist/cjs/messages/content.cjs +8 -5
  9. package/dist/cjs/messages/content.cjs.map +1 -1
  10. package/dist/cjs/messages/format.cjs +15 -5
  11. package/dist/cjs/messages/format.cjs.map +1 -1
  12. package/dist/cjs/messages/index.cjs +2 -1
  13. package/dist/cjs/messages/projectionInvariant.cjs +74 -0
  14. package/dist/cjs/messages/projectionInvariant.cjs.map +1 -0
  15. package/dist/cjs/messages/provenance.cjs +30 -8
  16. package/dist/cjs/messages/provenance.cjs.map +1 -1
  17. package/dist/cjs/messages/recency.cjs +202 -7
  18. package/dist/cjs/messages/recency.cjs.map +1 -1
  19. package/dist/cjs/messages/toolResultTypes.cjs +1 -0
  20. package/dist/cjs/session/AgentSession.cjs +4 -23
  21. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  22. package/dist/cjs/session/deriveMessages.cjs +25 -0
  23. package/dist/cjs/session/deriveMessages.cjs.map +1 -0
  24. package/dist/cjs/session/index.cjs +1 -0
  25. package/dist/cjs/summarization/node.cjs +13 -6
  26. package/dist/cjs/summarization/node.cjs.map +1 -1
  27. package/dist/cjs/tools/subagent/InMemorySubagentTaskStore.cjs +65 -8
  28. package/dist/cjs/tools/subagent/InMemorySubagentTaskStore.cjs.map +1 -1
  29. package/dist/esm/agents/AgentContext.mjs +11 -2
  30. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  31. package/dist/esm/graphs/Graph.mjs +8 -3
  32. package/dist/esm/graphs/Graph.mjs.map +1 -1
  33. package/dist/esm/llm/invoke.mjs +56 -9
  34. package/dist/esm/llm/invoke.mjs.map +1 -1
  35. package/dist/esm/main.mjs +6 -4
  36. package/dist/esm/messages/content.mjs +8 -6
  37. package/dist/esm/messages/content.mjs.map +1 -1
  38. package/dist/esm/messages/format.mjs +16 -6
  39. package/dist/esm/messages/format.mjs.map +1 -1
  40. package/dist/esm/messages/index.mjs +2 -1
  41. package/dist/esm/messages/projectionInvariant.mjs +72 -0
  42. package/dist/esm/messages/projectionInvariant.mjs.map +1 -0
  43. package/dist/esm/messages/provenance.mjs +30 -9
  44. package/dist/esm/messages/provenance.mjs.map +1 -1
  45. package/dist/esm/messages/recency.mjs +201 -8
  46. package/dist/esm/messages/recency.mjs.map +1 -1
  47. package/dist/esm/messages/toolResultTypes.mjs +1 -1
  48. package/dist/esm/session/AgentSession.mjs +4 -23
  49. package/dist/esm/session/AgentSession.mjs.map +1 -1
  50. package/dist/esm/session/deriveMessages.mjs +25 -0
  51. package/dist/esm/session/deriveMessages.mjs.map +1 -0
  52. package/dist/esm/session/index.mjs +1 -0
  53. package/dist/esm/summarization/node.mjs +14 -7
  54. package/dist/esm/summarization/node.mjs.map +1 -1
  55. package/dist/esm/tools/subagent/InMemorySubagentTaskStore.mjs +65 -8
  56. package/dist/esm/tools/subagent/InMemorySubagentTaskStore.mjs.map +1 -1
  57. package/dist/types/agents/AgentContext.d.ts +6 -1
  58. package/dist/types/messages/content.d.ts +4 -1
  59. package/dist/types/messages/format.d.ts +6 -0
  60. package/dist/types/messages/index.d.ts +1 -0
  61. package/dist/types/messages/projectionInvariant.d.ts +25 -0
  62. package/dist/types/messages/provenance.d.ts +10 -0
  63. package/dist/types/messages/recency.d.ts +30 -18
  64. package/dist/types/session/deriveMessages.d.ts +11 -0
  65. package/dist/types/session/index.d.ts +2 -0
  66. package/dist/types/tools/subagent/InMemorySubagentTaskStore.d.ts +13 -1
  67. package/dist/types/types/graph.d.ts +1 -1
  68. package/dist/types/types/subagentTasks.d.ts +22 -0
  69. package/dist/types/types/summarize.d.ts +15 -13
  70. package/package.json +3 -1
  71. package/src/agents/AgentContext.ts +19 -5
  72. package/src/graphs/Graph.ts +9 -1
  73. package/src/llm/invoke.ts +102 -23
  74. package/src/messages/content.ts +20 -10
  75. package/src/messages/format.ts +29 -5
  76. package/src/messages/index.ts +1 -0
  77. package/src/messages/projectionInvariant.ts +134 -0
  78. package/src/messages/provenance.ts +60 -18
  79. package/src/messages/recency.ts +429 -27
  80. package/src/session/AgentSession.ts +4 -30
  81. package/src/session/deriveMessages.ts +37 -0
  82. package/src/session/index.ts +2 -0
  83. package/src/summarization/node.ts +37 -15
  84. package/src/tools/subagent/InMemorySubagentTaskStore.ts +139 -6
  85. package/src/types/graph.ts +1 -0
  86. package/src/types/subagentTasks.ts +28 -0
  87. package/src/types/summarize.ts +15 -13
@@ -91,6 +91,12 @@ interface LangChainMessage {
91
91
  export declare const formatFromLangChain: (message: LangChainMessage) => Record<string, any>;
92
92
  interface FormatAgentMessagesOptions {
93
93
  provider?: ProviderName;
94
+ /** Emit flattenable text content as the joined string the legacy-content
95
+ * projection would produce, so the per-request `formatContentStrings` pass
96
+ * finds nothing to convert and every history message keeps its identity —
97
+ * which is what lets exact-count reuse skip re-tokenizing it. Set this if
98
+ * and only if the run's provider uses legacy string content. */
99
+ legacyContent?: boolean;
94
100
  /** Reconstruct hidden `reasoning_content` from `THINK` parts onto prior
95
101
  * tool-call messages. Explicit opt-in for OpenAI-compatible endpoints that
96
102
  * replay reasoning across turns; defaults to on for DeepSeek thinking-mode. */
@@ -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';
@@ -0,0 +1,25 @@
1
+ import type { BaseMessage } from '@langchain/core/messages';
2
+ export type ProviderMessageProjectionInvariantMode = 'off' | 'observe' | 'assert';
3
+ export type ProviderMessageProjectionIssueCode = 'absent_provenance' | 'invalid_provenance' | 'unsourced_non_synthetic_part';
4
+ export interface ProviderMessageProjectionInvariantIssue {
5
+ readonly code: ProviderMessageProjectionIssueCode;
6
+ readonly messageIndex: number;
7
+ readonly messageType: string;
8
+ }
9
+ export interface ProviderMessageProjectionInvariantReport {
10
+ readonly valid: boolean;
11
+ readonly messageCount: number;
12
+ readonly sourceBackedMessageCount: number;
13
+ readonly syntheticMessageCount: number;
14
+ readonly gapMessageCount: number;
15
+ readonly issues: readonly ProviderMessageProjectionInvariantIssue[];
16
+ }
17
+ /** Resolves the opt-in provider projection invariant without enabling it for
18
+ * unrecognized values. */
19
+ export declare function resolveProviderMessageProjectionInvariantMode(value?: string | undefined): ProviderMessageProjectionInvariantMode;
20
+ /** Inspects provider-bound lineage without reading message content or source ids. */
21
+ export declare function inspectProviderMessageProjection(messages: readonly BaseMessage[]): ProviderMessageProjectionInvariantReport;
22
+ export declare class ProviderMessageProjectionInvariantError extends Error {
23
+ readonly report: ProviderMessageProjectionInvariantReport;
24
+ constructor(report: ProviderMessageProjectionInvariantReport);
25
+ }
@@ -73,6 +73,16 @@ export declare function hasBijectiveProviderContentPartMapping(parts: readonly P
73
73
  export declare function getProviderSourceMessageIds(message: BaseMessage): string[];
74
74
  /** Replaces typed provenance and synchronizes its stable plural source ids. */
75
75
  export declare function setProviderMessageProvenance(message: BaseMessage, parts: readonly ProviderMessageProvenancePart[]): void;
76
+ /**
77
+ * {@link setProviderMessageProvenance} for a message the caller itself just
78
+ * constructed from locally built plain objects. Such a message cannot carry
79
+ * proxies, accessors, or foreign aliases in `additional_kwargs`/`lc_kwargs`,
80
+ * so the publication skips the hardened descriptor walks while producing the
81
+ * same end state: fresh replacement objects on both slots, with the
82
+ * serialization mirror aliasing the live kwargs. Never call this with a
83
+ * message received across a seam — that is what the hardened variant is for.
84
+ */
85
+ export declare function setFreshProviderMessageProvenance(message: BaseMessage, parts: readonly ProviderMessageProvenancePart[]): void;
76
86
  /** Publishes the canonical fail-closed marker for malformed provenance. */
77
87
  export declare function setInvalidProviderMessageProvenance(message: BaseMessage): void;
78
88
  /** Marks a provider-visible runtime message as host-generated context. */
@@ -1,20 +1,21 @@
1
1
  import type { BaseMessage } from '@langchain/core/messages';
2
2
  export declare const DEFAULT_RETAIN_RECENT_TURNS = 2;
3
+ export declare const DEFAULT_INTRA_TURN_RETAIN_RATIO = 0.16;
3
4
  /**
4
5
  * Configuration for splitting a message list into a head (to be summarized)
5
6
  * and a tail (to be preserved verbatim).
6
7
  */
7
8
  export interface RecencyWindowOptions {
8
9
  /**
9
- * Maximum number of recent user-led turns to keep in the tail. A "turn"
10
- * begins at a HumanMessage and includes every following AIMessage and
11
- * ToolMessage up to (but not including) the next HumanMessage. Cutting
12
- * at turn boundaries guarantees that tool_use / tool_result pairs are
13
- * never split across the head/tail divide.
10
+ * Maximum number of recent user-led turns to keep in the tail. A "turn"
11
+ * begins at a user-authored HumanMessage and includes every following
12
+ * AIMessage and tool result up to the next user-authored HumanMessage.
13
+ * Provider-native HumanMessages containing only tool results remain in the
14
+ * current turn, so the boundary cannot split them from their calls.
14
15
  *
15
- * The most recent turn is always preserved regardless of this value or
16
- * the token cap, so that a single oversized first message is never
17
- * destroyed by summarization.
16
+ * The most recent turn is preserved unless `intraTurnTokens` enables the
17
+ * pairing-balanced fallback for a tool-heavy history. A lone oversized user
18
+ * message is never eligible for that fallback.
18
19
  *
19
20
  * Defaults to `2`. A value of `0` disables the recency window (head =
20
21
  * everything, tail = empty), restoring the pre-recency-window behavior.
@@ -32,29 +33,40 @@ export interface RecencyWindowOptions {
32
33
  tokens?: number;
33
34
  /** Token-counter used to evaluate the optional `tokens` cap. */
34
35
  tokenCounter?: (m: BaseMessage) => number;
36
+ /**
37
+ * Minimum token budget to retain when the turn window would otherwise make
38
+ * the whole history indivisible. When set with `tokenCounter`, older closed
39
+ * tool-call/result units may be summarized from within the earliest retained
40
+ * turn. A lone user payload and open tool units remain indivisible.
41
+ */
42
+ intraTurnTokens?: number;
35
43
  }
36
44
  export interface RecencySplit {
37
45
  /** Older messages eligible for summarization. Empty when nothing to summarize. */
38
46
  head: BaseMessage[];
39
- /** Recent messages preserved verbatim. Always contains the most recent turn when any HumanMessage exists. */
47
+ /** Recent messages preserved verbatim, beginning at a pairing-balanced boundary. */
40
48
  tail: BaseMessage[];
41
49
  /** Number of user-led turns retained in the tail (0 if no HumanMessage exists). */
42
50
  tailTurnCount: number;
43
51
  /** Index in the original `messages` array where the tail begins. */
44
52
  tailStartIndex: number;
53
+ /** True when the pairing-balanced fallback selected the boundary. */
54
+ usedIntraTurnFallback: boolean;
45
55
  }
56
+ export declare function resolveIntraTurnRetainTokens({ tokens, maxContextTokens, }: {
57
+ tokens?: number;
58
+ maxContextTokens?: number;
59
+ }): number | undefined;
46
60
  /**
47
61
  * Splits `messages` into a head (older, to summarize) and a tail (recent,
48
- * to preserve verbatim) at user-message boundaries. The most recent
49
- * user-led turn is always included in the tail; additional older turns
50
- * are added subject to `turns` and `tokens` caps.
62
+ * to preserve verbatim), preferring user-message boundaries. The most recent
63
+ * user-led turn is normally included in the tail; additional older turns are
64
+ * added subject to `turns` and `tokens` caps.
51
65
  *
52
- * Cutting strictly at HumanMessage boundaries ensures that:
53
- * - tool_use tool_result pairs are never split (they always live within
54
- * the same turn);
55
- * - the first user message is never replaced by a summary, addressing
56
- * the "first turn destruction" failure mode where a single large
57
- * user-pasted payload would otherwise be replaced by a generic summary.
66
+ * When that policy exposes no compactable head, `intraTurnTokens` may select
67
+ * a boundary after older closed tool-call/result units. This keeps runaway
68
+ * first-turn tool loops compactable without splitting parallel calls from
69
+ * their results. A user payload without a completed tool unit stays intact.
58
70
  *
59
71
  * When `messages` contains no HumanMessage (degenerate state — e.g. system
60
72
  * + assistant messages from a programmatic preamble), everything is
@@ -0,0 +1,11 @@
1
+ import type { BaseMessage } from '@langchain/core/messages';
2
+ import type { SessionEntry } from './types';
3
+ export interface DerivedSessionMessages {
4
+ initialSummary?: {
5
+ text: string;
6
+ tokenCount: number;
7
+ };
8
+ messages: BaseMessage[];
9
+ }
10
+ /** Derives the active model context from one append-only session-log path. */
11
+ export declare function deriveMessages(entries: readonly SessionEntry[]): DerivedSessionMessages;
@@ -1,5 +1,7 @@
1
1
  export { AgentSession, createAgentSession } from './AgentSession';
2
+ export { deriveMessages } from './deriveMessages';
2
3
  export { JsonlSessionStore, SessionManager } from './JsonlSessionStore';
3
4
  export { createRunHandlers } from './handlers';
4
5
  export { serializeMessage, deserializeMessage, extractTextFromContent, } from './messageSerialization';
5
6
  export type { AgentSessionConfig, AgentSessionCheckpointLookupOptions, AgentSessionCheckpointReference, AgentSessionCheckpointing, AgentSessionCheckpointingOptions, AgentSessionHandlersResult, AgentSessionInput, AgentSessionRunOptions, AgentSessionRunResult, AgentSessionStream, AgentSessionStreamEvent, AgentSessionUsage, CreateSessionFileOptions, JsonObject, JsonPrimitive, JsonValue, SerializedSessionMessage, SessionBranchOptions, SessionCompactOptions, SessionCheckpointEntry, SessionCompactionEntry, SessionEntry, SessionEntryBase, SessionEntryType, SessionForkOptions, SessionHeader, SessionLabelEntry, SessionListItem, SessionMessageEntry, SessionPosition, SessionRunEventEntry, SessionStateEntry, SessionSummaryEntry, SessionTreeNode, } from './types';
7
+ export type { DerivedSessionMessages } from './deriveMessages';
@@ -1,8 +1,9 @@
1
- import type { SubagentTaskClaim, SubagentTaskControlCommand, SubagentTaskControlResult, SubagentTaskSnapshot, SubagentTaskStartRequest, SubagentTaskStartResult, SubagentTaskStore } from '@/types';
1
+ import type { SubagentTaskClaim, SubagentTaskControlCommand, SubagentTaskControlReceipt, SubagentTaskControlResult, SubagentTaskSnapshot, SubagentTaskStartRequest, SubagentTaskStartResult, SubagentTaskStore } from '@/types';
2
2
  export interface InMemorySubagentTaskStoreOptions {
3
3
  completedTtlMs?: number;
4
4
  maxControlMessageChars?: number;
5
5
  maxControlsPerTask?: number;
6
+ maxControlReceiptsPerTask?: number;
6
7
  maxErrorChars?: number;
7
8
  maxResultChars?: number;
8
9
  maxRunningPerScope?: number;
@@ -25,6 +26,14 @@ export declare class InMemorySubagentTaskStore implements SubagentTaskStore {
25
26
  private runningTasks;
26
27
  private totalTasks;
27
28
  constructor(options?: InMemorySubagentTaskStoreOptions);
29
+ /**
30
+ * Payload-free transition seam for hosts that durably project authoritative
31
+ * receipts. Implementations must return synchronously, must not reproduce
32
+ * task-store transition rules, and cannot veto task-store state transitions:
33
+ * hook failures are deliberately isolated by the caller.
34
+ */
35
+ protected onControlReceipt(_scopeId: string, _taskId: string, _receipt: SubagentTaskControlReceipt): void;
36
+ private emitControlReceipt;
28
37
  start(request: SubagentTaskStartRequest): SubagentTaskStartResult;
29
38
  get(scopeId: string, taskId: string): SubagentTaskSnapshot | undefined;
30
39
  list(scopeId: string): SubagentTaskSnapshot[];
@@ -37,6 +46,9 @@ export declare class InMemorySubagentTaskStore implements SubagentTaskStore {
37
46
  private sweepBucket;
38
47
  private removeTask;
39
48
  private dropEmptyBucket;
49
+ private makeRoomForControlReceipt;
50
+ private transitionControl;
51
+ private failPendingControls;
40
52
  private scheduleExpiry;
41
53
  private clearTaskExpiry;
42
54
  private clearTaskTimeout;
@@ -452,7 +452,7 @@ export type ExecutableSubagentConfig = ResolvedSubagentConfig | LazySingleAgentS
452
452
  /** Graph-aware config accepted by the executor. Graph configs stay eager. */
453
453
  export type ExecutableSubagentConfigEntry = ExecutableSubagentConfig | GraphSubagentConfig;
454
454
  /** Lifecycle phase carried on {@link SubagentUpdateEvent}. */
455
- export type SubagentUpdatePhase = 'start' | 'run_step' | 'run_step_delta' | 'run_step_completed' | 'run_step_closed' | 'message_delta' | 'reasoning_delta' | 'stop' | 'error';
455
+ export type SubagentUpdatePhase = 'start' | 'run_step' | 'run_step_delta' | 'run_step_completed' | 'run_step_closed' | 'message_delta' | 'reasoning_delta' | 'control' | 'stop' | 'error';
456
456
  export interface SubagentAncestryEntry {
457
457
  readonly subagentRunId: string;
458
458
  readonly subagentType: string;
@@ -5,6 +5,22 @@ import type { InjectedMessage } from './tools';
5
5
  export type SubagentTaskStatus = 'running' | 'completed' | 'error' | 'cancelled';
6
6
  /** Where a pending parent message may enter the child run. */
7
7
  export type SubagentTaskBoundary = 'preempt' | 'tool' | 'turn';
8
+ /**
9
+ * Lifecycle of one parent-to-child message after the task store accepts it.
10
+ * Hosts may render a transient `submitted` state before this authoritative
11
+ * receipt exists; that transport state is intentionally not persisted here.
12
+ */
13
+ export type SubagentTaskControlReceiptStatus = 'accepted' | 'applied' | 'rejected' | 'failed';
14
+ /** Bounded authoritative receipt for one steer, queue, or interrupt command. */
15
+ export interface SubagentTaskControlReceipt {
16
+ controlId: string;
17
+ action: 'steer' | 'queue' | 'interrupt';
18
+ status: SubagentTaskControlReceiptStatus;
19
+ createdAt: number;
20
+ updatedAt: number;
21
+ boundary?: SubagentTaskBoundary;
22
+ reason?: 'withdrawn' | 'task_completed' | 'task_cancelled' | 'task_failed';
23
+ }
8
24
  /** Parent-to-child control operations accepted while a task is running. */
9
25
  export type SubagentTaskControlCommand = {
10
26
  action: 'steer' | 'queue' | 'interrupt';
@@ -39,6 +55,12 @@ export interface SubagentTaskSnapshot {
39
55
  resultAvailable: boolean;
40
56
  resultClaimed: boolean;
41
57
  pendingControls: number;
58
+ /**
59
+ * Bounded receipts emitted by stores that support authoritative control
60
+ * tracking. Optional so legacy and custom stores remain compatible during
61
+ * rolling upgrades.
62
+ */
63
+ controlReceipts?: SubagentTaskControlReceipt[];
42
64
  progress?: SubagentTaskProgress;
43
65
  error?: string;
44
66
  }
@@ -5,25 +5,27 @@ export type SummarizationTrigger = {
5
5
  value: number;
6
6
  };
7
7
  /**
8
- * Controls how many recent messages are preserved verbatim during
9
- * compaction. The most recent user-led turn is always preserved
10
- * regardless of these caps, so a single oversized first message is
11
- * never destroyed by summarization.
8
+ * Controls how much recent context is preserved verbatim during compaction.
9
+ * User-turn boundaries are preferred. Under context pressure, older closed
10
+ * tool units inside an otherwise indivisible turn may be summarized while a
11
+ * token-priced recent tail is retained. A lone user payload stays intact.
12
12
  */
13
13
  export type RetainRecentConfig = {
14
14
  /**
15
- * Maximum number of recent user-led turns to keep in the tail. A turn
16
- * begins at a HumanMessage and includes every following AIMessage and
17
- * ToolMessage up to (but not including) the next HumanMessage. Cutting
18
- * at turn boundaries guarantees tool_use / tool_result pairs are never
19
- * split. Set to `0` to disable the recency window (legacy behavior:
20
- * summarize everything). Defaults to `2`.
15
+ * Maximum number of recent user-led turns to keep in the tail. A turn begins
16
+ * at a user-authored HumanMessage and includes every following AIMessage and
17
+ * tool result up to the next user-authored HumanMessage. Provider-native
18
+ * HumanMessages containing only tool results remain in the current turn.
19
+ * Set to `0` to disable the recency window (legacy behavior: summarize
20
+ * everything). Defaults to `2`.
21
21
  */
22
22
  turns?: number;
23
23
  /**
24
- * Optional cap on retained-recent tokens beyond the most recent turn.
25
- * Older turns are added whole only while cumulative tokens stay below
26
- * the cap. Defaults to undefined (no cap; bounded only by `turns`).
24
+ * Optional retained-recent token budget. Older turns are added whole only
25
+ * while cumulative tokens stay below the cap. If a tool-heavy history has
26
+ * no compactable turn-level head, this is also the minimum recent tail kept
27
+ * behind a pairing-balanced intra-turn cut. When omitted, that fallback
28
+ * retains 16% of the configured context window.
27
29
  */
28
30
  tokens?: number;
29
31
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.7.0",
3
+ "version": "3.7.1",
4
4
  "reova": {
5
5
  "enabled": true,
6
6
  "endpoint": "https://telemetry.reo.dev/data"
@@ -218,7 +218,9 @@
218
218
  "tool_search": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/tool_search.ts",
219
219
  "bench:cache": "node --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/bench-prompt-cache.ts",
220
220
  "bench:context-pressure": "node --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/bench-context-pressure-cache.ts",
221
+ "bench:compaction-range": "node --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/bench-compaction-range.ts",
221
222
  "bench:provider-derivation": "node --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/bench-provider-derivation.ts",
223
+ "bench:provider-projection": "node --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/bench-provider-request-projection.ts",
222
224
  "bench:execution-world": "node --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/bench-execution-world.ts",
223
225
  "probe:overflow": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/context-overflow-probe.ts",
224
226
  "subagent": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/multi-agent-subagent.ts",
@@ -357,6 +357,8 @@ export class AgentContext {
357
357
  * - `'none'`: no summary present
358
358
  */
359
359
  private _summaryLocation: 'system_prompt' | 'user_message' | 'none' = 'none';
360
+ /** Whether a mid-run summary must appear before every retained message. */
361
+ private summaryPrecedesMessages: boolean = false;
360
362
  /**
361
363
  * Durable summary that survives reset() calls. Set from initialSummary
362
364
  * during fromConfig() and updated by setSummary() so that the latest
@@ -365,6 +367,7 @@ export class AgentContext {
365
367
  */
366
368
  private _durableSummaryText?: string;
367
369
  private _durableSummaryTokenCount: number = 0;
370
+ private durableSummaryPrecedesMessages: boolean = false;
368
371
  /** Number of summarization cycles that have occurred for this agent context */
369
372
  private _summaryVersion: number = 0;
370
373
  /**
@@ -989,10 +992,10 @@ export class AgentContext {
989
992
  return messages;
990
993
  }
991
994
 
992
- const tailIndex = this.getPromptCacheDynamicTailIndex(
993
- messages,
994
- promptCacheProvider
995
- );
995
+ const tailIndex =
996
+ this._summaryLocation === 'user_message' && this.summaryPrecedesMessages
997
+ ? 0
998
+ : this.getPromptCacheDynamicTailIndex(messages, promptCacheProvider);
996
999
  const stablePrefix = messages.slice(0, tailIndex);
997
1000
  const trailingMessages = messages.slice(tailIndex);
998
1001
  const cacheablePrefix = this.addStablePromptCacheMarkers(
@@ -1204,6 +1207,7 @@ export class AgentContext {
1204
1207
 
1205
1208
  this.summaryText = this._durableSummaryText;
1206
1209
  this.summaryTokenCount = this._durableSummaryTokenCount;
1210
+ this.summaryPrecedesMessages = this.durableSummaryPrecedesMessages;
1207
1211
  this._lastSummarizationMsgCount = 0;
1208
1212
  this.lastCallUsage = undefined;
1209
1213
  this.totalTokensFresh = false;
@@ -1461,12 +1465,18 @@ export class AgentContext {
1461
1465
  }
1462
1466
  }
1463
1467
 
1464
- setSummary(text: string, tokenCount: number): void {
1468
+ setSummary(
1469
+ text: string,
1470
+ tokenCount: number,
1471
+ options?: { precedesMessages?: boolean }
1472
+ ): void {
1465
1473
  this.summaryText = text;
1466
1474
  this.summaryTokenCount = tokenCount;
1467
1475
  this._summaryLocation = 'user_message';
1476
+ this.summaryPrecedesMessages = options?.precedesMessages === true;
1468
1477
  this._durableSummaryText = text;
1469
1478
  this._durableSummaryTokenCount = tokenCount;
1479
+ this.durableSummaryPrecedesMessages = this.summaryPrecedesMessages;
1470
1480
  this._summaryVersion += 1;
1471
1481
  this.systemRunnableStale = true;
1472
1482
  this.pruneMessages = undefined;
@@ -1477,8 +1487,10 @@ export class AgentContext {
1477
1487
  this.summaryText = text;
1478
1488
  this.summaryTokenCount = tokenCount;
1479
1489
  this._summaryLocation = 'system_prompt';
1490
+ this.summaryPrecedesMessages = false;
1480
1491
  this._durableSummaryText = text;
1481
1492
  this._durableSummaryTokenCount = tokenCount;
1493
+ this.durableSummaryPrecedesMessages = false;
1482
1494
  this._summaryVersion += 1;
1483
1495
  this.systemRunnableStale = true;
1484
1496
  }
@@ -1683,6 +1695,8 @@ export class AgentContext {
1683
1695
  this.summaryTokenCount = 0;
1684
1696
  this._durableSummaryText = undefined;
1685
1697
  this._durableSummaryTokenCount = 0;
1698
+ this.summaryPrecedesMessages = false;
1699
+ this.durableSummaryPrecedesMessages = false;
1686
1700
  this._summaryLocation = 'none';
1687
1701
  this.systemRunnableStale = true;
1688
1702
  }
@@ -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/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;