diff --git a/CHANGELOG.md b/CHANGELOG.md index e715b6947c4..4a61aa5e412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ Docs: https://docs.openclaw.ai - Gateway/auth: disconnect shared-auth websocket sessions only for effective auth rotations on restart-capable config writes, and keep `config.set` auth edits from dropping still-valid live sessions. (#60387) Thanks @mappel-nv. - Control UI/chat: keep the Stop button visible during tool-only execution so abortable runs do not fall back to Send while tools are still running. (#54528) thanks @chziyue. - Discord/voice: make READY auto-join fire-and-forget while keeping the shorter initial voice-connect timeout separate from the longer playback-start wait. (#60345) Thanks @geekhuashan. +- Agents/skills: add inherited `agents.defaults.skills` allowlists, make per-agent `agents.list[].skills` replace defaults instead of merging, and scope embedded, session, sandbox, and cron skill snapshots through the effective runtime agent. (#59992) Thanks @gumadeiras. ## 2026.4.2 diff --git a/docs/.generated/config-baseline.core.json b/docs/.generated/config-baseline.core.json index 4798e55c83d..1ef70344164 100644 --- a/docs/.generated/config-baseline.core.json +++ b/docs/.generated/config-baseline.core.json @@ -3983,6 +3983,30 @@ "tags": [], "hasChildren": false }, + { + "path": "agents.defaults.skills", + "kind": "core", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Skills", + "help": "Optional default skill allowlist inherited by agents that omit agents.list[].skills. Omit for unrestricted skills, set [] to give inheriting agents no skills, and remember explicit agents.list[].skills replaces this default instead of merging with it.", + "hasChildren": true + }, + { + "path": "agents.defaults.skills.*", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "agents.defaults.skipBootstrap", "kind": "core", @@ -6460,7 +6484,7 @@ "advanced" ], "label": "Agent Skill Filter", - "help": "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", + "help": "Optional allowlist of skills for this agent. If omitted, the agent inherits agents.defaults.skills when set; otherwise skills stay unrestricted. Set [] for no skills. An explicit list fully replaces inherited defaults instead of merging with them.", "hasChildren": true }, { diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index 7c8d1c2f0b0..f73f1a2a71d 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -3982,6 +3982,30 @@ "tags": [], "hasChildren": false }, + { + "path": "agents.defaults.skills", + "kind": "core", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Skills", + "help": "Optional default skill allowlist inherited by agents that omit agents.list[].skills. Omit for unrestricted skills, set [] to give inheriting agents no skills, and remember explicit agents.list[].skills replaces this default instead of merging with it.", + "hasChildren": true + }, + { + "path": "agents.defaults.skills.*", + "kind": "core", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, { "path": "agents.defaults.skipBootstrap", "kind": "core", @@ -6459,7 +6483,7 @@ "advanced" ], "label": "Agent Skill Filter", - "help": "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", + "help": "Optional allowlist of skills for this agent. If omitted, the agent inherits agents.defaults.skills when set; otherwise skills stay unrestricted. Set [] for no skills. An explicit list fully replaces inherited defaults instead of merging with them.", "hasChildren": true }, { diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 2c1725cde8d..e0f4230a9ec 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -78,7 +78,8 @@ import { resolveThinkingDefault, } from "./model-selection.js"; import { buildWorkspaceSkillSnapshot } from "./skills.js"; -import { getSkillsSnapshotVersion } from "./skills/refresh.js"; +import { matchesSkillFilter } from "./skills/filter.js"; +import { getSkillsSnapshotVersion, shouldRefreshSnapshotForVersion } from "./skills/refresh.js"; import { normalizeSpawnedRunMetadata } from "./spawned-context.js"; import { resolveAgentTimeoutMs } from "./timeout.js"; import { ensureAgentWorkspace } from "./workspace.js"; @@ -497,17 +498,23 @@ async function agentCommandInternal( }); } - const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot; const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir); const skillFilter = resolveAgentSkillsFilter(cfg, sessionAgentId); + const currentSkillsSnapshot = sessionEntry?.skillsSnapshot; + const shouldRefreshSkillsSnapshot = + !currentSkillsSnapshot || + shouldRefreshSnapshotForVersion(currentSkillsSnapshot.version, skillsSnapshotVersion) || + !matchesSkillFilter(currentSkillsSnapshot.skillFilter, skillFilter); + const needsSkillsSnapshot = isNewSession || shouldRefreshSkillsSnapshot; const skillsSnapshot = needsSkillsSnapshot ? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg, eligibility: { remote: getRemoteSkillEligibility() }, snapshotVersion: skillsSnapshotVersion, skillFilter, + agentId: sessionAgentId, }) - : sessionEntry?.skillsSnapshot; + : currentSkillsSnapshot; if (skillsSnapshot && sessionStore && sessionKey && needsSkillsSnapshot) { const current = sessionEntry ?? { diff --git a/src/agents/agent-scope.test.ts b/src/agents/agent-scope.test.ts index e29792101a3..cf0c424c43a 100644 --- a/src/agents/agent-scope.test.ts +++ b/src/agents/agent-scope.test.ts @@ -9,6 +9,7 @@ import { resolveAgentDir, resolveAgentEffectiveModelPrimary, resolveAgentExplicitModelPrimary, + resolveAgentSkillsFilter, resolveFallbackAgentId, resolveEffectiveModelFallbacks, resolveAgentModelFallbacksOverride, @@ -521,3 +522,44 @@ describe("resolveAgentIdsByWorkspacePath", () => { ]); }); }); + +describe("resolveAgentSkillsFilter", () => { + it("inherits agents.defaults.skills when the agent omits skills", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + skills: ["github", "weather"], + }, + list: [{ id: "writer" }], + }, + }; + + expect(resolveAgentSkillsFilter(cfg, "writer")).toEqual(["github", "weather"]); + }); + + it("uses agents.list[].skills as a full replacement", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + skills: ["github", "weather"], + }, + list: [{ id: "writer", skills: ["docs-search"] }], + }, + }; + + expect(resolveAgentSkillsFilter(cfg, "writer")).toEqual(["docs-search"]); + }); + + it("keeps explicit empty agent skills as no skills", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + skills: ["github", "weather"], + }, + list: [{ id: "writer", skills: [] }], + }, + }; + + expect(resolveAgentSkillsFilter(cfg, "writer")).toEqual([]); + }); +}); diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 386c2c1f84f..7406d9990b5 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -11,7 +11,7 @@ import { resolveAgentIdFromSessionKey, } from "../routing/session-key.js"; import { resolveUserPath } from "../utils.js"; -import { normalizeSkillFilter } from "./skills/filter.js"; +import { resolveEffectiveAgentSkillFilter } from "./skills/agent-filter.js"; import { resolveDefaultAgentWorkspaceDir } from "./workspace.js"; let log: ReturnType | null = null; @@ -160,7 +160,7 @@ export function resolveAgentSkillsFilter( cfg: OpenClawConfig, agentId: string, ): string[] | undefined { - return normalizeSkillFilter(resolveAgentConfig(cfg, agentId)?.skills); + return resolveEffectiveAgentSkillFilter(cfg, agentId); } function resolveModelPrimary(raw: unknown): string | undefined { diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 88d703de1e3..a1a17669ed4 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -392,6 +392,10 @@ export async function compactEmbeddedPiSessionDirect( sessionId: params.sessionId, cwd: effectiveWorkspace, }); + const { sessionAgentId: effectiveSkillAgentId } = resolveSessionAgentIds({ + sessionKey: params.sessionKey, + config: params.config, + }); let restoreSkillEnv: (() => void) | undefined; let compactionSessionManager: unknown = null; @@ -399,6 +403,7 @@ export async function compactEmbeddedPiSessionDirect( const { shouldLoadSkillEntries, skillEntries } = resolveEmbeddedRunSkillEntries({ workspaceDir: effectiveWorkspace, config: params.config, + agentId: effectiveSkillAgentId, skillsSnapshot: params.skillsSnapshot, }); restoreSkillEnv = params.skillsSnapshot @@ -415,6 +420,7 @@ export async function compactEmbeddedPiSessionDirect( entries: shouldLoadSkillEntries ? skillEntries : undefined, config: params.config, workspaceDir: effectiveWorkspace, + agentId: effectiveSkillAgentId, }); const sessionLabel = params.sessionKey ?? params.sessionId; diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index bcbc9b0c66d..ace56abfc8b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -347,12 +347,18 @@ export async function runEmbeddedAttempt( : sandbox.workspaceDir : resolvedWorkspace; await fs.mkdir(effectiveWorkspace, { recursive: true }); + const { sessionAgentId } = resolveSessionAgentIds({ + sessionKey: params.sessionKey, + config: params.config, + agentId: params.agentId, + }); let restoreSkillEnv: (() => void) | undefined; try { const { shouldLoadSkillEntries, skillEntries } = resolveEmbeddedRunSkillEntries({ workspaceDir: effectiveWorkspace, config: params.config, + agentId: sessionAgentId, skillsSnapshot: params.skillsSnapshot, }); restoreSkillEnv = params.skillsSnapshot @@ -370,6 +376,7 @@ export async function runEmbeddedAttempt( entries: shouldLoadSkillEntries ? skillEntries : undefined, config: params.config, workspaceDir: effectiveWorkspace, + agentId: sessionAgentId, }); const sessionLabel = params.sessionKey ?? params.sessionId; @@ -408,7 +415,7 @@ export async function runEmbeddedAttempt( const agentDir = params.agentDir ?? resolveOpenClawAgentDir(); - const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ + const { defaultAgentId } = resolveSessionAgentIds({ sessionKey: params.sessionKey, config: params.config, agentId: params.agentId, diff --git a/src/agents/pi-embedded-runner/skills-runtime.test.ts b/src/agents/pi-embedded-runner/skills-runtime.test.ts index ede54ac2124..54f6eddc8a2 100644 --- a/src/agents/pi-embedded-runner/skills-runtime.test.ts +++ b/src/agents/pi-embedded-runner/skills-runtime.test.ts @@ -41,6 +41,23 @@ describe("resolveEmbeddedRunSkillEntries", () => { expect(loadWorkspaceSkillEntriesSpy).toHaveBeenCalledWith("/tmp/workspace", { config }); }); + it("threads agentId through live skill loading", () => { + resolveEmbeddedRunSkillEntries({ + workspaceDir: "/tmp/workspace", + config: {}, + agentId: "writer", + skillsSnapshot: { + prompt: "skills prompt", + skills: [], + }, + }); + + expect(loadWorkspaceSkillEntriesSpy).toHaveBeenCalledWith("/tmp/workspace", { + config: {}, + agentId: "writer", + }); + }); + it("prefers the active runtime snapshot when caller config still contains SecretRefs", () => { const sourceConfig: OpenClawConfig = { skills: { diff --git a/src/agents/pi-embedded-runner/skills-runtime.ts b/src/agents/pi-embedded-runner/skills-runtime.ts index 4279315fd83..6cadf851c7d 100644 --- a/src/agents/pi-embedded-runner/skills-runtime.ts +++ b/src/agents/pi-embedded-runner/skills-runtime.ts @@ -5,6 +5,7 @@ import { resolveSkillRuntimeConfig } from "../skills/runtime-config.js"; export function resolveEmbeddedRunSkillEntries(params: { workspaceDir: string; config?: OpenClawConfig; + agentId?: string; skillsSnapshot?: SkillSnapshot; }): { shouldLoadSkillEntries: boolean; @@ -15,7 +16,7 @@ export function resolveEmbeddedRunSkillEntries(params: { return { shouldLoadSkillEntries, skillEntries: shouldLoadSkillEntries - ? loadWorkspaceSkillEntries(params.workspaceDir, { config }) + ? loadWorkspaceSkillEntries(params.workspaceDir, { config, agentId: params.agentId }) : [], }; } diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index 1f6453f1254..eaba51524ea 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -23,6 +23,7 @@ import { ensureSandboxWorkspace } from "./workspace.js"; async function ensureSandboxWorkspaceLayout(params: { cfg: ReturnType; + agentId: string; rawSessionKey: string; config?: OpenClawConfig; workspaceDir?: string; @@ -55,6 +56,7 @@ async function ensureSandboxWorkspaceLayout(params: { sourceWorkspaceDir: agentWorkspaceDir, targetWorkspaceDir: sandboxWorkspaceDir, config: params.config, + agentId: params.agentId, }); } catch (error) { const message = error instanceof Error ? error.message : JSON.stringify(error); @@ -118,12 +120,13 @@ export async function resolveSandboxContext(params: { if (!resolved) { return null; } - const { rawSessionKey, cfg } = resolved; + const { rawSessionKey, cfg, runtime } = resolved; await maybePruneSandboxes(cfg); const { agentWorkspaceDir, scopeKey, workspaceDir } = await ensureSandboxWorkspaceLayout({ cfg, + agentId: runtime.agentId, rawSessionKey, config: params.config, workspaceDir: params.workspaceDir, @@ -224,10 +227,11 @@ export async function ensureSandboxWorkspaceForSession(params: { if (!resolved) { return null; } - const { rawSessionKey, cfg } = resolved; + const { rawSessionKey, cfg, runtime } = resolved; const { workspaceDir } = await ensureSandboxWorkspaceLayout({ cfg, + agentId: runtime.agentId, rawSessionKey, config: params.config, workspaceDir: params.workspaceDir, diff --git a/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts b/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts index b09571f540f..48a0854e21a 100644 --- a/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts +++ b/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts @@ -122,6 +122,53 @@ describe("buildWorkspaceSkillsPrompt", () => { expect(prompt).not.toContain("Extra version"); expect(prompt.replaceAll("\\", "/")).toContain("demo-skill/SKILL.md"); }); + + it("syncs the explicit agent skill subset instead of inherited defaults", async () => { + const sourceWorkspace = await createCaseDir("source"); + const targetWorkspace = await createCaseDir("target"); + await writeSkill({ + dir: path.join(sourceWorkspace, "skills", "foo_bar"), + name: "foo_bar", + description: "Underscore variant", + }); + await writeSkill({ + dir: path.join(sourceWorkspace, "skills", "foo.dot"), + name: "foo.dot", + description: "Dot variant", + }); + + await withEnv({ HOME: sourceWorkspace, PATH: "" }, () => + syncSkillsToWorkspace({ + sourceWorkspaceDir: sourceWorkspace, + targetWorkspaceDir: targetWorkspace, + agentId: "alpha", + config: { + agents: { + defaults: { + skills: ["foo_bar", "foo.dot"], + }, + list: [{ id: "alpha", skills: ["foo_bar"] }], + }, + }, + bundledSkillsDir: path.join(sourceWorkspace, ".bundled"), + managedSkillsDir: path.join(sourceWorkspace, ".managed"), + }), + ); + + const prompt = buildPrompt(targetWorkspace, { + bundledSkillsDir: path.join(targetWorkspace, ".bundled"), + managedSkillsDir: path.join(targetWorkspace, ".managed"), + }); + + expect(prompt).toContain("Underscore variant"); + expect(prompt).not.toContain("Dot variant"); + expect(await pathExists(path.join(targetWorkspace, "skills", "foo_bar", "SKILL.md"))).toBe( + true, + ); + expect(await pathExists(path.join(targetWorkspace, "skills", "foo.dot", "SKILL.md"))).toBe( + false, + ); + }); it.runIf(process.platform !== "win32")( "does not sync workspace skills that resolve outside the source workspace root", async () => { diff --git a/src/agents/skills.buildworkspaceskillsnapshot.test.ts b/src/agents/skills.buildworkspaceskillsnapshot.test.ts index 1292841ed13..11d664b134b 100644 --- a/src/agents/skills.buildworkspaceskillsnapshot.test.ts +++ b/src/agents/skills.buildworkspaceskillsnapshot.test.ts @@ -177,6 +177,43 @@ describe("buildWorkspaceSkillSnapshot", () => { expect(snapshot.prompt.length).toBeLessThan(2000); }); + it("uses agents.list[].skills as a full replacement for inherited defaults", async () => { + const workspaceDir = await fixtureSuite.createCaseDir("workspace"); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "github"), + name: "github", + description: "GitHub", + }); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "weather"), + name: "weather", + description: "Weather", + }); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "docs-search"), + name: "docs-search", + description: "Docs", + }); + + const snapshot = buildSnapshot(workspaceDir, { + agentId: "writer", + config: { + agents: { + defaults: { + skills: ["github", "weather"], + }, + list: [{ id: "writer", skills: ["docs-search", "github"] }], + }, + }, + }); + + expect(snapshot.skills.map((skill) => skill.name).toSorted()).toEqual([ + "docs-search", + "github", + ]); + expect(snapshot.skillFilter).toEqual(["docs-search", "github"]); + }); + it("limits discovery for nested repo-style skills roots (dir/skills/*)", async () => { const workspaceDir = await fixtureSuite.createCaseDir("workspace"); const repoDir = await cloneTemplateDir(nestedRepoTemplateDir, "skills-repo"); diff --git a/src/agents/skills.loadworkspaceskillentries.test.ts b/src/agents/skills.loadworkspaceskillentries.test.ts index e66b7db8fe0..970a0433651 100644 --- a/src/agents/skills.loadworkspaceskillentries.test.ts +++ b/src/agents/skills.loadworkspaceskillentries.test.ts @@ -149,6 +149,66 @@ describe("loadWorkspaceSkillEntries", () => { expect(entries.map((entry) => entry.skill.name)).toContain("fallback-name"); }); + it("inherits agents.defaults.skills when an agent omits skills", async () => { + const workspaceDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "github"), + name: "github", + description: "GitHub", + }); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "weather"), + name: "weather", + description: "Weather", + }); + + const entries = loadWorkspaceSkillEntries(workspaceDir, { + config: { + agents: { + defaults: { + skills: ["github"], + }, + list: [{ id: "writer" }], + }, + }, + agentId: "writer", + managedSkillsDir: path.join(workspaceDir, ".managed"), + bundledSkillsDir: path.join(workspaceDir, ".bundled"), + }); + + expect(entries.map((entry) => entry.skill.name)).toEqual(["github"]); + }); + + it("uses agents.list[].skills as a full replacement for defaults", async () => { + const workspaceDir = await createTempWorkspaceDir(); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "github"), + name: "github", + description: "GitHub", + }); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "docs-search"), + name: "docs-search", + description: "Docs", + }); + + const entries = loadWorkspaceSkillEntries(workspaceDir, { + config: { + agents: { + defaults: { + skills: ["github"], + }, + list: [{ id: "writer", skills: ["docs-search"] }], + }, + }, + agentId: "writer", + managedSkillsDir: path.join(workspaceDir, ".managed"), + bundledSkillsDir: path.join(workspaceDir, ".bundled"), + }); + + expect(entries.map((entry) => entry.skill.name)).toEqual(["docs-search"]); + }); + it.runIf(process.platform !== "win32")( "skips workspace skill directories that resolve outside the workspace root", async () => { diff --git a/src/agents/skills.resolveskillspromptforrun.test.ts b/src/agents/skills.resolveskillspromptforrun.test.ts index 8b7c2f9305f..77e9dd6265e 100644 --- a/src/agents/skills.resolveskillspromptforrun.test.ts +++ b/src/agents/skills.resolveskillspromptforrun.test.ts @@ -29,6 +29,86 @@ describe("resolveSkillsPromptForRun", () => { expect(prompt).toContain(""); expect(prompt).toContain("/app/skills/demo-skill/SKILL.md"); }); + + it("inherits agents.defaults.skills when rebuilding prompt for an agent", () => { + const visible: SkillEntry = { + skill: createFixtureSkill({ + name: "github", + description: "GitHub", + filePath: "/app/skills/github/SKILL.md", + baseDir: "/app/skills/github", + source: "openclaw-workspace", + }), + frontmatter: {}, + }; + const hidden: SkillEntry = { + skill: createFixtureSkill({ + name: "hidden-skill", + description: "Hidden", + filePath: "/app/skills/hidden-skill/SKILL.md", + baseDir: "/app/skills/hidden-skill", + source: "openclaw-workspace", + }), + frontmatter: {}, + }; + + const prompt = resolveSkillsPromptForRun({ + entries: [visible, hidden], + config: { + agents: { + defaults: { + skills: ["github"], + }, + list: [{ id: "writer" }], + }, + }, + workspaceDir: "/tmp/openclaw", + agentId: "writer", + }); + + expect(prompt).toContain("/app/skills/github/SKILL.md"); + expect(prompt).not.toContain("/app/skills/hidden-skill/SKILL.md"); + }); + + it("uses agents.list[].skills as a full replacement for defaults", () => { + const inheritedEntry: SkillEntry = { + skill: createFixtureSkill({ + name: "weather", + description: "Weather", + filePath: "/app/skills/weather/SKILL.md", + baseDir: "/app/skills/weather", + source: "openclaw-workspace", + }), + frontmatter: {}, + }; + const explicitEntry: SkillEntry = { + skill: createFixtureSkill({ + name: "docs-search", + description: "Docs", + filePath: "/app/skills/docs-search/SKILL.md", + baseDir: "/app/skills/docs-search", + source: "openclaw-workspace", + }), + frontmatter: {}, + }; + + const prompt = resolveSkillsPromptForRun({ + entries: [inheritedEntry, explicitEntry], + config: { + agents: { + defaults: { + skills: ["weather"], + }, + list: [{ id: "writer", skills: ["docs-search"] }], + }, + }, + workspaceDir: "/tmp/openclaw", + agentId: "writer", + }); + + expect(prompt).not.toContain("/app/skills/weather/SKILL.md"); + expect(prompt).toContain("/app/skills/docs-search/SKILL.md"); + }); }); function createFixtureSkill(params: { diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index 28ba7912ea6..8f1185fdd66 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -164,6 +164,35 @@ describe("buildWorkspaceSkillCommandSpecs", () => { expect(cmd?.dispatch).toEqual({ kind: "tool", toolName: "sessions_send", argMode: "raw" }); }); + it("inherits agents.defaults.skills when agentId is provided", async () => { + const workspaceDir = await makeWorkspace(); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "alpha-skill"), + name: "alpha-skill", + description: "Alpha skill", + }); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "beta-skill"), + name: "beta-skill", + description: "Beta skill", + }); + + const commands = buildWorkspaceSkillCommandSpecs(workspaceDir, { + ...resolveTestSkillDirs(workspaceDir), + config: { + agents: { + defaults: { + skills: ["alpha-skill"], + }, + list: [{ id: "writer", workspace: workspaceDir }], + }, + }, + agentId: "writer", + }); + + expect(commands.map((entry) => entry.skillName)).toEqual(["alpha-skill"]); + }); + it("includes enabled Claude bundle markdown commands as native OpenClaw slash commands", async () => { const workspaceDir = await makeWorkspace(); const pluginRoot = path.join(tempHome!.home, ".openclaw", "extensions", "compound-bundle"); diff --git a/src/agents/skills/agent-filter.ts b/src/agents/skills/agent-filter.ts new file mode 100644 index 00000000000..76fdf6ad359 --- /dev/null +++ b/src/agents/skills/agent-filter.ts @@ -0,0 +1,24 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; +import { normalizeSkillFilter } from "./filter.js"; + +/** + * Explicit per-agent skills win when present; otherwise fall back to shared defaults. + * Unknown agent ids also fall back to defaults so legacy/unresolved callers do not widen access. + */ +export function resolveEffectiveAgentSkillFilter( + cfg: OpenClawConfig | undefined, + agentId: string | undefined, +): string[] | undefined { + if (!cfg) { + return undefined; + } + const normalizedAgentId = normalizeAgentId(agentId); + const agentEntry = cfg.agents?.list?.find( + (entry) => normalizeAgentId(entry.id) === normalizedAgentId, + ); + if (agentEntry && Object.hasOwn(agentEntry, "skills")) { + return normalizeSkillFilter(agentEntry.skills); + } + return normalizeSkillFilter(cfg.agents?.defaults?.skills); +} diff --git a/src/agents/skills/command-specs.ts b/src/agents/skills/command-specs.ts index 2a27039e902..30bc7d6a3d3 100644 --- a/src/agents/skills/command-specs.ts +++ b/src/agents/skills/command-specs.ts @@ -1,8 +1,12 @@ import type { OpenClawConfig } from "../../config/config.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { loadEnabledClaudeBundleCommands } from "../../plugins/bundle-commands.js"; +import { resolveEffectiveAgentSkillFilter } from "./agent-filter.js"; import type { SkillEligibilityContext, SkillCommandSpec, SkillEntry } from "./types.js"; -import { filterWorkspaceSkillEntriesWithOptions, loadWorkspaceSkillEntries } from "./workspace.js"; +import { + filterWorkspaceSkillEntriesWithOptions, + loadVisibleWorkspaceSkillEntries, +} from "./workspace.js"; const skillsLogger = createSubsystemLogger("skills"); const skillCommandDebugOnce = new Set(); @@ -57,17 +61,27 @@ export function buildWorkspaceSkillCommandSpecs( managedSkillsDir?: string; bundledSkillsDir?: string; entries?: SkillEntry[]; + agentId?: string; skillFilter?: string[]; eligibility?: SkillEligibilityContext; reservedNames?: Set; }, ): SkillCommandSpec[] { - const skillEntries = opts?.entries ?? loadWorkspaceSkillEntries(workspaceDir, opts); - const eligible = filterWorkspaceSkillEntriesWithOptions(skillEntries, { - config: opts?.config, - skillFilter: opts?.skillFilter, - eligibility: opts?.eligibility, - }); + const effectiveSkillFilter = + opts?.skillFilter ?? resolveEffectiveAgentSkillFilter(opts?.config, opts?.agentId); + const eligible = opts?.entries + ? filterWorkspaceSkillEntriesWithOptions(opts.entries, { + config: opts?.config, + skillFilter: effectiveSkillFilter, + eligibility: opts?.eligibility, + }) + : loadVisibleWorkspaceSkillEntries(workspaceDir, { + config: opts?.config, + managedSkillsDir: opts?.managedSkillsDir, + bundledSkillsDir: opts?.bundledSkillsDir, + skillFilter: effectiveSkillFilter, + eligibility: opts?.eligibility, + }); const userInvocable = eligible.filter((entry) => entry.invocation?.userInvocable !== false); const used = new Set(); for (const reserved of opts?.reservedNames ?? []) { diff --git a/src/agents/skills/refresh-state.ts b/src/agents/skills/refresh-state.ts index a4f2de5851e..45054dda512 100644 --- a/src/agents/skills/refresh-state.ts +++ b/src/agents/skills/refresh-state.ts @@ -62,6 +62,15 @@ export function getSkillsSnapshotVersion(workspaceDir?: string): number { return Math.max(globalVersion, local); } +export function shouldRefreshSnapshotForVersion( + cachedVersion?: number, + nextVersion?: number, +): boolean { + const cached = typeof cachedVersion === "number" ? cachedVersion : 0; + const next = typeof nextVersion === "number" ? nextVersion : 0; + return next === 0 ? cached > 0 : cached < next; +} + export function resetSkillsRefreshStateForTest(): void { listeners.clear(); workspaceVersions.clear(); diff --git a/src/agents/skills/refresh.ts b/src/agents/skills/refresh.ts index 80b942ac605..f6293e0becd 100644 --- a/src/agents/skills/refresh.ts +++ b/src/agents/skills/refresh.ts @@ -12,11 +12,13 @@ import { registerSkillsChangeListener, resetSkillsRefreshStateForTest, setSkillsChangeListenerErrorHandler, + shouldRefreshSnapshotForVersion, } from "./refresh-state.js"; export { bumpSkillsSnapshotVersion, getSkillsSnapshotVersion, registerSkillsChangeListener, + shouldRefreshSnapshotForVersion, type SkillsChangeEvent, } from "./refresh-state.js"; diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index e695d363440..e2850ad33f6 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -6,6 +6,7 @@ import { isPathInside } from "../../infra/path-guards.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { CONFIG_DIR, resolveUserPath } from "../../utils.js"; import { resolveSandboxPath } from "../sandbox-paths.js"; +import { resolveEffectiveAgentSkillFilter } from "./agent-filter.js"; import { resolveBundledSkillsDir } from "./bundled-dir.js"; import { shouldIncludeSkill } from "./config.js"; import { normalizeSkillFilter } from "./filter.js"; @@ -568,7 +569,7 @@ export function buildWorkspaceSkillSnapshot( opts?: WorkspaceSkillBuildOptions & { snapshotVersion?: number }, ): SkillSnapshot { const { eligible, prompt, resolvedSkills } = resolveWorkspaceSkillPromptState(workspaceDir, opts); - const skillFilter = normalizeSkillFilter(opts?.skillFilter); + const skillFilter = resolveEffectiveWorkspaceSkillFilter(opts); return { prompt, skills: eligible.map((entry) => ({ @@ -594,11 +595,24 @@ type WorkspaceSkillBuildOptions = { managedSkillsDir?: string; bundledSkillsDir?: string; entries?: SkillEntry[]; + agentId?: string; /** If provided, only include skills with these names */ skillFilter?: string[]; eligibility?: SkillEligibilityContext; }; +function resolveEffectiveWorkspaceSkillFilter( + opts?: WorkspaceSkillBuildOptions, +): string[] | undefined { + if (opts?.skillFilter !== undefined) { + return normalizeSkillFilter(opts.skillFilter); + } + if (!opts?.config || !opts.agentId) { + return undefined; + } + return resolveEffectiveAgentSkillFilter(opts.config, opts.agentId); +} + function resolveWorkspaceSkillPromptState( workspaceDir: string, opts?: WorkspaceSkillBuildOptions, @@ -608,10 +622,11 @@ function resolveWorkspaceSkillPromptState( resolvedSkills: Skill[]; } { const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts); + const effectiveSkillFilter = resolveEffectiveWorkspaceSkillFilter(opts); const eligible = filterSkillEntries( skillEntries, opts?.config, - opts?.skillFilter, + effectiveSkillFilter, opts?.eligibility, ); const promptEntries = eligible.filter( @@ -648,6 +663,7 @@ export function resolveSkillsPromptForRun(params: { entries?: SkillEntry[]; config?: OpenClawConfig; workspaceDir: string; + agentId?: string; }): string { const snapshotPrompt = params.skillsSnapshot?.prompt?.trim(); if (snapshotPrompt) { @@ -657,6 +673,7 @@ export function resolveSkillsPromptForRun(params: { const prompt = buildWorkspaceSkillsPrompt(params.workspaceDir, { entries: params.entries, config: params.config, + agentId: params.agentId, }); return prompt.trim() ? prompt : ""; } @@ -669,9 +686,32 @@ export function loadWorkspaceSkillEntries( config?: OpenClawConfig; managedSkillsDir?: string; bundledSkillsDir?: string; + skillFilter?: string[]; + agentId?: string; }, ): SkillEntry[] { - return loadSkillEntries(workspaceDir, opts); + const entries = loadSkillEntries(workspaceDir, opts); + const effectiveSkillFilter = resolveEffectiveWorkspaceSkillFilter(opts); + if (effectiveSkillFilter === undefined) { + return entries; + } + return filterSkillEntries(entries, opts?.config, effectiveSkillFilter, undefined); +} + +export function loadVisibleWorkspaceSkillEntries( + workspaceDir: string, + opts?: { + config?: OpenClawConfig; + managedSkillsDir?: string; + bundledSkillsDir?: string; + skillFilter?: string[]; + agentId?: string; + eligibility?: SkillEligibilityContext; + }, +): SkillEntry[] { + const entries = loadSkillEntries(workspaceDir, opts); + const effectiveSkillFilter = resolveEffectiveWorkspaceSkillFilter(opts); + return filterSkillEntries(entries, opts?.config, effectiveSkillFilter, opts?.eligibility); } function resolveUniqueSyncedSkillDirName(base: string, used: Set): string { @@ -717,6 +757,8 @@ export async function syncSkillsToWorkspace(params: { sourceWorkspaceDir: string; targetWorkspaceDir: string; config?: OpenClawConfig; + skillFilter?: string[]; + agentId?: string; managedSkillsDir?: string; bundledSkillsDir?: string; }) { @@ -729,8 +771,10 @@ export async function syncSkillsToWorkspace(params: { await serializeByKey(`syncSkills:${targetDir}`, async () => { const targetSkillsDir = path.join(targetDir, "skills"); - const entries = loadSkillEntries(sourceDir, { + const entries = loadWorkspaceSkillEntries(sourceDir, { config: params.config, + skillFilter: params.skillFilter, + agentId: params.agentId, managedSkillsDir: params.managedSkillsDir, bundledSkillsDir: params.bundledSkillsDir, }); diff --git a/src/auto-reply/reply/commands-system-prompt.ts b/src/auto-reply/reply/commands-system-prompt.ts index 18b2e337d72..219f5bfc99a 100644 --- a/src/auto-reply/reply/commands-system-prompt.ts +++ b/src/auto-reply/reply/commands-system-prompt.ts @@ -28,6 +28,11 @@ export async function resolveCommandsSystemPromptBundle( params: HandleCommandsParams, ): Promise { const workspaceDir = params.workspaceDir; + const { sessionAgentId } = resolveSessionAgentIds({ + sessionKey: params.sessionKey, + config: params.cfg, + agentId: params.agentId, + }); const { bootstrapFiles, contextFiles: injectedFiles } = await resolveBootstrapContextForRun({ workspaceDir, config: params.cfg, @@ -38,6 +43,7 @@ export async function resolveCommandsSystemPromptBundle( try { return buildWorkspaceSkillSnapshot(workspaceDir, { config: params.cfg, + agentId: sessionAgentId, eligibility: { remote: getRemoteSkillEligibility() }, snapshotVersion: getSkillsSnapshotVersion(workspaceDir), }); @@ -73,11 +79,6 @@ export async function resolveCommandsSystemPromptBundle( })(); const toolSummaries = buildToolSummaryMap(tools); const toolNames = tools.map((t) => t.name); - const { sessionAgentId } = resolveSessionAgentIds({ - sessionKey: params.sessionKey, - config: params.cfg, - agentId: params.agentId, - }); const defaultModelRef = resolveDefaultModelForAgent({ cfg: params.cfg, agentId: sessionAgentId, diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index 7b0e186052a..2bacec89aeb 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -225,6 +225,7 @@ export async function resolveReplyDirectives(params: { ? (await loadSkillCommands()).listSkillCommandsForWorkspace({ workspaceDir, cfg, + agentId, skillFilter, }) : []; diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index a025c7cdd7e..ea85fbcf79c 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -193,6 +193,7 @@ export async function handleInlineActions(params: { ? (await import("../skill-commands.runtime.js")).listSkillCommandsForWorkspace({ workspaceDir, cfg, + agentId, skillFilter, }) : []; diff --git a/src/auto-reply/reply/session-updates.test.ts b/src/auto-reply/reply/session-updates.test.ts new file mode 100644 index 00000000000..5e02b2199fc --- /dev/null +++ b/src/auto-reply/reply/session-updates.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + buildWorkspaceSkillSnapshotMock, + ensureSkillsWatcherMock, + getSkillsSnapshotVersionMock, + shouldRefreshSnapshotForVersionMock, + getRemoteSkillEligibilityMock, + resolveSessionAgentIdMock, + resolveAgentIdFromSessionKeyMock, +} = vi.hoisted(() => ({ + buildWorkspaceSkillSnapshotMock: vi.fn(() => ({ prompt: "", skills: [], resolvedSkills: [] })), + ensureSkillsWatcherMock: vi.fn(), + getSkillsSnapshotVersionMock: vi.fn(() => 0), + shouldRefreshSnapshotForVersionMock: vi.fn(() => false), + getRemoteSkillEligibilityMock: vi.fn(() => ({ + platforms: [], + hasBin: () => false, + hasAnyBin: () => false, + })), + resolveSessionAgentIdMock: vi.fn(() => "writer"), + resolveAgentIdFromSessionKeyMock: vi.fn(() => "main"), +})); + +vi.mock("../../agents/agent-scope.js", () => ({ + resolveSessionAgentId: resolveSessionAgentIdMock, +})); + +vi.mock("../../agents/skills.js", () => ({ + buildWorkspaceSkillSnapshot: buildWorkspaceSkillSnapshotMock, +})); + +vi.mock("../../agents/skills/refresh.js", () => ({ + ensureSkillsWatcher: ensureSkillsWatcherMock, + getSkillsSnapshotVersion: getSkillsSnapshotVersionMock, + shouldRefreshSnapshotForVersion: shouldRefreshSnapshotForVersionMock, +})); + +vi.mock("../../config/sessions.js", () => ({ + updateSessionStore: vi.fn(), + resolveSessionFilePath: vi.fn(), + resolveSessionFilePathOptions: vi.fn(), +})); + +vi.mock("../../infra/skills-remote.js", () => ({ + getRemoteSkillEligibility: getRemoteSkillEligibilityMock, +})); + +vi.mock("../../routing/session-key.js", () => ({ + resolveAgentIdFromSessionKey: resolveAgentIdFromSessionKeyMock, +})); + +const { ensureSkillSnapshot } = await import("./session-updates.js"); + +describe("ensureSkillSnapshot", () => { + beforeEach(() => { + vi.clearAllMocks(); + buildWorkspaceSkillSnapshotMock.mockReturnValue({ prompt: "", skills: [], resolvedSkills: [] }); + getSkillsSnapshotVersionMock.mockReturnValue(0); + shouldRefreshSnapshotForVersionMock.mockReturnValue(false); + getRemoteSkillEligibilityMock.mockReturnValue({ + platforms: [], + hasBin: () => false, + hasAnyBin: () => false, + }); + resolveSessionAgentIdMock.mockReturnValue("writer"); + resolveAgentIdFromSessionKeyMock.mockReturnValue("main"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("uses config-aware session agent resolution for legacy session keys", async () => { + vi.stubEnv("OPENCLAW_TEST_FAST", "0"); + + await ensureSkillSnapshot({ + sessionKey: "main", + isFirstTurnInSession: false, + workspaceDir: "/tmp/workspace", + cfg: { + agents: { + list: [{ id: "writer", default: true }], + }, + }, + }); + + expect(resolveSessionAgentIdMock).toHaveBeenCalledWith({ + sessionKey: "main", + config: { + agents: { + list: [{ id: "writer", default: true }], + }, + }, + }); + expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledWith( + "/tmp/workspace", + expect.objectContaining({ agentId: "writer" }), + ); + expect(resolveAgentIdFromSessionKeyMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 9bc9f6a9584..7bea5d18a9d 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -1,8 +1,14 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; -import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; +import { matchesSkillFilter } from "../../agents/skills/filter.js"; +import { + ensureSkillsWatcher, + getSkillsSnapshotVersion, + shouldRefreshSnapshotForVersion, +} from "../../agents/skills/refresh.js"; import type { OpenClawConfig } from "../../config/config.js"; import { resolveSessionFilePath, @@ -127,9 +133,20 @@ export async function ensureSkillSnapshot(params: { let systemSent = sessionEntry?.systemSent ?? false; const remoteEligibility = getRemoteSkillEligibility(); const snapshotVersion = getSkillsSnapshotVersion(workspaceDir); + const sessionAgentId = resolveSessionAgentId({ sessionKey, config: cfg }); + const existingSnapshot = nextEntry?.skillsSnapshot; ensureSkillsWatcher({ workspaceDir, config: cfg }); const shouldRefreshSnapshot = - snapshotVersion > 0 && (nextEntry?.skillsSnapshot?.version ?? 0) < snapshotVersion; + shouldRefreshSnapshotForVersion(existingSnapshot?.version, snapshotVersion) || + !matchesSkillFilter(existingSnapshot?.skillFilter, skillFilter); + const buildSnapshot = () => + buildWorkspaceSkillSnapshot(workspaceDir, { + config: cfg, + agentId: sessionAgentId, + skillFilter, + eligibility: { remote: remoteEligibility }, + snapshotVersion, + }); if (isFirstTurnInSession && sessionStore && sessionKey) { const current = nextEntry ?? @@ -138,14 +155,7 @@ export async function ensureSkillSnapshot(params: { updatedAt: Date.now(), }; const skillSnapshot = - isFirstTurnInSession || !current.skillsSnapshot || shouldRefreshSnapshot - ? buildWorkspaceSkillSnapshot(workspaceDir, { - config: cfg, - skillFilter, - eligibility: { remote: remoteEligibility }, - snapshotVersion, - }) - : current.skillsSnapshot; + !current.skillsSnapshot || shouldRefreshSnapshot ? buildSnapshot() : current.skillsSnapshot; nextEntry = { ...current, sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(), @@ -157,22 +167,14 @@ export async function ensureSkillSnapshot(params: { systemSent = true; } - const skillsSnapshot = shouldRefreshSnapshot - ? buildWorkspaceSkillSnapshot(workspaceDir, { - config: cfg, - skillFilter, - eligibility: { remote: remoteEligibility }, - snapshotVersion, - }) - : (nextEntry?.skillsSnapshot ?? - (isFirstTurnInSession - ? undefined - : buildWorkspaceSkillSnapshot(workspaceDir, { - config: cfg, - skillFilter, - eligibility: { remote: remoteEligibility }, - snapshotVersion, - }))); + const hasFreshSnapshotInEntry = + Boolean(nextEntry?.skillsSnapshot) && + (nextEntry?.skillsSnapshot !== existingSnapshot || !shouldRefreshSnapshot); + const skillsSnapshot = hasFreshSnapshotInEntry + ? nextEntry?.skillsSnapshot + : shouldRefreshSnapshot || !nextEntry?.skillsSnapshot + ? buildSnapshot() + : nextEntry.skillsSnapshot; if ( skillsSnapshot && sessionStore && diff --git a/src/auto-reply/skill-commands.test.ts b/src/auto-reply/skill-commands.test.ts index 0c31f2a59d6..569a38f3b5c 100644 --- a/src/auto-reply/skill-commands.test.ts +++ b/src/auto-reply/skill-commands.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; let listSkillCommandsForAgents: typeof import("./skill-commands.js").listSkillCommandsForAgents; +let listSkillCommandsForWorkspace: typeof import("./skill-commands.js").listSkillCommandsForWorkspace; let resolveSkillCommandInvocation: typeof import("./skill-commands.js").resolveSkillCommandInvocation; let skillCommandsTesting: typeof import("./skill-commands.js").__testing; @@ -33,18 +34,40 @@ function resolveWorkspaceSkills( { skillName: "extra-skill", description: "Extra skill" }, ]; } + if (dirName === "shared-defaults") { + return [ + { skillName: "alpha-skill", description: "Alpha skill" }, + { skillName: "beta-skill", description: "Beta skill" }, + { skillName: "hidden-skill", description: "Hidden skill" }, + ]; + } return []; } function buildWorkspaceSkillCommandSpecs( workspaceDir: string, - opts?: { reservedNames?: Set; skillFilter?: string[] }, + opts?: { + reservedNames?: Set; + skillFilter?: string[]; + agentId?: string; + config?: { + agents?: { + defaults?: { skills?: string[] }; + list?: Array<{ id: string; skills?: string[] }>; + }; + }; + }, ) { const used = new Set(); for (const reserved of opts?.reservedNames ?? []) { used.add(String(reserved).toLowerCase()); } - const filter = opts?.skillFilter; + const agentSkills = opts?.config?.agents?.list?.find((entry) => entry.id === opts?.agentId); + const filter = + opts?.skillFilter ?? + (agentSkills && Object.hasOwn(agentSkills, "skills") + ? agentSkills.skills + : opts?.config?.agents?.defaults?.skills); const entries = filter === undefined ? resolveWorkspaceSkills(workspaceDir) @@ -84,6 +107,7 @@ async function loadFreshSkillCommandsModuleForTest() { installSkillCommandTestMocks(registerDynamicSkillCommandMock); ({ listSkillCommandsForAgents, + listSkillCommandsForWorkspace, resolveSkillCommandInvocation, __testing: skillCommandsTesting, } = await import("./skill-commands.js")); @@ -292,6 +316,53 @@ describe("listSkillCommandsForAgents", () => { expect(commands.map((entry) => entry.skillName)).toEqual(["extra-skill"]); }); + it("uses inherited defaults for agents that share one workspace", async () => { + const baseDir = await makeTempDir("openclaw-skills-defaults-"); + const sharedWorkspace = path.join(baseDir, "shared-defaults"); + await fs.mkdir(sharedWorkspace, { recursive: true }); + + const commands = listSkillCommandsForAgents({ + cfg: { + agents: { + defaults: { + skills: ["alpha-skill"], + }, + list: [ + { id: "alpha", workspace: sharedWorkspace }, + { id: "beta", workspace: sharedWorkspace, skills: ["beta-skill"] }, + { id: "gamma", workspace: sharedWorkspace }, + ], + }, + }, + agentIds: ["alpha", "beta", "gamma"], + }); + + expect(commands.map((entry) => entry.skillName)).toEqual(["alpha-skill", "beta-skill"]); + }); + + it("does not inherit defaults when an agent sets an explicit empty skills list", async () => { + const baseDir = await makeTempDir("openclaw-skills-defaults-empty-"); + const sharedWorkspace = path.join(baseDir, "shared-defaults"); + await fs.mkdir(sharedWorkspace, { recursive: true }); + + const commands = listSkillCommandsForAgents({ + cfg: { + agents: { + defaults: { + skills: ["alpha-skill", "hidden-skill"], + }, + list: [ + { id: "alpha", workspace: sharedWorkspace, skills: [] }, + { id: "beta", workspace: sharedWorkspace, skills: ["beta-skill"] }, + ], + }, + }, + agentIds: ["alpha", "beta"], + }); + + expect(commands.map((entry) => entry.skillName)).toEqual(["beta-skill"]); + }); + it("skips agents with missing workspaces gracefully", async () => { const baseDir = await makeTempDir("openclaw-skills-missing-"); const validWorkspace = path.join(baseDir, "research"); @@ -316,6 +387,41 @@ describe("listSkillCommandsForAgents", () => { }); }); +describe("listSkillCommandsForWorkspace", () => { + const tempDirs: string[] = []; + const makeTempDir = async (prefix: string) => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; + }; + afterAll(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + + it("inherits defaults when agentId is provided without an explicit skill filter", async () => { + const baseDir = await makeTempDir("openclaw-skills-workspace-defaults-"); + const sharedWorkspace = path.join(baseDir, "shared-defaults"); + await fs.mkdir(sharedWorkspace, { recursive: true }); + + const commands = listSkillCommandsForWorkspace({ + workspaceDir: sharedWorkspace, + cfg: { + agents: { + defaults: { + skills: ["alpha-skill"], + }, + list: [{ id: "alpha", workspace: sharedWorkspace }], + }, + }, + agentId: "alpha", + }); + + expect(commands.map((entry) => entry.skillName)).toEqual(["alpha-skill"]); + }); +}); + describe("dedupeBySkillName", () => { it("keeps the first entry when multiple commands share a skillName", () => { const input = [ diff --git a/src/auto-reply/skill-commands.ts b/src/auto-reply/skill-commands.ts index 6e2e84b87f0..d355b781f89 100644 --- a/src/auto-reply/skill-commands.ts +++ b/src/auto-reply/skill-commands.ts @@ -17,10 +17,12 @@ export { export function listSkillCommandsForWorkspace(params: { workspaceDir: string; cfg: OpenClawConfig; + agentId?: string; skillFilter?: string[]; }): SkillCommandSpec[] { return buildWorkspaceSkillCommandSpecs(params.workspaceDir, { config: params.cfg, + agentId: params.agentId, skillFilter: params.skillFilter, eligibility: { remote: getRemoteSkillEligibility() }, reservedNames: listReservedChatSlashCommandNames(), diff --git a/src/config/config.skills-entries-config.test.ts b/src/config/config.skills-entries-config.test.ts index c0398219642..4ce0e390466 100644 --- a/src/config/config.skills-entries-config.test.ts +++ b/src/config/config.skills-entries-config.test.ts @@ -44,4 +44,64 @@ describe("skills entries config schema", () => { ), ).toBe(true); }); + + it("accepts agents.defaults.skills", () => { + const res = OpenClawSchema.safeParse({ + agents: { + defaults: { + skills: ["github", "weather"], + }, + }, + }); + + expect(res.success).toBe(true); + }); + + it("accepts agents.list[].skills as explicit replacements", () => { + const res = OpenClawSchema.safeParse({ + agents: { + defaults: { + skills: ["github", "weather"], + }, + list: [{ id: "writer", skills: ["docs-search"] }], + }, + }); + + expect(res.success).toBe(true); + }); + + it("accepts explicit empty skills arrays for defaults and agents", () => { + const res = OpenClawSchema.safeParse({ + agents: { + defaults: { + skills: [], + }, + list: [{ id: "writer", skills: [] }], + }, + }); + + expect(res.success).toBe(true); + }); + + it("rejects legacy skills.policy config", () => { + const res = OpenClawSchema.safeParse({ + skills: { + policy: { + globalEnabled: ["github"], + } as never, + }, + }); + + expect(res.success).toBe(false); + if (res.success) { + return; + } + + expect( + res.error.issues.some( + (issue) => + issue.path.join(".") === "skills" && issue.message.toLowerCase().includes("unrecognized"), + ), + ).toBe(true); + }); }); diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index f01cd58c982..6265e28d15b 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -2627,6 +2627,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA = { workspace: { type: "string", }, + skills: { + type: "array", + items: { + type: "string", + }, + }, repoRoot: { type: "string", }, @@ -20387,7 +20393,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA = { }, "agents.list.*.skills": { label: "Agent Skill Filter", - help: "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", + help: "Optional allowlist of skills for this agent. If omitted, the agent inherits agents.defaults.skills when set; otherwise skills stay unrestricted. Set [] for no skills. An explicit list fully replaces inherited defaults instead of merging with them.", tags: ["advanced"], }, "agents.list[].runtime": { @@ -21778,6 +21784,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA = { help: "Debounce window in milliseconds for coalescing rapid skill file changes before reload logic runs. Increase to reduce reload churn on frequent writes, or lower for faster edit feedback.", tags: ["performance", "automation"], }, + "agents.defaults.skills": { + label: "Skills", + help: "Optional default skill allowlist inherited by agents that omit agents.list[].skills. Omit for unrestricted skills, set [] to give inheriting agents no skills, and remember explicit agents.list[].skills replaces this default instead of merging with it.", + tags: ["advanced"], + }, "agents.defaults.workspace": { label: "Workspace", help: "Default workspace path exposed to agent runtime tools for filesystem context and repo-aware behavior. Set this explicitly when running from wrappers so path resolution stays deterministic.", @@ -23914,7 +23925,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA = { }, "agents.list[].skills": { label: "Agent Skill Filter", - help: "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", + help: "Optional allowlist of skills for this agent. If omitted, the agent inherits agents.defaults.skills when set; otherwise skills stay unrestricted. Set [] for no skills. An explicit list fully replaces inherited defaults instead of merging with them.", tags: ["advanced"], }, "agents.list[].identity.avatar": { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 18579ab2ee9..656ef890bab 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -190,13 +190,15 @@ export const FIELD_HELP: Record = { "acp.runtime.installCommand": "Optional operator install/setup command shown by `/acp install` and `/acp doctor` when ACP backend wiring is missing.", "agents.list.*.skills": - "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", + "Optional allowlist of skills for this agent. If omitted, the agent inherits agents.defaults.skills when set; otherwise skills stay unrestricted. Set [] for no skills. An explicit list fully replaces inherited defaults instead of merging with them.", "agents.list[].skills": - "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", + "Optional allowlist of skills for this agent. If omitted, the agent inherits agents.defaults.skills when set; otherwise skills stay unrestricted. Set [] for no skills. An explicit list fully replaces inherited defaults instead of merging with them.", agents: "Agent runtime configuration root covering defaults and explicit agent entries used for routing and execution context. Keep this section explicit so model/tool behavior stays predictable across multi-agent workflows.", "agents.defaults": "Shared default settings inherited by agents unless overridden per entry in agents.list. Use defaults to enforce consistent baseline behavior and reduce duplicated per-agent configuration.", + "agents.defaults.skills": + "Optional default skill allowlist inherited by agents that omit agents.list[].skills. Omit for unrestricted skills, set [] to give inheriting agents no skills, and remember explicit agents.list[].skills replaces this default instead of merging with it.", "agents.list": "Explicit list of configured agents with IDs and optional overrides for model, tools, identity, and workspace. Keep IDs stable over time so bindings, approvals, and session routing remain deterministic.", "agents.list[].thinkingDefault": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 99a8b9f2eb7..8bacd9c3f74 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -322,6 +322,7 @@ export const FIELD_LABELS: Record = { "broadcast.*": "Broadcast Destination List", "skills.load.watch": "Watch Skills", "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", + "agents.defaults.skills": "Skills", "agents.defaults.workspace": "Workspace", "agents.defaults.repoRoot": "Repo Root", "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 2f29fe1f92f..0ec3d4ca511 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -136,6 +136,8 @@ export type AgentDefaultsConfig = { models?: Record; /** Agent working directory (preferred). Used as the default cwd for agent runs. */ workspace?: string; + /** Optional default allowlist of skills for agents that do not set agents.list[].skills. */ + skills?: string[]; /** Optional repository root for system prompt runtime line (overrides auto-detect). */ repoRoot?: string; /** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */ diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index 528fb089307..14a8b487956 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -71,7 +71,7 @@ export type AgentConfig = { reasoningDefault?: "on" | "off" | "stream"; /** Optional per-agent default for fast mode. */ fastModeDefault?: boolean; - /** Optional allowlist of skills for this agent (omit = all skills; empty = none). */ + /** Optional allowlist of skills for this agent; omitting it inherits agents.defaults.skills when set, and an explicit list replaces defaults instead of merging. */ skills?: string[]; memorySearch?: MemorySearchConfig; /** Human-like delay between block replies for this agent. */ diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index cac1204125d..d0702615a1f 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -39,6 +39,7 @@ export const AgentDefaultsSchema = z ) .optional(), workspace: z.string().optional(), + skills: z.array(z.string()).optional(), repoRoot: z.string().optional(), skipBootstrap: z.boolean().optional(), bootstrapMaxChars: z.number().int().positive().optional(), diff --git a/src/cron/isolated-agent/skills-snapshot.test.ts b/src/cron/isolated-agent/skills-snapshot.test.ts new file mode 100644 index 00000000000..3e0db14515a --- /dev/null +++ b/src/cron/isolated-agent/skills-snapshot.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + buildWorkspaceSkillSnapshotMock, + getRemoteSkillEligibilityMock, + getSkillsSnapshotVersionMock, + resolveAgentSkillsFilterMock, +} = vi.hoisted(() => ({ + buildWorkspaceSkillSnapshotMock: vi.fn(), + getRemoteSkillEligibilityMock: vi.fn(), + getSkillsSnapshotVersionMock: vi.fn(), + resolveAgentSkillsFilterMock: vi.fn(), +})); + +vi.mock("./run.runtime.js", () => ({ + buildWorkspaceSkillSnapshot: buildWorkspaceSkillSnapshotMock, + getRemoteSkillEligibility: getRemoteSkillEligibilityMock, + getSkillsSnapshotVersion: getSkillsSnapshotVersionMock, + resolveAgentSkillsFilter: resolveAgentSkillsFilterMock, +})); + +const { resolveCronSkillsSnapshot } = await import("./skills-snapshot.js"); + +describe("resolveCronSkillsSnapshot", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSkillsSnapshotVersionMock.mockReturnValue(0); + resolveAgentSkillsFilterMock.mockReturnValue(undefined); + getRemoteSkillEligibilityMock.mockReturnValue({ + platforms: [], + hasBin: () => false, + hasAnyBin: () => false, + }); + buildWorkspaceSkillSnapshotMock.mockReturnValue({ prompt: "fresh", skills: [] }); + }); + + it("refreshes when the cached skill filter changes", () => { + resolveAgentSkillsFilterMock.mockReturnValue(["docs-search", "github"]); + + const result = resolveCronSkillsSnapshot({ + workspaceDir: "/tmp/workspace", + config: {} as never, + agentId: "writer", + existingSnapshot: { + prompt: "old", + skills: [{ name: "github" }], + skillFilter: ["github"], + version: 0, + }, + isFastTestEnv: false, + }); + + expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce(); + expect(buildWorkspaceSkillSnapshotMock.mock.calls[0]?.[1]).toMatchObject({ + agentId: "writer", + snapshotVersion: 0, + }); + expect(result).toEqual({ prompt: "fresh", skills: [] }); + }); + + it("refreshes when the process version resets to 0 but the cached snapshot is stale", () => { + getSkillsSnapshotVersionMock.mockReturnValue(0); + + resolveCronSkillsSnapshot({ + workspaceDir: "/tmp/workspace", + config: {} as never, + agentId: "writer", + existingSnapshot: { + prompt: "old", + skills: [{ name: "github" }], + version: 42, + }, + isFastTestEnv: false, + }); + + expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/cron/isolated-agent/skills-snapshot.ts b/src/cron/isolated-agent/skills-snapshot.ts index 82d9981350d..6f12bf3b5f5 100644 --- a/src/cron/isolated-agent/skills-snapshot.ts +++ b/src/cron/isolated-agent/skills-snapshot.ts @@ -33,6 +33,7 @@ export function resolveCronSkillsSnapshot(params: { return buildWorkspaceSkillSnapshot(params.workspaceDir, { config: params.config, + agentId: params.agentId, skillFilter, eligibility: { remote: getRemoteSkillEligibility() }, snapshotVersion,