feat(skills): add inherited agent skill allowlists (#59992)

Merged via squash.

Prepared head SHA: 6f60779a57
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana 2026-04-03 13:41:28 -04:00 committed by GitHub
parent 04f59a7227
commit ddd250d130
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 910 additions and 60 deletions

View File

@ -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

View File

@ -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
},
{

View File

@ -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
},
{

View File

@ -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 ?? {

View File

@ -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([]);
});
});

View File

@ -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<typeof createSubsystemLogger> | 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 {

View File

@ -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;

View File

@ -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,

View File

@ -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: {

View File

@ -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 })
: [],
};
}

View File

@ -23,6 +23,7 @@ import { ensureSandboxWorkspace } from "./workspace.js";
async function ensureSandboxWorkspaceLayout(params: {
cfg: ReturnType<typeof resolveSandboxConfigForAgent>;
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,

View File

@ -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 () => {

View File

@ -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");

View File

@ -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 () => {

View File

@ -29,6 +29,86 @@ describe("resolveSkillsPromptForRun", () => {
expect(prompt).toContain("<available_skills>");
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: {

View File

@ -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");

View File

@ -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);
}

View File

@ -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<string>();
@ -57,17 +61,27 @@ export function buildWorkspaceSkillCommandSpecs(
managedSkillsDir?: string;
bundledSkillsDir?: string;
entries?: SkillEntry[];
agentId?: string;
skillFilter?: string[];
eligibility?: SkillEligibilityContext;
reservedNames?: Set<string>;
},
): 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<string>();
for (const reserved of opts?.reservedNames ?? []) {

View File

@ -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();

View File

@ -12,11 +12,13 @@ import {
registerSkillsChangeListener,
resetSkillsRefreshStateForTest,
setSkillsChangeListenerErrorHandler,
shouldRefreshSnapshotForVersion,
} from "./refresh-state.js";
export {
bumpSkillsSnapshotVersion,
getSkillsSnapshotVersion,
registerSkillsChangeListener,
shouldRefreshSnapshotForVersion,
type SkillsChangeEvent,
} from "./refresh-state.js";

View File

@ -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>): 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,
});

View File

@ -28,6 +28,11 @@ export async function resolveCommandsSystemPromptBundle(
params: HandleCommandsParams,
): Promise<CommandsSystemPromptBundle> {
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,

View File

@ -225,6 +225,7 @@ export async function resolveReplyDirectives(params: {
? (await loadSkillCommands()).listSkillCommandsForWorkspace({
workspaceDir,
cfg,
agentId,
skillFilter,
})
: [];

View File

@ -193,6 +193,7 @@ export async function handleInlineActions(params: {
? (await import("../skill-commands.runtime.js")).listSkillCommandsForWorkspace({
workspaceDir,
cfg,
agentId,
skillFilter,
})
: [];

View File

@ -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();
});
});

View File

@ -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 &&

View File

@ -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<string>; skillFilter?: string[] },
opts?: {
reservedNames?: Set<string>;
skillFilter?: string[];
agentId?: string;
config?: {
agents?: {
defaults?: { skills?: string[] };
list?: Array<{ id: string; skills?: string[] }>;
};
};
},
) {
const used = new Set<string>();
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 = [

View File

@ -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(),

View File

@ -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);
});
});

View File

@ -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": {

View File

@ -190,13 +190,15 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@ -322,6 +322,7 @@ export const FIELD_LABELS: Record<string, string> = {
"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",

View File

@ -136,6 +136,8 @@ export type AgentDefaultsConfig = {
models?: Record<string, AgentModelEntryConfig>;
/** 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. */

View File

@ -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. */

View File

@ -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(),

View File

@ -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();
});
});

View File

@ -33,6 +33,7 @@ export function resolveCronSkillsSnapshot(params: {
return buildWorkspaceSkillSnapshot(params.workspaceDir, {
config: params.config,
agentId: params.agentId,
skillFilter,
eligibility: { remote: getRemoteSkillEligibility() },
snapshotVersion,