Skip to content

Commit e4cee2e

Browse files
perf(gateway): cache session list resolver lookups
Refs #75839.\n\nRebases and lands the sessions.list resolver-cache fix from #77187 after maintainer conflict repair. The change keeps cache state scoped to a single sessions.list call and memoizes deterministic per-row resolver work for repeated provider/model tuples.\n\nVerification:\n- pnpm test src/gateway/session-utils.perf.test.ts src/gateway/session-utils.test.ts\n- pnpm exec oxfmt --check --threads=1 src/gateway/session-utils.ts src/gateway/session-utils.perf.test.ts scripts/github/real-behavior-proof-policy.mjs\n- git diff --check HEAD -- CHANGELOG.md scripts/github/real-behavior-proof-policy.mjs src/gateway/session-utils.perf.test.ts src/gateway/session-utils.ts\n- GitHub PR checks: 87 passing, CodeQL neutral, 21 skipped\n\nCo-authored-by: OpenClaw Agent <[email protected]>
1 parent 5b418c3 commit e4cee2e

4 files changed

Lines changed: 157 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,7 @@ Docs: https://docs.openclaw.ai
590590
- CLI/migrate: show native Codex plugin names before truncated plan items and prompt for plugin activation explicitly during interactive Codex migration instead of silently keeping every planned plugin. Thanks @kevinslin.
591591
- CLI/migrate: leave already configured target Codex plugins unchecked in the interactive plugin selector and show a `plugin exists` conflict hint while keeping new plugin activations selected by default. Thanks @kevinslin.
592592
- CLI/migrate: return cleanly without apply confirmation when interactive Codex migration leaves both skill copies and native plugin activations unselected. Thanks @kevinslin.
593+
- Gateway/sessions: extend the per-call sessions-list `rowContext` cache with memoization for `resolveSessionDisplayModelIdentityRef`, thinking metadata, and `resolveModelCostConfig` so deterministic per-row resolvers run once per unique `(provider, model[, agentId])` tuple instead of once per session. Cuts CPU on `sessions.list` for stores with many sessions sharing a small set of model tuples; behavior is unchanged for callers that pass no `rowContext`. Thanks @rolandrscheel.
593594
- Cron CLI: add `openclaw cron list --agent <id>`, normalize the requested agent id, and include jobs without a stored agent id under the configured default agent while keeping `cron list` unfiltered when no agent is supplied. Fixes #77118. Thanks @zhanggttry.
594595
- Slack/performance: reduce message preparation, stream recipient lookup, and thread-context allocation overhead on Slack reply hot paths. Thanks @vincentkoc.
595596
- Control UI/chat: strip untrusted sender metadata from live streams and transcript display, preserve canvas preview anchors, and stop operator UI clients from injecting their internal client id as sender identity. Fixes #78739. Thanks @tmimmanuel, @guguangxin-eng, @hclsys, and @BunsDev.

scripts/github/real-behavior-proof-policy.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ export function hasProofOverride(labels) {
112112
}
113113

114114
export function extractRealBehaviorProofSection(body = "") {
115+
// Normalize CRLF → LF so regexes and section slicing see GitHub web-editor PR
116+
// bodies the same way as locally-authored Markdown.
115117
const normalizedBody = normalizeLineEndings(body);
116118
const headingRegex = /^#{2,6}\s+real behavior proof\b[^\n]*$/gim;
117119
const match = headingRegex.exec(normalizedBody);
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import path from "node:path";
2+
import { describe, test, expect, vi } from "vitest";
3+
import * as thinking from "../auto-reply/thinking.js";
4+
import type { OpenClawConfig } from "../config/config.js";
5+
import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../config/config.js";
6+
import type { SessionEntry } from "../config/sessions.js";
7+
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
8+
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
9+
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
10+
import * as usageFormat from "../utils/usage-format.js";
11+
import { listSessionsFromStore } from "./session-utils.js";
12+
13+
/**
14+
* Regression smoke for the per-list rowContext resolver cache. The bug we are
15+
* guarding against is O(rows) scaling of deterministic resolvers whose results
16+
* only depend on `(provider, model[, agentId])`: with N sessions sharing K
17+
* unique model tuples, the cached path must perform at most O(K) underlying
18+
* resolver calls -- not O(N).
19+
*
20+
* We assert call counts directly instead of a wall-time bound because shared
21+
* CI runners cannot give a stable wall-time signal, and call-count regressions
22+
* are the actual scaling failure mode we care about.
23+
*/
24+
describe("listSessionsFromStore resolver cache", () => {
25+
test("collapses non-lightweight per-row resolver work to O(unique provider/model tuples)", async () => {
26+
await withStateDirEnv("openclaw-perf-", async ({ stateDir }) => {
27+
resetPluginRuntimeStateForTest();
28+
setActivePluginRegistry(createEmptyPluginRegistry());
29+
const cfg: OpenClawConfig = {
30+
agents: {
31+
defaults: { model: { primary: "google-vertex/gemini-3-flash-preview" } },
32+
},
33+
} as OpenClawConfig;
34+
resetConfigRuntimeState();
35+
setRuntimeConfigSnapshot(cfg);
36+
37+
const tuples: Array<{ modelProvider: string; model: string }> = [
38+
{ modelProvider: "google-vertex", model: "gemini-3-flash-preview" },
39+
{ modelProvider: "openai", model: "gpt-5" },
40+
{ modelProvider: "anthropic", model: "claude-opus-4-7" },
41+
{ modelProvider: "openrouter", model: "z-ai/glm-5" },
42+
{ modelProvider: "google", model: "gemini-2.5-pro" },
43+
];
44+
45+
const store: Record<string, SessionEntry> = {};
46+
const now = Date.now();
47+
const rowCount = 30;
48+
for (let i = 0; i < rowCount; i++) {
49+
const tuple = tuples[i % tuples.length];
50+
store[`agent:default:webchat:dm:${i}`] = {
51+
updatedAt: now - i,
52+
modelProvider: tuple.modelProvider,
53+
model: tuple.model,
54+
inputTokens: 100,
55+
outputTokens: 50,
56+
} as SessionEntry;
57+
}
58+
59+
const thinkingSpy = vi.spyOn(thinking, "listThinkingLevelOptions");
60+
const costSpy = vi.spyOn(usageFormat, "resolveModelCostConfig");
61+
try {
62+
const result = listSessionsFromStore({
63+
cfg,
64+
storePath: path.join(stateDir, "sessions.json"),
65+
store,
66+
// sessions.list bounds responses to 100 rows by default; the perf
67+
// smoke explicitly opts into the full set so the non-lightweight
68+
// row builder exercises the display-identity, thinking-default, and
69+
// model-cost caches at scale.
70+
opts: { limit: rowCount },
71+
});
72+
expect(result.sessions.length).toBe(rowCount);
73+
74+
// The cache keys on rowContext are (provider, model) or
75+
// (agentId, provider, model). With K=5 unique tuples we must see at
76+
// most a small constant number of resolver calls, not O(N=30). A
77+
// pre-cache regression would scale linearly and easily exceed the
78+
// threshold below.
79+
const cacheCallCeiling = tuples.length * 4;
80+
expect(thinkingSpy.mock.calls.length).toBeLessThanOrEqual(cacheCallCeiling);
81+
expect(costSpy.mock.calls.length).toBeLessThanOrEqual(cacheCallCeiling);
82+
} finally {
83+
thinkingSpy.mockRestore();
84+
costSpy.mockRestore();
85+
}
86+
});
87+
});
88+
});

src/gateway/session-utils.ts

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import {
7979
normalizeOptionalLowercaseString,
8080
} from "../shared/string-coerce.js";
8181
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.shared.js";
82+
import type { ModelCostConfig } from "../utils/usage-format.js";
8283
import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js";
8384
import {
8485
resolveSessionStoreAgentId,
@@ -294,6 +295,24 @@ function buildCompactionCheckpointPreview(
294295
};
295296
}
296297

298+
function resolveModelCostConfigCached(
299+
provider: string | undefined,
300+
model: string | undefined,
301+
cfg: OpenClawConfig,
302+
rowContext?: SessionListRowContext,
303+
): ModelCostConfig | undefined {
304+
if (!rowContext) {
305+
return resolveModelCostConfig({ provider, model, config: cfg });
306+
}
307+
const key = createSessionRowModelCacheKey(provider, model);
308+
if (rowContext.modelCostConfigByModelRef.has(key)) {
309+
return rowContext.modelCostConfigByModelRef.get(key);
310+
}
311+
const value = resolveModelCostConfig({ provider, model, config: cfg });
312+
rowContext.modelCostConfigByModelRef.set(key, value);
313+
return value;
314+
}
315+
297316
function resolveEstimatedSessionCostUsd(params: {
298317
cfg: OpenClawConfig;
299318
provider?: string;
@@ -303,6 +322,7 @@ function resolveEstimatedSessionCostUsd(params: {
303322
"estimatedCostUsd" | "inputTokens" | "outputTokens" | "cacheRead" | "cacheWrite"
304323
>;
305324
explicitCostUsd?: number;
325+
rowContext?: SessionListRowContext;
306326
}): number | undefined {
307327
const explicitCostUsd = resolveNonNegativeNumber(
308328
params.explicitCostUsd ?? params.entry?.estimatedCostUsd,
@@ -322,11 +342,12 @@ function resolveEstimatedSessionCostUsd(params: {
322342
) {
323343
return undefined;
324344
}
325-
const cost = resolveModelCostConfig({
326-
provider: params.provider,
327-
model: params.model,
328-
config: params.cfg,
329-
});
345+
const cost = resolveModelCostConfigCached(
346+
params.provider,
347+
params.model,
348+
params.cfg,
349+
params.rowContext,
350+
);
330351
if (!cost) {
331352
return undefined;
332353
}
@@ -372,13 +393,19 @@ type SessionListRowContext = {
372393
subagentRuns: ReturnType<typeof buildSubagentRunReadIndex>;
373394
storeChildSessionsByKey: Map<string, string[]>;
374395
selectedModelByOverrideRef: Map<string, ReturnType<typeof resolveSessionModelRef>>;
396+
// Per-list memoization for deterministic resolvers that scale linearly with
397+
// session count but only depend on (provider, model[, agentId]). Sessions
398+
// in a single list typically share a small set of those tuples, so caching
399+
// here collapses the work to O(unique tuples) per call.
375400
thinkingMetadataByModelRef: Map<
376401
string,
377402
{
378403
levels: ReturnType<typeof listThinkingLevelOptions>;
379404
defaultLevel: ReturnType<typeof resolveGatewaySessionThinkingDefault>;
380405
}
381406
>;
407+
displayModelIdentityByKey: Map<string, { provider?: string; model?: string }>;
408+
modelCostConfigByModelRef: Map<string, ModelCostConfig | undefined>;
382409
};
383410

384411
function resolveRuntimeChildSessionKeys(
@@ -497,6 +524,8 @@ function buildSessionListRowContext(params: {
497524
storeChildSessionsByKey: buildStoreChildSessionIndex(params.store, params.now, subagentRuns),
498525
selectedModelByOverrideRef: new Map(),
499526
thinkingMetadataByModelRef: new Map(),
527+
displayModelIdentityByKey: new Map(),
528+
modelCostConfigByModelRef: new Map(),
500529
};
501530
}
502531

@@ -622,6 +651,7 @@ function resolveTranscriptUsageFallback(params: {
622651
fallbackProvider?: string;
623652
fallbackModel?: string;
624653
maxTranscriptBytes?: number;
654+
rowContext?: SessionListRowContext;
625655
}): {
626656
estimatedCostUsd?: number;
627657
totalTokens?: number;
@@ -668,6 +698,7 @@ function resolveTranscriptUsageFallback(params: {
668698
cacheRead: snapshot.cacheRead,
669699
cacheWrite: snapshot.cacheWrite,
670700
},
701+
rowContext: params.rowContext,
671702
});
672703
return {
673704
modelProvider,
@@ -1508,6 +1539,30 @@ export function resolveSessionModelIdentityRef(
15081539
return { provider: resolved.provider, model: resolved.model };
15091540
}
15101541

1542+
function resolveSessionDisplayModelIdentityRefCached(params: {
1543+
cfg: OpenClawConfig;
1544+
agentId: string;
1545+
provider?: string;
1546+
model?: string;
1547+
rowContext?: SessionListRowContext;
1548+
}): { provider?: string; model?: string } {
1549+
const ctx = params.rowContext;
1550+
if (!ctx) {
1551+
return resolveSessionDisplayModelIdentityRef(params);
1552+
}
1553+
const key = `${params.agentId}\u0000${createSessionRowModelCacheKey(
1554+
params.provider,
1555+
params.model,
1556+
)}`;
1557+
const cached = ctx.displayModelIdentityByKey.get(key);
1558+
if (cached) {
1559+
return cached;
1560+
}
1561+
const value = resolveSessionDisplayModelIdentityRef(params);
1562+
ctx.displayModelIdentityByKey.set(key, value);
1563+
return value;
1564+
}
1565+
15111566
export function resolveSessionDisplayModelIdentityRef(params: {
15121567
cfg: OpenClawConfig;
15131568
agentId: string;
@@ -1671,6 +1726,7 @@ export function buildGatewaySessionRow(params: {
16711726
provider: resolvedModel.provider,
16721727
model: resolvedModel.model ?? DEFAULT_MODEL,
16731728
entry,
1729+
rowContext,
16741730
}) === undefined;
16751731
const transcriptUsage =
16761732
!skipTranscriptUsage &&
@@ -1683,6 +1739,7 @@ export function buildGatewaySessionRow(params: {
16831739
fallbackProvider: resolvedModel.provider,
16841740
fallbackModel: resolvedModel.model ?? DEFAULT_MODEL,
16851741
maxTranscriptBytes: params.transcriptUsageMaxBytes,
1742+
rowContext: params.rowContext,
16861743
})
16871744
: null;
16881745
const preferLiveSubagentModelIdentity =
@@ -1722,11 +1779,12 @@ export function buildGatewaySessionRow(params: {
17221779
const selectedOrRuntimeModel = selectedModel?.model ?? model;
17231780
const rowModelIdentity = lightweight
17241781
? { provider: selectedOrRuntimeModelProvider, model: selectedOrRuntimeModel }
1725-
: resolveSessionDisplayModelIdentityRef({
1782+
: resolveSessionDisplayModelIdentityRefCached({
17261783
cfg,
17271784
agentId: sessionAgentId,
17281785
provider: selectedOrRuntimeModelProvider,
17291786
model: selectedOrRuntimeModel,
1787+
rowContext: params.rowContext,
17301788
});
17311789
const rowModelProvider = rowModelIdentity.provider;
17321790
const rowModel = rowModelIdentity.model;
@@ -1746,6 +1804,7 @@ export function buildGatewaySessionRow(params: {
17461804
provider: rowModelProvider,
17471805
model: rowModel,
17481806
entry,
1807+
rowContext: params.rowContext,
17491808
}) ?? resolveNonNegativeNumber(transcriptUsage?.estimatedCostUsd));
17501809
const contextTokens = lightweight
17511810
? resolvePositiveNumber(entry?.contextTokens)
@@ -1817,7 +1876,7 @@ export function buildGatewaySessionRow(params: {
18171876
abortedLastRun: entry?.abortedLastRun,
18181877
thinkingLevel: entry?.thinkingLevel,
18191878
thinkingLevels,
1820-
thinkingOptions: thinkingLevels?.map((level) => level.label),
1879+
thinkingOptions: thinkingLevels.map((level) => level.label),
18211880
thinkingDefault,
18221881
fastMode: entry?.fastMode,
18231882
verboseLevel: entry?.verboseLevel,

0 commit comments

Comments
 (0)