mirror of https://github.com/openclaw/openclaw.git
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:
parent
04f59a7227
commit
ddd250d130
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 ?? {
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 ?? []) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -12,11 +12,13 @@ import {
|
|||
registerSkillsChangeListener,
|
||||
resetSkillsRefreshStateForTest,
|
||||
setSkillsChangeListenerErrorHandler,
|
||||
shouldRefreshSnapshotForVersion,
|
||||
} from "./refresh-state.js";
|
||||
export {
|
||||
bumpSkillsSnapshotVersion,
|
||||
getSkillsSnapshotVersion,
|
||||
registerSkillsChangeListener,
|
||||
shouldRefreshSnapshotForVersion,
|
||||
type SkillsChangeEvent,
|
||||
} from "./refresh-state.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ export async function resolveReplyDirectives(params: {
|
|||
? (await loadSkillCommands()).listSkillCommandsForWorkspace({
|
||||
workspaceDir,
|
||||
cfg,
|
||||
agentId,
|
||||
skillFilter,
|
||||
})
|
||||
: [];
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ export async function handleInlineActions(params: {
|
|||
? (await import("../skill-commands.runtime.js")).listSkillCommandsForWorkspace({
|
||||
workspaceDir,
|
||||
cfg,
|
||||
agentId,
|
||||
skillFilter,
|
||||
})
|
||||
: [];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -33,6 +33,7 @@ export function resolveCronSkillsSnapshot(params: {
|
|||
|
||||
return buildWorkspaceSkillSnapshot(params.workspaceDir, {
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
skillFilter,
|
||||
eligibility: { remote: getRemoteSkillEligibility() },
|
||||
snapshotVersion,
|
||||
|
|
|
|||
Loading…
Reference in New Issue