Skip to content

Commit d008743

Browse files
committed
feat(subagent): reject tier-gated requires_tools before spawn
1 parent 5913441 commit d008743

2 files changed

Lines changed: 185 additions & 1 deletion

File tree

‎src/subagent/agent-fleet-requires-tools.test.ts‎

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test";
33
import {
44
createFleetMailbox,
55
createSpawnAgentTool,
6+
tierGateRequiresTools,
67
type AgentFleetDeps,
78
} from "./agent-fleet.js";
89
import { unlimitedAdmissionQueue } from "./admission.js";
@@ -31,6 +32,11 @@ const READ_ONLY_PROFILE: AgentProfile = {
3132
capabilities: { mode: "allow", tools: ["read_file"] },
3233
};
3334

35+
const FULL_MOUNT_PROFILE: AgentProfile = {
36+
id: "full-worker",
37+
systemPromptRole: "You do anything.",
38+
};
39+
3440
function makeDeps(
3541
run: (params: RunSubAgentParams) => Promise<RunSubAgentResult>,
3642
capturedTelemetry: TelemetryEvent[],
@@ -45,7 +51,7 @@ function makeDeps(
4551
sessions,
4652
fleetRecords: createFleetMailbox(sessions),
4753
admission: unlimitedAdmissionQueue(),
48-
profiles: [READ_ONLY_PROFILE],
54+
profiles: [READ_ONLY_PROFILE, FULL_MOUNT_PROFILE],
4955
telemetry: {
5056
...NOOP_TELEMETRY,
5157
capture: (event: TelemetryEvent) => {
@@ -192,4 +198,128 @@ describe("spawn_agent requires_tools preflight", () => {
192198
expect(session?.requiresTools).toBeUndefined();
193199
expect(session?.snapshotRevision).toBeUndefined();
194200
});
201+
202+
test("manage_tasks survives dispatch under a narrow allowlist and stamps canonically", async () => {
203+
const telemetry: TelemetryEvent[] = [];
204+
let seenRequires: readonly string[] | undefined;
205+
const deps = makeDeps(async (params) => {
206+
seenRequires = params.requiresTools;
207+
return { report: "done" };
208+
}, telemetry);
209+
const spawn = createSpawnAgentTool(deps);
210+
211+
const result = await callSpawn(spawn, {
212+
description: "plan job",
213+
prompt: "track the work",
214+
agent: "read-only-worker",
215+
requires_tools: ["manage_tasks"],
216+
});
217+
218+
expect(result.isError).not.toBe(true);
219+
const body = JSON.parse(result.content) as { agent_id: string };
220+
expect(deps.sessions.get(body.agent_id)?.requiresTools).toEqual([
221+
"manage_tasks",
222+
]);
223+
expect(seenRequires).toEqual(["manage_tasks"]);
224+
});
225+
226+
test("update_plan alias survives dispatch and stamps manage_tasks", async () => {
227+
const telemetry: TelemetryEvent[] = [];
228+
const deps = makeDeps(async () => ({ report: "done" }), telemetry);
229+
const spawn = createSpawnAgentTool(deps);
230+
231+
const result = await callSpawn(spawn, {
232+
description: "alias plan job",
233+
prompt: "track the work",
234+
agent: "read-only-worker",
235+
requires_tools: ["update_plan"],
236+
});
237+
238+
expect(result.isError).not.toBe(true);
239+
const body = JSON.parse(result.content) as { agent_id: string };
240+
expect(deps.sessions.get(body.agent_id)?.requiresTools).toEqual([
241+
"manage_tasks",
242+
]);
243+
});
244+
245+
test("leaf requires_tools=[spawn_agent] rejects pre-spawn as missing_tool naming the leaf restriction", async () => {
246+
const telemetry: TelemetryEvent[] = [];
247+
let runCalled = false;
248+
const deps = makeDeps(async () => {
249+
runCalled = true;
250+
return { report: "done" };
251+
}, telemetry);
252+
const spawn = createSpawnAgentTool(deps);
253+
254+
const result = await callSpawn(spawn, {
255+
description: "fleet job",
256+
prompt: "spawn more workers",
257+
agent: "full-worker",
258+
requires_tools: ["spawn_agent"],
259+
});
260+
261+
expect(result.isError).toBe(true);
262+
expect(result.content.startsWith("Error:")).toBe(true);
263+
expect(result.content).toContain("spawn_agent");
264+
expect(result.content).toContain("Tier 3 leaf");
265+
expect(result.content).toMatch(/Re-dispatch|drop the requirement/);
266+
expect(result.content).not.toContain("stale_snapshot");
267+
expect(runCalled).toBe(false);
268+
expect(deps.sessions.list()).toEqual([]);
269+
expect(telemetry).toEqual([]);
270+
});
271+
});
272+
273+
describe("tierGateRequiresTools", () => {
274+
test("leaf requiring a fleet verb rejects as missing_tool naming the leaf restriction", () => {
275+
const gated = tierGateRequiresTools(["spawn_agent"], "leaf");
276+
expect(gated?.code).toBe("missing_tool");
277+
expect(gated?.tool).toBe("spawn_agent");
278+
expect(gated?.detail).toContain("Tier 3 leaf");
279+
});
280+
281+
test("orchestrator requiring a fleet verb passes", () => {
282+
expect(
283+
tierGateRequiresTools(["spawn_agent"], "orchestrator"),
284+
).toBeUndefined();
285+
expect(
286+
tierGateRequiresTools(["spawn_agent"], "nested-orchestrator"),
287+
).toBeUndefined();
288+
});
289+
290+
test("nested-orchestrator requiring fleet discovery rejects pre-spawn", () => {
291+
const gated = tierGateRequiresTools(
292+
["search_agents"],
293+
"nested-orchestrator",
294+
);
295+
expect(gated?.code).toBe("missing_tool");
296+
expect(gated?.detail).toContain("Tier 2");
297+
});
298+
299+
test("non-leaf requiring the leaf reporting channel rejects pre-spawn", () => {
300+
for (const engine of ["submit_result", "ask_director"]) {
301+
const gated = tierGateRequiresTools([engine], "orchestrator");
302+
expect(gated?.code).toBe("missing_tool");
303+
expect(gated?.tool).toBe(engine);
304+
expect(gated?.detail).toContain("Tier 3 leaf workers only");
305+
}
306+
});
307+
308+
test("leaf requiring the leaf reporting channel passes", () => {
309+
expect(
310+
tierGateRequiresTools(["submit_result", "ask_director"], "leaf"),
311+
).toBeUndefined();
312+
});
313+
314+
test("ordinary tools pass on every tier", () => {
315+
for (const tier of [
316+
"leaf",
317+
"orchestrator",
318+
"nested-orchestrator",
319+
] as const) {
320+
expect(
321+
tierGateRequiresTools(["read_file", "manage_tasks"], tier),
322+
).toBeUndefined();
323+
}
324+
});
195325
});

‎src/subagent/agent-fleet.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ import {
7272
DEFAULT_KNOWN_ENGINES,
7373
formatCapabilityUnavailable,
7474
preflightCapabilities,
75+
rerouteAlternatives,
76+
type CapabilityUnavailable,
7577
} from "./capability-preflight.js";
7678
import { isCodexProviderName } from "../config/codex-providers.js";
7779
import { buildDispatchBrief, type TaskIntent } from "./report.js";
@@ -107,6 +109,7 @@ import type { DirectorPackage, ModelRole } from "../agent/directors/types.js";
107109
import { SPAWN_AGENT_TOOL_NAME } from "./tool-taxonomy.js";
108110
import {
109111
assertCanTargetAgent,
112+
assertTierMayMountFleetVerb,
110113
FleetAuthorityError,
111114
type FleetNode,
112115
type SubagentTier,
@@ -723,6 +726,44 @@ function fleetJson(value: unknown): string {
723726
return JSON.stringify(value, null, 2);
724727
}
725728

729+
/**
730+
* Tier gate for stamped requires_tools (CL-9476). run.ts mounts submit_result
731+
* and ask_director on Tier 3 leaves only, and fleet verbs on orchestrator
732+
* tiers only — a requirement the dispatch tier can never mount rejects here
733+
* as missing_tool (pre-spawn) instead of surviving to the mount-time stale
734+
* echo. Returns the rejection, or undefined when the tier mounts everything
735+
* required.
736+
*/
737+
export function tierGateRequiresTools(
738+
canonical: readonly string[],
739+
tier: SubagentTier,
740+
): CapabilityUnavailable | undefined {
741+
for (const engine of canonical) {
742+
if (
743+
(engine === "submit_result" || engine === "ask_director") &&
744+
tier !== "leaf"
745+
) {
746+
return {
747+
code: "missing_tool",
748+
tool: engine,
749+
detail: `"${engine}" mounts on Tier 3 leaf workers only, never on ${tier} directors`,
750+
};
751+
}
752+
try {
753+
assertTierMayMountFleetVerb(tier, engine);
754+
} catch (error) {
755+
if (!(error instanceof FleetAuthorityError)) throw error;
756+
return {
757+
code: "missing_tool",
758+
tool: engine,
759+
alternatives: rerouteAlternatives(engine),
760+
detail: error.message.split(". ")[0] ?? error.message,
761+
};
762+
}
763+
}
764+
return undefined;
765+
}
766+
726767
interface ResolvedAgentDispatch {
727768
directorId: string;
728769
agentLabel: string;
@@ -1106,6 +1147,19 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
11061147
);
11071148
}
11081149
requiresTools = preflight.canonical;
1150+
// Tier-gated mounts (fleet verbs, the Tier 3 leaf reporting channel)
1151+
// reject here as missing_tool when the dispatch tier can never mount
1152+
// them — pre-spawn, never surviving to the mount-time stale echo.
1153+
const dispatchTier: SubagentTier = resolved.orchestrator
1154+
? (resolved.orchestratorTier ?? resolved.pkg?.tier ?? "orchestrator")
1155+
: "leaf";
1156+
const gated = tierGateRequiresTools(preflight.canonical, dispatchTier);
1157+
if (gated !== undefined) {
1158+
return fleetResult(
1159+
call.id,
1160+
formatCapabilityUnavailable(gated, resolved.agentLabel),
1161+
);
1162+
}
11091163
snapshotRevision = currentProfileSnapshotRevision();
11101164
}
11111165

0 commit comments

Comments
 (0)