From 088693697aca6bc148819408fa03ad71ccdfb8ea Mon Sep 17 00:00:00 2001 From: Z Date: Wed, 25 Mar 2026 22:08:41 +0800 Subject: [PATCH] feat: filter agent-restricted skills from prompts and tool description Skills with an `agent` frontmatter field are intended for a specific agent. Previously they still appeared in: - every agent's system prompt (via `buildAvailableSkills`) - the `skill` tool's `` description visible to all agents This wasted tokens and could mislead agents into attempting calls that would be rejected at execution time. Changes: - `buildAvailableSkills`: new optional `agentName` parameter; when provided, skills whose `definition.agent` does not match are excluded - `builtin-agents.ts`: pass per-agent name to `buildAvailableSkills` for sisyphus, hephaestus, and atlas, so each agent's prompt only lists the skills it is allowed to use - `createSkillTool` (`tools.ts`): exclude agent-restricted skills from both the eager and lazy description builds, keeping the shared tool description free of skills the current agent cannot access Execution-time enforcement (throwing on mismatch) is unchanged; this change adds the earlier, description-level visibility gate. Tests: new `available-skills.test.ts` (5 cases) + 3 new cases in `tools.test.ts` covering the description-filter and execute paths. Co-Authored-By: Claude Sonnet 4.5 --- src/agents/builtin-agents.ts | 8 +- .../builtin-agents/available-skills.test.ts | 83 ++++++++++++++++++- src/agents/builtin-agents/available-skills.ts | 9 +- src/tools/skill/tools.ts | 9 +- .../zauc-mocks-skill-tools/tools.test.ts | 44 ++++++++++ 5 files changed, 143 insertions(+), 10 deletions(-) diff --git a/src/agents/builtin-agents.ts b/src/agents/builtin-agents.ts index dde78131b..b3c56f7c0 100644 --- a/src/agents/builtin-agents.ts +++ b/src/agents/builtin-agents.ts @@ -100,8 +100,6 @@ export async function createBuiltinAgents( description: categories?.[name]?.description ?? CATEGORY_DESCRIPTIONS[name] ?? "General tasks", })) - const availableSkills = buildAvailableSkills(discoveredSkills, browserProvider, disabledSkills, teamModeEnabled) - // Collect general agents first (for availableAgents), but don't add to result yet const { pendingAgentConfigs, availableAgents } = collectPendingBuiltinAgents({ agentSources, @@ -129,7 +127,7 @@ export async function createBuiltinAgents( systemDefaultModel, isFirstRunNoCache, availableAgents, - availableSkills, + availableSkills: buildAvailableSkills(discoveredSkills, browserProvider, disabledSkills, teamModeEnabled, "sisyphus"), availableCategories, mergedCategories, directory, @@ -148,7 +146,7 @@ export async function createBuiltinAgents( systemDefaultModel, isFirstRunNoCache, availableAgents, - availableSkills, + availableSkills: buildAvailableSkills(discoveredSkills, browserProvider, disabledSkills, teamModeEnabled, "hephaestus"), availableCategories, mergedCategories, directory, @@ -171,7 +169,7 @@ export async function createBuiltinAgents( availableModels, systemDefaultModel, availableAgents, - availableSkills, + availableSkills: buildAvailableSkills(discoveredSkills, browserProvider, disabledSkills, teamModeEnabled, "atlas"), mergedCategories, directory, userCategories: categories, diff --git a/src/agents/builtin-agents/available-skills.test.ts b/src/agents/builtin-agents/available-skills.test.ts index fe5ea8441..581b63249 100644 --- a/src/agents/builtin-agents/available-skills.test.ts +++ b/src/agents/builtin-agents/available-skills.test.ts @@ -1,9 +1,24 @@ -import { describe, expect, test } from "bun:test" - +/// +import { describe, expect, it, test } from "bun:test" +import type { LoadedSkill } from "../../features/opencode-skill-loader/types" import { buildAvailableSkills } from "./available-skills" type DiscoveredSkills = Parameters[0] +function makeSkill(name: string, agent?: string): LoadedSkill { + return { + name, + resolvedPath: `/test/skills/${name}`, + definition: { + name, + description: `Skill ${name}`, + template: "", + agent, + }, + scope: "user", + } +} + describe("buildAvailableSkills", () => { test("includes team-mode when team mode is enabled", () => { // given @@ -27,3 +42,67 @@ describe("buildAvailableSkills", () => { expect(availableSkills.some((skill) => skill.name === "team-mode")).toBe(false) }) }) + +describe("buildAvailableSkills - agentName filtering", () => { + it("includes agent-restricted skill when agentName is not provided (backward compat)", () => { + // given + const skills = [makeSkill("oracle-only", "oracle")] + + // when + const result = buildAvailableSkills(skills, undefined, undefined, undefined, undefined) + + // then: no agentName → no filtering, skill is included + expect(result.map((s) => s.name)).toContain("oracle-only") + }) + + it("includes skill when agentName matches the skill's agent field", () => { + // given + const skills = [makeSkill("sisyphus-only", "sisyphus")] + + // when + const result = buildAvailableSkills(skills, undefined, undefined, undefined, "sisyphus") + + // then: matching agent → included + expect(result.map((s) => s.name)).toContain("sisyphus-only") + }) + + it("excludes skill when agentName does not match the skill's agent field", () => { + // given + const skills = [makeSkill("sisyphus-only", "sisyphus")] + + // when + const result = buildAvailableSkills(skills, undefined, undefined, undefined, "oracle") + + // then: wrong agent → excluded + expect(result.map((s) => s.name)).not.toContain("sisyphus-only") + }) + + it("includes skill with no agent field regardless of agentName", () => { + // given + const skills = [makeSkill("public-skill")] + + // when + const result = buildAvailableSkills(skills, undefined, undefined, undefined, "sisyphus") + + // then: no agent restriction → always included + expect(result.map((s) => s.name)).toContain("public-skill") + }) + + it("filters per-agent while keeping public skills", () => { + // given + const skills = [ + makeSkill("public-skill"), + makeSkill("sisyphus-only", "sisyphus"), + makeSkill("oracle-only", "oracle"), + ] + + // when + const result = buildAvailableSkills(skills, undefined, undefined, undefined, "sisyphus") + + // then + const names = result.map((s) => s.name) + expect(names).toContain("public-skill") + expect(names).toContain("sisyphus-only") + expect(names).not.toContain("oracle-only") + }) +}) diff --git a/src/agents/builtin-agents/available-skills.ts b/src/agents/builtin-agents/available-skills.ts index d6aafa8cd..136679168 100644 --- a/src/agents/builtin-agents/available-skills.ts +++ b/src/agents/builtin-agents/available-skills.ts @@ -14,6 +14,7 @@ export function buildAvailableSkills( browserProvider?: BrowserAutomationProvider, disabledSkills?: Set, teamModeEnabled?: boolean, + agentName?: string, ): AvailableSkill[] { const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills, teamModeEnabled }) const builtinSkillNames = new Set(builtinSkills.map(s => s.name)) @@ -25,7 +26,13 @@ export function buildAvailableSkills( })) const discoveredAvailable: AvailableSkill[] = discoveredSkills - .filter(s => !builtinSkillNames.has(s.name) && !disabledSkills?.has(s.name)) + .filter(s => { + if (builtinSkillNames.has(s.name) || disabledSkills?.has(s.name)) return false + // If the skill declares an agent restriction and we know the current agent, + // exclude skills that don't belong to this agent. + if (agentName && s.definition.agent && s.definition.agent !== agentName) return false + return true + }) .map((skill) => ({ name: skill.name, description: skill.definition.description ?? "", diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts index 0d4067505..2345b4298 100644 --- a/src/tools/skill/tools.ts +++ b/src/tools/skill/tools.ts @@ -64,13 +64,18 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition if (!force && cachedDescription) return cachedDescription const skills = await getSkills() const commands = getCommands() - const skillInfos = skills.map(loadedSkillToInfo) + // Exclude agent-restricted skills from the description: they must not be + // visible to agents that are not their designated owner. The execute-time + // check already enforces the restriction at call time. + const publicSkills = skills.filter((s) => !s.definition.agent) + const skillInfos = publicSkills.map(loadedSkillToInfo) cachedDescription = formatCombinedDescription(skillInfos, commands) return cachedDescription } if (options.skills !== undefined) { - const skillInfos = options.skills.map(loadedSkillToInfo) + const publicSkills = options.skills.filter((s) => !s.definition.agent) + const skillInfos = publicSkills.map(loadedSkillToInfo) const commandsForDescription = options.commands ?? [] let needsAsyncRefresh = false diff --git a/src/tools/skill/zauc-mocks-skill-tools/tools.test.ts b/src/tools/skill/zauc-mocks-skill-tools/tools.test.ts index ccdfba648..ec28c3e66 100644 --- a/src/tools/skill/zauc-mocks-skill-tools/tools.test.ts +++ b/src/tools/skill/zauc-mocks-skill-tools/tools.test.ts @@ -636,6 +636,50 @@ describe("skill tool - dynamic discovery", () => { expect(result).not.toContain("SHOULD_BE_OVERRIDDEN") }) }) +describe("skill tool - agent-restricted skill visibility in description", () => { + it("excludes agent-restricted skill from description ", () => { + // given: a skill restricted to oracle, and a public skill + const loadedSkills = [ + createMockSkill("public-skill"), + createMockSkill("oracle-only-skill", { agent: "oracle" }), + ] + + // when: tool is created with these skills (as tool-registry would inject them) + const tool = createSkillTool({ skills: loadedSkills }) + + // then: oracle-only skill must NOT appear in the description + expect(tool.description).toContain("public-skill") + expect(tool.description).not.toContain("oracle-only-skill") + }) + + it("includes public skill (no agent field) in description regardless of context", () => { + // given + const loadedSkills = [createMockSkill("public-skill")] + + // when + const tool = createSkillTool({ skills: loadedSkills }) + + // then + expect(tool.description).toContain("public-skill") + }) + + it("execute still works for agent-restricted skill when called with correct agent context", async () => { + // given: tool created WITHOUT the restricted skill in description list, + // but the full skill list is available for execute via getSkills() + // (simulating what tool-registry does: description uses filtered list, + // but execute discovers from disk / full list) + const restrictedSkill = createMockSkill("oracle-only-skill", { agent: "oracle" }) + const tool = createSkillTool({ skills: [restrictedSkill] }) + const oracleContext = { ...mockContext, agent: "oracle" } + + // when: oracle agent explicitly calls the skill + const result = await tool.execute({ name: "oracle-only-skill" }, oracleContext) + + // then: execution succeeds + expect(result).toContain("oracle-only-skill") + }) +}) + describe("skill tool - dynamic description cache invalidation", () => { it("keeps description available after execute misses a skill", async () => { // given