diff --git a/src/commands/sessions-cleanup.ts b/src/commands/sessions-cleanup.ts index a0b1d072386..64b10828eac 100644 --- a/src/commands/sessions-cleanup.ts +++ b/src/commands/sessions-cleanup.ts @@ -162,7 +162,7 @@ async function previewStoreCleanup(params: { activeKey?: string; fixMissing?: boolean; }) { - const maintenance = resolveMaintenanceConfig(); + const maintenance = resolveMaintenanceConfig(params.target.agentId); const beforeStore = loadSessionStore(params.target.storePath, { skipCache: true }); const previewStore = structuredClone(beforeStore); const staleKeys = new Set(); @@ -377,6 +377,7 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti }); }, { + agentId: target.agentId, activeSessionKey: opts.activeKey, maintenanceOverride: { mode, diff --git a/src/config/sessions/store-maintenance.per-agent.test.ts b/src/config/sessions/store-maintenance.per-agent.test.ts new file mode 100644 index 00000000000..04af27d8979 --- /dev/null +++ b/src/config/sessions/store-maintenance.per-agent.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from "vitest"; + +// Mock config loading to control test inputs. +vi.mock("../config.js", () => ({ + loadConfig: vi.fn().mockReturnValue({}), +})); + +import { loadConfig } from "../config.js"; +import { resolveMaintenanceConfig } from "./store-maintenance.js"; + +const mockLoadConfig = vi.mocked(loadConfig); + +const DAY_MS = 24 * 60 * 60 * 1000; + +// --------------------------------------------------------------------------- +// Per-agent session maintenance config resolution. +// --------------------------------------------------------------------------- + +describe("resolveMaintenanceConfig with agentId", () => { + it("returns global config when no agentId is provided", () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "7d", + maxEntries: 200, + }, + }, + agents: { + list: [ + { + id: "worker", + maintenance: { mode: "warn", maxEntries: 50 }, + }, + ], + }, + } as ReturnType); + + const result = resolveMaintenanceConfig(); + + expect(result.mode).toBe("enforce"); + expect(result.maxEntries).toBe(200); + expect(result.pruneAfterMs).toBe(7 * DAY_MS); + }); + + it("merges per-agent overrides over global config", () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "30d", + maxEntries: 500, + rotateBytes: "10mb", + }, + }, + agents: { + list: [ + { + id: "approval", + maintenance: { + mode: "warn", + maxEntries: 50, + pruneAfter: "1d", + }, + }, + ], + }, + } as ReturnType); + + const result = resolveMaintenanceConfig("approval"); + + expect(result.mode).toBe("warn"); + expect(result.maxEntries).toBe(50); + expect(result.pruneAfterMs).toBe(1 * DAY_MS); + // rotateBytes falls through from global. + expect(result.rotateBytes).toBe(10_485_760); + }); + + it("falls through to global when agent has no maintenance config", () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + maxEntries: 300, + }, + }, + agents: { + list: [{ id: "worker" }], + }, + } as ReturnType); + + const result = resolveMaintenanceConfig("worker"); + + expect(result.mode).toBe("enforce"); + expect(result.maxEntries).toBe(300); + }); + + it("falls through to global when agent is not found in list", () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + maxEntries: 100, + }, + }, + agents: { + list: [{ id: "other" }], + }, + } as ReturnType); + + const result = resolveMaintenanceConfig("nonexistent"); + + expect(result.mode).toBe("enforce"); + expect(result.maxEntries).toBe(100); + }); + + it("uses built-in defaults when no global or per-agent config exists", () => { + mockLoadConfig.mockReturnValue({} as ReturnType); + + const result = resolveMaintenanceConfig("any-agent"); + + expect(result.mode).toBe("warn"); + expect(result.maxEntries).toBe(500); + expect(result.pruneAfterMs).toBe(30 * DAY_MS); + expect(result.rotateBytes).toBe(10_485_760); + }); + + it("per-agent maxDiskBytes/highWaterBytes override global", () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + maxDiskBytes: "200mb", + }, + }, + agents: { + list: [ + { + id: "heavy", + maintenance: { + maxDiskBytes: "500mb", + highWaterBytes: "400mb", + }, + }, + ], + }, + } as ReturnType); + + const result = resolveMaintenanceConfig("heavy"); + + expect(result.maxDiskBytes).toBe(500 * 1024 * 1024); + expect(result.highWaterBytes).toBe(400 * 1024 * 1024); + }); + + it("per-agent config without global maintenance still works", () => { + mockLoadConfig.mockReturnValue({ + agents: { + list: [ + { + id: "standalone", + maintenance: { + mode: "enforce", + pruneAfter: "2d", + maxEntries: 10, + }, + }, + ], + }, + } as ReturnType); + + const result = resolveMaintenanceConfig("standalone"); + + expect(result.mode).toBe("enforce"); + expect(result.maxEntries).toBe(10); + expect(result.pruneAfterMs).toBe(2 * DAY_MS); + }); +}); diff --git a/src/config/sessions/store-maintenance.ts b/src/config/sessions/store-maintenance.ts index 410fcbc00f0..93e8bf327b5 100644 --- a/src/config/sessions/store-maintenance.ts +++ b/src/config/sessions/store-maintenance.ts @@ -124,16 +124,58 @@ function resolveHighWaterBytes( } /** - * Resolve maintenance settings from openclaw.json (`session.maintenance`). + * Look up per-agent maintenance overrides from `agents.list[].maintenance`. + * Returns `undefined` when the agent has no overrides or is not found. + */ +function resolveAgentMaintenanceOverride( + agentId: string | undefined, +): SessionMaintenanceConfig | undefined { + if (!agentId) { + return undefined; + } + try { + const agents = loadConfig().agents?.list; + if (!agents) { + return undefined; + } + const agent = agents.find((a) => a.id === agentId); + return agent?.maintenance ?? undefined; + } catch { + return undefined; + } +} + +/** + * Merge global session.maintenance with per-agent overrides. + * Per-agent values take precedence; unset fields fall through to global. + */ +function mergeMaintenanceConfig( + global: SessionMaintenanceConfig | undefined, + perAgent: SessionMaintenanceConfig | undefined, +): SessionMaintenanceConfig | undefined { + if (!perAgent) { + return global; + } + if (!global) { + return perAgent; + } + return { ...global, ...perAgent }; +} + +/** + * Resolve maintenance settings from openclaw.json (`session.maintenance`), + * optionally merging per-agent overrides from `agents.list[].maintenance`. * Falls back to built-in defaults when config is missing or unset. */ -export function resolveMaintenanceConfig(): ResolvedSessionMaintenanceConfig { - let maintenance: SessionMaintenanceConfig | undefined; +export function resolveMaintenanceConfig(agentId?: string): ResolvedSessionMaintenanceConfig { + let globalMaintenance: SessionMaintenanceConfig | undefined; try { - maintenance = loadConfig().session?.maintenance; + globalMaintenance = loadConfig().session?.maintenance; } catch { // Config may not be available (e.g. in tests). Use defaults. } + const agentOverride = resolveAgentMaintenanceOverride(agentId); + const maintenance = mergeMaintenanceConfig(globalMaintenance, agentOverride); const pruneAfterMs = resolvePruneAfterMs(maintenance); const maxDiskBytes = resolveMaxDiskBytes(maintenance); return { diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 3936086beb8..62ea03419cf 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -307,6 +307,8 @@ export type { ResolvedSessionMaintenanceConfig, SessionMaintenanceWarning }; type SaveSessionStoreOptions = { /** Skip pruning, capping, and rotation (e.g. during one-time migrations). */ skipMaintenance?: boolean; + /** Agent id for per-agent maintenance config resolution. */ + agentId?: string; /** Active session key for warn-only maintenance. */ activeSessionKey?: string; /** @@ -410,7 +412,10 @@ async function saveSessionStoreUnlocked( if (!opts?.skipMaintenance) { // Resolve maintenance config once (avoids repeated loadConfig() calls). - const maintenance = { ...resolveMaintenanceConfig(), ...opts?.maintenanceOverride }; + const maintenance = { + ...resolveMaintenanceConfig(opts?.agentId), + ...opts?.maintenanceOverride, + }; const shouldWarnOnly = maintenance.mode === "warn"; const beforeCount = Object.keys(store).length; diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index a979506a2ab..720fdde17b2 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -1,7 +1,7 @@ import type { ChatType } from "../channels/chat-type.js"; import type { AgentDefaultsConfig } from "./types.agent-defaults.js"; import type { AgentModelConfig, AgentSandboxConfig } from "./types.agents-shared.js"; -import type { HumanDelayConfig, IdentityConfig } from "./types.base.js"; +import type { HumanDelayConfig, IdentityConfig, SessionMaintenanceConfig } from "./types.base.js"; import type { GroupChatConfig } from "./types.messages.js"; import type { AgentToolsConfig, MemorySearchConfig } from "./types.tools.js"; @@ -80,6 +80,8 @@ export type AgentConfig = { /** Per-agent default model for spawned sub-agents (string or {primary,fallbacks}). */ model?: AgentModelConfig; }; + /** Optional per-agent session maintenance overrides. */ + maintenance?: SessionMaintenanceConfig; /** Optional per-agent sandbox overrides. */ sandbox?: AgentSandboxConfig; /** Optional per-agent stream params (e.g. cacheRetention, temperature). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 10f0f8637e9..9306d34c434 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -10,6 +10,7 @@ import { ToolsLinksSchema, ToolsMediaSchema, } from "./zod-schema.core.js"; +import { SessionMaintenanceSchema } from "./zod-schema.maintenance.js"; import { sensitive } from "./zod-schema.sensitive.js"; export const HeartbeatSchema = z @@ -791,6 +792,7 @@ export const AgentEntrySchema = z }) .strict() .optional(), + maintenance: SessionMaintenanceSchema, sandbox: AgentSandboxSchema, params: z.record(z.string(), z.unknown()).optional(), tools: AgentToolsSchema, diff --git a/src/config/zod-schema.maintenance.ts b/src/config/zod-schema.maintenance.ts new file mode 100644 index 00000000000..e466908e4cd --- /dev/null +++ b/src/config/zod-schema.maintenance.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import { parseByteSize } from "../cli/parse-bytes.js"; +import { parseDurationMs } from "../cli/parse-duration.js"; + +export const SessionMaintenanceSchema = z + .object({ + mode: z.enum(["enforce", "warn"]).optional(), + pruneAfter: z.union([z.string(), z.number()]).optional(), + /** @deprecated Use pruneAfter instead. */ + pruneDays: z.number().int().positive().optional(), + maxEntries: z.number().int().positive().optional(), + rotateBytes: z.union([z.string(), z.number()]).optional(), + resetArchiveRetention: z.union([z.string(), z.number(), z.literal(false)]).optional(), + maxDiskBytes: z.union([z.string(), z.number()]).optional(), + highWaterBytes: z.union([z.string(), z.number()]).optional(), + }) + .strict() + .superRefine((val, ctx) => { + if (val.pruneAfter !== undefined) { + try { + parseDurationMs(String(val.pruneAfter).trim(), { defaultUnit: "d" }); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["pruneAfter"], + message: "invalid duration (use ms, s, m, h, d)", + }); + } + } + if (val.rotateBytes !== undefined) { + try { + parseByteSize(String(val.rotateBytes).trim(), { defaultUnit: "b" }); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["rotateBytes"], + message: "invalid size (use b, kb, mb, gb, tb)", + }); + } + } + if (val.resetArchiveRetention !== undefined && val.resetArchiveRetention !== false) { + try { + parseDurationMs(String(val.resetArchiveRetention).trim(), { defaultUnit: "d" }); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["resetArchiveRetention"], + message: "invalid duration (use ms, s, m, h, d)", + }); + } + } + if (val.maxDiskBytes !== undefined) { + try { + parseByteSize(String(val.maxDiskBytes).trim(), { defaultUnit: "b" }); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["maxDiskBytes"], + message: "invalid size (use b, kb, mb, gb, tb)", + }); + } + } + if (val.highWaterBytes !== undefined) { + try { + parseByteSize(String(val.highWaterBytes).trim(), { defaultUnit: "b" }); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["highWaterBytes"], + message: "invalid size (use b, kb, mb, gb, tb)", + }); + } + } + }) + .optional(); diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 3f4b6a24d80..ec70a0057ca 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -1,6 +1,4 @@ import { z } from "zod"; -import { parseByteSize } from "../cli/parse-bytes.js"; -import { parseDurationMs } from "../cli/parse-duration.js"; import { ElevatedAllowFromSchema } from "./zod-schema.agent-runtime.js"; import { createAllowDenyChannelRulesSchema } from "./zod-schema.allowdeny.js"; import { @@ -11,8 +9,11 @@ import { TypingModeSchema, TtsConfigSchema, } from "./zod-schema.core.js"; +import { SessionMaintenanceSchema } from "./zod-schema.maintenance.js"; import { sensitive } from "./zod-schema.sensitive.js"; +export { SessionMaintenanceSchema }; + const SessionResetConfigSchema = z .object({ mode: z.union([z.literal("daily"), z.literal("idle")]).optional(), @@ -69,77 +70,7 @@ export const SessionSchema = z }) .strict() .optional(), - maintenance: z - .object({ - mode: z.enum(["enforce", "warn"]).optional(), - pruneAfter: z.union([z.string(), z.number()]).optional(), - /** @deprecated Use pruneAfter instead. */ - pruneDays: z.number().int().positive().optional(), - maxEntries: z.number().int().positive().optional(), - rotateBytes: z.union([z.string(), z.number()]).optional(), - resetArchiveRetention: z.union([z.string(), z.number(), z.literal(false)]).optional(), - maxDiskBytes: z.union([z.string(), z.number()]).optional(), - highWaterBytes: z.union([z.string(), z.number()]).optional(), - }) - .strict() - .superRefine((val, ctx) => { - if (val.pruneAfter !== undefined) { - try { - parseDurationMs(String(val.pruneAfter).trim(), { defaultUnit: "d" }); - } catch { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["pruneAfter"], - message: "invalid duration (use ms, s, m, h, d)", - }); - } - } - if (val.rotateBytes !== undefined) { - try { - parseByteSize(String(val.rotateBytes).trim(), { defaultUnit: "b" }); - } catch { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["rotateBytes"], - message: "invalid size (use b, kb, mb, gb, tb)", - }); - } - } - if (val.resetArchiveRetention !== undefined && val.resetArchiveRetention !== false) { - try { - parseDurationMs(String(val.resetArchiveRetention).trim(), { defaultUnit: "d" }); - } catch { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["resetArchiveRetention"], - message: "invalid duration (use ms, s, m, h, d)", - }); - } - } - if (val.maxDiskBytes !== undefined) { - try { - parseByteSize(String(val.maxDiskBytes).trim(), { defaultUnit: "b" }); - } catch { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["maxDiskBytes"], - message: "invalid size (use b, kb, mb, gb, tb)", - }); - } - } - if (val.highWaterBytes !== undefined) { - try { - parseByteSize(String(val.highWaterBytes).trim(), { defaultUnit: "b" }); - } catch { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["highWaterBytes"], - message: "invalid size (use b, kb, mb, gb, tb)", - }); - } - } - }) - .optional(), + maintenance: SessionMaintenanceSchema, }) .strict() .optional();