crewly 1.20.436 → 1.20.437

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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "eba65f5222082df4731cd741161afb6d8590b84a",
3
- "builtAt": "2026-10-26T10:15:11.100Z"
2
+ "commit": "0325d3b3ba4216522326cbe59359c72df5760c4f",
3
+ "builtAt": "2026-10-26T10:49:46.811Z"
4
4
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewly",
3
- "version": "1.20.436",
3
+ "version": "1.20.437",
4
4
  "type": "module",
5
5
  "description": "Multi-agent orchestration platform for AI coding teams — coordinates Claude Code, Gemini CLI, and Codex agents with a real-time web dashboard",
6
6
  "workspaces": [
@@ -253,6 +253,65 @@ describe('AgentRunnerService', () => {
253
253
  });
254
254
  });
255
255
 
256
+ describe('sub-agents (#1317)', () => {
257
+ const okResult = {
258
+ text: 'done',
259
+ steps: [{ toolCalls: [], toolResults: [] }],
260
+ usage: { inputTokens: 10, outputTokens: 5 },
261
+ finishReason: 'stop',
262
+ };
263
+
264
+ it('gives a normal agent spawn_subagent on top of the full tool set', async () => {
265
+ await runner.initialize();
266
+ mockGenerateText.mockResolvedValueOnce(okResult);
267
+ await runner.run('hi');
268
+ const tools = mockGenerateText.mock.calls[0][0].tools as Record<string, unknown>;
269
+ expect(tools.spawn_subagent).toBeDefined();
270
+ expect(tools.report_to_parent).toBeUndefined();
271
+ expect(tools.send_message).toBeDefined();
272
+ expect(tools.read_file).toBeDefined();
273
+ });
274
+
275
+ it('gives a sub-agent only the confined file/search/shell tools plus report_to_parent', async () => {
276
+ const child = new AgentRunnerService(
277
+ {
278
+ ...baseConfig,
279
+ sessionName: 'test-session/sub-1',
280
+ projectPath: '/tmp/proj',
281
+ subagent: { cwd: '/tmp/proj', readOnly: false, depth: 1, parentSession: 'test-session' },
282
+ },
283
+ mockModelManager,
284
+ mockApiClient,
285
+ );
286
+ child._generateTextFn = mockGenerateText;
287
+ await child.initialize();
288
+ mockGenerateText.mockResolvedValueOnce(okResult);
289
+ await child.run('task');
290
+ const tools = mockGenerateText.mock.calls[0][0].tools as Record<string, unknown>;
291
+ expect(tools.report_to_parent).toBeDefined();
292
+ expect(tools.read_file).toBeDefined();
293
+ expect(tools.bash_exec).toBeDefined();
294
+ expect(tools.spawn_subagent).toBeUndefined();
295
+ expect(tools.send_message).toBeUndefined();
296
+ expect(tools.delegate_task).toBeUndefined();
297
+ expect(tools.remember).toBeUndefined();
298
+ expect(child.getSubagentReport()).toBeNull();
299
+ });
300
+
301
+ it('folds a child\'s tokens into the parent\'s next run result and totals, once', async () => {
302
+ await runner.initialize();
303
+ runner.recordChildUsage({ input: 1000, output: 200, cachedInput: 50 });
304
+ mockGenerateText.mockResolvedValueOnce(okResult);
305
+ const first = await runner.run('one');
306
+ expect(first.usage).toEqual({ input: 1010, output: 205, cachedInput: 50 });
307
+ expect(runner.getState().totalTokens).toEqual({ input: 1010, output: 205 });
308
+
309
+ mockGenerateText.mockResolvedValueOnce(okResult);
310
+ const second = await runner.run('two');
311
+ expect(second.usage).toEqual({ input: 10, output: 5, cachedInput: 0 });
312
+ });
313
+ });
314
+
256
315
  describe('serial queue', () => {
257
316
  beforeEach(async () => {
258
317
  await runner.initialize();
@@ -17,6 +17,13 @@ import { ApprovalQueueService, type PendingApproval } from './approval-queue.ser
17
17
  import { OutputFilterService } from './output-filter.service.js';
18
18
  import { parseTextToolCalls, coerceArgs, resolveToolName, type TextToolCall, type SchemaLike } from './text-tool-calls.js';
19
19
  import { unverifiedDesktopWork } from './desktop-task.tool.js';
20
+ import {
21
+ createSpawnSubagentTool,
22
+ createSubagentRegistry,
23
+ restrictToolsForSubagent,
24
+ type SubagentRegistry,
25
+ type SubagentReport,
26
+ } from './subagent.js';
20
27
  import type { ToolDefinition, McpClientLike } from './types.js';
21
28
  import {
22
29
  type CrewlyAgentConfig,
@@ -477,6 +484,12 @@ export class AgentRunnerService {
477
484
  private outputFilter: OutputFilterService = new OutputFilterService();
478
485
  /** @internal Override for testing — replaces the AI SDK generateText call */
479
486
  _generateTextFn: GenerateTextFn | null = null;
487
+ /** Children spawned by this runner (#1317): how many run now, how many ever */
488
+ private readonly subagentRegistry: SubagentRegistry = createSubagentRegistry();
489
+ /** What this runner reported when it is itself a sub-agent */
490
+ private subagentReport: SubagentReport | null = null;
491
+ /** Tokens spent by children since the last run result, folded into the next one */
492
+ private childUsage = { input: 0, output: 0, cachedInput: 0 };
480
493
 
481
494
  /**
482
495
  * Create a new AgentRunnerService.
@@ -1070,7 +1083,9 @@ export class AgentRunnerService {
1070
1083
  },
1071
1084
  };
1072
1085
  const mcpTools = Object.keys(this.mcpToolDefs).length > 0 ? this.mcpToolDefs : undefined;
1073
- const tools = createTools(this.apiClient, this.config.sessionName, this.config.projectPath, callbacks, this.currentConversationId, this.currentSlackContext, mcpTools);
1086
+ const tools = this.attachSubagentTools(
1087
+ createTools(this.apiClient, this.config.sessionName, this.config.projectPath, callbacks, this.currentConversationId, this.currentSlackContext, mcpTools),
1088
+ );
1074
1089
 
1075
1090
  // Create abort controller that merges external signal with internal control
1076
1091
  const runAbort = new AbortController();
@@ -1134,6 +1149,72 @@ export class AgentRunnerService {
1134
1149
  }
1135
1150
  }
1136
1151
 
1152
+ /**
1153
+ * Give a run its sub-agent tools (#1317).
1154
+ *
1155
+ * A sub-agent (config.subagent set) gets the restricted map — file, search
1156
+ * and shell tools confined to its cwd, plus `report_to_parent` — and
1157
+ * nothing that talks to people or spawns further helpers. Any other runner
1158
+ * gets the full map plus `spawn_subagent`, whose children are built from
1159
+ * this runner's config and counted in its registry and usage.
1160
+ *
1161
+ * @param tools - The full `createTools()` map
1162
+ * @returns The map this run should see
1163
+ */
1164
+ private attachSubagentTools(tools: Record<string, ToolDefinition>): Record<string, ToolDefinition> {
1165
+ if (this.config.subagent) {
1166
+ return restrictToolsForSubagent(tools, this.config.subagent, (report) => { this.subagentReport = report; });
1167
+ }
1168
+ return {
1169
+ ...tools,
1170
+ spawn_subagent: createSpawnSubagentTool({
1171
+ parentConfig: this.config,
1172
+ registry: this.subagentRegistry,
1173
+ createRunner: (config) => new AgentRunnerService(config, this.modelManager, this.apiClient),
1174
+ onUsage: (usage) => this.recordChildUsage(usage),
1175
+ }),
1176
+ };
1177
+ }
1178
+
1179
+ /**
1180
+ * What this runner reported through `report_to_parent`, when it is a
1181
+ * sub-agent; null for a parent or a child that never reported.
1182
+ *
1183
+ * @returns The last report
1184
+ */
1185
+ getSubagentReport(): SubagentReport | null {
1186
+ return this.subagentReport;
1187
+ }
1188
+
1189
+ /**
1190
+ * Count a child's tokens against this runner (#1317): they are spent on
1191
+ * this agent's behalf, so its ledger and spend cap must see them.
1192
+ *
1193
+ * @param usage - The child's usage
1194
+ */
1195
+ recordChildUsage(usage: { input: number; output: number; cachedInput: number }): void {
1196
+ this.childUsage.input += usage.input;
1197
+ this.childUsage.output += usage.output;
1198
+ this.childUsage.cachedInput += usage.cachedInput;
1199
+ }
1200
+
1201
+ /**
1202
+ * Fold pending child usage into a run's reported usage and this runner's
1203
+ * totals, then reset it. Context occupancy is not touched: a child's
1204
+ * tokens never sat in this runner's context window.
1205
+ *
1206
+ * @param own - The run's own usage
1207
+ * @returns Own plus children
1208
+ */
1209
+ private absorbChildUsage(own: { input: number; output: number; cachedInput: number }): { input: number; output: number; cachedInput: number } {
1210
+ const child = this.childUsage;
1211
+ if (child.input === 0 && child.output === 0 && child.cachedInput === 0) return own;
1212
+ this.childUsage = { input: 0, output: 0, cachedInput: 0 };
1213
+ this.state.totalTokens.input += child.input;
1214
+ this.state.totalTokens.output += child.output;
1215
+ return { input: own.input + child.input, output: own.output + child.output, cachedInput: own.cachedInput + child.cachedInput };
1216
+ }
1217
+
1137
1218
  /**
1138
1219
  * Resolve the wall-clock budget for one run.
1139
1220
  *
@@ -1637,7 +1718,7 @@ export class AgentRunnerService {
1637
1718
  return {
1638
1719
  text,
1639
1720
  steps: stepCount,
1640
- usage,
1721
+ usage: this.absorbChildUsage(usage),
1641
1722
  toolCalls,
1642
1723
  finishReason,
1643
1724
  budgetWarning,
@@ -1818,7 +1899,7 @@ export class AgentRunnerService {
1818
1899
  return {
1819
1900
  text: finalText,
1820
1901
  steps: result.steps.length,
1821
- usage,
1902
+ usage: this.absorbChildUsage(usage),
1822
1903
  toolCalls,
1823
1904
  finishReason: result.finishReason,
1824
1905
  budgetWarning,
@@ -0,0 +1,466 @@
1
+ // Short-lived sub-agents (#1317): tool restriction, config derivation, spawn tool.
2
+ import { describe, it, expect, vi, type Mocked } from 'vitest';
3
+ import path from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import {
6
+ SUBAGENT_READ_TOOLS,
7
+ SUBAGENT_WRITE_TOOLS,
8
+ REPORT_TOOL_NAME,
9
+ SPAWN_TOOL_NAME,
10
+ buildSubagentConfig,
11
+ buildSubagentPrompt,
12
+ createSpawnSubagentTool,
13
+ createSubagentRegistry,
14
+ isInsideDir,
15
+ resolveSubagentModel,
16
+ resolveSubagentPath,
17
+ restrictToolsForSubagent,
18
+ summarizeSubagentRun,
19
+ type SubagentReport,
20
+ type SubagentRunner,
21
+ } from './subagent.js';
22
+ import { createTools, getToolNames, TOOL_SENSITIVITY } from './tool-registry.js';
23
+ import { CrewlyApiClient } from './api-client.js';
24
+ import { CREWLY_AGENT_DEFAULTS, WRITE_TOOLS, type AgentRunResult, type CrewlyAgentConfig, type ToolDefinition } from './types.js';
25
+
26
+ const CWD = '/work/project';
27
+
28
+ const parentConfig: CrewlyAgentConfig = {
29
+ model: { provider: 'deepseek', modelId: 'deepseek-chat', temperature: 0.2, maxTokens: 4096 },
30
+ maxSteps: 500,
31
+ sessionName: 'think-tank-kai',
32
+ apiBaseUrl: 'http://localhost:8787',
33
+ systemPrompt: 'You are Kai, the team lead.',
34
+ maxHistoryMessages: 40,
35
+ compactionThreshold: 0.8,
36
+ projectPath: CWD,
37
+ memberId: 'member-1',
38
+ mcpServers: { playwright: { command: 'npx', args: ['playwright-mcp'] } },
39
+ };
40
+
41
+ /** A run result with the given tool calls and no incompleteness. */
42
+ function runResult(partial: Partial<AgentRunResult> = {}): AgentRunResult {
43
+ return {
44
+ text: 'final text',
45
+ steps: 3,
46
+ usage: { input: 100, output: 20, cachedInput: 5 },
47
+ toolCalls: [],
48
+ finishReason: 'stop',
49
+ ...partial,
50
+ };
51
+ }
52
+
53
+ /** A scripted child runner. */
54
+ function fakeRunner(opts: {
55
+ result?: AgentRunResult | (() => Promise<AgentRunResult>);
56
+ report?: SubagentReport | null;
57
+ throwOn?: 'initialize' | 'run';
58
+ }): SubagentRunner & { shutdown: ReturnType<typeof vi.fn>; run: ReturnType<typeof vi.fn> } {
59
+ const run = vi.fn(async (_m: string, _c?: string, _md?: Record<string, string>, _o?: { abortSignal?: AbortSignal }) => {
60
+ if (opts.throwOn === 'run') throw new Error('provider exploded');
61
+ return typeof opts.result === 'function' ? opts.result() : (opts.result ?? runResult());
62
+ });
63
+ return {
64
+ initialize: vi.fn(async () => {
65
+ if (opts.throwOn === 'initialize') throw new Error('no api key');
66
+ }),
67
+ run,
68
+ shutdown: vi.fn(async () => undefined),
69
+ getSubagentReport: () => opts.report ?? null,
70
+ };
71
+ }
72
+
73
+ describe('subagent', () => {
74
+ describe('isInsideDir / resolveSubagentPath', () => {
75
+ it('accepts the directory itself and anything beneath it', () => {
76
+ expect(isInsideDir(CWD, CWD)).toBe(true);
77
+ expect(isInsideDir(`${CWD}/src/a.ts`, CWD)).toBe(true);
78
+ expect(isInsideDir(`${CWD}/src/../src/b.ts`, CWD)).toBe(true);
79
+ });
80
+
81
+ it('rejects siblings, parents and look-alike prefixes', () => {
82
+ expect(isInsideDir('/work/project2/a.ts', CWD)).toBe(false);
83
+ expect(isInsideDir('/work', CWD)).toBe(false);
84
+ expect(isInsideDir(`${CWD}/../other/x`, CWD)).toBe(false);
85
+ expect(isInsideDir('/etc/passwd', CWD)).toBe(false);
86
+ });
87
+
88
+ it('resolves relative paths against cwd and expands ~ and $HOME like the file tools', () => {
89
+ expect(resolveSubagentPath('src/a.ts', CWD)).toBe(`${CWD}/src/a.ts`);
90
+ expect(resolveSubagentPath('/abs/x', CWD)).toBe('/abs/x');
91
+ expect(resolveSubagentPath('~/notes.md', CWD)).toBe(path.join(homedir(), 'notes.md'));
92
+ expect(resolveSubagentPath('$HOME/notes.md', CWD)).toBe(path.join(homedir(), 'notes.md'));
93
+ });
94
+ });
95
+
96
+ describe('restrictToolsForSubagent', () => {
97
+ let mockClient: Mocked<CrewlyApiClient>;
98
+ let full: Record<string, ToolDefinition>;
99
+
100
+ const policy = { cwd: CWD, readOnly: false, depth: 1, parentSession: 'think-tank-kai' };
101
+
102
+ function freshTools(): Record<string, ToolDefinition> {
103
+ mockClient = {
104
+ get: vi.fn<any>().mockResolvedValue({ success: false, data: null, status: 404 }),
105
+ post: vi.fn<any>(),
106
+ delete: vi.fn<any>(),
107
+ } as any;
108
+ return createTools(mockClient, 'think-tank-kai/sub-1', CWD);
109
+ }
110
+
111
+ it('keeps only file, search and shell tools plus report_to_parent', () => {
112
+ full = freshTools();
113
+ const restricted = restrictToolsForSubagent(full, policy, () => undefined);
114
+ const names = Object.keys(restricted).sort();
115
+ const expected = [...SUBAGENT_READ_TOOLS, ...SUBAGENT_WRITE_TOOLS, REPORT_TOOL_NAME].filter((n) => n === REPORT_TOOL_NAME || full[n]).sort();
116
+ expect(names).toEqual(expected);
117
+ expect(names).toContain('read_file');
118
+ expect(names).toContain('grep');
119
+ expect(names).toContain('bash_exec');
120
+ expect(names).toContain('edit_file');
121
+ });
122
+
123
+ it('has none of the communication, delegation, lifecycle, memory or ticket tools', () => {
124
+ full = freshTools();
125
+ const restricted = restrictToolsForSubagent(full, policy, () => undefined);
126
+ for (const forbidden of [
127
+ 'send_message', 'reply_slack', 'broadcast', 'delegate_task', 'handoff_task', 'report_status', 'complete_task',
128
+ 'get_tasks', 'start_agent', 'stop_agent', 'handle_agent_failure', 'remember', 'recall_memory', 'schedule_check',
129
+ 'register_self', 'subscribe_event', 'get_audit_log', 'compact_memory', 'computer', 'desktop_task', 'git_commit',
130
+ SPAWN_TOOL_NAME,
131
+ ]) {
132
+ expect(restricted[forbidden], forbidden).toBeUndefined();
133
+ }
134
+ });
135
+
136
+ it('never passes through a spawn_subagent or MCP tool present in the input map', () => {
137
+ full = freshTools();
138
+ const withExtras: Record<string, ToolDefinition> = {
139
+ ...full,
140
+ [SPAWN_TOOL_NAME]: { description: 'x', inputSchema: {} as any, execute: async () => ({}) },
141
+ 'playwright:click': { description: 'x', inputSchema: {} as any, execute: async () => ({}) },
142
+ };
143
+ const restricted = restrictToolsForSubagent(withExtras, policy, () => undefined);
144
+ expect(restricted[SPAWN_TOOL_NAME]).toBeUndefined();
145
+ expect(restricted['playwright:click']).toBeUndefined();
146
+ });
147
+
148
+ it('readOnly drops the shell and the write tools', () => {
149
+ full = freshTools();
150
+ const restricted = restrictToolsForSubagent(full, { ...policy, readOnly: true }, () => undefined);
151
+ const names = Object.keys(restricted);
152
+ for (const w of SUBAGENT_WRITE_TOOLS) expect(names).not.toContain(w);
153
+ for (const w of WRITE_TOOLS) expect(names).not.toContain(w);
154
+ expect(names).toContain('read_file');
155
+ expect(names).toContain(REPORT_TOOL_NAME);
156
+ });
157
+
158
+ it('refuses edit_file and write_file outside cwd and resolves relative paths inside it', async () => {
159
+ const inner = vi.fn(async () => ({ success: true }));
160
+ const tools: Record<string, ToolDefinition> = {
161
+ edit_file: { description: 'e', inputSchema: {} as any, execute: inner },
162
+ write_file: { description: 'w', inputSchema: {} as any, execute: inner },
163
+ };
164
+ const restricted = restrictToolsForSubagent(tools, policy, () => undefined);
165
+
166
+ const outside = await restricted.edit_file.execute({ file_path: '/etc/hosts', old_string: 'a', new_string: 'b' }) as Record<string, unknown>;
167
+ expect(outside.success).toBe(false);
168
+ expect(String(outside.error)).toMatch(/outside the sub-agent working directory/);
169
+ const climb = await restricted.write_file.execute({ file_path: '../sibling/x.ts', content: '' }) as Record<string, unknown>;
170
+ expect(climb.success).toBe(false);
171
+ const home = await restricted.write_file.execute({ file_path: '~/x.ts', content: '' }) as Record<string, unknown>;
172
+ expect(home.success).toBe(false);
173
+ expect(inner).not.toHaveBeenCalled();
174
+
175
+ await restricted.edit_file.execute({ file_path: 'src/a.ts', old_string: 'a', new_string: 'b' });
176
+ expect(inner).toHaveBeenCalledWith(expect.objectContaining({ file_path: `${CWD}/src/a.ts` }));
177
+ });
178
+
179
+ it('forces bash_exec into cwd when the model asks for a directory outside it', async () => {
180
+ const inner = vi.fn(async () => ({ success: true, stdout: '' }));
181
+ const tools: Record<string, ToolDefinition> = {
182
+ bash_exec: { description: 'b', inputSchema: {} as any, execute: inner },
183
+ };
184
+ const restricted = restrictToolsForSubagent(tools, policy, () => undefined);
185
+ await restricted.bash_exec.execute({ command: 'ls' });
186
+ expect(inner).toHaveBeenLastCalledWith({ command: 'ls', cwd: CWD });
187
+ await restricted.bash_exec.execute({ command: 'ls', cwd: '/' });
188
+ expect(inner).toHaveBeenLastCalledWith({ command: 'ls', cwd: CWD });
189
+ await restricted.bash_exec.execute({ command: 'ls', cwd: 'packages' });
190
+ expect(inner).toHaveBeenLastCalledWith({ command: 'ls', cwd: `${CWD}/packages` });
191
+ });
192
+
193
+ it('report_to_parent records the report and tells the child to stop', async () => {
194
+ const reports: SubagentReport[] = [];
195
+ const restricted = restrictToolsForSubagent({}, policy, (r) => reports.push(r));
196
+ const out = await restricted[REPORT_TOOL_NAME].execute({ summary: 'found 3 call sites', filesChanged: ['a.ts'] }) as Record<string, unknown>;
197
+ expect(out.success).toBe(true);
198
+ expect(String(out.note)).toMatch(/finish your turn/i);
199
+ expect(reports).toEqual([{ summary: 'found 3 call sites', filesChanged: ['a.ts'] }]);
200
+ expect(TOOL_SENSITIVITY[REPORT_TOOL_NAME]).toBe('safe');
201
+ expect(TOOL_SENSITIVITY[SPAWN_TOOL_NAME]).toBe('sensitive');
202
+ // Neither is a registry tool: the runner adds them per run.
203
+ expect(getToolNames()).not.toContain(SPAWN_TOOL_NAME);
204
+ });
205
+ });
206
+
207
+ describe('resolveSubagentModel', () => {
208
+ const parent = parentConfig.model;
209
+
210
+ it('keeps the parent model when none is asked for', () => {
211
+ expect(resolveSubagentModel(parent)).toEqual({ model: parent });
212
+ expect(resolveSubagentModel(parent, ' ')).toEqual({ model: parent });
213
+ });
214
+
215
+ it('swaps the model id on the same provider, or takes provider/modelId', () => {
216
+ expect(resolveSubagentModel(parent, 'deepseek-reasoner')).toEqual({ model: { ...parent, modelId: 'deepseek-reasoner' } });
217
+ expect(resolveSubagentModel(parent, 'google/gemini-3-flash-preview')).toEqual({
218
+ model: { ...parent, provider: 'google', modelId: 'gemini-3-flash-preview' },
219
+ });
220
+ });
221
+
222
+ it('refuses an unknown provider or an empty model id', () => {
223
+ expect(resolveSubagentModel(parent, 'acme/foo')).toEqual({ error: expect.stringMatching(/unknown model provider "acme"/) });
224
+ expect(resolveSubagentModel(parent, 'openai/')).toEqual({ error: expect.stringMatching(/names no model/) });
225
+ });
226
+ });
227
+
228
+ describe('buildSubagentConfig', () => {
229
+ it('derives a child config: own session name, short prompt, cwd as project path, policy, no MCP or member id', () => {
230
+ const built = buildSubagentConfig(parentConfig, { task: 'Find every caller of foo()' }, 2);
231
+ expect('config' in built).toBe(true);
232
+ const cfg = (built as { config: CrewlyAgentConfig }).config;
233
+ expect(cfg.sessionName).toBe('think-tank-kai/sub-2');
234
+ expect(cfg.role).toBe('subagent');
235
+ expect(cfg.model).toEqual(parentConfig.model);
236
+ expect(cfg.maxSteps).toBe(CREWLY_AGENT_DEFAULTS.SUBAGENT.MAX_STEPS);
237
+ expect(cfg.projectPath).toBe(CWD);
238
+ expect(cfg.apiBaseUrl).toBe(parentConfig.apiBaseUrl);
239
+ expect(cfg.systemPrompt).toBe(buildSubagentPrompt('think-tank-kai', CWD, false));
240
+ expect(cfg.systemPrompt).not.toContain('You are Kai');
241
+ expect(cfg.subagent).toEqual({ cwd: CWD, readOnly: false, depth: 1, parentSession: 'think-tank-kai' });
242
+ expect(cfg.mcpServers).toBeUndefined();
243
+ expect(cfg.memberId).toBeUndefined();
244
+ expect(cfg.evalMode).toBeUndefined();
245
+ });
246
+
247
+ it('caps maxSteps, resolves cwd under the project, and honours readOnly', () => {
248
+ const built = buildSubagentConfig(parentConfig, { task: 'x', maxSteps: 999, cwd: 'packages/ui', readOnly: true }, 1);
249
+ const cfg = (built as { config: CrewlyAgentConfig }).config;
250
+ expect(cfg.maxSteps).toBe(CREWLY_AGENT_DEFAULTS.SUBAGENT.MAX_STEPS_CAP);
251
+ expect(cfg.projectPath).toBe(`${CWD}/packages/ui`);
252
+ expect(cfg.subagent?.readOnly).toBe(true);
253
+ expect(cfg.systemPrompt).toMatch(/read-only tools/);
254
+ const small = buildSubagentConfig(parentConfig, { task: 'x', maxSteps: 5 }, 1);
255
+ expect((small as { config: CrewlyAgentConfig }).config.maxSteps).toBe(5);
256
+ });
257
+
258
+ it('refuses an empty or oversized task and a bad model', () => {
259
+ expect(buildSubagentConfig(parentConfig, { task: ' ' }, 1)).toEqual({ error: 'task is required' });
260
+ const huge = 'x'.repeat(CREWLY_AGENT_DEFAULTS.SUBAGENT.TASK_MAX_CHARS + 1);
261
+ expect(buildSubagentConfig(parentConfig, { task: huge }, 1)).toEqual({ error: expect.stringMatching(/too long/) });
262
+ expect(buildSubagentConfig(parentConfig, { task: 'x', model: 'nope/m' }, 1)).toEqual({ error: expect.stringMatching(/unknown model provider/) });
263
+ });
264
+
265
+ it('a child of a child would be depth 2 — but a child never gets the spawn tool, so this stays theoretical', () => {
266
+ const child = (buildSubagentConfig(parentConfig, { task: 'x' }, 1) as { config: CrewlyAgentConfig }).config;
267
+ const grandchild = (buildSubagentConfig(child, { task: 'y' }, 1) as { config: CrewlyAgentConfig }).config;
268
+ expect(grandchild.subagent?.depth).toBe(2);
269
+ expect(grandchild.sessionName).toBe('think-tank-kai/sub-1/sub-1');
270
+ });
271
+ });
272
+
273
+ describe('summarizeSubagentRun', () => {
274
+ it('zero working tool calls is did_nothing, even when the child reported', () => {
275
+ const out = summarizeSubagentRun(
276
+ runResult({ toolCalls: [{ toolName: REPORT_TOOL_NAME, args: { summary: 'all good' }, result: { success: true } }] }),
277
+ { summary: 'all good', filesChanged: [] },
278
+ 's/sub-1',
279
+ 10,
280
+ false,
281
+ );
282
+ expect(out.stoppedReason).toBe('did_nothing');
283
+ expect(out.success).toBe(false);
284
+ expect(out.toolCalls).toBe(0);
285
+ expect(out.summary).toBe('all good');
286
+ });
287
+
288
+ it('done: counts working calls, lists tool names, collects files from successful writes and the report', () => {
289
+ const out = summarizeSubagentRun(
290
+ runResult({
291
+ toolCalls: [
292
+ { toolName: 'grep', args: { pattern: 'foo' }, result: { success: true } },
293
+ { toolName: 'edit_file', args: { file_path: `${CWD}/a.ts` }, result: { success: true } },
294
+ { toolName: 'edit_file', args: { file_path: `${CWD}/b.ts` }, result: { success: false, error: 'not unique' } },
295
+ { toolName: 'write_file', args: { file_path: `${CWD}/c.ts` }, result: { success: true } },
296
+ { toolName: REPORT_TOOL_NAME, args: { summary: 'r' }, result: { success: true } },
297
+ ],
298
+ }),
299
+ { summary: 'changed a and c', filesChanged: [`${CWD}/d.md`] },
300
+ 's/sub-1',
301
+ 1234,
302
+ false,
303
+ );
304
+ expect(out.stoppedReason).toBe('done');
305
+ expect(out.success).toBe(true);
306
+ expect(out.toolCalls).toBe(4);
307
+ expect(out.toolNames).toEqual(['grep', 'edit_file', 'write_file']);
308
+ expect(out.filesChanged).toEqual([`${CWD}/a.ts`, `${CWD}/c.ts`, `${CWD}/d.md`]);
309
+ expect(out.usage).toEqual({ input: 100, output: 20, cachedInput: 5 });
310
+ expect(out.durationMs).toBe(1234);
311
+ expect(out.sessionName).toBe('s/sub-1');
312
+ });
313
+
314
+ it('maps incompleteness and timeouts, and falls back to the final text without a report', () => {
315
+ const calls = [{ toolName: 'read_file', args: { file_path: 'x' }, result: { success: true } }];
316
+ expect(summarizeSubagentRun(runResult({ toolCalls: calls, incomplete: { reason: 'steps-exhausted', detail: '', finishReason: 'tool-calls', recoveryAttempts: 0 } }), null, 's', 1, false).stoppedReason).toBe('steps_exhausted');
317
+ expect(summarizeSubagentRun(runResult({ toolCalls: calls, incomplete: { reason: 'truncated', detail: '', finishReason: 'length', recoveryAttempts: 3 } }), null, 's', 1, false).stoppedReason).toBe('incomplete');
318
+ const timed = summarizeSubagentRun(runResult({ toolCalls: calls }), null, 's', 1, true);
319
+ expect(timed.stoppedReason).toBe('timeout');
320
+ expect(timed.error).toMatch(/exceeded/);
321
+ expect(summarizeSubagentRun(runResult({ toolCalls: calls, text: ' the answer ' }), null, 's', 1, false).summary).toBe('the answer');
322
+ });
323
+
324
+ it('caps a long summary', () => {
325
+ const long = 'y'.repeat(CREWLY_AGENT_DEFAULTS.SUBAGENT.SUMMARY_MAX_CHARS + 50);
326
+ const out = summarizeSubagentRun(runResult({ toolCalls: [{ toolName: 'grep', args: {}, result: {} }] }), { summary: long, filesChanged: [] }, 's', 1, false);
327
+ expect(out.summary.length).toBeLessThan(long.length);
328
+ expect(out.summary).toMatch(/\[truncated\]$/);
329
+ });
330
+ });
331
+
332
+ describe('createSpawnSubagentTool', () => {
333
+ it('runs a child with the task as its only message, returns its outcome, counts usage and disposes it', async () => {
334
+ const registry = createSubagentRegistry();
335
+ const child = fakeRunner({
336
+ result: runResult({ toolCalls: [{ toolName: 'grep', args: { pattern: 'x' }, result: { success: true } }] }),
337
+ report: { summary: 'two call sites: a.ts:10, b.ts:42', filesChanged: [] },
338
+ });
339
+ const createRunner = vi.fn(() => child);
340
+ const onUsage = vi.fn();
341
+ const tool = createSpawnSubagentTool({ parentConfig, registry, createRunner, onUsage });
342
+
343
+ const out = await tool.execute({ task: ' Find callers of foo() ', readOnly: true }) as Record<string, unknown>;
344
+
345
+ expect(createRunner).toHaveBeenCalledTimes(1);
346
+ const cfg = createRunner.mock.calls[0]![0] as unknown as CrewlyAgentConfig;
347
+ expect(cfg.sessionName).toBe('think-tank-kai/sub-1');
348
+ expect(cfg.subagent).toEqual({ cwd: CWD, readOnly: true, depth: 1, parentSession: 'think-tank-kai' });
349
+ expect(child.initialize).toHaveBeenCalledTimes(1);
350
+ expect(child.run).toHaveBeenCalledTimes(1);
351
+ expect(child.run.mock.calls[0]![0]).toBe('Find callers of foo()');
352
+ expect(child.run.mock.calls[0]![3]?.abortSignal).toBeInstanceOf(AbortSignal);
353
+ expect(out.success).toBe(true);
354
+ expect(out.stoppedReason).toBe('done');
355
+ expect(out.summary).toBe('two call sites: a.ts:10, b.ts:42');
356
+ expect(out.sessionName).toBe('think-tank-kai/sub-1');
357
+ expect(onUsage).toHaveBeenCalledWith({ input: 100, output: 20, cachedInput: 5 });
358
+ expect(child.shutdown).toHaveBeenCalledTimes(1);
359
+ expect(registry).toEqual({ active: 0, spawned: 1 });
360
+ });
361
+
362
+ it('a child that made no tool call comes back as did_nothing, not success', async () => {
363
+ const tool = createSpawnSubagentTool({
364
+ parentConfig,
365
+ registry: createSubagentRegistry(),
366
+ createRunner: () => fakeRunner({ result: runResult({ toolCalls: [], text: 'I would look at the files.' }) }),
367
+ });
368
+ const out = await tool.execute({ task: 'look' }) as Record<string, unknown>;
369
+ expect(out.success).toBe(false);
370
+ expect(out.stoppedReason).toBe('did_nothing');
371
+ expect(out.summary).toBe('I would look at the files.');
372
+ });
373
+
374
+ it('numbers children in spawn order and refuses a fourth concurrent child', async () => {
375
+ const registry = createSubagentRegistry();
376
+ let release!: () => void;
377
+ const gate = new Promise<void>((r) => { release = r; });
378
+ const slow = () => fakeRunner({
379
+ result: async () => { await gate; return runResult({ toolCalls: [{ toolName: 'grep', args: {}, result: {} }] }); },
380
+ });
381
+ const createRunner = vi.fn(slow);
382
+ const tool = createSpawnSubagentTool({ parentConfig, registry, createRunner });
383
+
384
+ const running = [tool.execute({ task: 'a' }), tool.execute({ task: 'b' }), tool.execute({ task: 'c' })];
385
+ await Promise.resolve();
386
+ expect(registry.active).toBe(CREWLY_AGENT_DEFAULTS.SUBAGENT.MAX_CONCURRENT);
387
+ const refused = await tool.execute({ task: 'd' }) as Record<string, unknown>;
388
+ expect(refused.success).toBe(false);
389
+ expect(refused.stoppedReason).toBe('error');
390
+ expect(String(refused.error)).toMatch(/at most 3 sub-agents/);
391
+ expect(createRunner).toHaveBeenCalledTimes(3);
392
+
393
+ release();
394
+ const done = await Promise.all(running) as Array<Record<string, unknown>>;
395
+ expect(done.map((d) => d.sessionName)).toEqual(['think-tank-kai/sub-1', 'think-tank-kai/sub-2', 'think-tank-kai/sub-3']);
396
+ expect(registry).toEqual({ active: 0, spawned: 3 });
397
+
398
+ // A slot is free again.
399
+ const fifth = await createSpawnSubagentTool({ parentConfig, registry, createRunner: () => fakeRunner({ result: runResult({ toolCalls: [{ toolName: 'grep', args: {}, result: {} }] }) }) })
400
+ .execute({ task: 'e' }) as Record<string, unknown>;
401
+ expect(fifth.sessionName).toBe('think-tank-kai/sub-4');
402
+ });
403
+
404
+ it('a runner that throws yields error, still disposes the child and frees the slot', async () => {
405
+ const registry = createSubagentRegistry();
406
+ const child = fakeRunner({ throwOn: 'run' });
407
+ const tool = createSpawnSubagentTool({ parentConfig, registry, createRunner: () => child });
408
+ const out = await tool.execute({ task: 'boom' }) as Record<string, unknown>;
409
+ expect(out.success).toBe(false);
410
+ expect(out.stoppedReason).toBe('error');
411
+ expect(out.error).toBe('provider exploded');
412
+ expect(out.sessionName).toBe('think-tank-kai/sub-1');
413
+ expect(child.shutdown).toHaveBeenCalledTimes(1);
414
+ expect(registry.active).toBe(0);
415
+
416
+ const bad = fakeRunner({ throwOn: 'initialize' });
417
+ const out2 = await createSpawnSubagentTool({ parentConfig, registry, createRunner: () => bad }).execute({ task: 'x' }) as Record<string, unknown>;
418
+ expect(out2.error).toBe('no api key');
419
+ expect(bad.shutdown).toHaveBeenCalledTimes(1);
420
+ });
421
+
422
+ it('aborts the child when the wall-clock budget runs out and reports timeout', async () => {
423
+ vi.useFakeTimers();
424
+ try {
425
+ const registry = createSubagentRegistry();
426
+ const child = fakeRunner({
427
+ result: () => new Promise<AgentRunResult>((_, reject) => {
428
+ // The real runner rejects on abort; mirror that.
429
+ setTimeout(() => reject(new Error('aborted')), 60_000);
430
+ }),
431
+ });
432
+ const tool = createSpawnSubagentTool({ parentConfig, registry, createRunner: () => child, timeoutMs: 1_000 });
433
+ const pending = tool.execute({ task: 'slow' });
434
+ await vi.advanceTimersByTimeAsync(1_000);
435
+ const signal = child.run.mock.calls[0]![3]?.abortSignal as AbortSignal;
436
+ expect(signal.aborted).toBe(true);
437
+ await vi.advanceTimersByTimeAsync(60_000);
438
+ const out = await pending as Record<string, unknown>;
439
+ expect(out.stoppedReason).toBe('timeout');
440
+ expect(out.success).toBe(false);
441
+ expect(child.shutdown).toHaveBeenCalledTimes(1);
442
+ expect(registry.active).toBe(0);
443
+ } finally {
444
+ vi.useRealTimers();
445
+ }
446
+ });
447
+
448
+ it('refuses a bad spawn before creating any runner', async () => {
449
+ const createRunner = vi.fn();
450
+ const tool = createSpawnSubagentTool({ parentConfig, registry: createSubagentRegistry(), createRunner });
451
+ const out = await tool.execute({ task: 'x', model: 'nope/m' }) as Record<string, unknown>;
452
+ expect(out.success).toBe(false);
453
+ expect(String(out.error)).toMatch(/unknown model provider/);
454
+ expect(createRunner).not.toHaveBeenCalled();
455
+ });
456
+
457
+ it('describes when to use it and what did_nothing means, and declares its sensitivity', () => {
458
+ const tool = createSpawnSubagentTool({ parentConfig, registry: createSubagentRegistry(), createRunner: vi.fn() });
459
+ expect(tool.sensitivity).toBe('sensitive');
460
+ expect(tool.description).toMatch(/teammate instead/);
461
+ expect(tool.description).toMatch(/did_nothing/);
462
+ const parsed = (tool.inputSchema as any).safeParse({ task: 'x', maxSteps: 0 });
463
+ expect(parsed.success).toBe(false);
464
+ });
465
+ });
466
+ });
@@ -0,0 +1,510 @@
1
+ /**
2
+ * Short-lived sub-agents for the in-process runtime (#1317, slice 1).
3
+ *
4
+ * A Claude Code agent can hand a bounded piece of work to a helper that does
5
+ * it and exits. Agents on the in-process runtime (DeepSeek, Gemini API, …)
6
+ * had no such thing: `createTools()` only knows named teammates. This module
7
+ * gives the parent a `spawn_subagent` tool that runs a fresh
8
+ * `AgentRunnerService` loop inside the same process with the task text as
9
+ * its only context, and gives that child a tool set the harness has cut
10
+ * down — no messaging, no delegation, no tickets, no nested spawning — plus
11
+ * `report_to_parent`. The child's result (summary, tool-call count, files
12
+ * changed, stop reason, usage) comes back as the tool result and the child
13
+ * runner is disposed. Nothing persists: no session, no TeamMember, no Slack
14
+ * identity.
15
+ *
16
+ * Scope is enforced here, not in the prompt (the lesson of #852 /
17
+ * specs/2026-10-03-subagent-guard.md): a forbidden tool is absent from the
18
+ * child's tool map, a write outside `cwd` is refused by a wrapper, and a
19
+ * child has no `spawn_subagent` because {@link restrictToolsForSubagent}
20
+ * never copies one.
21
+ */
22
+
23
+ import { z } from 'zod';
24
+ import path from 'node:path';
25
+ import { homedir } from 'node:os';
26
+ import {
27
+ CREWLY_AGENT_DEFAULTS,
28
+ isModelProvider,
29
+ type AgentRunResult,
30
+ type CrewlyAgentConfig,
31
+ type ModelConfig,
32
+ type SubagentPolicy,
33
+ type ToolDefinition,
34
+ } from './types.js';
35
+
36
+ /** Tools a sub-agent may use in every mode: they read, they do not act. */
37
+ export const SUBAGENT_READ_TOOLS: readonly string[] = ['read_file', 'glob', 'grep', 'git_status', 'git_diff', 'web_search'];
38
+
39
+ /** Tools a sub-agent may use unless it was spawned `readOnly`. */
40
+ export const SUBAGENT_WRITE_TOOLS: readonly string[] = ['bash_exec', 'edit_file', 'write_file'];
41
+
42
+ /** Name of the tool a sub-agent reports with. */
43
+ export const REPORT_TOOL_NAME = 'report_to_parent';
44
+
45
+ /** Name of the tool a parent spawns with. */
46
+ export const SPAWN_TOOL_NAME = 'spawn_subagent';
47
+
48
+ /** Why a sub-agent stopped, as the parent sees it. */
49
+ export type SubagentStopReason =
50
+ /** Ran to its natural end and did some work. */
51
+ | 'done'
52
+ /** Finished without a single working tool call — the parent must not read that as success. */
53
+ | 'did_nothing'
54
+ /** Hit its step cap with work outstanding. */
55
+ | 'steps_exhausted'
56
+ /** Hit the wall-clock budget. */
57
+ | 'timeout'
58
+ /** Ended early for another reason (truncated output, provider error…). */
59
+ | 'incomplete'
60
+ /** The runner threw. */
61
+ | 'error';
62
+
63
+ /** What a sub-agent hands back through `report_to_parent`. */
64
+ export interface SubagentReport {
65
+ /** The child's own account of what it found or changed. */
66
+ summary: string;
67
+ /** Files the child says it changed (merged with what its tool calls show). */
68
+ filesChanged: string[];
69
+ }
70
+
71
+ /** The tool result the parent receives. */
72
+ export interface SubagentOutcome {
73
+ /** True only when `stoppedReason` is `done`. */
74
+ success: boolean;
75
+ stoppedReason: SubagentStopReason;
76
+ /** The child's report, else its final text (capped). */
77
+ summary: string;
78
+ /** Working tool calls the child made (`report_to_parent` not counted). */
79
+ toolCalls: number;
80
+ /** Distinct tool names used, so the parent can see what kind of work happened. */
81
+ toolNames: string[];
82
+ /** Files written or edited successfully, absolute paths. */
83
+ filesChanged: string[];
84
+ /** Tokens the child spent; also folded into the parent's usage. */
85
+ usage: { input: number; output: number; cachedInput: number };
86
+ /** Model steps the child took. */
87
+ steps: number;
88
+ /** The child's session name, `<parent>/sub-<n>`, for traces and logs. */
89
+ sessionName: string;
90
+ durationMs: number;
91
+ /** Set when `stoppedReason` is `error` or `timeout`. */
92
+ error?: string;
93
+ }
94
+
95
+ /** The arguments of `spawn_subagent`. */
96
+ export interface SpawnSubagentInput {
97
+ task: string;
98
+ cwd?: string;
99
+ readOnly?: boolean;
100
+ maxSteps?: number;
101
+ /** `modelId` on the parent's provider, or `provider/modelId`. */
102
+ model?: string;
103
+ }
104
+
105
+ /**
106
+ * The part of `AgentRunnerService` a spawn needs. Injected so the tool can
107
+ * be tested with a scripted runner and so this module does not import the
108
+ * runner (which imports this module).
109
+ */
110
+ export interface SubagentRunner {
111
+ initialize(): Promise<void>;
112
+ run(
113
+ message: string,
114
+ conversationId?: string,
115
+ metadata?: Record<string, string>,
116
+ options?: { abortSignal?: AbortSignal },
117
+ ): Promise<AgentRunResult>;
118
+ shutdown(): Promise<void>;
119
+ getSubagentReport(): SubagentReport | null;
120
+ }
121
+
122
+ /** Per-parent bookkeeping: how many children run now, how many ever. */
123
+ export interface SubagentRegistry {
124
+ active: number;
125
+ spawned: number;
126
+ }
127
+
128
+ /**
129
+ * A fresh registry for one parent runner.
130
+ *
131
+ * @returns Zeroed counters
132
+ */
133
+ export function createSubagentRegistry(): SubagentRegistry {
134
+ return { active: 0, spawned: 0 };
135
+ }
136
+
137
+ /**
138
+ * Whether `target` is `dir` or lies beneath it (after resolving both).
139
+ *
140
+ * @param target - Path to test
141
+ * @param dir - Directory that must contain it
142
+ * @returns True when inside
143
+ */
144
+ export function isInsideDir(target: string, dir: string): boolean {
145
+ const rel = path.relative(path.resolve(dir), path.resolve(target));
146
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
147
+ }
148
+
149
+ /**
150
+ * Resolve a tool's path argument the way the file tools do (`~`, `$HOME`,
151
+ * relative to the working directory), so the containment check sees the
152
+ * same file the tool would touch.
153
+ *
154
+ * @param filePath - As the model passed it
155
+ * @param cwd - The sub-agent's working directory
156
+ * @returns Absolute path
157
+ */
158
+ export function resolveSubagentPath(filePath: string, cwd: string): string {
159
+ const home = homedir();
160
+ let p = filePath;
161
+ if (p === '~' || p.startsWith('~/')) p = home + p.slice(1);
162
+ else if (p === '$HOME' || p.startsWith('$HOME/')) p = home + p.slice(5);
163
+ return path.isAbsolute(p) ? path.normalize(p) : path.resolve(cwd, p);
164
+ }
165
+
166
+ /**
167
+ * Wrap one allowed tool so it cannot act outside the sub-agent's directory.
168
+ *
169
+ * `edit_file` / `write_file` refuse a path outside `cwd`; `bash_exec` runs in
170
+ * `cwd` (or a directory beneath it) whatever `cwd` the model asked for. Read
171
+ * tools pass through: the issue confines writes, not reads.
172
+ *
173
+ * @param name - Tool name
174
+ * @param tool - The parent's definition of it
175
+ * @param cwd - Containment root
176
+ * @returns The same tool, confined
177
+ */
178
+ function confineTool(name: string, tool: ToolDefinition, cwd: string): ToolDefinition {
179
+ if (name === 'edit_file' || name === 'write_file') {
180
+ return {
181
+ ...tool,
182
+ execute: async (args) => {
183
+ const requested = String(args['file_path'] ?? '');
184
+ const abs = resolveSubagentPath(requested, cwd);
185
+ if (!isInsideDir(abs, cwd)) {
186
+ return { success: false, error: `refused: ${requested} is outside the sub-agent working directory ${cwd}` };
187
+ }
188
+ return tool.execute({ ...args, file_path: abs });
189
+ },
190
+ };
191
+ }
192
+ if (name === 'bash_exec') {
193
+ return {
194
+ ...tool,
195
+ execute: async (args) => {
196
+ const requested = typeof args['cwd'] === 'string' && args['cwd'] ? resolveSubagentPath(args['cwd'], cwd) : cwd;
197
+ return tool.execute({ ...args, cwd: isInsideDir(requested, cwd) ? requested : cwd });
198
+ },
199
+ };
200
+ }
201
+ return tool;
202
+ }
203
+
204
+ /**
205
+ * The tool set a sub-agent gets: the allowed subset of the parent's tools,
206
+ * confined to `policy.cwd`, plus `report_to_parent`. Everything else —
207
+ * `send_message`, `reply_slack`, `broadcast`, `delegate_task`,
208
+ * `handoff_task`, `report_status`, `complete_task`, `start_agent`,
209
+ * `stop_agent`, `remember`, approvals, browser and desktop tools, MCP tools
210
+ * and `spawn_subagent` itself — is simply not in the map.
211
+ *
212
+ * @param tools - The full `createTools()` result of the child runner
213
+ * @param policy - The child's policy (cwd, readOnly)
214
+ * @param onReport - Receives the child's `report_to_parent` payload
215
+ * @returns The restricted tool map
216
+ */
217
+ export function restrictToolsForSubagent(
218
+ tools: Record<string, ToolDefinition>,
219
+ policy: SubagentPolicy,
220
+ onReport: (report: SubagentReport) => void,
221
+ ): Record<string, ToolDefinition> {
222
+ const allowed = policy.readOnly ? SUBAGENT_READ_TOOLS : [...SUBAGENT_READ_TOOLS, ...SUBAGENT_WRITE_TOOLS];
223
+ const out: Record<string, ToolDefinition> = {};
224
+ for (const name of allowed) {
225
+ const tool = tools[name];
226
+ if (tool) out[name] = confineTool(name, tool, policy.cwd);
227
+ }
228
+ out[REPORT_TOOL_NAME] = {
229
+ description:
230
+ 'Hand your result to the agent that spawned you. Call it once, at the end: what you found or changed, '
231
+ + 'the files you touched, what you could not do and why. The parent sees only this report and your final text.',
232
+ inputSchema: z.object({
233
+ summary: z.string().min(1).describe('Concrete findings or changes, with file paths and line references where they help'),
234
+ filesChanged: z.array(z.string()).optional().describe('Files you created or edited'),
235
+ }),
236
+ sensitivity: 'safe',
237
+ execute: async (args) => {
238
+ onReport({
239
+ summary: String(args['summary'] ?? ''),
240
+ filesChanged: Array.isArray(args['filesChanged']) ? args['filesChanged'].map(String) : [],
241
+ });
242
+ return { success: true, note: 'Reported to the parent. Finish your turn now with one line at most.' };
243
+ },
244
+ };
245
+ return out;
246
+ }
247
+
248
+ /**
249
+ * The system prompt of a sub-agent. Short on purpose: the task text is the
250
+ * context, and the harness rules are appended by the runner.
251
+ *
252
+ * @param parentSession - Who spawned it
253
+ * @param cwd - Where it may write
254
+ * @param readOnly - Whether writes are off
255
+ * @returns Prompt text
256
+ */
257
+ export function buildSubagentPrompt(parentSession: string, cwd: string, readOnly: boolean): string {
258
+ return [
259
+ `You are a short-lived helper spawned by the agent "${parentSession}" for one bounded task.`,
260
+ `Working directory: ${cwd}. ${readOnly ? 'You have read-only tools: look, do not change anything.' : `You may change files only under ${cwd}.`}`,
261
+ 'You cannot message anyone, delegate, schedule, or touch tickets; nobody but your parent will read you.',
262
+ `Do the task with your tools, then call ${REPORT_TOOL_NAME} once with what you found or changed, the files you touched, and anything you could not do.`,
263
+ 'Report facts you verified, with paths and line numbers; if you did nothing, say so instead of inventing a result.',
264
+ ].join('\n');
265
+ }
266
+
267
+ /**
268
+ * Pick the child's model: the parent's by default, a different id on the
269
+ * parent's provider, or `provider/modelId`.
270
+ *
271
+ * @param parent - Parent model
272
+ * @param requested - The `model` argument, if any
273
+ * @returns The model, or why it was refused
274
+ */
275
+ export function resolveSubagentModel(parent: ModelConfig, requested?: string): { model: ModelConfig } | { error: string } {
276
+ if (!requested || !requested.trim()) return { model: parent };
277
+ const spec = requested.trim();
278
+ const slash = spec.indexOf('/');
279
+ if (slash < 0) return { model: { ...parent, modelId: spec } };
280
+ const provider = spec.slice(0, slash);
281
+ const modelId = spec.slice(slash + 1);
282
+ if (!isModelProvider(provider)) return { error: `unknown model provider "${provider}" in "${spec}"` };
283
+ if (!modelId) return { error: `"${spec}" names no model` };
284
+ return { model: { ...parent, provider, modelId } };
285
+ }
286
+
287
+ /**
288
+ * The child's configuration, derived from the parent's.
289
+ *
290
+ * The child shares the API base URL, history and compaction settings; it
291
+ * gets its own session name (`<parent>/sub-<n>`), its own short prompt, the
292
+ * requested working directory as project path, no MCP servers (one process
293
+ * per server is the parent's cost to bear, not every helper's), no member id
294
+ * and the `subagent` policy that makes the runner restrict its tools.
295
+ *
296
+ * @param parent - Parent config
297
+ * @param input - Spawn arguments
298
+ * @param ordinal - 1-based child number for this parent
299
+ * @returns The config, or why the spawn was refused
300
+ */
301
+ export function buildSubagentConfig(
302
+ parent: CrewlyAgentConfig,
303
+ input: SpawnSubagentInput,
304
+ ordinal: number,
305
+ ): { config: CrewlyAgentConfig } | { error: string } {
306
+ const task = (input.task ?? '').trim();
307
+ if (!task) return { error: 'task is required' };
308
+ if (task.length > CREWLY_AGENT_DEFAULTS.SUBAGENT.TASK_MAX_CHARS) {
309
+ return { error: `task is too long (${task.length} chars; keep it under ${CREWLY_AGENT_DEFAULTS.SUBAGENT.TASK_MAX_CHARS})` };
310
+ }
311
+ const base = parent.projectPath ?? process.cwd();
312
+ const cwd = input.cwd ? resolveSubagentPath(input.cwd, base) : base;
313
+ const picked = resolveSubagentModel(parent.model, input.model);
314
+ if ('error' in picked) return picked;
315
+ const requestedSteps = Number.isFinite(input.maxSteps) && (input.maxSteps as number) > 0
316
+ ? Math.floor(input.maxSteps as number)
317
+ : CREWLY_AGENT_DEFAULTS.SUBAGENT.MAX_STEPS;
318
+ const readOnly = input.readOnly === true;
319
+ const sessionName = `${parent.sessionName}/sub-${ordinal}`;
320
+ return {
321
+ config: {
322
+ model: picked.model,
323
+ maxSteps: Math.min(requestedSteps, CREWLY_AGENT_DEFAULTS.SUBAGENT.MAX_STEPS_CAP),
324
+ sessionName,
325
+ role: 'subagent',
326
+ apiBaseUrl: parent.apiBaseUrl,
327
+ systemPrompt: buildSubagentPrompt(parent.sessionName, cwd, readOnly),
328
+ maxHistoryMessages: parent.maxHistoryMessages,
329
+ compactionThreshold: parent.compactionThreshold,
330
+ projectPath: cwd,
331
+ subagent: { cwd, readOnly, depth: (parent.subagent?.depth ?? 0) + 1, parentSession: parent.sessionName },
332
+ },
333
+ };
334
+ }
335
+
336
+ /**
337
+ * Turn a child's run result into what the parent gets back.
338
+ *
339
+ * `report_to_parent` is not a working call: a child that only reported
340
+ * "did nothing". Files changed are read off successful `edit_file` /
341
+ * `write_file` calls and merged with the report's list, so a child cannot
342
+ * claim a change its tools never made — and cannot hide one either.
343
+ *
344
+ * @param result - The child's run result
345
+ * @param report - What it reported, if it did
346
+ * @param sessionName - The child's session name
347
+ * @param durationMs - Wall-clock time
348
+ * @param timedOut - Whether the parent's timer fired
349
+ * @returns The outcome
350
+ */
351
+ export function summarizeSubagentRun(
352
+ result: AgentRunResult,
353
+ report: SubagentReport | null,
354
+ sessionName: string,
355
+ durationMs: number,
356
+ timedOut: boolean,
357
+ ): SubagentOutcome {
358
+ const work = result.toolCalls.filter((tc) => tc.toolName !== REPORT_TOOL_NAME);
359
+ const changed = new Set<string>();
360
+ for (const tc of work) {
361
+ if (tc.toolName !== 'edit_file' && tc.toolName !== 'write_file') continue;
362
+ const ok = tc.result == null || typeof tc.result !== 'object' || (tc.result as Record<string, unknown>)['success'] !== false;
363
+ const fp = tc.args['file_path'];
364
+ if (ok && typeof fp === 'string' && fp) changed.add(fp);
365
+ }
366
+ for (const fp of report?.filesChanged ?? []) if (fp) changed.add(fp);
367
+
368
+ let stoppedReason: SubagentStopReason = 'done';
369
+ if (timedOut) stoppedReason = 'timeout';
370
+ else if (work.length === 0) stoppedReason = 'did_nothing';
371
+ else if (result.incomplete?.reason === 'steps-exhausted') stoppedReason = 'steps_exhausted';
372
+ else if (result.incomplete) stoppedReason = 'incomplete';
373
+
374
+ const raw = (report?.summary ?? result.text ?? '').trim();
375
+ const cap = CREWLY_AGENT_DEFAULTS.SUBAGENT.SUMMARY_MAX_CHARS;
376
+ const summary = raw.length > cap ? `${raw.slice(0, cap)}\n…[truncated]` : raw;
377
+ return {
378
+ success: stoppedReason === 'done',
379
+ stoppedReason,
380
+ summary,
381
+ toolCalls: work.length,
382
+ toolNames: [...new Set(work.map((tc) => tc.toolName))],
383
+ filesChanged: [...changed],
384
+ usage: result.usage,
385
+ steps: result.steps,
386
+ sessionName,
387
+ durationMs,
388
+ ...(timedOut ? { error: `sub-agent exceeded ${CREWLY_AGENT_DEFAULTS.SUBAGENT.TIMEOUT_MS} ms` } : {}),
389
+ };
390
+ }
391
+
392
+ /** What the spawn tool needs from its parent runner. */
393
+ export interface SpawnSubagentContext {
394
+ /** The parent's config; the child's is derived from it. */
395
+ parentConfig: CrewlyAgentConfig;
396
+ /** Shared counters for this parent. */
397
+ registry: SubagentRegistry;
398
+ /** Builds a child runner from a config (the runner injects `new AgentRunnerService`). */
399
+ createRunner: (config: CrewlyAgentConfig) => SubagentRunner;
400
+ /** Receives the child's usage so the parent's ledger counts it. */
401
+ onUsage?: (usage: { input: number; output: number; cachedInput: number }) => void;
402
+ /** Wall-clock budget per child; defaults to the runtime default. */
403
+ timeoutMs?: number;
404
+ /** Clock, for tests. */
405
+ now?: () => number;
406
+ }
407
+
408
+ /**
409
+ * An outcome for a spawn that failed before or while running.
410
+ *
411
+ * @param sessionName - Child session name, or the parent's when none was built
412
+ * @param reason - `error` or `timeout`
413
+ * @param error - What went wrong
414
+ * @param durationMs - Time spent
415
+ * @returns The outcome
416
+ */
417
+ function failedOutcome(sessionName: string, reason: 'error' | 'timeout', error: string, durationMs: number): SubagentOutcome {
418
+ return {
419
+ success: false,
420
+ stoppedReason: reason,
421
+ summary: '',
422
+ toolCalls: 0,
423
+ toolNames: [],
424
+ filesChanged: [],
425
+ usage: { input: 0, output: 0, cachedInput: 0 },
426
+ steps: 0,
427
+ sessionName,
428
+ durationMs,
429
+ error,
430
+ };
431
+ }
432
+
433
+ /**
434
+ * The parent's `spawn_subagent` tool.
435
+ *
436
+ * Refuses when the parent already has the maximum number of children
437
+ * running. Otherwise builds the child config, runs the child once with the
438
+ * task as its only message under a wall-clock budget, folds its usage into
439
+ * the parent's, and always disposes the child runner — on success, error or
440
+ * timeout.
441
+ *
442
+ * @param ctx - Parent-side dependencies
443
+ * @returns The tool definition
444
+ *
445
+ * @example
446
+ * ```ts
447
+ * tools.spawn_subagent = createSpawnSubagentTool({
448
+ * parentConfig: this.config,
449
+ * registry: this.subagentRegistry,
450
+ * createRunner: (cfg) => new AgentRunnerService(cfg, this.modelManager, this.apiClient),
451
+ * onUsage: (u) => this.recordChildUsage(u),
452
+ * });
453
+ * ```
454
+ */
455
+ export function createSpawnSubagentTool(ctx: SpawnSubagentContext): ToolDefinition {
456
+ const limits = CREWLY_AGENT_DEFAULTS.SUBAGENT;
457
+ const now = ctx.now ?? (() => Date.now());
458
+ const timeoutMs = ctx.timeoutMs ?? limits.TIMEOUT_MS;
459
+ return {
460
+ description:
461
+ 'Run a short-lived helper agent on one bounded, self-contained piece of work — parallel research, a large read you want '
462
+ + 'kept out of your own context, a scoped fix — and get its report back as this tool result. The helper starts with only '
463
+ + 'your task text, works in the given directory with file, search and shell tools (no messaging, no delegation, no tickets, '
464
+ + 'no further helpers), and is gone when it returns. Use a teammate instead for anything that needs a named owner, a review, '
465
+ + `or communication. At most ${limits.MAX_CONCURRENT} helpers run at once; each gets up to ${limits.MAX_STEPS} steps `
466
+ + `(cap ${limits.MAX_STEPS_CAP}) and ${Math.round(limits.TIMEOUT_MS / 60000)} minutes. A result with stoppedReason `
467
+ + '"did_nothing" means the helper made no tool call: treat it as a failure, not as an answer.',
468
+ inputSchema: z.object({
469
+ task: z.string().min(1).describe('Everything the helper needs: the goal, the files or area, what "done" looks like, what to report'),
470
+ cwd: z.string().optional().describe('Directory the helper works in and may write under (default: your project path)'),
471
+ readOnly: z.boolean().optional().describe('True for research or review: no shell, no file writes'),
472
+ maxSteps: z.number().int().positive().optional().describe(`Model steps the helper may take (default ${limits.MAX_STEPS}, cap ${limits.MAX_STEPS_CAP})`),
473
+ model: z.string().optional().describe('A cheaper model for simple work: a model id on your provider, or provider/modelId'),
474
+ }),
475
+ sensitivity: 'sensitive',
476
+ execute: async (args): Promise<SubagentOutcome> => {
477
+ const started = now();
478
+ if (ctx.registry.active >= limits.MAX_CONCURRENT) {
479
+ return failedOutcome(
480
+ ctx.parentConfig.sessionName,
481
+ 'error',
482
+ `at most ${limits.MAX_CONCURRENT} sub-agents run at once; wait for one to finish`,
483
+ now() - started,
484
+ );
485
+ }
486
+ const built = buildSubagentConfig(ctx.parentConfig, args as unknown as SpawnSubagentInput, ctx.registry.spawned + 1);
487
+ if ('error' in built) return failedOutcome(ctx.parentConfig.sessionName, 'error', built.error, now() - started);
488
+ const { config } = built;
489
+ ctx.registry.spawned += 1;
490
+ ctx.registry.active += 1;
491
+ const abort = new AbortController();
492
+ const timer = setTimeout(() => abort.abort(), timeoutMs);
493
+ let runner: SubagentRunner | null = null;
494
+ try {
495
+ runner = ctx.createRunner(config);
496
+ await runner.initialize();
497
+ const result = await runner.run(String(args['task']).trim(), undefined, undefined, { abortSignal: abort.signal });
498
+ ctx.onUsage?.(result.usage);
499
+ return summarizeSubagentRun(result, runner.getSubagentReport(), config.sessionName, now() - started, abort.signal.aborted);
500
+ } catch (error) {
501
+ const message = error instanceof Error ? error.message : String(error);
502
+ return failedOutcome(config.sessionName, abort.signal.aborted ? 'timeout' : 'error', message, now() - started);
503
+ } finally {
504
+ clearTimeout(timer);
505
+ ctx.registry.active -= 1;
506
+ if (runner) await runner.shutdown().catch(() => { /* the child is gone either way */ });
507
+ }
508
+ },
509
+ };
510
+ }
@@ -617,6 +617,10 @@ export const TOOL_SENSITIVITY: Record<string, ToolSensitivity> = {
617
617
  // The task layer only plans and verifies; the acting happens through
618
618
  // `computer`, which carries the destructive rating.
619
619
  desktop_task: 'sensitive',
620
+ // Short-lived sub-agents (#1317): spawning spends tokens and may write
621
+ // files under cwd; reporting back is informational.
622
+ spawn_subagent: 'sensitive',
623
+ report_to_parent: 'safe',
620
624
  };
621
625
 
622
626
  /**
@@ -125,6 +125,22 @@ export interface ConversationState {
125
125
  lastActivityAt: Date;
126
126
  }
127
127
 
128
+ /**
129
+ * What a short-lived sub-agent may do (#1317). Set on a child runner's
130
+ * config by `spawn_subagent`; its presence makes the runner cut the tool set
131
+ * down with `restrictToolsForSubagent` instead of adding `spawn_subagent`.
132
+ */
133
+ export interface SubagentPolicy {
134
+ /** Directory the child works in and may write under */
135
+ cwd: string;
136
+ /** No shell and no file writes when true */
137
+ readOnly: boolean;
138
+ /** 1 for a direct child; a child never gets `spawn_subagent`, so it stays 1 */
139
+ depth: number;
140
+ /** Session name of the agent that spawned it */
141
+ parentSession: string;
142
+ }
143
+
128
144
  /**
129
145
  * Configuration for the Crewly Agent runtime
130
146
  */
@@ -160,6 +176,11 @@ export interface CrewlyAgentConfig {
160
176
  * - Enables post-execution deliverable self-check (Stop Hook)
161
177
  */
162
178
  evalMode?: boolean;
179
+ /**
180
+ * Set when this runner is a short-lived sub-agent (#1317): restricts its
181
+ * tools to file/search/shell within `cwd` plus `report_to_parent`.
182
+ */
183
+ subagent?: SubagentPolicy;
163
184
  }
164
185
 
165
186
  /**
@@ -663,6 +684,21 @@ export const CREWLY_AGENT_DEFAULTS = {
663
684
  maxAuditEntries: 500,
664
685
  readOnlyMode: false,
665
686
  } satisfies SecurityPolicy,
687
+ /** Short-lived sub-agents spawned by an in-process agent (#1317) */
688
+ SUBAGENT: {
689
+ /** Model steps a child gets when the parent names none */
690
+ MAX_STEPS: 30,
691
+ /** Hard cap on the steps a parent may grant */
692
+ MAX_STEPS_CAP: 60,
693
+ /** Wall-clock budget per child */
694
+ TIMEOUT_MS: 600_000,
695
+ /** Children one parent may have running at once */
696
+ MAX_CONCURRENT: 3,
697
+ /** Characters of the child's report kept for the parent */
698
+ SUMMARY_MAX_CHARS: 6000,
699
+ /** Characters a task may have; longer means the parent is pasting context it should summarise */
700
+ TASK_MAX_CHARS: 12_000,
701
+ },
666
702
  /** Default security guardrail configuration */
667
703
  SECURITY_GUARDRAILS: {
668
704
  outputFilterEnabled: true,