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,545 @@
|
|
|
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 StreamingEventCallbacks,
|
|
33
|
+
type SubagentPolicy,
|
|
34
|
+
type ToolDefinition,
|
|
35
|
+
} from './types.js';
|
|
36
|
+
|
|
37
|
+
/** Tools a sub-agent may use in every mode: they read, they do not act. */
|
|
38
|
+
export const SUBAGENT_READ_TOOLS: readonly string[] = ['read_file', 'glob', 'grep', 'git_status', 'git_diff', 'web_search'];
|
|
39
|
+
|
|
40
|
+
/** Tools a sub-agent may use unless it was spawned `readOnly`. */
|
|
41
|
+
export const SUBAGENT_WRITE_TOOLS: readonly string[] = ['bash_exec', 'edit_file', 'write_file'];
|
|
42
|
+
|
|
43
|
+
/** Name of the tool a sub-agent reports with. */
|
|
44
|
+
export const REPORT_TOOL_NAME = 'report_to_parent';
|
|
45
|
+
|
|
46
|
+
/** Name of the tool a parent spawns with. */
|
|
47
|
+
export const SPAWN_TOOL_NAME = 'spawn_subagent';
|
|
48
|
+
|
|
49
|
+
/** Why a sub-agent stopped, as the parent sees it. */
|
|
50
|
+
export type SubagentStopReason =
|
|
51
|
+
/** Ran to its natural end and did some work. */
|
|
52
|
+
| 'done'
|
|
53
|
+
/** Finished without a single working tool call — the parent must not read that as success. */
|
|
54
|
+
| 'did_nothing'
|
|
55
|
+
/** Hit its step cap with work outstanding. */
|
|
56
|
+
| 'steps_exhausted'
|
|
57
|
+
/** Hit the wall-clock budget. */
|
|
58
|
+
| 'timeout'
|
|
59
|
+
/** Ended early for another reason (truncated output, provider error…). */
|
|
60
|
+
| 'incomplete'
|
|
61
|
+
/** The runner threw. */
|
|
62
|
+
| 'error';
|
|
63
|
+
|
|
64
|
+
/** What a sub-agent hands back through `report_to_parent`. */
|
|
65
|
+
export interface SubagentReport {
|
|
66
|
+
/** The child's own account of what it found or changed. */
|
|
67
|
+
summary: string;
|
|
68
|
+
/** Files the child says it changed (merged with what its tool calls show). */
|
|
69
|
+
filesChanged: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The tool result the parent receives. */
|
|
73
|
+
export interface SubagentOutcome {
|
|
74
|
+
/** True only when `stoppedReason` is `done`. */
|
|
75
|
+
success: boolean;
|
|
76
|
+
stoppedReason: SubagentStopReason;
|
|
77
|
+
/** The child's report, else its final text (capped). */
|
|
78
|
+
summary: string;
|
|
79
|
+
/** Working tool calls the child made (`report_to_parent` not counted). */
|
|
80
|
+
toolCalls: number;
|
|
81
|
+
/** Distinct tool names used, so the parent can see what kind of work happened. */
|
|
82
|
+
toolNames: string[];
|
|
83
|
+
/** Files written or edited successfully, absolute paths. */
|
|
84
|
+
filesChanged: string[];
|
|
85
|
+
/** Tokens the child spent; also folded into the parent's usage. */
|
|
86
|
+
usage: { input: number; output: number; cachedInput: number };
|
|
87
|
+
/** Model steps the child took. */
|
|
88
|
+
steps: number;
|
|
89
|
+
/** The child's session name, `<parent>/sub-<n>`, for traces and logs. */
|
|
90
|
+
sessionName: string;
|
|
91
|
+
durationMs: number;
|
|
92
|
+
/** Set when `stoppedReason` is `error` or `timeout`. */
|
|
93
|
+
error?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The arguments of `spawn_subagent`. */
|
|
97
|
+
export interface SpawnSubagentInput {
|
|
98
|
+
task: string;
|
|
99
|
+
cwd?: string;
|
|
100
|
+
readOnly?: boolean;
|
|
101
|
+
maxSteps?: number;
|
|
102
|
+
/** `modelId` on the parent's provider, or `provider/modelId`. */
|
|
103
|
+
model?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The part of `AgentRunnerService` a spawn needs. Injected so the tool can
|
|
108
|
+
* be tested with a scripted runner and so this module does not import the
|
|
109
|
+
* runner (which imports this module).
|
|
110
|
+
*/
|
|
111
|
+
export interface SubagentRunner {
|
|
112
|
+
initialize(): Promise<void>;
|
|
113
|
+
run(
|
|
114
|
+
message: string,
|
|
115
|
+
conversationId?: string,
|
|
116
|
+
metadata?: Record<string, string>,
|
|
117
|
+
options?: { abortSignal?: AbortSignal; streaming?: StreamingEventCallbacks },
|
|
118
|
+
): Promise<AgentRunResult>;
|
|
119
|
+
shutdown(): Promise<void>;
|
|
120
|
+
getSubagentReport(): SubagentReport | null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Per-parent bookkeeping: how many children run now, how many ever. */
|
|
124
|
+
export interface SubagentRegistry {
|
|
125
|
+
active: number;
|
|
126
|
+
spawned: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A fresh registry for one parent runner.
|
|
131
|
+
*
|
|
132
|
+
* @returns Zeroed counters
|
|
133
|
+
*/
|
|
134
|
+
export function createSubagentRegistry(): SubagentRegistry {
|
|
135
|
+
return { active: 0, spawned: 0 };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether `target` is `dir` or lies beneath it (after resolving both).
|
|
140
|
+
*
|
|
141
|
+
* @param target - Path to test
|
|
142
|
+
* @param dir - Directory that must contain it
|
|
143
|
+
* @returns True when inside
|
|
144
|
+
*/
|
|
145
|
+
export function isInsideDir(target: string, dir: string): boolean {
|
|
146
|
+
const rel = path.relative(path.resolve(dir), path.resolve(target));
|
|
147
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve a tool's path argument the way the file tools do (`~`, `$HOME`,
|
|
152
|
+
* relative to the working directory), so the containment check sees the
|
|
153
|
+
* same file the tool would touch.
|
|
154
|
+
*
|
|
155
|
+
* @param filePath - As the model passed it
|
|
156
|
+
* @param cwd - The sub-agent's working directory
|
|
157
|
+
* @returns Absolute path
|
|
158
|
+
*/
|
|
159
|
+
export function resolveSubagentPath(filePath: string, cwd: string): string {
|
|
160
|
+
const home = homedir();
|
|
161
|
+
let p = filePath;
|
|
162
|
+
if (p === '~' || p.startsWith('~/')) p = home + p.slice(1);
|
|
163
|
+
else if (p === '$HOME' || p.startsWith('$HOME/')) p = home + p.slice(5);
|
|
164
|
+
return path.isAbsolute(p) ? path.normalize(p) : path.resolve(cwd, p);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Wrap one allowed tool so it cannot act outside the sub-agent's directory.
|
|
169
|
+
*
|
|
170
|
+
* `edit_file` / `write_file` refuse a path outside `cwd`; `bash_exec` runs in
|
|
171
|
+
* `cwd` (or a directory beneath it) whatever `cwd` the model asked for. Read
|
|
172
|
+
* tools pass through: the issue confines writes, not reads.
|
|
173
|
+
*
|
|
174
|
+
* @param name - Tool name
|
|
175
|
+
* @param tool - The parent's definition of it
|
|
176
|
+
* @param cwd - Containment root
|
|
177
|
+
* @returns The same tool, confined
|
|
178
|
+
*/
|
|
179
|
+
function confineTool(name: string, tool: ToolDefinition, cwd: string): ToolDefinition {
|
|
180
|
+
if (name === 'edit_file' || name === 'write_file') {
|
|
181
|
+
return {
|
|
182
|
+
...tool,
|
|
183
|
+
execute: async (args) => {
|
|
184
|
+
const requested = String(args['file_path'] ?? '');
|
|
185
|
+
const abs = resolveSubagentPath(requested, cwd);
|
|
186
|
+
if (!isInsideDir(abs, cwd)) {
|
|
187
|
+
return { success: false, error: `refused: ${requested} is outside the sub-agent working directory ${cwd}` };
|
|
188
|
+
}
|
|
189
|
+
return tool.execute({ ...args, file_path: abs });
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (name === 'bash_exec') {
|
|
194
|
+
return {
|
|
195
|
+
...tool,
|
|
196
|
+
execute: async (args) => {
|
|
197
|
+
const requested = typeof args['cwd'] === 'string' && args['cwd'] ? resolveSubagentPath(args['cwd'], cwd) : cwd;
|
|
198
|
+
return tool.execute({ ...args, cwd: isInsideDir(requested, cwd) ? requested : cwd });
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return tool;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The tool set a sub-agent gets: the allowed subset of the parent's tools,
|
|
207
|
+
* confined to `policy.cwd`, plus `report_to_parent`. Everything else —
|
|
208
|
+
* `send_message`, `reply_slack`, `broadcast`, `delegate_task`,
|
|
209
|
+
* `handoff_task`, `report_status`, `complete_task`, `start_agent`,
|
|
210
|
+
* `stop_agent`, `remember`, approvals, browser and desktop tools, MCP tools
|
|
211
|
+
* and `spawn_subagent` itself — is simply not in the map.
|
|
212
|
+
*
|
|
213
|
+
* @param tools - The full `createTools()` result of the child runner
|
|
214
|
+
* @param policy - The child's policy (cwd, readOnly)
|
|
215
|
+
* @param onReport - Receives the child's `report_to_parent` payload
|
|
216
|
+
* @returns The restricted tool map
|
|
217
|
+
*/
|
|
218
|
+
export function restrictToolsForSubagent(
|
|
219
|
+
tools: Record<string, ToolDefinition>,
|
|
220
|
+
policy: SubagentPolicy,
|
|
221
|
+
onReport: (report: SubagentReport) => void,
|
|
222
|
+
): Record<string, ToolDefinition> {
|
|
223
|
+
const allowed = policy.readOnly ? SUBAGENT_READ_TOOLS : [...SUBAGENT_READ_TOOLS, ...SUBAGENT_WRITE_TOOLS];
|
|
224
|
+
const out: Record<string, ToolDefinition> = {};
|
|
225
|
+
for (const name of allowed) {
|
|
226
|
+
const tool = tools[name];
|
|
227
|
+
if (tool) out[name] = confineTool(name, tool, policy.cwd);
|
|
228
|
+
}
|
|
229
|
+
out[REPORT_TOOL_NAME] = {
|
|
230
|
+
description:
|
|
231
|
+
'Hand your result to the agent that spawned you. Call it once, at the end: what you found or changed, '
|
|
232
|
+
+ 'the files you touched, what you could not do and why. The parent sees only this report and your final text.',
|
|
233
|
+
inputSchema: z.object({
|
|
234
|
+
summary: z.string().min(1).describe('Concrete findings or changes, with file paths and line references where they help'),
|
|
235
|
+
filesChanged: z.array(z.string()).optional().describe('Files you created or edited'),
|
|
236
|
+
}),
|
|
237
|
+
sensitivity: 'safe',
|
|
238
|
+
execute: async (args) => {
|
|
239
|
+
onReport({
|
|
240
|
+
summary: String(args['summary'] ?? ''),
|
|
241
|
+
filesChanged: Array.isArray(args['filesChanged']) ? args['filesChanged'].map(String) : [],
|
|
242
|
+
});
|
|
243
|
+
return { success: true, note: 'Reported to the parent. Finish your turn now with one line at most.' };
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The system prompt of a sub-agent. Short on purpose: the task text is the
|
|
251
|
+
* context, and the harness rules are appended by the runner.
|
|
252
|
+
*
|
|
253
|
+
* @param parentSession - Who spawned it
|
|
254
|
+
* @param cwd - Where it may write
|
|
255
|
+
* @param readOnly - Whether writes are off
|
|
256
|
+
* @returns Prompt text
|
|
257
|
+
*/
|
|
258
|
+
export function buildSubagentPrompt(parentSession: string, cwd: string, readOnly: boolean): string {
|
|
259
|
+
return [
|
|
260
|
+
`You are a short-lived helper spawned by the agent "${parentSession}" for one bounded task.`,
|
|
261
|
+
`Working directory: ${cwd}. ${readOnly ? 'You have read-only tools: look, do not change anything.' : `You may change files only under ${cwd}.`}`,
|
|
262
|
+
'You cannot message anyone, delegate, schedule, or touch tickets; nobody but your parent will read you.',
|
|
263
|
+
`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.`,
|
|
264
|
+
'Report facts you verified, with paths and line numbers; if you did nothing, say so instead of inventing a result.',
|
|
265
|
+
].join('\n');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Pick the child's model: the parent's by default, a different id on the
|
|
270
|
+
* parent's provider, or `provider/modelId`.
|
|
271
|
+
*
|
|
272
|
+
* @param parent - Parent model
|
|
273
|
+
* @param requested - The `model` argument, if any
|
|
274
|
+
* @returns The model, or why it was refused
|
|
275
|
+
*/
|
|
276
|
+
export function resolveSubagentModel(parent: ModelConfig, requested?: string): { model: ModelConfig } | { error: string } {
|
|
277
|
+
if (!requested || !requested.trim()) return { model: parent };
|
|
278
|
+
const spec = requested.trim();
|
|
279
|
+
const slash = spec.indexOf('/');
|
|
280
|
+
if (slash < 0) return { model: { ...parent, modelId: spec } };
|
|
281
|
+
const provider = spec.slice(0, slash);
|
|
282
|
+
const modelId = spec.slice(slash + 1);
|
|
283
|
+
if (!isModelProvider(provider)) return { error: `unknown model provider "${provider}" in "${spec}"` };
|
|
284
|
+
if (!modelId) return { error: `"${spec}" names no model` };
|
|
285
|
+
return { model: { ...parent, provider, modelId } };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The child's configuration, derived from the parent's.
|
|
290
|
+
*
|
|
291
|
+
* The child shares the API base URL, history and compaction settings; it
|
|
292
|
+
* gets its own session name (`<parent>/sub-<n>`), its own short prompt, the
|
|
293
|
+
* requested working directory as project path, no MCP servers (one process
|
|
294
|
+
* per server is the parent's cost to bear, not every helper's), no member id
|
|
295
|
+
* and the `subagent` policy that makes the runner restrict its tools.
|
|
296
|
+
*
|
|
297
|
+
* @param parent - Parent config
|
|
298
|
+
* @param input - Spawn arguments
|
|
299
|
+
* @param ordinal - 1-based child number for this parent
|
|
300
|
+
* @returns The config, or why the spawn was refused
|
|
301
|
+
*/
|
|
302
|
+
export function buildSubagentConfig(
|
|
303
|
+
parent: CrewlyAgentConfig,
|
|
304
|
+
input: SpawnSubagentInput,
|
|
305
|
+
ordinal: number,
|
|
306
|
+
): { config: CrewlyAgentConfig } | { error: string } {
|
|
307
|
+
const task = (input.task ?? '').trim();
|
|
308
|
+
if (!task) return { error: 'task is required' };
|
|
309
|
+
if (task.length > CREWLY_AGENT_DEFAULTS.SUBAGENT.TASK_MAX_CHARS) {
|
|
310
|
+
return { error: `task is too long (${task.length} chars; keep it under ${CREWLY_AGENT_DEFAULTS.SUBAGENT.TASK_MAX_CHARS})` };
|
|
311
|
+
}
|
|
312
|
+
const base = parent.projectPath ?? process.cwd();
|
|
313
|
+
const cwd = input.cwd ? resolveSubagentPath(input.cwd, base) : base;
|
|
314
|
+
const picked = resolveSubagentModel(parent.model, input.model);
|
|
315
|
+
if ('error' in picked) return picked;
|
|
316
|
+
const requestedSteps = Number.isFinite(input.maxSteps) && (input.maxSteps as number) > 0
|
|
317
|
+
? Math.floor(input.maxSteps as number)
|
|
318
|
+
: CREWLY_AGENT_DEFAULTS.SUBAGENT.MAX_STEPS;
|
|
319
|
+
const readOnly = input.readOnly === true;
|
|
320
|
+
const sessionName = `${parent.sessionName}/sub-${ordinal}`;
|
|
321
|
+
return {
|
|
322
|
+
config: {
|
|
323
|
+
model: picked.model,
|
|
324
|
+
maxSteps: Math.min(requestedSteps, CREWLY_AGENT_DEFAULTS.SUBAGENT.MAX_STEPS_CAP),
|
|
325
|
+
sessionName,
|
|
326
|
+
role: 'subagent',
|
|
327
|
+
apiBaseUrl: parent.apiBaseUrl,
|
|
328
|
+
systemPrompt: buildSubagentPrompt(parent.sessionName, cwd, readOnly),
|
|
329
|
+
maxHistoryMessages: parent.maxHistoryMessages,
|
|
330
|
+
compactionThreshold: parent.compactionThreshold,
|
|
331
|
+
projectPath: cwd,
|
|
332
|
+
subagent: { cwd, readOnly, depth: (parent.subagent?.depth ?? 0) + 1, parentSession: parent.sessionName },
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Turn a child's run result into what the parent gets back.
|
|
339
|
+
*
|
|
340
|
+
* `report_to_parent` is not a working call: a child that only reported
|
|
341
|
+
* "did nothing". Files changed are read off successful `edit_file` /
|
|
342
|
+
* `write_file` calls and merged with the report's list, so a child cannot
|
|
343
|
+
* claim a change its tools never made — and cannot hide one either.
|
|
344
|
+
*
|
|
345
|
+
* @param result - The child's run result
|
|
346
|
+
* @param report - What it reported, if it did
|
|
347
|
+
* @param sessionName - The child's session name
|
|
348
|
+
* @param durationMs - Wall-clock time
|
|
349
|
+
* @param timedOut - Whether the parent's timer fired
|
|
350
|
+
* @returns The outcome
|
|
351
|
+
*/
|
|
352
|
+
export function summarizeSubagentRun(
|
|
353
|
+
result: AgentRunResult,
|
|
354
|
+
report: SubagentReport | null,
|
|
355
|
+
sessionName: string,
|
|
356
|
+
durationMs: number,
|
|
357
|
+
timedOut: boolean,
|
|
358
|
+
): SubagentOutcome {
|
|
359
|
+
const work = result.toolCalls.filter((tc) => tc.toolName !== REPORT_TOOL_NAME);
|
|
360
|
+
const changed = new Set<string>();
|
|
361
|
+
for (const tc of work) {
|
|
362
|
+
if (tc.toolName !== 'edit_file' && tc.toolName !== 'write_file') continue;
|
|
363
|
+
const ok = tc.result == null || typeof tc.result !== 'object' || (tc.result as Record<string, unknown>)['success'] !== false;
|
|
364
|
+
const fp = tc.args['file_path'];
|
|
365
|
+
if (ok && typeof fp === 'string' && fp) changed.add(fp);
|
|
366
|
+
}
|
|
367
|
+
for (const fp of report?.filesChanged ?? []) if (fp) changed.add(fp);
|
|
368
|
+
|
|
369
|
+
let stoppedReason: SubagentStopReason = 'done';
|
|
370
|
+
if (timedOut) stoppedReason = 'timeout';
|
|
371
|
+
else if (work.length === 0) stoppedReason = 'did_nothing';
|
|
372
|
+
else if (result.incomplete?.reason === 'steps-exhausted') stoppedReason = 'steps_exhausted';
|
|
373
|
+
else if (result.incomplete) stoppedReason = 'incomplete';
|
|
374
|
+
|
|
375
|
+
const raw = (report?.summary ?? result.text ?? '').trim();
|
|
376
|
+
const cap = CREWLY_AGENT_DEFAULTS.SUBAGENT.SUMMARY_MAX_CHARS;
|
|
377
|
+
const summary = raw.length > cap ? `${raw.slice(0, cap)}\n…[truncated]` : raw;
|
|
378
|
+
return {
|
|
379
|
+
success: stoppedReason === 'done',
|
|
380
|
+
stoppedReason,
|
|
381
|
+
summary,
|
|
382
|
+
toolCalls: work.length,
|
|
383
|
+
toolNames: [...new Set(work.map((tc) => tc.toolName))],
|
|
384
|
+
filesChanged: [...changed],
|
|
385
|
+
usage: result.usage,
|
|
386
|
+
steps: result.steps,
|
|
387
|
+
sessionName,
|
|
388
|
+
durationMs,
|
|
389
|
+
...(timedOut ? { error: `sub-agent exceeded ${CREWLY_AGENT_DEFAULTS.SUBAGENT.TIMEOUT_MS} ms` } : {}),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** What the spawn tool needs from its parent runner. */
|
|
394
|
+
export interface SpawnSubagentContext {
|
|
395
|
+
/** The parent's config; the child's is derived from it. */
|
|
396
|
+
parentConfig: CrewlyAgentConfig;
|
|
397
|
+
/** Shared counters for this parent. */
|
|
398
|
+
registry: SubagentRegistry;
|
|
399
|
+
/** Builds a child runner from a config (the runner injects `new AgentRunnerService`). */
|
|
400
|
+
createRunner: (config: CrewlyAgentConfig) => SubagentRunner;
|
|
401
|
+
/** Receives the child's usage so the parent's ledger counts it. */
|
|
402
|
+
onUsage?: (usage: { input: number; output: number; cachedInput: number }) => void;
|
|
403
|
+
/** Wall-clock budget per child; defaults to the runtime default. */
|
|
404
|
+
timeoutMs?: number;
|
|
405
|
+
/**
|
|
406
|
+
* Streaming callbacks for a child, by its session name. The runner passes
|
|
407
|
+
* `forwardChildToolCalls(...)` so the child's tool calls show up in the
|
|
408
|
+
* parent's session log as `sub-<n>:<tool>`.
|
|
409
|
+
*/
|
|
410
|
+
childStreaming?: (sessionName: string) => StreamingEventCallbacks;
|
|
411
|
+
/** Clock, for tests. */
|
|
412
|
+
now?: () => number;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Streaming callbacks for a child that relay its tool calls to the parent's
|
|
417
|
+
* current callbacks, with the tool name prefixed by the child's short name
|
|
418
|
+
* (`sub-1:grep`). Text chunks and step events stay with the child: forwarded
|
|
419
|
+
* text would interleave with the parent's own stream.
|
|
420
|
+
*
|
|
421
|
+
* `getParent` is read on every call, because the parent's callbacks are set
|
|
422
|
+
* per run and a child always runs inside one of the parent's runs.
|
|
423
|
+
*
|
|
424
|
+
* @param getParent - Returns the parent's current streaming callbacks
|
|
425
|
+
* @param childSession - The child's session name (`<parent>/sub-<n>`)
|
|
426
|
+
* @returns Callbacks to pass to the child's `run`
|
|
427
|
+
*/
|
|
428
|
+
export function forwardChildToolCalls(getParent: () => StreamingEventCallbacks, childSession: string): StreamingEventCallbacks {
|
|
429
|
+
const short = childSession.slice(childSession.lastIndexOf('/') + 1);
|
|
430
|
+
return {
|
|
431
|
+
onToolCallStart: (toolName, args) => {
|
|
432
|
+
getParent().onToolCallStart?.(`${short}:${toolName}`, args);
|
|
433
|
+
},
|
|
434
|
+
onToolCallFinish: (toolName, args, result, durationMs) => {
|
|
435
|
+
getParent().onToolCallFinish?.(`${short}:${toolName}`, args, result, durationMs);
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* An outcome for a spawn that failed before or while running.
|
|
442
|
+
*
|
|
443
|
+
* @param sessionName - Child session name, or the parent's when none was built
|
|
444
|
+
* @param reason - `error` or `timeout`
|
|
445
|
+
* @param error - What went wrong
|
|
446
|
+
* @param durationMs - Time spent
|
|
447
|
+
* @returns The outcome
|
|
448
|
+
*/
|
|
449
|
+
function failedOutcome(sessionName: string, reason: 'error' | 'timeout', error: string, durationMs: number): SubagentOutcome {
|
|
450
|
+
return {
|
|
451
|
+
success: false,
|
|
452
|
+
stoppedReason: reason,
|
|
453
|
+
summary: '',
|
|
454
|
+
toolCalls: 0,
|
|
455
|
+
toolNames: [],
|
|
456
|
+
filesChanged: [],
|
|
457
|
+
usage: { input: 0, output: 0, cachedInput: 0 },
|
|
458
|
+
steps: 0,
|
|
459
|
+
sessionName,
|
|
460
|
+
durationMs,
|
|
461
|
+
error,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The parent's `spawn_subagent` tool.
|
|
467
|
+
*
|
|
468
|
+
* Refuses when the parent already has the maximum number of children
|
|
469
|
+
* running. Otherwise builds the child config, runs the child once with the
|
|
470
|
+
* task as its only message under a wall-clock budget, folds its usage into
|
|
471
|
+
* the parent's, and always disposes the child runner — on success, error or
|
|
472
|
+
* timeout.
|
|
473
|
+
*
|
|
474
|
+
* @param ctx - Parent-side dependencies
|
|
475
|
+
* @returns The tool definition
|
|
476
|
+
*
|
|
477
|
+
* @example
|
|
478
|
+
* ```ts
|
|
479
|
+
* tools.spawn_subagent = createSpawnSubagentTool({
|
|
480
|
+
* parentConfig: this.config,
|
|
481
|
+
* registry: this.subagentRegistry,
|
|
482
|
+
* createRunner: (cfg) => new AgentRunnerService(cfg, this.modelManager, this.apiClient),
|
|
483
|
+
* onUsage: (u) => this.recordChildUsage(u),
|
|
484
|
+
* });
|
|
485
|
+
* ```
|
|
486
|
+
*/
|
|
487
|
+
export function createSpawnSubagentTool(ctx: SpawnSubagentContext): ToolDefinition {
|
|
488
|
+
const limits = CREWLY_AGENT_DEFAULTS.SUBAGENT;
|
|
489
|
+
const now = ctx.now ?? (() => Date.now());
|
|
490
|
+
const timeoutMs = ctx.timeoutMs ?? limits.TIMEOUT_MS;
|
|
491
|
+
return {
|
|
492
|
+
description:
|
|
493
|
+
'Run a short-lived helper agent on one bounded, self-contained piece of work — parallel research, a large read you want '
|
|
494
|
+
+ 'kept out of your own context, a scoped fix — and get its report back as this tool result. The helper starts with only '
|
|
495
|
+
+ 'your task text, works in the given directory with file, search and shell tools (no messaging, no delegation, no tickets, '
|
|
496
|
+
+ 'no further helpers), and is gone when it returns. Use a teammate instead for anything that needs a named owner, a review, '
|
|
497
|
+
+ `or communication. At most ${limits.MAX_CONCURRENT} helpers run at once; each gets up to ${limits.MAX_STEPS} steps `
|
|
498
|
+
+ `(cap ${limits.MAX_STEPS_CAP}) and ${Math.round(limits.TIMEOUT_MS / 60000)} minutes. A result with stoppedReason `
|
|
499
|
+
+ '"did_nothing" means the helper made no tool call: treat it as a failure, not as an answer.',
|
|
500
|
+
inputSchema: z.object({
|
|
501
|
+
task: z.string().min(1).describe('Everything the helper needs: the goal, the files or area, what "done" looks like, what to report'),
|
|
502
|
+
cwd: z.string().optional().describe('Directory the helper works in and may write under (default: your project path)'),
|
|
503
|
+
readOnly: z.boolean().optional().describe('True for research or review: no shell, no file writes'),
|
|
504
|
+
maxSteps: z.number().int().positive().optional().describe(`Model steps the helper may take (default ${limits.MAX_STEPS}, cap ${limits.MAX_STEPS_CAP})`),
|
|
505
|
+
model: z.string().optional().describe('A cheaper model for simple work: a model id on your provider, or provider/modelId'),
|
|
506
|
+
}),
|
|
507
|
+
sensitivity: 'sensitive',
|
|
508
|
+
execute: async (args): Promise<SubagentOutcome> => {
|
|
509
|
+
const started = now();
|
|
510
|
+
if (ctx.registry.active >= limits.MAX_CONCURRENT) {
|
|
511
|
+
return failedOutcome(
|
|
512
|
+
ctx.parentConfig.sessionName,
|
|
513
|
+
'error',
|
|
514
|
+
`at most ${limits.MAX_CONCURRENT} sub-agents run at once; wait for one to finish`,
|
|
515
|
+
now() - started,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
const built = buildSubagentConfig(ctx.parentConfig, args as unknown as SpawnSubagentInput, ctx.registry.spawned + 1);
|
|
519
|
+
if ('error' in built) return failedOutcome(ctx.parentConfig.sessionName, 'error', built.error, now() - started);
|
|
520
|
+
const { config } = built;
|
|
521
|
+
ctx.registry.spawned += 1;
|
|
522
|
+
ctx.registry.active += 1;
|
|
523
|
+
const abort = new AbortController();
|
|
524
|
+
const timer = setTimeout(() => abort.abort(), timeoutMs);
|
|
525
|
+
let runner: SubagentRunner | null = null;
|
|
526
|
+
try {
|
|
527
|
+
runner = ctx.createRunner(config);
|
|
528
|
+
await runner.initialize();
|
|
529
|
+
const result = await runner.run(String(args['task']).trim(), undefined, undefined, {
|
|
530
|
+
abortSignal: abort.signal,
|
|
531
|
+
streaming: ctx.childStreaming?.(config.sessionName),
|
|
532
|
+
});
|
|
533
|
+
ctx.onUsage?.(result.usage);
|
|
534
|
+
return summarizeSubagentRun(result, runner.getSubagentReport(), config.sessionName, now() - started, abort.signal.aborted);
|
|
535
|
+
} catch (error) {
|
|
536
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
537
|
+
return failedOutcome(config.sessionName, abort.signal.aborted ? 'timeout' : 'error', message, now() - started);
|
|
538
|
+
} finally {
|
|
539
|
+
clearTimeout(timer);
|
|
540
|
+
ctx.registry.active -= 1;
|
|
541
|
+
if (runner) await runner.shutdown().catch(() => { /* the child is gone either way */ });
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
};
|
|
545
|
+
}
|
|
@@ -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,
|