@crewhaus/tool-task 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ import type { SubAgentDefinition } from "@crewhaus/agent-context-isolation";
2
+ import { CrewhausError } from "@crewhaus/errors";
3
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
4
+ import { z } from "zod";
5
+ export declare class SubAgentResolutionError extends CrewhausError {
6
+ readonly name = "SubAgentResolutionError";
7
+ constructor(message: string, cause?: unknown);
8
+ }
9
+ export declare const BUILTIN_GENERAL_PURPOSE: SubAgentDefinition;
10
+ declare const taskInputSchema: z.ZodObject<{
11
+ description: z.ZodString;
12
+ prompt: z.ZodString;
13
+ subagent_type: z.ZodOptional<z.ZodString>;
14
+ }, "strip", z.ZodTypeAny, {
15
+ description: string;
16
+ prompt: string;
17
+ subagent_type?: string | undefined;
18
+ }, {
19
+ description: string;
20
+ prompt: string;
21
+ subagent_type?: string | undefined;
22
+ }>;
23
+ export type TaskInput = z.infer<typeof taskInputSchema>;
24
+ /**
25
+ * Parse a `<dir>/<name>.md` sub-agent file. Same shape as SKILL.md: a
26
+ * leading `---` frontmatter block; the body is the agent instructions.
27
+ */
28
+ export declare function parseSubAgentFile(content: string, fallbackName?: string): SubAgentDefinition;
29
+ export type CreateTaskToolOptions = {
30
+ /** Inline spec-supplied sub-agent definitions (highest priority). */
31
+ readonly subAgents?: ReadonlyMap<string, SubAgentDefinition>;
32
+ /** Filesystem fallback dir. Defaults to `<cwd>/.crewhaus/sub-agents`. */
33
+ readonly subAgentDir?: string;
34
+ };
35
+ /** Resolve a sub-agent name to a definition using the documented order. */
36
+ export declare function resolveSubAgentDefinition(name: string | undefined, opts: CreateTaskToolOptions): SubAgentDefinition;
37
+ export declare function createTaskTool(opts?: CreateTaskToolOptions): RegisteredTool;
38
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Catalog R4 `tool-task` — Section 13.
3
+ *
4
+ * The `Task(description, prompt, subagent_type?)` tool. When invoked, it
5
+ * resolves the named sub-agent definition, builds the child's tool catalog,
6
+ * resolves child permissions, and dispatches `bridge.spawnSubAgent`.
7
+ * Returns the child's final assistant text as the tool result so the parent
8
+ * model can react to the child's verdict.
9
+ *
10
+ * Resolution order for `subagent_type`:
11
+ * 1. `opts.subAgents.get(name)` — inline spec map (codegen-supplied).
12
+ * 2. `<opts.subAgentDir or cwd/.crewhaus/sub-agents>/<name>.md` — frontmatter
13
+ * file on disk. Format mirrors SKILL.md: leading `---` YAML block, body
14
+ * becomes `instructions`.
15
+ * 3. Built-in `general-purpose` fallback.
16
+ *
17
+ * Concurrency: marked `concurrencySafe: true` because parallel sub-agents
18
+ * are independent — each gets its own IsolatedContext. T7 in
19
+ * sub-agent-spawner exercises 10-way parallelism end-to-end.
20
+ *
21
+ * The bridge is opaque from `tool-catalog`'s perspective; we cast inside
22
+ * `execute` so ordinary tools (which never touch `ctx.bridge`) keep their
23
+ * unchanged shape. If the bridge is missing the `Task` tool returns a
24
+ * clean error result rather than crashing the turn — that case only
25
+ * occurs when callers wire `Task` into a `runChatLoop` invocation that
26
+ * doesn't pass `spawnSubAgent`.
27
+ */
28
+ import { existsSync, readFileSync } from "node:fs";
29
+ import { join } from "node:path";
30
+ import { CrewhausError } from "@crewhaus/errors";
31
+ import { resolveChildPermissions } from "@crewhaus/sub-agent-permission-inheritance";
32
+ import { buildTool } from "@crewhaus/tool-builder";
33
+ import { parse as parseYaml } from "yaml";
34
+ import { z } from "zod";
35
+ export class SubAgentResolutionError extends CrewhausError {
36
+ name = "SubAgentResolutionError";
37
+ constructor(message, cause) {
38
+ super("config", message, cause);
39
+ }
40
+ }
41
+ export const BUILTIN_GENERAL_PURPOSE = {
42
+ name: "general-purpose",
43
+ description: "General-purpose sub-agent. Pass it a task and it will use the parent's tools (filtered by inherited permissions) to complete it and return a single summary.",
44
+ instructions: "You are a sub-agent invoked by another agent via the Task tool. Complete the user's task using whatever tools you have. When you have an answer, respond with a single concise final message — your reply is what the parent agent will see.",
45
+ // Tools list undefined → child inherits parent's full tool catalog (subject to permissions).
46
+ permissions: "inherit",
47
+ };
48
+ const taskInputSchema = z.object({
49
+ description: z
50
+ .string()
51
+ .min(1)
52
+ .describe("Short label for the sub-task (e.g. 'review the auth flow')."),
53
+ prompt: z.string().min(1).describe("Full instructions/context the sub-agent should act on."),
54
+ subagent_type: z
55
+ .string()
56
+ .min(1)
57
+ .optional()
58
+ .describe("Name of the sub-agent definition to spawn. Falls back to 'general-purpose' if omitted."),
59
+ });
60
+ const FRONTMATTER_SCHEMA = z.object({
61
+ name: z.string().min(1),
62
+ description: z.string().min(1),
63
+ tools: z.array(z.string().min(1)).optional(),
64
+ model: z.string().min(1).optional(),
65
+ permissions: z
66
+ .union([
67
+ z.enum(["inherit", "scoped"]),
68
+ z.object({ allow: z.array(z.string()), deny: z.array(z.string()) }).strict(),
69
+ ])
70
+ .optional(),
71
+ inherit_bypass: z.boolean().optional(),
72
+ });
73
+ /**
74
+ * Parse a `<dir>/<name>.md` sub-agent file. Same shape as SKILL.md: a
75
+ * leading `---` frontmatter block; the body is the agent instructions.
76
+ */
77
+ export function parseSubAgentFile(content, fallbackName) {
78
+ const trimmed = content.replace(/^/, "");
79
+ if (!trimmed.startsWith("---")) {
80
+ throw new SubAgentResolutionError("sub-agent file must start with `---` frontmatter delimiter");
81
+ }
82
+ const firstNl = trimmed.indexOf("\n");
83
+ if (firstNl === -1) {
84
+ throw new SubAgentResolutionError("sub-agent file frontmatter never terminated");
85
+ }
86
+ const closer = trimmed.indexOf("\n---", firstNl);
87
+ if (closer === -1) {
88
+ throw new SubAgentResolutionError("sub-agent file frontmatter never terminated");
89
+ }
90
+ const fmRaw = trimmed.slice(firstNl + 1, closer).trimEnd();
91
+ const bodyStart = trimmed.indexOf("\n", closer + 4);
92
+ const body = bodyStart === -1 ? "" : trimmed.slice(bodyStart + 1).trim();
93
+ let parsedYaml;
94
+ try {
95
+ parsedYaml = parseYaml(fmRaw);
96
+ }
97
+ catch (err) {
98
+ throw new SubAgentResolutionError(`sub-agent frontmatter is not valid YAML: ${err.message}`);
99
+ }
100
+ const parsed = FRONTMATTER_SCHEMA.safeParse(parsedYaml);
101
+ if (!parsed.success) {
102
+ const issue = parsed.error.issues[0];
103
+ const path = issue && issue.path.length > 0 ? issue.path.join(".") : "(root)";
104
+ const message = issue ? issue.message : "validation failed";
105
+ throw new SubAgentResolutionError(`sub-agent frontmatter ${path}: ${message}`);
106
+ }
107
+ if (body.length === 0) {
108
+ throw new SubAgentResolutionError("sub-agent file has empty body — instructions required");
109
+ }
110
+ const def = {
111
+ name: parsed.data.name || fallbackName || "(unnamed)",
112
+ description: parsed.data.description,
113
+ instructions: body,
114
+ ...(parsed.data.tools !== undefined ? { tools: parsed.data.tools } : {}),
115
+ ...(parsed.data.model !== undefined ? { model: parsed.data.model } : {}),
116
+ ...(parsed.data.permissions !== undefined ? { permissions: parsed.data.permissions } : {}),
117
+ ...(parsed.data.inherit_bypass !== undefined
118
+ ? { inherit_bypass: parsed.data.inherit_bypass }
119
+ : {}),
120
+ };
121
+ return def;
122
+ }
123
+ function loadSubAgentFromDisk(name, dir) {
124
+ const filePath = join(dir, `${name}.md`);
125
+ if (!existsSync(filePath))
126
+ return null;
127
+ const content = readFileSync(filePath, "utf-8");
128
+ return parseSubAgentFile(content, name);
129
+ }
130
+ /**
131
+ * `subagent_type` is a model-filled tool argument and model output is
132
+ * attacker-steerable (via any untrusted input). It becomes a filesystem path
133
+ * segment in `loadSubAgentFromDisk` (`join(dir, `${name}.md`)`), and `join`
134
+ * resolves `..` and path separators — so an unsanitized name like
135
+ * `../../../tmp/evil` reads an arbitrary `.md` on the host and loads it as a
136
+ * sub-agent definition. Reject anything that isn't a single safe path
137
+ * component before it reaches the disk lookup. Inline spec-supplied names are
138
+ * exact-key map lookups that never touch the filesystem, so they are resolved
139
+ * first (above) and are unaffected.
140
+ */
141
+ function isUnsafeSubAgentName(name) {
142
+ return (name.includes("/") ||
143
+ name.includes("\\") ||
144
+ name.includes("\0") ||
145
+ name === "." ||
146
+ name === "..");
147
+ }
148
+ /** Resolve a sub-agent name to a definition using the documented order. */
149
+ export function resolveSubAgentDefinition(name, opts) {
150
+ const resolveName = name ?? "general-purpose";
151
+ if (opts.subAgents !== undefined) {
152
+ const inline = opts.subAgents.get(resolveName);
153
+ if (inline !== undefined)
154
+ return inline;
155
+ }
156
+ // Guard the disk lookup against path traversal (the inline map above is an
157
+ // exact-key lookup and is intentionally not gated).
158
+ if (isUnsafeSubAgentName(resolveName)) {
159
+ throw new SubAgentResolutionError(`invalid subagent_type "${resolveName}" — must not contain path separators, "..", ".", or NUL bytes`);
160
+ }
161
+ const dir = opts.subAgentDir ?? join(process.cwd(), ".crewhaus", "sub-agents");
162
+ const onDisk = loadSubAgentFromDisk(resolveName, dir);
163
+ if (onDisk !== null)
164
+ return onDisk;
165
+ if (resolveName === "general-purpose")
166
+ return BUILTIN_GENERAL_PURPOSE;
167
+ throw new SubAgentResolutionError(`unknown subagent_type "${resolveName}" — not in spec sub_agents map, not on disk at ${dir}, and no built-in by that name`);
168
+ }
169
+ /**
170
+ * Build the child catalog by filtering the parent catalog to the
171
+ * tool-name allowlist on the definition. Undefined `def.tools` (with
172
+ * `permissions: "inherit"`) → the child inherits the parent's full
173
+ * catalog. Undefined `def.tools` with any other permission mode → empty
174
+ * child catalog (the user has implicitly opted out).
175
+ */
176
+ function buildChildCatalog(parentTools, def) {
177
+ const allowed = def.tools;
178
+ if (allowed === undefined) {
179
+ return def.permissions === undefined || def.permissions === "inherit" ? parentTools : [];
180
+ }
181
+ const allowlist = new Set(allowed);
182
+ return parentTools.filter((t) => allowlist.has(t.name));
183
+ }
184
+ export function createTaskTool(opts = {}) {
185
+ const knownNames = opts.subAgents !== undefined ? [...opts.subAgents.keys()] : [];
186
+ const description = knownNames.length === 0
187
+ ? "Spawn a sub-agent to handle a delegated task. Pass a `description` (label) and a `prompt` (the task instructions). The sub-agent runs in an isolated context and returns a single final message."
188
+ : `Spawn a sub-agent to handle a delegated task. Pass a \`description\` (label), a \`prompt\` (the task instructions), and optionally a \`subagent_type\` to pick a specialist. Available subagent_type values: ${knownNames.join(", ")}. The sub-agent runs in an isolated context and returns a single final message.`;
189
+ return buildTool({
190
+ name: "Task",
191
+ description,
192
+ inputSchema: taskInputSchema,
193
+ concurrencySafe: true,
194
+ readOnly: false,
195
+ destructive: false,
196
+ execute: async (input, ctx) => {
197
+ const bridge = ctx?.bridge;
198
+ if (bridge === undefined || bridge.spawnSubAgent === undefined) {
199
+ return "[Task error] runtime bridge is not available — Task can only be invoked from runChatLoop with sub-agent support enabled.";
200
+ }
201
+ const spawnSubAgent = bridge.spawnSubAgent;
202
+ let def;
203
+ try {
204
+ def = resolveSubAgentDefinition(input.subagent_type, opts);
205
+ }
206
+ catch (err) {
207
+ return `[Task error] ${err.message}`;
208
+ }
209
+ const childTools = buildChildCatalog(bridge.tools, def);
210
+ const childPerms = resolveChildPermissions({ mode: bridge.permissionMode, rules: bridge.permissionRules }, def);
211
+ const parentHandle = {
212
+ runContext: bridge.runContext,
213
+ eventLog: bridge.eventLog,
214
+ permissionMode: bridge.permissionMode,
215
+ permissionRules: bridge.permissionRules,
216
+ tools: bridge.tools,
217
+ model: bridge.model,
218
+ maxTokens: bridge.maxTokens,
219
+ ...(bridge.sessionRootDir !== undefined ? { sessionRootDir: bridge.sessionRootDir } : {}),
220
+ };
221
+ const result = await spawnSubAgent(parentHandle, {
222
+ def,
223
+ prompt: input.prompt,
224
+ permissionMode: childPerms.mode,
225
+ permissionRules: childPerms.rules,
226
+ childTools,
227
+ ...(bridge.sessionRootDir !== undefined ? { sessionRootDir: bridge.sessionRootDir } : {}),
228
+ });
229
+ return result.finalMessage;
230
+ },
231
+ });
232
+ }
package/package.json CHANGED
@@ -1,29 +1,32 @@
1
1
  {
2
2
  "name": "@crewhaus/tool-task",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Task(description, prompt, subagent_type?) tool — spawns a sub-agent and returns its final message (Section 13)",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/agent-context-isolation": "0.1.3",
16
- "@crewhaus/errors": "0.1.3",
17
- "@crewhaus/sub-agent-permission-inheritance": "0.1.3",
18
- "@crewhaus/tool-builder": "0.1.3",
19
- "@crewhaus/tool-catalog": "0.1.3",
18
+ "@crewhaus/agent-context-isolation": "0.1.5",
19
+ "@crewhaus/errors": "0.1.5",
20
+ "@crewhaus/sub-agent-permission-inheritance": "0.1.5",
21
+ "@crewhaus/tool-builder": "0.1.5",
22
+ "@crewhaus/tool-catalog": "0.1.5",
20
23
  "yaml": "^2.6.0",
21
24
  "zod": "^3.23.8"
22
25
  },
23
26
  "devDependencies": {
24
- "@crewhaus/event-log": "0.1.3",
25
- "@crewhaus/permission-engine": "0.1.3",
26
- "@crewhaus/run-context": "0.1.3"
27
+ "@crewhaus/event-log": "0.1.5",
28
+ "@crewhaus/permission-engine": "0.1.5",
29
+ "@crewhaus/run-context": "0.1.5"
27
30
  },
28
31
  "license": "Apache-2.0",
29
32
  "author": {
@@ -43,5 +46,5 @@
43
46
  "publishConfig": {
44
47
  "access": "public"
45
48
  },
46
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
49
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
47
50
  }
package/src/index.test.ts DELETED
@@ -1,373 +0,0 @@
1
- import { describe, expect, mock, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import type {
6
- ParentRunHandle,
7
- RuntimeBridge,
8
- SpawnSubAgentFn,
9
- SpawnSubAgentOptions,
10
- SubAgentDefinition,
11
- SubAgentResult,
12
- } from "@crewhaus/agent-context-isolation";
13
- import { type EventLog, openEventLog } from "@crewhaus/event-log";
14
- import { emptyRuleSet } from "@crewhaus/permission-engine";
15
- import { createRunContext } from "@crewhaus/run-context";
16
- import { buildTool } from "@crewhaus/tool-builder";
17
- import type { RegisteredTool } from "@crewhaus/tool-catalog";
18
- import { z } from "zod";
19
- import {
20
- BUILTIN_GENERAL_PURPOSE,
21
- createTaskTool,
22
- parseSubAgentFile,
23
- resolveSubAgentDefinition,
24
- } from "./index.js";
25
-
26
- function newTempDir(): string {
27
- return mkdtempSync(join(tmpdir(), "tool-task-"));
28
- }
29
-
30
- function makeReadTool(): RegisteredTool {
31
- return buildTool({
32
- name: "Read",
33
- description: "read",
34
- inputSchema: z.object({}),
35
- execute: async () => "ok",
36
- readOnly: true,
37
- concurrencySafe: true,
38
- });
39
- }
40
-
41
- function makeBashTool(): RegisteredTool {
42
- return buildTool({
43
- name: "Bash",
44
- description: "shell",
45
- inputSchema: z.object({}),
46
- execute: async () => "ok",
47
- destructive: true,
48
- });
49
- }
50
-
51
- async function makeBridge(
52
- rootDir: string,
53
- spawn: SpawnSubAgentFn,
54
- parentTools: ReadonlyArray<RegisteredTool>,
55
- ): Promise<{ bridge: RuntimeBridge; close: () => Promise<void> }> {
56
- const ac = new AbortController();
57
- const runContext = createRunContext({ abortSignal: ac.signal });
58
- const eventLog: EventLog = await openEventLog(runContext.sessionId, { rootDir });
59
- const parent: ParentRunHandle = {
60
- runContext,
61
- eventLog,
62
- permissionMode: "default",
63
- permissionRules: { ...emptyRuleSet },
64
- tools: parentTools,
65
- model: "test-model",
66
- maxTokens: 1024,
67
- sessionRootDir: rootDir,
68
- };
69
- const bridge: RuntimeBridge = {
70
- ...parent,
71
- hooks: [],
72
- spawnSubAgent: spawn,
73
- };
74
- return {
75
- bridge,
76
- close: async () => {
77
- await eventLog.close();
78
- },
79
- };
80
- }
81
-
82
- describe("parseSubAgentFile", () => {
83
- test("extracts frontmatter and body", () => {
84
- const content = `---
85
- name: code-reviewer
86
- description: reviews code thoroughly
87
- tools:
88
- - Read
89
- - Grep
90
- ---
91
- You are a code reviewer.`;
92
- const def = parseSubAgentFile(content);
93
- expect(def.name).toBe("code-reviewer");
94
- expect(def.description).toBe("reviews code thoroughly");
95
- expect(def.tools).toEqual(["Read", "Grep"]);
96
- expect(def.instructions).toBe("You are a code reviewer.");
97
- });
98
-
99
- test("rejects missing frontmatter", () => {
100
- expect(() => parseSubAgentFile("just body, no fm")).toThrow(/frontmatter delimiter/);
101
- });
102
-
103
- test("rejects unterminated frontmatter", () => {
104
- expect(() => parseSubAgentFile("---\nname: x\ndescription: y")).toThrow(/never terminated/);
105
- });
106
-
107
- test("rejects empty body", () => {
108
- expect(() =>
109
- parseSubAgentFile(`---
110
- name: x
111
- description: y
112
- ---
113
- `),
114
- ).toThrow(/empty body/);
115
- });
116
-
117
- test("falls back to file name when name is omitted from frontmatter", () => {
118
- // The schema requires name explicitly so this raises a validation error;
119
- // confirm the error path is hit cleanly.
120
- expect(() =>
121
- parseSubAgentFile(
122
- `---
123
- description: missing name
124
- ---
125
- body`,
126
- "fallback",
127
- ),
128
- ).toThrow(/name/);
129
- });
130
- });
131
-
132
- describe("resolveSubAgentDefinition", () => {
133
- test("inline map wins over disk + builtin", () => {
134
- const inline: SubAgentDefinition = {
135
- name: "summarizer",
136
- description: "inline def",
137
- instructions: "summarize.",
138
- tools: [],
139
- };
140
- const out = resolveSubAgentDefinition("summarizer", {
141
- subAgents: new Map([["summarizer", inline]]),
142
- });
143
- expect(out).toBe(inline);
144
- });
145
-
146
- test("falls back to disk when not in inline map", () => {
147
- const dir = newTempDir();
148
- try {
149
- writeFileSync(
150
- join(dir, "ondisk.md"),
151
- `---
152
- name: ondisk
153
- description: from disk
154
- ---
155
- disk instructions`,
156
- );
157
- const out = resolveSubAgentDefinition("ondisk", { subAgentDir: dir });
158
- expect(out.name).toBe("ondisk");
159
- expect(out.instructions).toBe("disk instructions");
160
- } finally {
161
- rmSync(dir, { recursive: true, force: true });
162
- }
163
- });
164
-
165
- test("falls back to general-purpose for that exact name", () => {
166
- const out = resolveSubAgentDefinition("general-purpose", {});
167
- expect(out).toBe(BUILTIN_GENERAL_PURPOSE);
168
- });
169
-
170
- test("undefined subagent_type → general-purpose", () => {
171
- const out = resolveSubAgentDefinition(undefined, {});
172
- expect(out.name).toBe("general-purpose");
173
- });
174
-
175
- test("unknown name with no inline + no disk + not general-purpose → throws", () => {
176
- expect(() =>
177
- resolveSubAgentDefinition("nonexistent", { subAgentDir: "/tmp/does-not-exist-xyz" }),
178
- ).toThrow(/unknown subagent_type/);
179
- });
180
-
181
- // SECURITY: subagent_type is model-filled and becomes `join(dir, name+".md")`,
182
- // so an unsanitized `..`/separator escapes the sub-agent dir and loads an
183
- // arbitrary host .md as a sub-agent definition. Must be rejected.
184
- test.each([
185
- "../../../../etc/passwd",
186
- "../sibling",
187
- "foo/bar",
188
- "foo\\bar",
189
- "..",
190
- ".",
191
- "a/../../b",
192
- ])("rejects path-traversal subagent_type %p", (evil) => {
193
- expect(() =>
194
- resolveSubAgentDefinition(evil, { subAgentDir: "/tmp/does-not-exist-xyz" }),
195
- ).toThrow(/invalid subagent_type/);
196
- });
197
-
198
- test("path-traversal is rejected even when an inline map is present (disk path is the sink)", () => {
199
- expect(() =>
200
- resolveSubAgentDefinition("../../tmp/evil", {
201
- subAgents: new Map(),
202
- subAgentDir: "/tmp/does-not-exist-xyz",
203
- }),
204
- ).toThrow(/invalid subagent_type/);
205
- });
206
-
207
- test("ordinary names with dots that are not traversal still resolve via disk", () => {
208
- const dir = newTempDir();
209
- try {
210
- writeFileSync(
211
- join(dir, "my.agent.md"),
212
- "---\nname: my.agent\ndescription: d\n---\ndo the thing\n",
213
- );
214
- const out = resolveSubAgentDefinition("my.agent", { subAgentDir: dir });
215
- expect(out.name).toBe("my.agent");
216
- } finally {
217
- rmSync(dir, { recursive: true, force: true });
218
- }
219
- });
220
- });
221
-
222
- describe("createTaskTool — execute round-trip", () => {
223
- test("calls bridge.spawnSubAgent with resolved def + filtered tools, returns finalMessage", async () => {
224
- const root = newTempDir();
225
- try {
226
- const calls: SpawnSubAgentOptions[] = [];
227
- const spawn: SpawnSubAgentFn = mock(async (_parent, opts) => {
228
- calls.push(opts);
229
- return {
230
- finalMessage: `child saw: ${opts.prompt}`,
231
- transcript: [],
232
- toolCalls: [],
233
- usage: { input_tokens: 0, output_tokens: 0 },
234
- } satisfies SubAgentResult;
235
- });
236
- const parentTools = [makeReadTool(), makeBashTool()];
237
- const { bridge, close } = await makeBridge(root, spawn, parentTools);
238
-
239
- const inline: SubAgentDefinition = {
240
- name: "code-reviewer",
241
- description: "scoped reviewer",
242
- instructions: "review",
243
- tools: ["Read"],
244
- permissions: "scoped",
245
- };
246
- const tool = createTaskTool({ subAgents: new Map([["code-reviewer", inline]]) });
247
-
248
- const result = await tool.execute(
249
- { description: "review", prompt: "look at this", subagent_type: "code-reviewer" },
250
- { bridge },
251
- );
252
-
253
- expect(result).toBe("child saw: look at this");
254
- expect(calls).toHaveLength(1);
255
- const called = calls[0];
256
- expect(called?.def).toBe(inline);
257
- expect(called?.permissionMode).toBe("default");
258
- // Bash filtered out, only Read survives.
259
- expect(called?.childTools.map((t) => t.name)).toEqual(["Read"]);
260
- await close();
261
- } finally {
262
- rmSync(root, { recursive: true, force: true });
263
- }
264
- });
265
-
266
- test("returns clean error when bridge is missing", async () => {
267
- const tool = createTaskTool({});
268
- const result = await tool.execute({ description: "x", prompt: "y" }, { signal: undefined });
269
- expect(result).toContain("[Task error]");
270
- expect(result).toContain("runtime bridge");
271
- });
272
-
273
- test("returns error result on unknown subagent_type", async () => {
274
- const root = newTempDir();
275
- try {
276
- const spawn: SpawnSubAgentFn = mock(async () => {
277
- throw new Error("should not be called");
278
- });
279
- const { bridge, close } = await makeBridge(root, spawn, []);
280
- const tool = createTaskTool({ subAgentDir: "/tmp/nope-xyz-doesntexist" });
281
- const result = await tool.execute(
282
- { description: "x", prompt: "y", subagent_type: "ghost" },
283
- { bridge },
284
- );
285
- expect(result).toContain("[Task error]");
286
- expect(result).toContain("ghost");
287
- await close();
288
- } finally {
289
- rmSync(root, { recursive: true, force: true });
290
- }
291
- });
292
-
293
- test("filesystem fallback resolves a sub-agent definition from .crewhaus/sub-agents", async () => {
294
- const root = newTempDir();
295
- const subAgentDir = join(root, "subs");
296
- mkdirSync(subAgentDir, { recursive: true });
297
- writeFileSync(
298
- join(subAgentDir, "fooagent.md"),
299
- `---
300
- name: fooagent
301
- description: hello
302
- tools:
303
- - Read
304
- permissions: scoped
305
- ---
306
- Be brief.`,
307
- );
308
- try {
309
- let captured: SubAgentDefinition | undefined;
310
- const spawn: SpawnSubAgentFn = mock(async (_p, opts) => {
311
- captured = opts.def;
312
- return {
313
- finalMessage: "ok",
314
- transcript: [],
315
- toolCalls: [],
316
- usage: { input_tokens: 0, output_tokens: 0 },
317
- };
318
- });
319
- const { bridge, close } = await makeBridge(root, spawn, [makeReadTool()]);
320
- const tool = createTaskTool({ subAgentDir });
321
- const result = await tool.execute(
322
- { description: "x", prompt: "y", subagent_type: "fooagent" },
323
- { bridge },
324
- );
325
- expect(result).toBe("ok");
326
- expect(captured?.name).toBe("fooagent");
327
- expect(captured?.instructions).toBe("Be brief.");
328
- expect(captured?.tools).toEqual(["Read"]);
329
- await close();
330
- } finally {
331
- rmSync(root, { recursive: true, force: true });
332
- }
333
- });
334
-
335
- test("def.tools=[] yields empty child catalog (full permission isolation)", async () => {
336
- const root = newTempDir();
337
- try {
338
- let captured: ReadonlyArray<RegisteredTool> | undefined;
339
- const spawn: SpawnSubAgentFn = mock(async (_p, opts) => {
340
- captured = opts.childTools;
341
- return {
342
- finalMessage: "denied",
343
- transcript: [],
344
- toolCalls: [],
345
- usage: { input_tokens: 0, output_tokens: 0 },
346
- };
347
- });
348
- const parentTools = [makeReadTool(), makeBashTool()];
349
- const { bridge, close } = await makeBridge(root, spawn, parentTools);
350
- const inline: SubAgentDefinition = {
351
- name: "no-tools",
352
- description: "no tools",
353
- instructions: "x",
354
- tools: [],
355
- permissions: "scoped",
356
- };
357
- const tool = createTaskTool({ subAgents: new Map([["no-tools", inline]]) });
358
- await tool.execute({ description: "x", prompt: "y", subagent_type: "no-tools" }, { bridge });
359
- expect(captured).toEqual([]);
360
- await close();
361
- } finally {
362
- rmSync(root, { recursive: true, force: true });
363
- }
364
- });
365
-
366
- test("metadata: concurrencySafe=true, readOnly=false, destructive=false", () => {
367
- const tool = createTaskTool();
368
- expect(tool.name).toBe("Task");
369
- expect(tool.concurrencySafe).toBe(true);
370
- expect(tool.readOnly).toBe(false);
371
- expect(tool.destructive).toBe(false);
372
- });
373
- });
package/src/index.ts DELETED
@@ -1,275 +0,0 @@
1
- /**
2
- * Catalog R4 `tool-task` — Section 13.
3
- *
4
- * The `Task(description, prompt, subagent_type?)` tool. When invoked, it
5
- * resolves the named sub-agent definition, builds the child's tool catalog,
6
- * resolves child permissions, and dispatches `bridge.spawnSubAgent`.
7
- * Returns the child's final assistant text as the tool result so the parent
8
- * model can react to the child's verdict.
9
- *
10
- * Resolution order for `subagent_type`:
11
- * 1. `opts.subAgents.get(name)` — inline spec map (codegen-supplied).
12
- * 2. `<opts.subAgentDir or cwd/.crewhaus/sub-agents>/<name>.md` — frontmatter
13
- * file on disk. Format mirrors SKILL.md: leading `---` YAML block, body
14
- * becomes `instructions`.
15
- * 3. Built-in `general-purpose` fallback.
16
- *
17
- * Concurrency: marked `concurrencySafe: true` because parallel sub-agents
18
- * are independent — each gets its own IsolatedContext. T7 in
19
- * sub-agent-spawner exercises 10-way parallelism end-to-end.
20
- *
21
- * The bridge is opaque from `tool-catalog`'s perspective; we cast inside
22
- * `execute` so ordinary tools (which never touch `ctx.bridge`) keep their
23
- * unchanged shape. If the bridge is missing the `Task` tool returns a
24
- * clean error result rather than crashing the turn — that case only
25
- * occurs when callers wire `Task` into a `runChatLoop` invocation that
26
- * doesn't pass `spawnSubAgent`.
27
- */
28
- import { existsSync, readFileSync } from "node:fs";
29
- import { join } from "node:path";
30
- import type { RuntimeBridge, SubAgentDefinition } from "@crewhaus/agent-context-isolation";
31
- import { CrewhausError } from "@crewhaus/errors";
32
- import { resolveChildPermissions } from "@crewhaus/sub-agent-permission-inheritance";
33
- import { buildTool } from "@crewhaus/tool-builder";
34
- import type { RegisteredTool } from "@crewhaus/tool-catalog";
35
- import { parse as parseYaml } from "yaml";
36
- import { z } from "zod";
37
-
38
- export class SubAgentResolutionError extends CrewhausError {
39
- override readonly name = "SubAgentResolutionError";
40
- constructor(message: string, cause?: unknown) {
41
- super("config", message, cause);
42
- }
43
- }
44
-
45
- export const BUILTIN_GENERAL_PURPOSE: SubAgentDefinition = {
46
- name: "general-purpose",
47
- description:
48
- "General-purpose sub-agent. Pass it a task and it will use the parent's tools (filtered by inherited permissions) to complete it and return a single summary.",
49
- instructions:
50
- "You are a sub-agent invoked by another agent via the Task tool. Complete the user's task using whatever tools you have. When you have an answer, respond with a single concise final message — your reply is what the parent agent will see.",
51
- // Tools list undefined → child inherits parent's full tool catalog (subject to permissions).
52
- permissions: "inherit",
53
- };
54
-
55
- const taskInputSchema = z.object({
56
- description: z
57
- .string()
58
- .min(1)
59
- .describe("Short label for the sub-task (e.g. 'review the auth flow')."),
60
- prompt: z.string().min(1).describe("Full instructions/context the sub-agent should act on."),
61
- subagent_type: z
62
- .string()
63
- .min(1)
64
- .optional()
65
- .describe(
66
- "Name of the sub-agent definition to spawn. Falls back to 'general-purpose' if omitted.",
67
- ),
68
- });
69
-
70
- export type TaskInput = z.infer<typeof taskInputSchema>;
71
-
72
- const FRONTMATTER_SCHEMA = z.object({
73
- name: z.string().min(1),
74
- description: z.string().min(1),
75
- tools: z.array(z.string().min(1)).optional(),
76
- model: z.string().min(1).optional(),
77
- permissions: z
78
- .union([
79
- z.enum(["inherit", "scoped"]),
80
- z.object({ allow: z.array(z.string()), deny: z.array(z.string()) }).strict(),
81
- ])
82
- .optional(),
83
- inherit_bypass: z.boolean().optional(),
84
- });
85
-
86
- /**
87
- * Parse a `<dir>/<name>.md` sub-agent file. Same shape as SKILL.md: a
88
- * leading `---` frontmatter block; the body is the agent instructions.
89
- */
90
- export function parseSubAgentFile(content: string, fallbackName?: string): SubAgentDefinition {
91
- const trimmed = content.replace(/^/, "");
92
- if (!trimmed.startsWith("---")) {
93
- throw new SubAgentResolutionError("sub-agent file must start with `---` frontmatter delimiter");
94
- }
95
- const firstNl = trimmed.indexOf("\n");
96
- if (firstNl === -1) {
97
- throw new SubAgentResolutionError("sub-agent file frontmatter never terminated");
98
- }
99
- const closer = trimmed.indexOf("\n---", firstNl);
100
- if (closer === -1) {
101
- throw new SubAgentResolutionError("sub-agent file frontmatter never terminated");
102
- }
103
- const fmRaw = trimmed.slice(firstNl + 1, closer).trimEnd();
104
- const bodyStart = trimmed.indexOf("\n", closer + 4);
105
- const body = bodyStart === -1 ? "" : trimmed.slice(bodyStart + 1).trim();
106
-
107
- let parsedYaml: unknown;
108
- try {
109
- parsedYaml = parseYaml(fmRaw);
110
- } catch (err) {
111
- throw new SubAgentResolutionError(
112
- `sub-agent frontmatter is not valid YAML: ${(err as Error).message}`,
113
- );
114
- }
115
- const parsed = FRONTMATTER_SCHEMA.safeParse(parsedYaml);
116
- if (!parsed.success) {
117
- const issue = parsed.error.issues[0];
118
- const path = issue && issue.path.length > 0 ? issue.path.join(".") : "(root)";
119
- const message = issue ? issue.message : "validation failed";
120
- throw new SubAgentResolutionError(`sub-agent frontmatter ${path}: ${message}`);
121
- }
122
- if (body.length === 0) {
123
- throw new SubAgentResolutionError("sub-agent file has empty body — instructions required");
124
- }
125
- const def: SubAgentDefinition = {
126
- name: parsed.data.name || fallbackName || "(unnamed)",
127
- description: parsed.data.description,
128
- instructions: body,
129
- ...(parsed.data.tools !== undefined ? { tools: parsed.data.tools } : {}),
130
- ...(parsed.data.model !== undefined ? { model: parsed.data.model } : {}),
131
- ...(parsed.data.permissions !== undefined ? { permissions: parsed.data.permissions } : {}),
132
- ...(parsed.data.inherit_bypass !== undefined
133
- ? { inherit_bypass: parsed.data.inherit_bypass }
134
- : {}),
135
- };
136
- return def;
137
- }
138
-
139
- function loadSubAgentFromDisk(name: string, dir: string): SubAgentDefinition | null {
140
- const filePath = join(dir, `${name}.md`);
141
- if (!existsSync(filePath)) return null;
142
- const content = readFileSync(filePath, "utf-8");
143
- return parseSubAgentFile(content, name);
144
- }
145
-
146
- /**
147
- * `subagent_type` is a model-filled tool argument and model output is
148
- * attacker-steerable (via any untrusted input). It becomes a filesystem path
149
- * segment in `loadSubAgentFromDisk` (`join(dir, `${name}.md`)`), and `join`
150
- * resolves `..` and path separators — so an unsanitized name like
151
- * `../../../tmp/evil` reads an arbitrary `.md` on the host and loads it as a
152
- * sub-agent definition. Reject anything that isn't a single safe path
153
- * component before it reaches the disk lookup. Inline spec-supplied names are
154
- * exact-key map lookups that never touch the filesystem, so they are resolved
155
- * first (above) and are unaffected.
156
- */
157
- function isUnsafeSubAgentName(name: string): boolean {
158
- return (
159
- name.includes("/") ||
160
- name.includes("\\") ||
161
- name.includes("\0") ||
162
- name === "." ||
163
- name === ".."
164
- );
165
- }
166
-
167
- export type CreateTaskToolOptions = {
168
- /** Inline spec-supplied sub-agent definitions (highest priority). */
169
- readonly subAgents?: ReadonlyMap<string, SubAgentDefinition>;
170
- /** Filesystem fallback dir. Defaults to `<cwd>/.crewhaus/sub-agents`. */
171
- readonly subAgentDir?: string;
172
- };
173
-
174
- /** Resolve a sub-agent name to a definition using the documented order. */
175
- export function resolveSubAgentDefinition(
176
- name: string | undefined,
177
- opts: CreateTaskToolOptions,
178
- ): SubAgentDefinition {
179
- const resolveName = name ?? "general-purpose";
180
- if (opts.subAgents !== undefined) {
181
- const inline = opts.subAgents.get(resolveName);
182
- if (inline !== undefined) return inline;
183
- }
184
- // Guard the disk lookup against path traversal (the inline map above is an
185
- // exact-key lookup and is intentionally not gated).
186
- if (isUnsafeSubAgentName(resolveName)) {
187
- throw new SubAgentResolutionError(
188
- `invalid subagent_type "${resolveName}" — must not contain path separators, "..", ".", or NUL bytes`,
189
- );
190
- }
191
- const dir = opts.subAgentDir ?? join(process.cwd(), ".crewhaus", "sub-agents");
192
- const onDisk = loadSubAgentFromDisk(resolveName, dir);
193
- if (onDisk !== null) return onDisk;
194
- if (resolveName === "general-purpose") return BUILTIN_GENERAL_PURPOSE;
195
- throw new SubAgentResolutionError(
196
- `unknown subagent_type "${resolveName}" — not in spec sub_agents map, not on disk at ${dir}, and no built-in by that name`,
197
- );
198
- }
199
-
200
- /**
201
- * Build the child catalog by filtering the parent catalog to the
202
- * tool-name allowlist on the definition. Undefined `def.tools` (with
203
- * `permissions: "inherit"`) → the child inherits the parent's full
204
- * catalog. Undefined `def.tools` with any other permission mode → empty
205
- * child catalog (the user has implicitly opted out).
206
- */
207
- function buildChildCatalog(
208
- parentTools: ReadonlyArray<RegisteredTool>,
209
- def: SubAgentDefinition,
210
- ): ReadonlyArray<RegisteredTool> {
211
- const allowed = def.tools;
212
- if (allowed === undefined) {
213
- return def.permissions === undefined || def.permissions === "inherit" ? parentTools : [];
214
- }
215
- const allowlist = new Set(allowed);
216
- return parentTools.filter((t) => allowlist.has(t.name));
217
- }
218
-
219
- export function createTaskTool(opts: CreateTaskToolOptions = {}): RegisteredTool {
220
- const knownNames = opts.subAgents !== undefined ? [...opts.subAgents.keys()] : [];
221
- const description =
222
- knownNames.length === 0
223
- ? "Spawn a sub-agent to handle a delegated task. Pass a `description` (label) and a `prompt` (the task instructions). The sub-agent runs in an isolated context and returns a single final message."
224
- : `Spawn a sub-agent to handle a delegated task. Pass a \`description\` (label), a \`prompt\` (the task instructions), and optionally a \`subagent_type\` to pick a specialist. Available subagent_type values: ${knownNames.join(", ")}. The sub-agent runs in an isolated context and returns a single final message.`;
225
-
226
- return buildTool({
227
- name: "Task",
228
- description,
229
- inputSchema: taskInputSchema,
230
- concurrencySafe: true,
231
- readOnly: false,
232
- destructive: false,
233
- execute: async (input, ctx) => {
234
- const bridge = ctx?.bridge as RuntimeBridge | undefined;
235
- if (bridge === undefined || bridge.spawnSubAgent === undefined) {
236
- return "[Task error] runtime bridge is not available — Task can only be invoked from runChatLoop with sub-agent support enabled.";
237
- }
238
- const spawnSubAgent = bridge.spawnSubAgent;
239
- let def: SubAgentDefinition;
240
- try {
241
- def = resolveSubAgentDefinition(input.subagent_type, opts);
242
- } catch (err) {
243
- return `[Task error] ${(err as Error).message}`;
244
- }
245
-
246
- const childTools = buildChildCatalog(bridge.tools, def);
247
- const childPerms = resolveChildPermissions(
248
- { mode: bridge.permissionMode, rules: bridge.permissionRules },
249
- def,
250
- );
251
-
252
- const parentHandle = {
253
- runContext: bridge.runContext,
254
- eventLog: bridge.eventLog,
255
- permissionMode: bridge.permissionMode,
256
- permissionRules: bridge.permissionRules,
257
- tools: bridge.tools,
258
- model: bridge.model,
259
- maxTokens: bridge.maxTokens,
260
- ...(bridge.sessionRootDir !== undefined ? { sessionRootDir: bridge.sessionRootDir } : {}),
261
- };
262
-
263
- const result = await spawnSubAgent(parentHandle, {
264
- def,
265
- prompt: input.prompt,
266
- permissionMode: childPerms.mode,
267
- permissionRules: childPerms.rules,
268
- childTools,
269
- ...(bridge.sessionRootDir !== undefined ? { sessionRootDir: bridge.sessionRootDir } : {}),
270
- });
271
-
272
- return result.finalMessage;
273
- },
274
- });
275
- }