fix(gateway): bound sessions list responses

Bound default Gateway sessions.list responses to 100 rows when callers omit limit, with response metadata for totalCount, limitApplied, and hasMore.\n\nFixes #77062.
This commit is contained in:
Peter Steinberger
2026-05-04 06:51:56 +01:00
committed by GitHub
parent 1df6226d90
commit a224810a7f
7 changed files with 124 additions and 6 deletions

View File

@@ -94,6 +94,7 @@ Docs: https://docs.openclaw.ai
- Release validation: let Windows packaged-upgrade checks continue after the shipped 2026.5.2 updater hits its native-module swap cleanup fallback, verifying the fallback-installed candidate through package metadata and downstream smoke instead of crashing on the immediate update-status probe. Thanks @vincentkoc.
- Doctor/plugins: skip channel-derived official plugin installs when another configured plugin is the effective owner for the same channel, so `doctor --repair` does not reinstall `feishu` while `openclaw-lark` handles `channels.feishu`. Fixes #76623. Thanks @fuyizheng3120.
- Gateway/sessions: memoize repeated thinking-option enrichment and skip unused cost fallback checks while listing sessions, reducing per-row work on large multi-agent stores. Fixes #76931.
- Gateway/sessions: bound default `sessions.list` RPC responses and report truncation metadata, preventing Slack-heavy long-lived stores from forcing unbounded Gateway row construction. Fixes #77062.
- Agents/tools: use config-only runtime snapshots for plugin tool registration and live runtime config getters, avoiding expensive full secrets snapshot clones on the core-plugin-tools prep path. Fixes #76295.
- Agents/tools: honor the effective tool denylist before constructing optional PDF/media tool factories, so `tools.deny: ["pdf"]` skips PDF setup before later policy filtering. Fixes #76997.
- MCP/plugin tools: apply global `tools.profile`, `tools.alsoAllow`, and `tools.deny` policy while exposing plugin tools over the standalone MCP bridge, so ACP clients do not see policy-hidden plugin tools or miss opt-in optional tools. Thanks @vincentkoc.

View File

@@ -16,6 +16,12 @@ until a message is processed. Use `openclaw channels status --probe`,
`openclaw status --deep`, or `openclaw health --verbose` when you need live
channel connectivity.
Gateway `sessions.list` responses are bounded by default so large long-lived
stores cannot monopolize the Gateway event loop. Pass an explicit positive
`limit` from RPC clients when a different result window is needed; responses
include `totalCount`, `limitApplied`, and `hasMore` when callers need to show
that more rows exist.
```bash
openclaw sessions
openclaw sessions --agent work

View File

@@ -38,6 +38,10 @@ export const SessionCompactionCheckpointSchema = Type.Object(
export const SessionsListParamsSchema = Type.Object(
{
/**
* Maximum rows to return. Omitted Gateway RPC calls use a bounded default
* to keep large session stores from monopolizing the event loop.
*/
limit: Type.Optional(Type.Integer({ minimum: 1 })),
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
includeGlobal: Type.Optional(Type.Boolean()),

View File

@@ -83,6 +83,64 @@ describe("gateway session utils", () => {
expect(res.items).toEqual(["b", "c"]);
});
test("session lists apply a bounded default and expose truncation metadata", async () => {
const cfg = createModelDefaultsConfig({ primary: "openai/gpt-5.4" });
const store = Object.fromEntries(
Array.from({ length: 105 }, (_value, index) => [
`session-${index}`,
{
sessionId: `session-${index}`,
updatedAt: 1_000 - index,
} satisfies SessionEntry,
]),
);
const listed = await listSessionsFromStoreAsync({
cfg,
storePath: "",
store,
opts: {},
});
expect(listed.sessions).toHaveLength(100);
expect(listed.count).toBe(100);
expect(listed.totalCount).toBe(105);
expect(listed.limitApplied).toBe(100);
expect(listed.hasMore).toBe(true);
expect(listed.sessions[0]?.key).toBe("session-0");
expect(listed.sessions.at(-1)?.key).toBe("session-99");
});
test("session lists honor explicit caller limits", () => {
const cfg = createModelDefaultsConfig({ primary: "openai/gpt-5.4" });
const store = Object.fromEntries(
Array.from({ length: 5 }, (_value, index) => [
`session-${index}`,
{
sessionId: `session-${index}`,
updatedAt: 1_000 - index,
} satisfies SessionEntry,
]),
);
const listed = listSessionsFromStore({
cfg,
storePath: "",
store,
opts: { limit: 3 },
});
expect(listed.sessions.map((session) => session.key)).toEqual([
"session-0",
"session-1",
"session-2",
]);
expect(listed.count).toBe(3);
expect(listed.totalCount).toBe(5);
expect(listed.limitApplied).toBe(3);
expect(listed.hasMore).toBe(true);
});
test("parseGroupKey handles group keys", () => {
expect(parseGroupKey("discord:group:dev")).toEqual({
channel: "discord",

View File

@@ -1795,8 +1795,14 @@ export function loadGatewaySessionRow(
*/
const SESSIONS_LIST_YIELD_BATCH_SIZE = 10;
const SESSIONS_LIST_TOP_N_LIMIT = 200;
const SESSIONS_LIST_DEFAULT_LIMIT = 100;
type SessionEntryPair = [string, SessionEntry];
type SessionEntrySelection = {
entries: SessionEntryPair[];
totalCount: number;
limitApplied?: number;
};
function compareSessionEntryPairsByUpdatedAt(a: SessionEntryPair, b: SessionEntryPair): number {
return (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0);
@@ -1804,9 +1810,10 @@ function compareSessionEntryPairsByUpdatedAt(a: SessionEntryPair, b: SessionEntr
function resolveSessionsListLimit(
opts: import("./protocol/index.js").SessionsListParams,
defaultLimit?: number,
): number | undefined {
if (typeof opts.limit !== "number" || !Number.isFinite(opts.limit)) {
return undefined;
return defaultLimit;
}
return Math.max(1, Math.floor(opts.limit));
}
@@ -1843,12 +1850,12 @@ function sortAndLimitSessionEntries(
return limit === undefined ? sorted : sorted.slice(0, limit);
}
export function filterAndSortSessionEntries(params: {
function filterSessionEntries(params: {
store: Record<string, SessionEntry>;
opts: import("./protocol/index.js").SessionsListParams;
now: number;
rowContext?: SessionListRowContext;
}): [string, SessionEntry][] {
}): SessionEntryPair[] {
const { store, opts, now } = params;
const rowContext = params.rowContext;
const includeGlobal = opts.includeGlobal === true;
@@ -1941,7 +1948,33 @@ export function filterAndSortSessionEntries(params: {
entries = entries.filter(([, entry]) => (entry?.updatedAt ?? 0) >= cutoff);
}
return sortAndLimitSessionEntries(entries, resolveSessionsListLimit(opts));
return entries;
}
function selectSessionEntries(params: {
store: Record<string, SessionEntry>;
opts: import("./protocol/index.js").SessionsListParams;
now: number;
rowContext?: SessionListRowContext;
defaultLimit?: number;
}): SessionEntrySelection {
const filtered = filterSessionEntries(params);
const limit = resolveSessionsListLimit(params.opts, params.defaultLimit);
const entries = sortAndLimitSessionEntries(filtered, limit);
return {
entries,
totalCount: filtered.length,
limitApplied: limit,
};
}
export function filterAndSortSessionEntries(params: {
store: Record<string, SessionEntry>;
opts: import("./protocol/index.js").SessionsListParams;
now: number;
rowContext?: SessionListRowContext;
}): [string, SessionEntry][] {
return selectSessionEntries(params).entries;
}
export function listSessionsFromStore(params: {
@@ -1964,12 +1997,14 @@ export function listSessionsFromStore(params: {
const includeLastMessage = opts.includeLastMessage === true;
const hasSpawnedByFilter = typeof opts.spawnedBy === "string" && opts.spawnedBy.length > 0;
const entries = filterAndSortSessionEntries({
const selection = selectSessionEntries({
store,
opts,
now,
rowContext: hasSpawnedByFilter ? getRowContext() : undefined,
defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT,
});
const { entries, totalCount, limitApplied } = selection;
const sessions = entries.map(([key, entry], index) => {
const includeTranscriptFields = index < sessionListTranscriptFieldRows;
@@ -1993,6 +2028,9 @@ export function listSessionsFromStore(params: {
ts: now,
path: storePath,
count: sessions.length,
totalCount,
limitApplied,
hasMore: sessions.length < totalCount,
defaults: getSessionDefaults(cfg, params.modelCatalog),
sessions,
};
@@ -2028,12 +2066,14 @@ export async function listSessionsFromStoreAsync(params: {
const includeLastMessage = opts.includeLastMessage === true;
const hasSpawnedByFilter = typeof opts.spawnedBy === "string" && opts.spawnedBy.length > 0;
const entries = filterAndSortSessionEntries({
const selection = selectSessionEntries({
store,
opts,
now,
rowContext: hasSpawnedByFilter ? getRowContext() : undefined,
defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT,
});
const { entries, totalCount, limitApplied } = selection;
const sessions: GatewaySessionRow[] = [];
for (let i = 0; i < entries.length; i++) {
@@ -2089,6 +2129,9 @@ export async function listSessionsFromStoreAsync(params: {
ts: now,
path: storePath,
count: sessions.length,
totalCount,
limitApplied,
hasMore: sessions.length < totalCount,
defaults: getSessionDefaults(cfg, params.modelCatalog),
sessions,
};

View File

@@ -30,6 +30,9 @@ export type SessionsListResultBase<TDefaults, TRow> = {
ts: number;
path: string;
count: number;
totalCount?: number;
limitApplied?: number;
hasMore?: boolean;
defaults: TDefaults;
sessions: TRow[];
};

View File

@@ -25,6 +25,9 @@ export type TuiSessionList = {
ts: number;
path: string;
count: number;
totalCount?: number;
limitApplied?: number;
hasMore?: boolean;
defaults?: {
model?: string | null;
modelProvider?: string | null;