Skip to content

Commit 92a5657

Browse files
committed
Mount scoped skill_search and use_skill on every worker
1 parent f65ca2b commit 92a5657

4 files changed

Lines changed: 229 additions & 0 deletions

File tree

‎src/subagent/agent-fleet.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1399,6 +1399,9 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
13991399
...(resolved.capabilities !== undefined
14001400
? { capabilities: resolved.capabilities }
14011401
: {}),
1402+
...(resolved.pkg?.optionalSkills !== undefined
1403+
? { allowedSkillNames: resolved.pkg.optionalSkills }
1404+
: {}),
14021405
...(resolved.systemPromptRole !== undefined
14031406
? { systemPromptRole: resolved.systemPromptRole }
14041407
: {}),
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/**
2+
* runSubAgent mounts skill_search + use_skill on every worker, scoped to the
3+
* dispatch's allowedSkillNames (pkg.optionalSkills). The scope cannot widen:
4+
* use_skill refuses names outside the allowlist (CL-6803 stays closed) and
5+
* skill_search hides them.
6+
*
7+
* Pattern follows run-authority.test.ts: drive the real runSubAgent with
8+
* failing inference (mount decisions run before the send) while wrapping the
9+
* real skill factories to capture the mounted tools.
10+
*/
11+
import { describe, expect, test } from "bun:test";
12+
import { mkdir, writeFile } from "node:fs/promises";
13+
import { tmpdir } from "node:os";
14+
import { join } from "node:path";
15+
16+
import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js";
17+
import { createPermissionGate } from "../permission/gate.js";
18+
import type { RunSubAgentParams } from "./types.js";
19+
20+
const testPermissionGate = createPermissionGate({
21+
approvals: [],
22+
interactive: false,
23+
skipPermissions: true,
24+
reactorGated: false,
25+
});
26+
27+
async function tmpCwd(): Promise<string> {
28+
const cwd = join(
29+
tmpdir(),
30+
`cl7668-skill-scope-${Date.now()}-${Math.random()}`,
31+
);
32+
await mkdir(cwd, { recursive: true });
33+
return cwd;
34+
}
35+
36+
async function writeSkill(
37+
cwd: string,
38+
name: string,
39+
description: string,
40+
body: string,
41+
): Promise<void> {
42+
const dir = join(cwd, ".agents", "skills", name);
43+
await mkdir(dir, { recursive: true });
44+
await writeFile(
45+
join(dir, "SKILL.md"),
46+
`---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`,
47+
);
48+
}
49+
50+
async function runWithFailingInference(
51+
run: (baseURL: string) => Promise<unknown>,
52+
): Promise<void> {
53+
const server = Bun.serve({
54+
port: 0,
55+
fetch: () =>
56+
new Response(JSON.stringify({ error: { message: "probe" } }), {
57+
status: 401,
58+
headers: { "content-type": "application/json" },
59+
}),
60+
});
61+
try {
62+
await run(server.url.origin);
63+
} finally {
64+
server.stop(true);
65+
}
66+
}
67+
68+
function baseParams(
69+
cwd: string,
70+
workdirBase: string,
71+
baseURL: string,
72+
): RunSubAgentParams {
73+
return {
74+
cwd,
75+
workdirBase,
76+
permissionGate: testPermissionGate,
77+
provider: { providerName: "test", baseURL, model: "test-model" },
78+
description: "skill scope probe",
79+
prompt: "no-op",
80+
allowedSkillNames: ["style"],
81+
};
82+
}
83+
84+
describe("runSubAgent worker skill mounts (CL-7668)", () => {
85+
test("mounts skill_search + use_skill scoped to allowedSkillNames; out-of-scope names refuse", async () => {
86+
const cwd = await tmpCwd();
87+
await writeSkill(
88+
cwd,
89+
"style",
90+
"Code style rules.",
91+
"Follow the style guide.",
92+
);
93+
await writeSkill(cwd, "off-lane", "Unrelated lane.", "Off-lane body.");
94+
95+
let searchArgs:
96+
| { skills: { name: string }[]; allowedNames?: readonly string[] }
97+
| undefined;
98+
let useSkillArgs: readonly unknown[] | undefined;
99+
let searchTool:
100+
| {
101+
kind: string;
102+
handler: (
103+
args: Record<string, unknown>,
104+
signal: AbortSignal,
105+
) => Promise<string>;
106+
}
107+
| undefined;
108+
let useSkillTool:
109+
| {
110+
kind: string;
111+
handler: (
112+
args: Record<string, unknown>,
113+
signal: AbortSignal,
114+
) => Promise<string>;
115+
}
116+
| undefined;
117+
118+
await runWithFailingInference((baseURL) =>
119+
withMockedModuleDuring(
120+
import.meta.resolve("../agent/skill-search.js"),
121+
(real: typeof import("../agent/skill-search.js")) => ({
122+
...real,
123+
createSkillSearchTool: (args: {
124+
skills: { name: string; description: string }[];
125+
allowedNames?: readonly string[];
126+
}) => {
127+
searchArgs = args;
128+
const tool = real.createSkillSearchTool(args);
129+
if (tool.kind !== "string") throw new Error("expected string tool");
130+
searchTool = tool as typeof searchTool & {};
131+
return tool;
132+
},
133+
}),
134+
() =>
135+
withMockedModuleDuring(
136+
import.meta.resolve("../agent/use-skill.js"),
137+
(real: typeof import("../agent/use-skill.js")) => ({
138+
...real,
139+
createUseSkillTool: (...args: unknown[]) => {
140+
useSkillArgs = args;
141+
const tool = (
142+
real.createUseSkillTool as (...a: never[]) => unknown
143+
)(...(args as never[]));
144+
if (
145+
typeof tool !== "object" ||
146+
tool === null ||
147+
(tool as { kind: string }).kind !== "string"
148+
)
149+
throw new Error("expected string tool");
150+
useSkillTool = tool as typeof useSkillTool & {};
151+
return tool;
152+
},
153+
}),
154+
async () => {
155+
const { runSubAgent: run } = await import("./run.js");
156+
await run(baseParams(cwd, join(cwd, ".ctx"), baseURL)).catch(
157+
() => {
158+
// Inference fails by design; mount decisions run first.
159+
},
160+
);
161+
},
162+
),
163+
),
164+
);
165+
166+
// Both tools mount exactly once, scoped to the dispatch allowlist.
167+
expect(searchArgs).toBeDefined();
168+
expect(searchArgs?.allowedNames).toEqual(["style"]);
169+
expect(searchArgs?.skills.map((s) => s.name).sort()).toEqual([
170+
"off-lane",
171+
"style",
172+
]);
173+
expect(useSkillArgs?.[0]).toBe(cwd);
174+
expect(useSkillArgs?.[3]).toEqual(["style"]);
175+
expect(searchTool).toBeDefined();
176+
expect(useSkillTool).toBeDefined();
177+
178+
const signal = new AbortController().signal;
179+
// In-scope skill loads.
180+
const loaded = await useSkillTool?.handler({ name: "style" }, signal);
181+
expect(loaded).toContain('Skill "style"');
182+
expect(loaded).toContain("Follow the style guide.");
183+
const found = await searchTool?.handler({ query: "style" }, signal);
184+
expect(found).toContain("- style: Code style rules.");
185+
186+
// Out-of-scope names refuse — CL-6803 stays closed.
187+
expect(await useSkillTool?.handler({ name: "off-lane" }, signal)).toBe(
188+
'No skill named "off-lane" is available.',
189+
);
190+
expect(await searchTool?.handler({ query: "off-lane" }, signal)).toBe(
191+
'No skills matched "off-lane". Try different keywords describing the capability.',
192+
);
193+
}, 15_000);
194+
});

‎src/subagent/run.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ import type { CapabilityFilter } from "../agent/profiles.js";
107107
import type { Settings } from "../config/settings.js";
108108
import { toolWatchdogFromSettings } from "../config/settings.js";
109109
import { createSearchAgentsTool } from "../agent/agent-search.js";
110+
import { createSkillSearchTool } from "../agent/skill-search.js";
111+
import { createUseSkillTool } from "../agent/use-skill.js";
112+
import { discoverSkills } from "../extensions/skills.js";
110113
import {
111114
createManageTasksRunner,
112115
manageTasksDefinition,
@@ -719,6 +722,27 @@ async function runSubAgentInner(
719722
}),
720723
];
721724

725+
// Every worker mounts skill_search + use_skill, scoped to the dispatch's
726+
// allowedSkillNames (pkg.optionalSkills). Mounted before the capability
727+
// filter so worker allowlists keep them like any other named tool; the
728+
// scope cannot widen — use_skill refuses names outside the allowlist.
729+
const skillSnapshot = await discoverSkills(params.cwd);
730+
tools = [
731+
...tools,
732+
createSkillSearchTool({
733+
skills: skillSnapshot,
734+
...(params.allowedSkillNames !== undefined
735+
? { allowedNames: params.allowedSkillNames }
736+
: {}),
737+
}),
738+
createUseSkillTool(
739+
params.cwd,
740+
[],
741+
liveTelemetry,
742+
params.allowedSkillNames,
743+
),
744+
];
745+
722746
if (params.capabilities !== undefined) {
723747
tools = applyCapabilityFilter(tools, params.capabilities);
724748
}

‎src/subagent/types.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@ export type RunSubAgentParams = {
117117
onProgress?: (info: { description: string; toolName: string }) => void;
118118
onRunSettled?: (summary: Readonly<SubAgentRunSettlement>) => void;
119119
capabilities?: CapabilityFilter;
120+
/**
121+
* Skill allowlist for the worker's skill_search + use_skill mounts,
122+
* resolved by the caller (agent-fleet.ts) from the dispatch's
123+
* DirectorPackage.optionalSkills. When set, both tools only see these
124+
* names (the allowlist cannot widen: unknown names refuse). When unset
125+
* (non-director plugin profiles), the worker sees every discovered skill.
126+
*/
127+
allowedSkillNames?: readonly string[];
120128
systemPromptRole?: string;
121129
/** Resolved closed-director id (e.g. "critic") when the worker is one. Structured gate key — prefer over persona-string matching in systemPromptRole. */
122130
directorId?: string;

0 commit comments

Comments
 (0)