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.
@@ -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,