Skip to content

Commit efb895d

Browse files
committed
feat(skills): refuse reloading attached and already-loaded skills
1 parent 317acfe commit efb895d

4 files changed

Lines changed: 141 additions & 3 deletions

File tree

‎src/agent/use-skill.test.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
33
import { join } from "node:path";
44
import { describe, expect, test } from "bun:test";
55

6+
import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js";
67
import {
78
createUseSkillTool,
89
useSkillDefinition,
@@ -66,3 +67,65 @@ describe("createUseSkillTool allowedNames", () => {
6667
expect(out).toContain("Create worktree recipe.");
6768
});
6869
});
70+
71+
describe("createUseSkillTool already-in-context", () => {
72+
test("attached name is refused without resolving the body", async () => {
73+
const cwd = await fixtureWithHiddenSkill();
74+
let resolveCalls = 0;
75+
await withMockedModuleDuring(
76+
import.meta.resolve("../extensions/skills.js"),
77+
(real: typeof import("../extensions/skills.js")) => ({
78+
...real,
79+
resolveSkillBody: async (
80+
...args: Parameters<typeof real.resolveSkillBody>
81+
) => {
82+
resolveCalls += 1;
83+
return real.resolveSkillBody(...args);
84+
},
85+
}),
86+
async () => {
87+
const tool = createUseSkillTool(
88+
cwd,
89+
[],
90+
undefined,
91+
undefined,
92+
useSkillDefinition,
93+
["git-worktrees"],
94+
);
95+
const out = await call(tool, { name: "git-worktrees" });
96+
expect(out).toBe(
97+
'Skill "git-worktrees" is already attached / already in context.',
98+
);
99+
expect(out).not.toContain("Create worktree recipe.");
100+
},
101+
);
102+
expect(resolveCalls).toBe(0);
103+
});
104+
105+
test("second use_skill of the same name is refused without returning the body", async () => {
106+
const cwd = await fixtureWithHiddenSkill();
107+
const tool = createUseSkillTool(cwd);
108+
const first = await call(tool, { name: "git-worktrees" });
109+
expect(first).toContain("Create worktree recipe.");
110+
const second = await call(tool, { name: "git-worktrees" });
111+
expect(second).toBe(
112+
'Skill "git-worktrees" is already attached / already in context.',
113+
);
114+
expect(second).not.toContain("Create worktree recipe.");
115+
});
116+
117+
test("first load still returns the body when the name is not attached", async () => {
118+
const cwd = await fixtureWithHiddenSkill();
119+
const tool = createUseSkillTool(
120+
cwd,
121+
[],
122+
undefined,
123+
undefined,
124+
useSkillDefinition,
125+
["style"],
126+
);
127+
const out = await call(tool, { name: "git-worktrees" });
128+
expect(out).toContain("Create worktree recipe.");
129+
expect(out).toContain('Skill "git-worktrees"');
130+
});
131+
});

‎src/agent/use-skill.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import { captureSkillUsed } from "../telemetry/product-events.js";
1212
// model decides one applies. There is no operator invocation — discovery and
1313
// loading are entirely model-driven. Primary copy is on-demand catalog
1414
// (Skywalker has no attached skills). Workers mount workerUseSkillDefinition
15-
// so they do not reload bodies already injected as attached.
15+
// so they do not reload bodies already injected as attached. The handler
16+
// refuses attached names and names already loaded this session so the body
17+
// is never dumped twice.
1618
const USE_SKILL_INPUT_SCHEMA = {
1719
type: "object",
1820
properties: {
@@ -40,15 +42,21 @@ export const workerUseSkillDefinition: ToolDefinition = {
4042

4143
const UseSkillArgs = type({ name: "string" });
4244

45+
function alreadyInContextMessage(name: string): string {
46+
return `Skill "${name}" is already attached / already in context.`;
47+
}
48+
4349
export function createUseSkillTool(
4450
cwd: string,
4551
skillDirs: string[] = [],
4652
telemetry: Telemetry = NOOP_TELEMETRY,
4753
allowedNames?: readonly string[],
4854
definition: ToolDefinition = useSkillDefinition,
55+
attachedNames?: readonly string[],
4956
): AgentTool {
5057
const allowed =
5158
allowedNames === undefined ? undefined : new Set(allowedNames);
59+
const loaded = new Set(attachedNames ?? []);
5260
return stringTool({
5361
definition,
5462
handler: async (rawArgs: Record<string, unknown>): Promise<string> => {
@@ -61,12 +69,14 @@ export function createUseSkillTool(
6169
if (allowed !== undefined && !allowed.has(name)) {
6270
return `No skill named "${name}" is available.`;
6371
}
72+
if (loaded.has(name)) return alreadyInContextMessage(name);
6473
const body = await resolveSkillBody(cwd, name, skillDirs);
6574
if (body === undefined) return `No skill named "${name}" is available.`;
6675
// Skill names are project- or plugin-authored, so an unrecognised
6776
// name never leaves the process: first-party `corbits-skills` names
6877
// are reported by name, everything else as `custom`.
6978
captureSkillUsed(telemetry, name);
79+
loaded.add(name);
7080
return `Skill "${name}" — follow these instructions for this task:\n\n${body}`;
7181
},
7282
});

‎src/subagent/run-skill-scope.test.ts‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,6 @@ describe("runSubAgent worker skill mounts (CL-7668)", () => {
359359
await run({
360360
...baseParams(cwd, join(cwd, ".ctx"), baseURL),
361361
skillDirs: [pluginRoot],
362-
attachedSkills: ["style"],
363362
}).catch(() => {
364363
// Inference fails by design; mount decisions run first.
365364
});
@@ -377,6 +376,70 @@ describe("runSubAgent worker skill mounts (CL-7668)", () => {
377376
expect(loaded).toContain("Follow the style guide.");
378377
}, 15_000);
379378

379+
test("threads attachedSkills into use_skill and refuses those names without returning the body", async () => {
380+
const cwd = await tmpCwd();
381+
await writeSkill(
382+
cwd,
383+
"style",
384+
"Code style rules.",
385+
"Follow the style guide.",
386+
);
387+
388+
let useSkillArgs: readonly unknown[] | undefined;
389+
let useSkillTool:
390+
| {
391+
kind: string;
392+
handler: (
393+
args: Record<string, unknown>,
394+
signal: AbortSignal,
395+
) => Promise<string>;
396+
}
397+
| undefined;
398+
399+
await runWithFailingInference((baseURL) =>
400+
withMockedModuleDuring(
401+
import.meta.resolve("../agent/use-skill.js"),
402+
(real: typeof import("../agent/use-skill.js")) => ({
403+
...real,
404+
createUseSkillTool: (...args: unknown[]) => {
405+
useSkillArgs = args;
406+
const tool = (
407+
real.createUseSkillTool as (...a: never[]) => unknown
408+
)(...(args as never[]));
409+
if (
410+
typeof tool !== "object" ||
411+
tool === null ||
412+
(tool as { kind: string }).kind !== "string"
413+
)
414+
throw new Error("expected string tool");
415+
useSkillTool = tool as typeof useSkillTool & {};
416+
return tool;
417+
},
418+
}),
419+
async () => {
420+
const { runSubAgent: run } = await import("./run.js");
421+
await run({
422+
...baseParams(cwd, join(cwd, ".ctx"), baseURL),
423+
attachedSkills: ["style"],
424+
}).catch(() => {
425+
// Inference fails by design; mount decisions run first.
426+
});
427+
},
428+
),
429+
);
430+
431+
expect(useSkillArgs?.[5]).toEqual(["style"]);
432+
expect(useSkillTool).toBeDefined();
433+
const refused = await useSkillTool?.handler(
434+
{ name: "style" },
435+
new AbortController().signal,
436+
);
437+
expect(refused).toBe(
438+
'Skill "style" is already attached / already in context.',
439+
);
440+
expect(refused).not.toContain("Follow the style guide.");
441+
}, 15_000);
442+
380443
test("injects attached skill bodies into the worker prompt and notes misses without parking", async () => {
381444
const cwd = await tmpCwd();
382445
const pluginRoot = join(cwd, "plugin");

‎src/subagent/run.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -782,7 +782,8 @@ async function runSubAgentInner(
782782
// (union of pkg.attachedSkills and optionalSkills). Mounted before the
783783
// capability filter so worker allowlists keep them like any other named
784784
// tool; the scope cannot widen — use_skill refuses names outside the
785-
// allowlist. Plugin skill dirs match the primary so bundled
785+
// allowlist and refuses attached/already-loaded names without dumping the
786+
// body again. Plugin skill dirs match the primary so bundled
786787
// corbits-skills (style/philosophy) resolve.
787788
const modelFamilyPolicy = resolveModelFamilyPolicy({
788789
providerName: params.provider.providerName,
@@ -806,6 +807,7 @@ async function runSubAgentInner(
806807
liveTelemetry,
807808
params.allowedSkillNames,
808809
workerUseSkillDefinition,
810+
params.attachedSkills,
809811
),
810812
];
811813

0 commit comments

Comments
 (0)