@crewhaus/tool-task 0.1.0
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.
- package/package.json +52 -0
- package/src/index.test.ts +333 -0
- package/src/index.ts +247 -0
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-task",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
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",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/agent-context-isolation": "0.0.0",
|
|
16
|
+
"@crewhaus/errors": "0.0.0",
|
|
17
|
+
"@crewhaus/sub-agent-permission-inheritance": "0.0.0",
|
|
18
|
+
"@crewhaus/tool-builder": "0.0.0",
|
|
19
|
+
"@crewhaus/tool-catalog": "0.0.0",
|
|
20
|
+
"yaml": "^2.6.0",
|
|
21
|
+
"zod": "^3.23.8"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@crewhaus/event-log": "0.0.0",
|
|
25
|
+
"@crewhaus/permission-engine": "0.0.0",
|
|
26
|
+
"@crewhaus/run-context": "0.0.0"
|
|
27
|
+
},
|
|
28
|
+
"license": "Apache-2.0",
|
|
29
|
+
"author": {
|
|
30
|
+
"name": "Max Meier",
|
|
31
|
+
"email": "max@studiomax.io",
|
|
32
|
+
"url": "https://studiomax.io"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
37
|
+
"directory": "packages/tool-task"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-task#readme",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "restricted"
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"src",
|
|
48
|
+
"README.md",
|
|
49
|
+
"LICENSE",
|
|
50
|
+
"NOTICE"
|
|
51
|
+
]
|
|
52
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
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
|
+
|
|
182
|
+
describe("createTaskTool — execute round-trip", () => {
|
|
183
|
+
test("calls bridge.spawnSubAgent with resolved def + filtered tools, returns finalMessage", async () => {
|
|
184
|
+
const root = newTempDir();
|
|
185
|
+
try {
|
|
186
|
+
const calls: SpawnSubAgentOptions[] = [];
|
|
187
|
+
const spawn: SpawnSubAgentFn = mock(async (_parent, opts) => {
|
|
188
|
+
calls.push(opts);
|
|
189
|
+
return {
|
|
190
|
+
finalMessage: `child saw: ${opts.prompt}`,
|
|
191
|
+
transcript: [],
|
|
192
|
+
toolCalls: [],
|
|
193
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
194
|
+
} satisfies SubAgentResult;
|
|
195
|
+
});
|
|
196
|
+
const parentTools = [makeReadTool(), makeBashTool()];
|
|
197
|
+
const { bridge, close } = await makeBridge(root, spawn, parentTools);
|
|
198
|
+
|
|
199
|
+
const inline: SubAgentDefinition = {
|
|
200
|
+
name: "code-reviewer",
|
|
201
|
+
description: "scoped reviewer",
|
|
202
|
+
instructions: "review",
|
|
203
|
+
tools: ["Read"],
|
|
204
|
+
permissions: "scoped",
|
|
205
|
+
};
|
|
206
|
+
const tool = createTaskTool({ subAgents: new Map([["code-reviewer", inline]]) });
|
|
207
|
+
|
|
208
|
+
const result = await tool.execute(
|
|
209
|
+
{ description: "review", prompt: "look at this", subagent_type: "code-reviewer" },
|
|
210
|
+
{ bridge },
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(result).toBe("child saw: look at this");
|
|
214
|
+
expect(calls).toHaveLength(1);
|
|
215
|
+
const called = calls[0];
|
|
216
|
+
expect(called?.def).toBe(inline);
|
|
217
|
+
expect(called?.permissionMode).toBe("default");
|
|
218
|
+
// Bash filtered out, only Read survives.
|
|
219
|
+
expect(called?.childTools.map((t) => t.name)).toEqual(["Read"]);
|
|
220
|
+
await close();
|
|
221
|
+
} finally {
|
|
222
|
+
rmSync(root, { recursive: true, force: true });
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("returns clean error when bridge is missing", async () => {
|
|
227
|
+
const tool = createTaskTool({});
|
|
228
|
+
const result = await tool.execute({ description: "x", prompt: "y" }, { signal: undefined });
|
|
229
|
+
expect(result).toContain("[Task error]");
|
|
230
|
+
expect(result).toContain("runtime bridge");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("returns error result on unknown subagent_type", async () => {
|
|
234
|
+
const root = newTempDir();
|
|
235
|
+
try {
|
|
236
|
+
const spawn: SpawnSubAgentFn = mock(async () => {
|
|
237
|
+
throw new Error("should not be called");
|
|
238
|
+
});
|
|
239
|
+
const { bridge, close } = await makeBridge(root, spawn, []);
|
|
240
|
+
const tool = createTaskTool({ subAgentDir: "/tmp/nope-xyz-doesntexist" });
|
|
241
|
+
const result = await tool.execute(
|
|
242
|
+
{ description: "x", prompt: "y", subagent_type: "ghost" },
|
|
243
|
+
{ bridge },
|
|
244
|
+
);
|
|
245
|
+
expect(result).toContain("[Task error]");
|
|
246
|
+
expect(result).toContain("ghost");
|
|
247
|
+
await close();
|
|
248
|
+
} finally {
|
|
249
|
+
rmSync(root, { recursive: true, force: true });
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("filesystem fallback resolves a sub-agent definition from .crewhaus/sub-agents", async () => {
|
|
254
|
+
const root = newTempDir();
|
|
255
|
+
const subAgentDir = join(root, "subs");
|
|
256
|
+
mkdirSync(subAgentDir, { recursive: true });
|
|
257
|
+
writeFileSync(
|
|
258
|
+
join(subAgentDir, "fooagent.md"),
|
|
259
|
+
`---
|
|
260
|
+
name: fooagent
|
|
261
|
+
description: hello
|
|
262
|
+
tools:
|
|
263
|
+
- Read
|
|
264
|
+
permissions: scoped
|
|
265
|
+
---
|
|
266
|
+
Be brief.`,
|
|
267
|
+
);
|
|
268
|
+
try {
|
|
269
|
+
let captured: SubAgentDefinition | undefined;
|
|
270
|
+
const spawn: SpawnSubAgentFn = mock(async (_p, opts) => {
|
|
271
|
+
captured = opts.def;
|
|
272
|
+
return {
|
|
273
|
+
finalMessage: "ok",
|
|
274
|
+
transcript: [],
|
|
275
|
+
toolCalls: [],
|
|
276
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
const { bridge, close } = await makeBridge(root, spawn, [makeReadTool()]);
|
|
280
|
+
const tool = createTaskTool({ subAgentDir });
|
|
281
|
+
const result = await tool.execute(
|
|
282
|
+
{ description: "x", prompt: "y", subagent_type: "fooagent" },
|
|
283
|
+
{ bridge },
|
|
284
|
+
);
|
|
285
|
+
expect(result).toBe("ok");
|
|
286
|
+
expect(captured?.name).toBe("fooagent");
|
|
287
|
+
expect(captured?.instructions).toBe("Be brief.");
|
|
288
|
+
expect(captured?.tools).toEqual(["Read"]);
|
|
289
|
+
await close();
|
|
290
|
+
} finally {
|
|
291
|
+
rmSync(root, { recursive: true, force: true });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("def.tools=[] yields empty child catalog (full permission isolation)", async () => {
|
|
296
|
+
const root = newTempDir();
|
|
297
|
+
try {
|
|
298
|
+
let captured: ReadonlyArray<RegisteredTool> | undefined;
|
|
299
|
+
const spawn: SpawnSubAgentFn = mock(async (_p, opts) => {
|
|
300
|
+
captured = opts.childTools;
|
|
301
|
+
return {
|
|
302
|
+
finalMessage: "denied",
|
|
303
|
+
transcript: [],
|
|
304
|
+
toolCalls: [],
|
|
305
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
306
|
+
};
|
|
307
|
+
});
|
|
308
|
+
const parentTools = [makeReadTool(), makeBashTool()];
|
|
309
|
+
const { bridge, close } = await makeBridge(root, spawn, parentTools);
|
|
310
|
+
const inline: SubAgentDefinition = {
|
|
311
|
+
name: "no-tools",
|
|
312
|
+
description: "no tools",
|
|
313
|
+
instructions: "x",
|
|
314
|
+
tools: [],
|
|
315
|
+
permissions: "scoped",
|
|
316
|
+
};
|
|
317
|
+
const tool = createTaskTool({ subAgents: new Map([["no-tools", inline]]) });
|
|
318
|
+
await tool.execute({ description: "x", prompt: "y", subagent_type: "no-tools" }, { bridge });
|
|
319
|
+
expect(captured).toEqual([]);
|
|
320
|
+
await close();
|
|
321
|
+
} finally {
|
|
322
|
+
rmSync(root, { recursive: true, force: true });
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("metadata: concurrencySafe=true, readOnly=false, destructive=false", () => {
|
|
327
|
+
const tool = createTaskTool();
|
|
328
|
+
expect(tool.name).toBe("Task");
|
|
329
|
+
expect(tool.concurrencySafe).toBe(true);
|
|
330
|
+
expect(tool.readOnly).toBe(false);
|
|
331
|
+
expect(tool.destructive).toBe(false);
|
|
332
|
+
});
|
|
333
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
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
|
+
export type CreateTaskToolOptions = {
|
|
147
|
+
/** Inline spec-supplied sub-agent definitions (highest priority). */
|
|
148
|
+
readonly subAgents?: ReadonlyMap<string, SubAgentDefinition>;
|
|
149
|
+
/** Filesystem fallback dir. Defaults to `<cwd>/.crewhaus/sub-agents`. */
|
|
150
|
+
readonly subAgentDir?: string;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** Resolve a sub-agent name to a definition using the documented order. */
|
|
154
|
+
export function resolveSubAgentDefinition(
|
|
155
|
+
name: string | undefined,
|
|
156
|
+
opts: CreateTaskToolOptions,
|
|
157
|
+
): SubAgentDefinition {
|
|
158
|
+
const resolveName = name ?? "general-purpose";
|
|
159
|
+
if (opts.subAgents !== undefined) {
|
|
160
|
+
const inline = opts.subAgents.get(resolveName);
|
|
161
|
+
if (inline !== undefined) return inline;
|
|
162
|
+
}
|
|
163
|
+
const dir = opts.subAgentDir ?? join(process.cwd(), ".crewhaus", "sub-agents");
|
|
164
|
+
const onDisk = loadSubAgentFromDisk(resolveName, dir);
|
|
165
|
+
if (onDisk !== null) return onDisk;
|
|
166
|
+
if (resolveName === "general-purpose") return BUILTIN_GENERAL_PURPOSE;
|
|
167
|
+
throw new SubAgentResolutionError(
|
|
168
|
+
`unknown subagent_type "${resolveName}" — not in spec sub_agents map, not on disk at ${dir}, and no built-in by that name`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Build the child catalog by filtering the parent catalog to the
|
|
174
|
+
* tool-name allowlist on the definition. Undefined `def.tools` (with
|
|
175
|
+
* `permissions: "inherit"`) → the child inherits the parent's full
|
|
176
|
+
* catalog. Undefined `def.tools` with any other permission mode → empty
|
|
177
|
+
* child catalog (the user has implicitly opted out).
|
|
178
|
+
*/
|
|
179
|
+
function buildChildCatalog(
|
|
180
|
+
parentTools: ReadonlyArray<RegisteredTool>,
|
|
181
|
+
def: SubAgentDefinition,
|
|
182
|
+
): ReadonlyArray<RegisteredTool> {
|
|
183
|
+
const allowed = def.tools;
|
|
184
|
+
if (allowed === undefined) {
|
|
185
|
+
return def.permissions === undefined || def.permissions === "inherit" ? parentTools : [];
|
|
186
|
+
}
|
|
187
|
+
const allowlist = new Set(allowed);
|
|
188
|
+
return parentTools.filter((t) => allowlist.has(t.name));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function createTaskTool(opts: CreateTaskToolOptions = {}): RegisteredTool {
|
|
192
|
+
const knownNames = opts.subAgents !== undefined ? [...opts.subAgents.keys()] : [];
|
|
193
|
+
const description =
|
|
194
|
+
knownNames.length === 0
|
|
195
|
+
? "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."
|
|
196
|
+
: `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.`;
|
|
197
|
+
|
|
198
|
+
return buildTool({
|
|
199
|
+
name: "Task",
|
|
200
|
+
description,
|
|
201
|
+
inputSchema: taskInputSchema,
|
|
202
|
+
concurrencySafe: true,
|
|
203
|
+
readOnly: false,
|
|
204
|
+
destructive: false,
|
|
205
|
+
execute: async (input, ctx) => {
|
|
206
|
+
const bridge = ctx?.bridge as RuntimeBridge | undefined;
|
|
207
|
+
if (bridge === undefined || bridge.spawnSubAgent === undefined) {
|
|
208
|
+
return "[Task error] runtime bridge is not available — Task can only be invoked from runChatLoop with sub-agent support enabled.";
|
|
209
|
+
}
|
|
210
|
+
const spawnSubAgent = bridge.spawnSubAgent;
|
|
211
|
+
let def: SubAgentDefinition;
|
|
212
|
+
try {
|
|
213
|
+
def = resolveSubAgentDefinition(input.subagent_type, opts);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
return `[Task error] ${(err as Error).message}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const childTools = buildChildCatalog(bridge.tools, def);
|
|
219
|
+
const childPerms = resolveChildPermissions(
|
|
220
|
+
{ mode: bridge.permissionMode, rules: bridge.permissionRules },
|
|
221
|
+
def,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const parentHandle = {
|
|
225
|
+
runContext: bridge.runContext,
|
|
226
|
+
eventLog: bridge.eventLog,
|
|
227
|
+
permissionMode: bridge.permissionMode,
|
|
228
|
+
permissionRules: bridge.permissionRules,
|
|
229
|
+
tools: bridge.tools,
|
|
230
|
+
model: bridge.model,
|
|
231
|
+
maxTokens: bridge.maxTokens,
|
|
232
|
+
...(bridge.sessionRootDir !== undefined ? { sessionRootDir: bridge.sessionRootDir } : {}),
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const result = await spawnSubAgent(parentHandle, {
|
|
236
|
+
def,
|
|
237
|
+
prompt: input.prompt,
|
|
238
|
+
permissionMode: childPerms.mode,
|
|
239
|
+
permissionRules: childPerms.rules,
|
|
240
|
+
childTools,
|
|
241
|
+
...(bridge.sessionRootDir !== undefined ? { sessionRootDir: bridge.sessionRootDir } : {}),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return result.finalMessage;
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
}
|