crewly 1.20.435 → 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.
@@ -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
+ });