crewly 1.20.436 → 1.20.438
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/dist/backend/backend/src/services/ai/prompt-modules/role-boundary.module.d.ts.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/role-boundary.module.js +5 -0
- package/dist/backend/backend/src/services/ai/prompt-modules/role-boundary.module.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/services/ai/prompt-modules/role-boundary.module.d.ts.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/role-boundary.module.js +5 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/role-boundary.module.js.map +1 -1
- package/package.json +1 -1
- package/packages/crewly-agent/src/runtime/agent-runner.service.test.ts +59 -0
- package/packages/crewly-agent/src/runtime/agent-runner.service.ts +87 -3
- package/packages/crewly-agent/src/runtime/subagent.test.ts +497 -0
- package/packages/crewly-agent/src/runtime/subagent.ts +545 -0
- package/packages/crewly-agent/src/runtime/tool-registry.ts +4 -0
- package/packages/crewly-agent/src/runtime/types.ts +36 -0
|
@@ -0,0 +1,497 @@
|
|
|
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
|
+
forwardChildToolCalls,
|
|
15
|
+
isInsideDir,
|
|
16
|
+
resolveSubagentModel,
|
|
17
|
+
resolveSubagentPath,
|
|
18
|
+
restrictToolsForSubagent,
|
|
19
|
+
summarizeSubagentRun,
|
|
20
|
+
type SubagentReport,
|
|
21
|
+
type SubagentRunner,
|
|
22
|
+
} from './subagent.js';
|
|
23
|
+
import { createTools, getToolNames, TOOL_SENSITIVITY } from './tool-registry.js';
|
|
24
|
+
import { CrewlyApiClient } from './api-client.js';
|
|
25
|
+
import { CREWLY_AGENT_DEFAULTS, WRITE_TOOLS, type AgentRunResult, type CrewlyAgentConfig, type ToolDefinition } from './types.js';
|
|
26
|
+
|
|
27
|
+
const CWD = '/work/project';
|
|
28
|
+
|
|
29
|
+
const parentConfig: CrewlyAgentConfig = {
|
|
30
|
+
model: { provider: 'deepseek', modelId: 'deepseek-chat', temperature: 0.2, maxTokens: 4096 },
|
|
31
|
+
maxSteps: 500,
|
|
32
|
+
sessionName: 'think-tank-kai',
|
|
33
|
+
apiBaseUrl: 'http://localhost:8787',
|
|
34
|
+
systemPrompt: 'You are Kai, the team lead.',
|
|
35
|
+
maxHistoryMessages: 40,
|
|
36
|
+
compactionThreshold: 0.8,
|
|
37
|
+
projectPath: CWD,
|
|
38
|
+
memberId: 'member-1',
|
|
39
|
+
mcpServers: { playwright: { command: 'npx', args: ['playwright-mcp'] } },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** A run result with the given tool calls and no incompleteness. */
|
|
43
|
+
function runResult(partial: Partial<AgentRunResult> = {}): AgentRunResult {
|
|
44
|
+
return {
|
|
45
|
+
text: 'final text',
|
|
46
|
+
steps: 3,
|
|
47
|
+
usage: { input: 100, output: 20, cachedInput: 5 },
|
|
48
|
+
toolCalls: [],
|
|
49
|
+
finishReason: 'stop',
|
|
50
|
+
...partial,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A scripted child runner. */
|
|
55
|
+
function fakeRunner(opts: {
|
|
56
|
+
result?: AgentRunResult | (() => Promise<AgentRunResult>);
|
|
57
|
+
report?: SubagentReport | null;
|
|
58
|
+
throwOn?: 'initialize' | 'run';
|
|
59
|
+
}): SubagentRunner & { shutdown: ReturnType<typeof vi.fn>; run: ReturnType<typeof vi.fn> } {
|
|
60
|
+
const run = vi.fn(async (_m: string, _c?: string, _md?: Record<string, string>, _o?: { abortSignal?: AbortSignal }) => {
|
|
61
|
+
if (opts.throwOn === 'run') throw new Error('provider exploded');
|
|
62
|
+
return typeof opts.result === 'function' ? opts.result() : (opts.result ?? runResult());
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
initialize: vi.fn(async () => {
|
|
66
|
+
if (opts.throwOn === 'initialize') throw new Error('no api key');
|
|
67
|
+
}),
|
|
68
|
+
run,
|
|
69
|
+
shutdown: vi.fn(async () => undefined),
|
|
70
|
+
getSubagentReport: () => opts.report ?? null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('subagent', () => {
|
|
75
|
+
describe('isInsideDir / resolveSubagentPath', () => {
|
|
76
|
+
it('accepts the directory itself and anything beneath it', () => {
|
|
77
|
+
expect(isInsideDir(CWD, CWD)).toBe(true);
|
|
78
|
+
expect(isInsideDir(`${CWD}/src/a.ts`, CWD)).toBe(true);
|
|
79
|
+
expect(isInsideDir(`${CWD}/src/../src/b.ts`, CWD)).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('rejects siblings, parents and look-alike prefixes', () => {
|
|
83
|
+
expect(isInsideDir('/work/project2/a.ts', CWD)).toBe(false);
|
|
84
|
+
expect(isInsideDir('/work', CWD)).toBe(false);
|
|
85
|
+
expect(isInsideDir(`${CWD}/../other/x`, CWD)).toBe(false);
|
|
86
|
+
expect(isInsideDir('/etc/passwd', CWD)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('resolves relative paths against cwd and expands ~ and $HOME like the file tools', () => {
|
|
90
|
+
expect(resolveSubagentPath('src/a.ts', CWD)).toBe(`${CWD}/src/a.ts`);
|
|
91
|
+
expect(resolveSubagentPath('/abs/x', CWD)).toBe('/abs/x');
|
|
92
|
+
expect(resolveSubagentPath('~/notes.md', CWD)).toBe(path.join(homedir(), 'notes.md'));
|
|
93
|
+
expect(resolveSubagentPath('$HOME/notes.md', CWD)).toBe(path.join(homedir(), 'notes.md'));
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('restrictToolsForSubagent', () => {
|
|
98
|
+
let mockClient: Mocked<CrewlyApiClient>;
|
|
99
|
+
let full: Record<string, ToolDefinition>;
|
|
100
|
+
|
|
101
|
+
const policy = { cwd: CWD, readOnly: false, depth: 1, parentSession: 'think-tank-kai' };
|
|
102
|
+
|
|
103
|
+
function freshTools(): Record<string, ToolDefinition> {
|
|
104
|
+
mockClient = {
|
|
105
|
+
get: vi.fn<any>().mockResolvedValue({ success: false, data: null, status: 404 }),
|
|
106
|
+
post: vi.fn<any>(),
|
|
107
|
+
delete: vi.fn<any>(),
|
|
108
|
+
} as any;
|
|
109
|
+
return createTools(mockClient, 'think-tank-kai/sub-1', CWD);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
it('keeps only file, search and shell tools plus report_to_parent', () => {
|
|
113
|
+
full = freshTools();
|
|
114
|
+
const restricted = restrictToolsForSubagent(full, policy, () => undefined);
|
|
115
|
+
const names = Object.keys(restricted).sort();
|
|
116
|
+
const expected = [...SUBAGENT_READ_TOOLS, ...SUBAGENT_WRITE_TOOLS, REPORT_TOOL_NAME].filter((n) => n === REPORT_TOOL_NAME || full[n]).sort();
|
|
117
|
+
expect(names).toEqual(expected);
|
|
118
|
+
expect(names).toContain('read_file');
|
|
119
|
+
expect(names).toContain('grep');
|
|
120
|
+
expect(names).toContain('bash_exec');
|
|
121
|
+
expect(names).toContain('edit_file');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('has none of the communication, delegation, lifecycle, memory or ticket tools', () => {
|
|
125
|
+
full = freshTools();
|
|
126
|
+
const restricted = restrictToolsForSubagent(full, policy, () => undefined);
|
|
127
|
+
for (const forbidden of [
|
|
128
|
+
'send_message', 'reply_slack', 'broadcast', 'delegate_task', 'handoff_task', 'report_status', 'complete_task',
|
|
129
|
+
'get_tasks', 'start_agent', 'stop_agent', 'handle_agent_failure', 'remember', 'recall_memory', 'schedule_check',
|
|
130
|
+
'register_self', 'subscribe_event', 'get_audit_log', 'compact_memory', 'computer', 'desktop_task', 'git_commit',
|
|
131
|
+
SPAWN_TOOL_NAME,
|
|
132
|
+
]) {
|
|
133
|
+
expect(restricted[forbidden], forbidden).toBeUndefined();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('never passes through a spawn_subagent or MCP tool present in the input map', () => {
|
|
138
|
+
full = freshTools();
|
|
139
|
+
const withExtras: Record<string, ToolDefinition> = {
|
|
140
|
+
...full,
|
|
141
|
+
[SPAWN_TOOL_NAME]: { description: 'x', inputSchema: {} as any, execute: async () => ({}) },
|
|
142
|
+
'playwright:click': { description: 'x', inputSchema: {} as any, execute: async () => ({}) },
|
|
143
|
+
};
|
|
144
|
+
const restricted = restrictToolsForSubagent(withExtras, policy, () => undefined);
|
|
145
|
+
expect(restricted[SPAWN_TOOL_NAME]).toBeUndefined();
|
|
146
|
+
expect(restricted['playwright:click']).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('readOnly drops the shell and the write tools', () => {
|
|
150
|
+
full = freshTools();
|
|
151
|
+
const restricted = restrictToolsForSubagent(full, { ...policy, readOnly: true }, () => undefined);
|
|
152
|
+
const names = Object.keys(restricted);
|
|
153
|
+
for (const w of SUBAGENT_WRITE_TOOLS) expect(names).not.toContain(w);
|
|
154
|
+
for (const w of WRITE_TOOLS) expect(names).not.toContain(w);
|
|
155
|
+
expect(names).toContain('read_file');
|
|
156
|
+
expect(names).toContain(REPORT_TOOL_NAME);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('refuses edit_file and write_file outside cwd and resolves relative paths inside it', async () => {
|
|
160
|
+
const inner = vi.fn(async () => ({ success: true }));
|
|
161
|
+
const tools: Record<string, ToolDefinition> = {
|
|
162
|
+
edit_file: { description: 'e', inputSchema: {} as any, execute: inner },
|
|
163
|
+
write_file: { description: 'w', inputSchema: {} as any, execute: inner },
|
|
164
|
+
};
|
|
165
|
+
const restricted = restrictToolsForSubagent(tools, policy, () => undefined);
|
|
166
|
+
|
|
167
|
+
const outside = await restricted.edit_file.execute({ file_path: '/etc/hosts', old_string: 'a', new_string: 'b' }) as Record<string, unknown>;
|
|
168
|
+
expect(outside.success).toBe(false);
|
|
169
|
+
expect(String(outside.error)).toMatch(/outside the sub-agent working directory/);
|
|
170
|
+
const climb = await restricted.write_file.execute({ file_path: '../sibling/x.ts', content: '' }) as Record<string, unknown>;
|
|
171
|
+
expect(climb.success).toBe(false);
|
|
172
|
+
const home = await restricted.write_file.execute({ file_path: '~/x.ts', content: '' }) as Record<string, unknown>;
|
|
173
|
+
expect(home.success).toBe(false);
|
|
174
|
+
expect(inner).not.toHaveBeenCalled();
|
|
175
|
+
|
|
176
|
+
await restricted.edit_file.execute({ file_path: 'src/a.ts', old_string: 'a', new_string: 'b' });
|
|
177
|
+
expect(inner).toHaveBeenCalledWith(expect.objectContaining({ file_path: `${CWD}/src/a.ts` }));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('forces bash_exec into cwd when the model asks for a directory outside it', async () => {
|
|
181
|
+
const inner = vi.fn(async () => ({ success: true, stdout: '' }));
|
|
182
|
+
const tools: Record<string, ToolDefinition> = {
|
|
183
|
+
bash_exec: { description: 'b', inputSchema: {} as any, execute: inner },
|
|
184
|
+
};
|
|
185
|
+
const restricted = restrictToolsForSubagent(tools, policy, () => undefined);
|
|
186
|
+
await restricted.bash_exec.execute({ command: 'ls' });
|
|
187
|
+
expect(inner).toHaveBeenLastCalledWith({ command: 'ls', cwd: CWD });
|
|
188
|
+
await restricted.bash_exec.execute({ command: 'ls', cwd: '/' });
|
|
189
|
+
expect(inner).toHaveBeenLastCalledWith({ command: 'ls', cwd: CWD });
|
|
190
|
+
await restricted.bash_exec.execute({ command: 'ls', cwd: 'packages' });
|
|
191
|
+
expect(inner).toHaveBeenLastCalledWith({ command: 'ls', cwd: `${CWD}/packages` });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('report_to_parent records the report and tells the child to stop', async () => {
|
|
195
|
+
const reports: SubagentReport[] = [];
|
|
196
|
+
const restricted = restrictToolsForSubagent({}, policy, (r) => reports.push(r));
|
|
197
|
+
const out = await restricted[REPORT_TOOL_NAME].execute({ summary: 'found 3 call sites', filesChanged: ['a.ts'] }) as Record<string, unknown>;
|
|
198
|
+
expect(out.success).toBe(true);
|
|
199
|
+
expect(String(out.note)).toMatch(/finish your turn/i);
|
|
200
|
+
expect(reports).toEqual([{ summary: 'found 3 call sites', filesChanged: ['a.ts'] }]);
|
|
201
|
+
expect(TOOL_SENSITIVITY[REPORT_TOOL_NAME]).toBe('safe');
|
|
202
|
+
expect(TOOL_SENSITIVITY[SPAWN_TOOL_NAME]).toBe('sensitive');
|
|
203
|
+
// Neither is a registry tool: the runner adds them per run.
|
|
204
|
+
expect(getToolNames()).not.toContain(SPAWN_TOOL_NAME);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('resolveSubagentModel', () => {
|
|
209
|
+
const parent = parentConfig.model;
|
|
210
|
+
|
|
211
|
+
it('keeps the parent model when none is asked for', () => {
|
|
212
|
+
expect(resolveSubagentModel(parent)).toEqual({ model: parent });
|
|
213
|
+
expect(resolveSubagentModel(parent, ' ')).toEqual({ model: parent });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('swaps the model id on the same provider, or takes provider/modelId', () => {
|
|
217
|
+
expect(resolveSubagentModel(parent, 'deepseek-reasoner')).toEqual({ model: { ...parent, modelId: 'deepseek-reasoner' } });
|
|
218
|
+
expect(resolveSubagentModel(parent, 'google/gemini-3-flash-preview')).toEqual({
|
|
219
|
+
model: { ...parent, provider: 'google', modelId: 'gemini-3-flash-preview' },
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('refuses an unknown provider or an empty model id', () => {
|
|
224
|
+
expect(resolveSubagentModel(parent, 'acme/foo')).toEqual({ error: expect.stringMatching(/unknown model provider "acme"/) });
|
|
225
|
+
expect(resolveSubagentModel(parent, 'openai/')).toEqual({ error: expect.stringMatching(/names no model/) });
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('buildSubagentConfig', () => {
|
|
230
|
+
it('derives a child config: own session name, short prompt, cwd as project path, policy, no MCP or member id', () => {
|
|
231
|
+
const built = buildSubagentConfig(parentConfig, { task: 'Find every caller of foo()' }, 2);
|
|
232
|
+
expect('config' in built).toBe(true);
|
|
233
|
+
const cfg = (built as { config: CrewlyAgentConfig }).config;
|
|
234
|
+
expect(cfg.sessionName).toBe('think-tank-kai/sub-2');
|
|
235
|
+
expect(cfg.role).toBe('subagent');
|
|
236
|
+
expect(cfg.model).toEqual(parentConfig.model);
|
|
237
|
+
expect(cfg.maxSteps).toBe(CREWLY_AGENT_DEFAULTS.SUBAGENT.MAX_STEPS);
|
|
238
|
+
expect(cfg.projectPath).toBe(CWD);
|
|
239
|
+
expect(cfg.apiBaseUrl).toBe(parentConfig.apiBaseUrl);
|
|
240
|
+
expect(cfg.systemPrompt).toBe(buildSubagentPrompt('think-tank-kai', CWD, false));
|
|
241
|
+
expect(cfg.systemPrompt).not.toContain('You are Kai');
|
|
242
|
+
expect(cfg.subagent).toEqual({ cwd: CWD, readOnly: false, depth: 1, parentSession: 'think-tank-kai' });
|
|
243
|
+
expect(cfg.mcpServers).toBeUndefined();
|
|
244
|
+
expect(cfg.memberId).toBeUndefined();
|
|
245
|
+
expect(cfg.evalMode).toBeUndefined();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('caps maxSteps, resolves cwd under the project, and honours readOnly', () => {
|
|
249
|
+
const built = buildSubagentConfig(parentConfig, { task: 'x', maxSteps: 999, cwd: 'packages/ui', readOnly: true }, 1);
|
|
250
|
+
const cfg = (built as { config: CrewlyAgentConfig }).config;
|
|
251
|
+
expect(cfg.maxSteps).toBe(CREWLY_AGENT_DEFAULTS.SUBAGENT.MAX_STEPS_CAP);
|
|
252
|
+
expect(cfg.projectPath).toBe(`${CWD}/packages/ui`);
|
|
253
|
+
expect(cfg.subagent?.readOnly).toBe(true);
|
|
254
|
+
expect(cfg.systemPrompt).toMatch(/read-only tools/);
|
|
255
|
+
const small = buildSubagentConfig(parentConfig, { task: 'x', maxSteps: 5 }, 1);
|
|
256
|
+
expect((small as { config: CrewlyAgentConfig }).config.maxSteps).toBe(5);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('refuses an empty or oversized task and a bad model', () => {
|
|
260
|
+
expect(buildSubagentConfig(parentConfig, { task: ' ' }, 1)).toEqual({ error: 'task is required' });
|
|
261
|
+
const huge = 'x'.repeat(CREWLY_AGENT_DEFAULTS.SUBAGENT.TASK_MAX_CHARS + 1);
|
|
262
|
+
expect(buildSubagentConfig(parentConfig, { task: huge }, 1)).toEqual({ error: expect.stringMatching(/too long/) });
|
|
263
|
+
expect(buildSubagentConfig(parentConfig, { task: 'x', model: 'nope/m' }, 1)).toEqual({ error: expect.stringMatching(/unknown model provider/) });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('a child of a child would be depth 2 — but a child never gets the spawn tool, so this stays theoretical', () => {
|
|
267
|
+
const child = (buildSubagentConfig(parentConfig, { task: 'x' }, 1) as { config: CrewlyAgentConfig }).config;
|
|
268
|
+
const grandchild = (buildSubagentConfig(child, { task: 'y' }, 1) as { config: CrewlyAgentConfig }).config;
|
|
269
|
+
expect(grandchild.subagent?.depth).toBe(2);
|
|
270
|
+
expect(grandchild.sessionName).toBe('think-tank-kai/sub-1/sub-1');
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('summarizeSubagentRun', () => {
|
|
275
|
+
it('zero working tool calls is did_nothing, even when the child reported', () => {
|
|
276
|
+
const out = summarizeSubagentRun(
|
|
277
|
+
runResult({ toolCalls: [{ toolName: REPORT_TOOL_NAME, args: { summary: 'all good' }, result: { success: true } }] }),
|
|
278
|
+
{ summary: 'all good', filesChanged: [] },
|
|
279
|
+
's/sub-1',
|
|
280
|
+
10,
|
|
281
|
+
false,
|
|
282
|
+
);
|
|
283
|
+
expect(out.stoppedReason).toBe('did_nothing');
|
|
284
|
+
expect(out.success).toBe(false);
|
|
285
|
+
expect(out.toolCalls).toBe(0);
|
|
286
|
+
expect(out.summary).toBe('all good');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('done: counts working calls, lists tool names, collects files from successful writes and the report', () => {
|
|
290
|
+
const out = summarizeSubagentRun(
|
|
291
|
+
runResult({
|
|
292
|
+
toolCalls: [
|
|
293
|
+
{ toolName: 'grep', args: { pattern: 'foo' }, result: { success: true } },
|
|
294
|
+
{ toolName: 'edit_file', args: { file_path: `${CWD}/a.ts` }, result: { success: true } },
|
|
295
|
+
{ toolName: 'edit_file', args: { file_path: `${CWD}/b.ts` }, result: { success: false, error: 'not unique' } },
|
|
296
|
+
{ toolName: 'write_file', args: { file_path: `${CWD}/c.ts` }, result: { success: true } },
|
|
297
|
+
{ toolName: REPORT_TOOL_NAME, args: { summary: 'r' }, result: { success: true } },
|
|
298
|
+
],
|
|
299
|
+
}),
|
|
300
|
+
{ summary: 'changed a and c', filesChanged: [`${CWD}/d.md`] },
|
|
301
|
+
's/sub-1',
|
|
302
|
+
1234,
|
|
303
|
+
false,
|
|
304
|
+
);
|
|
305
|
+
expect(out.stoppedReason).toBe('done');
|
|
306
|
+
expect(out.success).toBe(true);
|
|
307
|
+
expect(out.toolCalls).toBe(4);
|
|
308
|
+
expect(out.toolNames).toEqual(['grep', 'edit_file', 'write_file']);
|
|
309
|
+
expect(out.filesChanged).toEqual([`${CWD}/a.ts`, `${CWD}/c.ts`, `${CWD}/d.md`]);
|
|
310
|
+
expect(out.usage).toEqual({ input: 100, output: 20, cachedInput: 5 });
|
|
311
|
+
expect(out.durationMs).toBe(1234);
|
|
312
|
+
expect(out.sessionName).toBe('s/sub-1');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('maps incompleteness and timeouts, and falls back to the final text without a report', () => {
|
|
316
|
+
const calls = [{ toolName: 'read_file', args: { file_path: 'x' }, result: { success: true } }];
|
|
317
|
+
expect(summarizeSubagentRun(runResult({ toolCalls: calls, incomplete: { reason: 'steps-exhausted', detail: '', finishReason: 'tool-calls', recoveryAttempts: 0 } }), null, 's', 1, false).stoppedReason).toBe('steps_exhausted');
|
|
318
|
+
expect(summarizeSubagentRun(runResult({ toolCalls: calls, incomplete: { reason: 'truncated', detail: '', finishReason: 'length', recoveryAttempts: 3 } }), null, 's', 1, false).stoppedReason).toBe('incomplete');
|
|
319
|
+
const timed = summarizeSubagentRun(runResult({ toolCalls: calls }), null, 's', 1, true);
|
|
320
|
+
expect(timed.stoppedReason).toBe('timeout');
|
|
321
|
+
expect(timed.error).toMatch(/exceeded/);
|
|
322
|
+
expect(summarizeSubagentRun(runResult({ toolCalls: calls, text: ' the answer ' }), null, 's', 1, false).summary).toBe('the answer');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('caps a long summary', () => {
|
|
326
|
+
const long = 'y'.repeat(CREWLY_AGENT_DEFAULTS.SUBAGENT.SUMMARY_MAX_CHARS + 50);
|
|
327
|
+
const out = summarizeSubagentRun(runResult({ toolCalls: [{ toolName: 'grep', args: {}, result: {} }] }), { summary: long, filesChanged: [] }, 's', 1, false);
|
|
328
|
+
expect(out.summary.length).toBeLessThan(long.length);
|
|
329
|
+
expect(out.summary).toMatch(/\[truncated\]$/);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe('forwardChildToolCalls', () => {
|
|
334
|
+
it('relays tool start/finish to the parent with a sub-n prefix and forwards nothing else', () => {
|
|
335
|
+
const parent = { onToolCallStart: vi.fn(), onToolCallFinish: vi.fn(), onTextChunk: vi.fn(), onStepFinish: vi.fn() };
|
|
336
|
+
const cb = forwardChildToolCalls(() => parent, 'think-tank-kai/sub-2');
|
|
337
|
+
cb.onToolCallStart!('grep', { pattern: 'x' });
|
|
338
|
+
cb.onToolCallFinish!('grep', { pattern: 'x' }, { success: true }, 12);
|
|
339
|
+
expect(parent.onToolCallStart).toHaveBeenCalledWith('sub-2:grep', { pattern: 'x' });
|
|
340
|
+
expect(parent.onToolCallFinish).toHaveBeenCalledWith('sub-2:grep', { pattern: 'x' }, { success: true }, 12);
|
|
341
|
+
expect(cb.onTextChunk).toBeUndefined();
|
|
342
|
+
expect(cb.onStepFinish).toBeUndefined();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('reads the parent callbacks on every call, so a parent with none set is fine', () => {
|
|
346
|
+
let parent: Record<string, unknown> = {};
|
|
347
|
+
const cb = forwardChildToolCalls(() => parent, 'p/sub-1');
|
|
348
|
+
expect(() => cb.onToolCallStart!('read_file', {})).not.toThrow();
|
|
349
|
+
const start = vi.fn();
|
|
350
|
+
parent = { onToolCallStart: start };
|
|
351
|
+
cb.onToolCallStart!('read_file', { file_path: 'a' });
|
|
352
|
+
expect(start).toHaveBeenCalledWith('sub-1:read_file', { file_path: 'a' });
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe('createSpawnSubagentTool', () => {
|
|
357
|
+
it('runs a child with the task as its only message, returns its outcome, counts usage and disposes it', async () => {
|
|
358
|
+
const registry = createSubagentRegistry();
|
|
359
|
+
const child = fakeRunner({
|
|
360
|
+
result: runResult({ toolCalls: [{ toolName: 'grep', args: { pattern: 'x' }, result: { success: true } }] }),
|
|
361
|
+
report: { summary: 'two call sites: a.ts:10, b.ts:42', filesChanged: [] },
|
|
362
|
+
});
|
|
363
|
+
const createRunner = vi.fn(() => child);
|
|
364
|
+
const onUsage = vi.fn();
|
|
365
|
+
const tool = createSpawnSubagentTool({
|
|
366
|
+
parentConfig,
|
|
367
|
+
registry,
|
|
368
|
+
createRunner,
|
|
369
|
+
onUsage,
|
|
370
|
+
childStreaming: (sessionName) => ({ relay: sessionName }) as unknown as import('./types.js').StreamingEventCallbacks,
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const out = await tool.execute({ task: ' Find callers of foo() ', readOnly: true }) as Record<string, unknown>;
|
|
374
|
+
|
|
375
|
+
expect(createRunner).toHaveBeenCalledTimes(1);
|
|
376
|
+
const cfg = createRunner.mock.calls[0]![0] as unknown as CrewlyAgentConfig;
|
|
377
|
+
expect(cfg.sessionName).toBe('think-tank-kai/sub-1');
|
|
378
|
+
expect(cfg.subagent).toEqual({ cwd: CWD, readOnly: true, depth: 1, parentSession: 'think-tank-kai' });
|
|
379
|
+
expect(child.initialize).toHaveBeenCalledTimes(1);
|
|
380
|
+
expect(child.run).toHaveBeenCalledTimes(1);
|
|
381
|
+
expect(child.run.mock.calls[0]![0]).toBe('Find callers of foo()');
|
|
382
|
+
expect(child.run.mock.calls[0]![3]?.abortSignal).toBeInstanceOf(AbortSignal);
|
|
383
|
+
expect((child.run.mock.calls[0]![3] as { streaming?: unknown })?.streaming).toEqual({ relay: 'think-tank-kai/sub-1' });
|
|
384
|
+
expect(out.success).toBe(true);
|
|
385
|
+
expect(out.stoppedReason).toBe('done');
|
|
386
|
+
expect(out.summary).toBe('two call sites: a.ts:10, b.ts:42');
|
|
387
|
+
expect(out.sessionName).toBe('think-tank-kai/sub-1');
|
|
388
|
+
expect(onUsage).toHaveBeenCalledWith({ input: 100, output: 20, cachedInput: 5 });
|
|
389
|
+
expect(child.shutdown).toHaveBeenCalledTimes(1);
|
|
390
|
+
expect(registry).toEqual({ active: 0, spawned: 1 });
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('a child that made no tool call comes back as did_nothing, not success', async () => {
|
|
394
|
+
const tool = createSpawnSubagentTool({
|
|
395
|
+
parentConfig,
|
|
396
|
+
registry: createSubagentRegistry(),
|
|
397
|
+
createRunner: () => fakeRunner({ result: runResult({ toolCalls: [], text: 'I would look at the files.' }) }),
|
|
398
|
+
});
|
|
399
|
+
const out = await tool.execute({ task: 'look' }) as Record<string, unknown>;
|
|
400
|
+
expect(out.success).toBe(false);
|
|
401
|
+
expect(out.stoppedReason).toBe('did_nothing');
|
|
402
|
+
expect(out.summary).toBe('I would look at the files.');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('numbers children in spawn order and refuses a fourth concurrent child', async () => {
|
|
406
|
+
const registry = createSubagentRegistry();
|
|
407
|
+
let release!: () => void;
|
|
408
|
+
const gate = new Promise<void>((r) => { release = r; });
|
|
409
|
+
const slow = () => fakeRunner({
|
|
410
|
+
result: async () => { await gate; return runResult({ toolCalls: [{ toolName: 'grep', args: {}, result: {} }] }); },
|
|
411
|
+
});
|
|
412
|
+
const createRunner = vi.fn(slow);
|
|
413
|
+
const tool = createSpawnSubagentTool({ parentConfig, registry, createRunner });
|
|
414
|
+
|
|
415
|
+
const running = [tool.execute({ task: 'a' }), tool.execute({ task: 'b' }), tool.execute({ task: 'c' })];
|
|
416
|
+
await Promise.resolve();
|
|
417
|
+
expect(registry.active).toBe(CREWLY_AGENT_DEFAULTS.SUBAGENT.MAX_CONCURRENT);
|
|
418
|
+
const refused = await tool.execute({ task: 'd' }) as Record<string, unknown>;
|
|
419
|
+
expect(refused.success).toBe(false);
|
|
420
|
+
expect(refused.stoppedReason).toBe('error');
|
|
421
|
+
expect(String(refused.error)).toMatch(/at most 3 sub-agents/);
|
|
422
|
+
expect(createRunner).toHaveBeenCalledTimes(3);
|
|
423
|
+
|
|
424
|
+
release();
|
|
425
|
+
const done = await Promise.all(running) as Array<Record<string, unknown>>;
|
|
426
|
+
expect(done.map((d) => d.sessionName)).toEqual(['think-tank-kai/sub-1', 'think-tank-kai/sub-2', 'think-tank-kai/sub-3']);
|
|
427
|
+
expect(registry).toEqual({ active: 0, spawned: 3 });
|
|
428
|
+
|
|
429
|
+
// A slot is free again.
|
|
430
|
+
const fifth = await createSpawnSubagentTool({ parentConfig, registry, createRunner: () => fakeRunner({ result: runResult({ toolCalls: [{ toolName: 'grep', args: {}, result: {} }] }) }) })
|
|
431
|
+
.execute({ task: 'e' }) as Record<string, unknown>;
|
|
432
|
+
expect(fifth.sessionName).toBe('think-tank-kai/sub-4');
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('a runner that throws yields error, still disposes the child and frees the slot', async () => {
|
|
436
|
+
const registry = createSubagentRegistry();
|
|
437
|
+
const child = fakeRunner({ throwOn: 'run' });
|
|
438
|
+
const tool = createSpawnSubagentTool({ parentConfig, registry, createRunner: () => child });
|
|
439
|
+
const out = await tool.execute({ task: 'boom' }) as Record<string, unknown>;
|
|
440
|
+
expect(out.success).toBe(false);
|
|
441
|
+
expect(out.stoppedReason).toBe('error');
|
|
442
|
+
expect(out.error).toBe('provider exploded');
|
|
443
|
+
expect(out.sessionName).toBe('think-tank-kai/sub-1');
|
|
444
|
+
expect(child.shutdown).toHaveBeenCalledTimes(1);
|
|
445
|
+
expect(registry.active).toBe(0);
|
|
446
|
+
|
|
447
|
+
const bad = fakeRunner({ throwOn: 'initialize' });
|
|
448
|
+
const out2 = await createSpawnSubagentTool({ parentConfig, registry, createRunner: () => bad }).execute({ task: 'x' }) as Record<string, unknown>;
|
|
449
|
+
expect(out2.error).toBe('no api key');
|
|
450
|
+
expect(bad.shutdown).toHaveBeenCalledTimes(1);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('aborts the child when the wall-clock budget runs out and reports timeout', async () => {
|
|
454
|
+
vi.useFakeTimers();
|
|
455
|
+
try {
|
|
456
|
+
const registry = createSubagentRegistry();
|
|
457
|
+
const child = fakeRunner({
|
|
458
|
+
result: () => new Promise<AgentRunResult>((_, reject) => {
|
|
459
|
+
// The real runner rejects on abort; mirror that.
|
|
460
|
+
setTimeout(() => reject(new Error('aborted')), 60_000);
|
|
461
|
+
}),
|
|
462
|
+
});
|
|
463
|
+
const tool = createSpawnSubagentTool({ parentConfig, registry, createRunner: () => child, timeoutMs: 1_000 });
|
|
464
|
+
const pending = tool.execute({ task: 'slow' });
|
|
465
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
466
|
+
const signal = child.run.mock.calls[0]![3]?.abortSignal as AbortSignal;
|
|
467
|
+
expect(signal.aborted).toBe(true);
|
|
468
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
469
|
+
const out = await pending as Record<string, unknown>;
|
|
470
|
+
expect(out.stoppedReason).toBe('timeout');
|
|
471
|
+
expect(out.success).toBe(false);
|
|
472
|
+
expect(child.shutdown).toHaveBeenCalledTimes(1);
|
|
473
|
+
expect(registry.active).toBe(0);
|
|
474
|
+
} finally {
|
|
475
|
+
vi.useRealTimers();
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('refuses a bad spawn before creating any runner', async () => {
|
|
480
|
+
const createRunner = vi.fn();
|
|
481
|
+
const tool = createSpawnSubagentTool({ parentConfig, registry: createSubagentRegistry(), createRunner });
|
|
482
|
+
const out = await tool.execute({ task: 'x', model: 'nope/m' }) as Record<string, unknown>;
|
|
483
|
+
expect(out.success).toBe(false);
|
|
484
|
+
expect(String(out.error)).toMatch(/unknown model provider/);
|
|
485
|
+
expect(createRunner).not.toHaveBeenCalled();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('describes when to use it and what did_nothing means, and declares its sensitivity', () => {
|
|
489
|
+
const tool = createSpawnSubagentTool({ parentConfig, registry: createSubagentRegistry(), createRunner: vi.fn() });
|
|
490
|
+
expect(tool.sensitivity).toBe('sensitive');
|
|
491
|
+
expect(tool.description).toMatch(/teammate instead/);
|
|
492
|
+
expect(tool.description).toMatch(/did_nothing/);
|
|
493
|
+
const parsed = (tool.inputSchema as any).safeParse({ task: 'x', maxSteps: 0 });
|
|
494
|
+
expect(parsed.success).toBe(false);
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
});
|