From 0d6a9dc259783997064bfc0d75840ec2b5c5872b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Feb 2026 04:09:03 +0100 Subject: [PATCH] fix(cron): refresh isolated skill snapshots when filter changes (#13457) (thanks @mcaxtr) --- CHANGELOG.md | 1 + src/agents/skills/types.ts | 2 + src/agents/skills/workspace.ts | 5 ++ src/config/sessions/types.ts | 2 + .../isolated-agent/run.skill-filter.test.ts | 80 ++++++++++++++++++- src/cron/isolated-agent/run.ts | 27 ++++++- 6 files changed, 114 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0859cdaf61f..6603445862a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang. - Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus. - Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz. +- Cron/Agents: honor per-agent `skills` allowlists for isolated cron runs and refresh cached skill snapshots when the agent skill filter changes, preventing stale unrestricted skill prompts. (#13457) Thanks @mcaxtr. - Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber. - Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu. diff --git a/src/agents/skills/types.ts b/src/agents/skills/types.ts index b518d4bb601..abfb8743dd7 100644 --- a/src/agents/skills/types.ts +++ b/src/agents/skills/types.ts @@ -82,6 +82,8 @@ export type SkillEligibilityContext = { export type SkillSnapshot = { prompt: string; skills: Array<{ name: string; primaryEnv?: string }>; + /** Normalized agent-level filter used to build this snapshot; undefined means unrestricted. */ + skillFilter?: string[]; resolvedSkills?: Skill[]; version?: number; }; diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index ee666eacaab..34c759c37c1 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -232,12 +232,17 @@ export function buildWorkspaceSkillSnapshot( const resolvedSkills = promptEntries.map((entry) => entry.skill); const remoteNote = opts?.eligibility?.remote?.note?.trim(); const prompt = [remoteNote, formatSkillsForPrompt(resolvedSkills)].filter(Boolean).join("\n"); + const skillFilter = + opts?.skillFilter === undefined + ? undefined + : opts.skillFilter.map((entry) => String(entry).trim()).filter(Boolean); return { prompt, skills: eligible.map((entry) => ({ name: entry.skill.name, primaryEnv: entry.metadata?.primaryEnv, })), + skillFilter, resolvedSkills, version: opts?.snapshotVersion, }; diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 1d0012a749f..012d59f728d 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -144,6 +144,8 @@ export type GroupKeyResolution = { export type SessionSkillSnapshot = { prompt: string; skills: Array<{ name: string; primaryEnv?: string }>; + /** Normalized agent-level filter used to build this snapshot; undefined means unrestricted. */ + skillFilter?: string[]; resolvedSkills?: Skill[]; version?: number; }; diff --git a/src/cron/isolated-agent/run.skill-filter.test.ts b/src/cron/isolated-agent/run.skill-filter.test.ts index 58444cab38d..4b261a96ba5 100644 --- a/src/cron/isolated-agent/run.skill-filter.test.ts +++ b/src/cron/isolated-agent/run.skill-filter.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; // ---------- mocks ---------- @@ -193,6 +193,16 @@ function makeParams(overrides?: Record) { // ---------- tests ---------- describe("runCronIsolatedAgentTurn — skill filter", () => { + const originalFastEnv = process.env.OPENCLAW_TEST_FAST; + + beforeAll(() => { + process.env.OPENCLAW_TEST_FAST = "0"; + }); + + afterAll(() => { + process.env.OPENCLAW_TEST_FAST = originalFastEnv; + }); + beforeEach(() => { vi.clearAllMocks(); buildWorkspaceSkillSnapshotMock.mockReturnValue({ @@ -271,4 +281,72 @@ describe("runCronIsolatedAgentTurn — skill filter", () => { // Explicit empty skills list should forward [] to filter out all skills expect(buildWorkspaceSkillSnapshotMock.mock.calls[0][1]).toHaveProperty("skillFilter", []); }); + + it("refreshes cached snapshot when skillFilter changes without version bump", async () => { + resolveAgentSkillsFilterMock.mockReturnValue(["weather"]); + resolveCronSessionMock.mockReturnValue({ + storePath: "/tmp/store.json", + store: {}, + sessionEntry: { + sessionId: "test-session-id", + updatedAt: 0, + systemSent: false, + skillsSnapshot: { + prompt: "meme-factory", + skills: [{ name: "meme-factory" }], + version: 42, + }, + }, + systemSent: false, + isNewSession: true, + }); + + const { runCronIsolatedAgentTurn } = await import("./run.js"); + + const result = await runCronIsolatedAgentTurn( + makeParams({ + cfg: { agents: { list: [{ id: "weather-bot", skills: ["weather"] }] } }, + agentId: "weather-bot", + }), + ); + + expect(result.status).toBe("ok"); + expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce(); + expect(buildWorkspaceSkillSnapshotMock.mock.calls[0][1]).toHaveProperty("skillFilter", [ + "weather", + ]); + }); + + it("reuses cached snapshot when version and skillFilter are unchanged", async () => { + resolveAgentSkillsFilterMock.mockReturnValue(["weather", "meme-factory"]); + resolveCronSessionMock.mockReturnValue({ + storePath: "/tmp/store.json", + store: {}, + sessionEntry: { + sessionId: "test-session-id", + updatedAt: 0, + systemSent: false, + skillsSnapshot: { + prompt: "weather", + skills: [{ name: "weather" }], + skillFilter: ["meme-factory", "weather"], + version: 42, + }, + }, + systemSent: false, + isNewSession: true, + }); + + const { runCronIsolatedAgentTurn } = await import("./run.js"); + + const result = await runCronIsolatedAgentTurn( + makeParams({ + cfg: { agents: { list: [{ id: "weather-bot", skills: ["weather", "meme-factory"] }] } }, + agentId: "weather-bot", + }), + ); + + expect(result.status).toBe("ok"); + expect(buildWorkspaceSkillSnapshotMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 7b3a7eb9566..8a1f975d756 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -105,6 +105,27 @@ const CRON_SUBAGENT_WAIT_POLL_MS = 500; const CRON_SUBAGENT_WAIT_MIN_MS = 30_000; const CRON_SUBAGENT_FINAL_REPLY_GRACE_MS = 5_000; +function normalizeSkillFilterForSnapshot(skillFilter?: string[]): string[] | undefined { + if (skillFilter === undefined) { + return undefined; + } + return Array.from( + new Set(skillFilter.map((entry) => String(entry).trim()).filter(Boolean)), + ).toSorted(); +} + +function matchesCachedSkillFilter(cached?: string[], next?: string[]): boolean { + const cachedNormalized = normalizeSkillFilterForSnapshot(cached); + const nextNormalized = normalizeSkillFilterForSnapshot(next); + if (cachedNormalized === undefined || nextNormalized === undefined) { + return cachedNormalized === nextNormalized; + } + if (cachedNormalized.length !== nextNormalized.length) { + return false; + } + return cachedNormalized.every((entry, index) => entry === nextNormalized[index]); +} + function isLikelyInterimCronMessage(value: string): boolean { const text = value.trim(); if (!text) { @@ -528,9 +549,11 @@ export async function runCronIsolatedAgentTurn(params: { } else { const existingSnapshot = cronSession.sessionEntry.skillsSnapshot; const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir); - const needsSkillsSnapshot = - !existingSnapshot || existingSnapshot.version !== skillsSnapshotVersion; const skillFilter = resolveAgentSkillsFilter(cfgWithAgentDefaults, agentId); + const needsSkillsSnapshot = + !existingSnapshot || + existingSnapshot.version !== skillsSnapshotVersion || + !matchesCachedSkillFilter(existingSnapshot.skillFilter, skillFilter); if (needsSkillsSnapshot) { skillsSnapshot = buildWorkspaceSkillSnapshot(workspaceDir, { config: cfgWithAgentDefaults,