Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/session-projection-benchmark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Session projection cache

`AgentSession` now reuses an in-memory index and the active message-entry projection across continuations. The indexed entry count is the append revision; the active leaf identifies the projected branch. Reads index only newly appended log entries, extend the active projection along the new parent chain, or rebuild when the leaf switches branches. A summary discards the preceding projection.

Every read still materializes fresh LangChain messages. Full message materialization remains O(active messages); this change makes **log indexing and path traversal** incremental, not the entire agent turn. `Run` callers that do not use `AgentSession`, including LibreChat's direct `Run` integrations, do not automatically benefit.

## Compatibility

- No persisted format or public signature changes.
- Existing mutable entry access through `AgentSession.getSessionStore()` permanently disables caching for that store and its shared-entry fork family. Subsequent reads use the original full derivation, including mutations made through previously retained references.
- Reopened stores have independent caches. Forks share an ownership flag because their entry objects are shared.
- Each read returns fresh message wrappers and summary objects. Existing nested content/metadata reference semantics are preserved.
- Duplicate IDs in legacy logs fall back to the existing derivation.
- No trace events, provider payloads, token accounting, or summary content are changed.

## Reproduce

```sh
npx tsx src/scripts/bench-session-projection.ts
```

The benchmark compares `deriveMessages(store.getPath())` with `deriveSessionMessages(store)` on the same real JSONL store, asserts output equivalence, and alternates measurement order. Warm timings are medians of seven batches after warm-up. Delta timings are medians of 30 single reads, each after a real appended message. Both include fresh message construction; disk writes, model calls, and network latency are excluded. The generated tool-rich log has three records per original message. Compacted scenarios retain 40 messages after a summary while keeping the older log records.

## Local results, 2026-09-07

Node 24, macOS. Values are milliseconds per projection; they are microbenchmark results, not end-to-end request speedups.

| Original messages | Compacted | Full warm read | Cached warm read | Full delta read | Cached delta read |
| --- | --- | ---: | ---: | ---: | ---: |
| 100 | No | 0.017 | 0.005 | 0.024 | 0.011 |
| 1,000 | No | 0.181 | 0.060 | 0.179 | 0.064 |
| 10,000 | No | 3.369 | 0.756 | 3.466 | 0.946 |
| 100 | Yes | 0.018 | 0.002 | 0.035 | 0.008 |
| 1,000 | Yes | 0.167 | 0.002 | 0.310 | 0.009 |
| 10,000 | Yes | 3.395 | 0.002 | 3.598 | 0.013 |

First cache reads still build an O(log size) index, and the index adds one map entry per log record. Cold-start timings are printed separately but are not claimed as a gain. The cache holds one active projection per store, not one per historical branch. Public mutable-store users deliberately retain baseline performance.
28 changes: 27 additions & 1 deletion src/agents/AgentContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export class AgentContext {
useLegacyContent,
discoveredTools,
summarizationEnabled,
summarizeOnly,
summarizationConfig,
compactionSemanticIndex,
initialSummary,
Expand Down Expand Up @@ -158,6 +159,7 @@ export class AgentContext {
useLegacyContent,
discoveredTools,
summarizationEnabled,
summarizeOnly,
summarizationConfig,
compactionSemanticIndex,
contextPruningConfig,
Expand Down Expand Up @@ -383,6 +385,10 @@ export class AgentContext {
useLegacyContent: boolean = false;
/** Enables graph-level summarization for this agent */
summarizationEnabled?: boolean;
/** Summarize-only run: request a summary on the first model step and end after it */
summarizeOnly?: boolean;
/** Whether the summarize-only run has already issued its one request */
private _manualSummarizationRequested: boolean = false;
/** Summarization runtime settings used by graph pruning hooks */
summarizationConfig?: t.SummarizationConfig;
/** Host-supplied advisory guidance consumed only when compaction runs. */
Expand Down Expand Up @@ -480,6 +486,7 @@ export class AgentContext {
useLegacyContent,
discoveredTools,
summarizationEnabled,
summarizeOnly,
summarizationConfig,
compactionSemanticIndex,
contextPruningConfig,
Expand Down Expand Up @@ -508,6 +515,7 @@ export class AgentContext {
useLegacyContent?: boolean;
discoveredTools?: string[];
summarizationEnabled?: boolean;
summarizeOnly?: boolean;
summarizationConfig?: t.SummarizationConfig;
compactionSemanticIndex?: t.CompactionSemanticIndex;
contextPruningConfig?: t.ContextPruningConfig;
Expand Down Expand Up @@ -549,6 +557,7 @@ export class AgentContext {

this.useLegacyContent = useLegacyContent ?? false;
this.summarizationEnabled = summarizationEnabled;
this.summarizeOnly = summarizeOnly;
this.summarizationConfig = summarizationConfig;
if (compactionSemanticIndex != null) {
this.compactionSemanticIndex = snapshotCompactionSemanticIndex(
Expand Down Expand Up @@ -1268,6 +1277,7 @@ export class AgentContext {
this.summaryPrecedesMessages = this.durableSummaryPrecedesMessages;
this._lastSummarizationMsgCount = 0;
this._summarizationFailures = 0;
this._manualSummarizationRequested = false;
this.lastCallUsage = undefined;
this.totalTokensFresh = false;
this.restoreContextBudgetAfterOverflow();
Expand Down Expand Up @@ -1617,6 +1627,20 @@ export class AgentContext {
this._lastSummarizationMsgCount = msgCount;
}

/**
* Claims the single summarization request a summarize-only run makes.
* True exactly once per run, on the first model step; the step that gets
* `false` is the one after the summary, which reports usage and ends
* without a model call. Always false when `summarizeOnly` is off.
*/
claimManualSummarization(): boolean {
if (this.summarizeOnly !== true || this._manualSummarizationRequested) {
return false;
}
this._manualSummarizationRequested = true;
return true;
}

/**
* Records a summarization attempt that produced no usable summary — an
* empty model response, or a provider failure the run declined to paper
Expand Down Expand Up @@ -2116,7 +2140,9 @@ export class AgentContext {
syncBudgetDerivedFields(
usage,
context,
this.contextPressureTokenCounts?.count ?? tokenCounter
this.contextPressureTokenCounts?.count ?? tokenCounter,
undefined,
this.provider
);
return usage;
}
Expand Down
Loading
Loading