From 61f720b94561a9de48a95aa15ae6a3705c35173c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 7 Jan 2026 11:22:55 +0100 Subject: [PATCH] feat: add skill filter + group system prompt plumbing --- src/agents/skills.test.ts | 27 ++++++++++++++++++ src/agents/skills.ts | 37 +++++++++++++++++++++++-- src/auto-reply/reply.ts | 7 ++++- src/auto-reply/reply/session-updates.ts | 13 +++++++-- src/auto-reply/templating.ts | 1 + src/auto-reply/types.ts | 2 ++ 6 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index 51922f81c..9178bf971 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -165,6 +165,33 @@ describe("buildWorkspaceSkillsPrompt", () => { } }); + it("applies skill filters, including empty lists", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-")); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "alpha"), + name: "alpha", + description: "Alpha skill", + }); + await writeSkill({ + dir: path.join(workspaceDir, "skills", "beta"), + name: "beta", + description: "Beta skill", + }); + + const filteredPrompt = buildWorkspaceSkillsPrompt(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + skillFilter: ["alpha"], + }); + expect(filteredPrompt).toContain("alpha"); + expect(filteredPrompt).not.toContain("beta"); + + const emptyPrompt = buildWorkspaceSkillsPrompt(workspaceDir, { + managedSkillsDir: path.join(workspaceDir, ".managed"), + skillFilter: [], + }); + expect(emptyPrompt).toBe(""); + }); + it("prefers workspace skills over managed skills", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-")); const managedDir = path.join(workspaceDir, ".managed"); diff --git a/src/agents/skills.ts b/src/agents/skills.ts index 300d75e71..e323da6b9 100644 --- a/src/agents/skills.ts +++ b/src/agents/skills.ts @@ -382,8 +382,27 @@ function shouldIncludeSkill(params: { function filterSkillEntries( entries: SkillEntry[], config?: ClawdbotConfig, + skillFilter?: string[], ): SkillEntry[] { - return entries.filter((entry) => shouldIncludeSkill({ entry, config })); + let filtered = entries.filter((entry) => + shouldIncludeSkill({ entry, config }), + ); + // If skillFilter is provided, only include skills in the filter list. + if (skillFilter !== undefined) { + const normalized = skillFilter + .map((entry) => String(entry).trim()) + .filter(Boolean); + const label = normalized.length > 0 ? normalized.join(", ") : "(none)"; + console.log(`[skills] Applying skill filter: ${label}`); + filtered = + normalized.length > 0 + ? filtered.filter((entry) => normalized.includes(entry.skill.name)) + : []; + console.log( + `[skills] After filter: ${filtered.map((entry) => entry.skill.name).join(", ")}`, + ); + } + return filtered; } export function applySkillEnvOverrides(params: { @@ -548,10 +567,16 @@ export function buildWorkspaceSkillSnapshot( managedSkillsDir?: string; bundledSkillsDir?: string; entries?: SkillEntry[]; + /** If provided, only include skills with these names */ + skillFilter?: string[]; }, ): SkillSnapshot { const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts); - const eligible = filterSkillEntries(skillEntries, opts?.config); + const eligible = filterSkillEntries( + skillEntries, + opts?.config, + opts?.skillFilter, + ); const resolvedSkills = eligible.map((entry) => entry.skill); return { prompt: formatSkillsForPrompt(resolvedSkills), @@ -570,10 +595,16 @@ export function buildWorkspaceSkillsPrompt( managedSkillsDir?: string; bundledSkillsDir?: string; entries?: SkillEntry[]; + /** If provided, only include skills with these names */ + skillFilter?: string[]; }, ): string { const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts); - const eligible = filterSkillEntries(skillEntries, opts?.config); + const eligible = filterSkillEntries( + skillEntries, + opts?.config, + opts?.skillFilter, + ); return formatSkillsForPrompt(eligible.map((entry) => entry.skill)); } diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 21f9988c1..f4cc9b445 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -593,6 +593,10 @@ export async function getReplyFromConfig( silentToken: SILENT_REPLY_TOKEN, }) : ""; + const groupSystemPrompt = sessionCtx.GroupSystemPrompt?.trim() ?? ""; + const extraSystemPrompt = [groupIntro, groupSystemPrompt] + .filter(Boolean) + .join("\n\n"); const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; const rawBodyTrimmed = (ctx.Body ?? "").trim(); const baseBodyTrimmedRaw = baseBody.trim(); @@ -651,6 +655,7 @@ export async function getReplyFromConfig( isFirstTurnInSession, workspaceDir, cfg, + skillFilter: opts?.skillFilter, }); sessionEntry = skillResult.sessionEntry ?? sessionEntry; systemSent = skillResult.systemSent; @@ -759,7 +764,7 @@ export async function getReplyFromConfig( blockReplyBreak: resolvedBlockStreamingBreak, ownerNumbers: command.ownerList.length > 0 ? command.ownerList : undefined, - extraSystemPrompt: groupIntro || undefined, + extraSystemPrompt: extraSystemPrompt || undefined, ...(provider === "ollama" ? { enforceFinalTag: true } : {}), }, }; diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index f780455e0..a09d441c6 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -49,6 +49,8 @@ export async function ensureSkillSnapshot(params: { isFirstTurnInSession: boolean; workspaceDir: string; cfg: ClawdbotConfig; + /** If provided, only load skills with these names (for per-channel skill filtering) */ + skillFilter?: string[]; }): Promise<{ sessionEntry?: SessionEntry; skillsSnapshot?: SessionEntry["skillsSnapshot"]; @@ -63,6 +65,7 @@ export async function ensureSkillSnapshot(params: { isFirstTurnInSession, workspaceDir, cfg, + skillFilter, } = params; let nextEntry = sessionEntry; @@ -76,7 +79,10 @@ export async function ensureSkillSnapshot(params: { }; const skillSnapshot = isFirstTurnInSession || !current.skillsSnapshot - ? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg }) + ? buildWorkspaceSkillSnapshot(workspaceDir, { + config: cfg, + skillFilter, + }) : current.skillsSnapshot; nextEntry = { ...current, @@ -96,7 +102,10 @@ export async function ensureSkillSnapshot(params: { nextEntry?.skillsSnapshot ?? (isFirstTurnInSession ? undefined - : buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg })); + : buildWorkspaceSkillSnapshot(workspaceDir, { + config: cfg, + skillFilter, + })); if ( skillsSnapshot && sessionStore && diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index cccdc3663..a63243237 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -31,6 +31,7 @@ export type MsgContext = { GroupRoom?: string; GroupSpace?: string; GroupMembers?: string; + GroupSystemPrompt?: string; SenderName?: string; SenderId?: string; SenderUsername?: string; diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index ba1562740..6726c6492 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -9,6 +9,8 @@ export type GetReplyOptions = { onBlockReply?: (payload: ReplyPayload) => Promise | void; onToolResult?: (payload: ReplyPayload) => Promise | void; disableBlockStreaming?: boolean; + /** If provided, only load these skills for this session (empty = no skills). */ + skillFilter?: string[]; }; export type ReplyPayload = {