@moxxy/plugin-subagents 0.26.0

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,344 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { z } from 'zod';
3
+ import { defineTool, type SubagentResult, type SubagentSpawner, type ToolContext } from '@moxxy/sdk';
4
+ import { FakeProvider, createFakeSession, textReply, toolUseReply } from '@moxxy/testing';
5
+ import { defaultModePlugin } from '@moxxy/mode-default';
6
+ import { collectTurn } from '@moxxy/core';
7
+ import { buildDispatchAgentTool } from './dispatch-agent.js';
8
+
9
+ const dispatchAgentTool = buildDispatchAgentTool({ getAgent: () => undefined });
10
+
11
+ type DispatchResults = { results: ReadonlyArray<Record<string, unknown>> };
12
+
13
+ /** Minimal ToolContext carrying only the spawner the handler reads. */
14
+ function ctxWith(spawner: SubagentSpawner): ToolContext {
15
+ return { subagents: spawner } as unknown as ToolContext;
16
+ }
17
+
18
+ describe('subagents — basic spawning', () => {
19
+ it('spawns a child that runs a tool and returns its text', async () => {
20
+ // The parent immediately dispatches one child agent.
21
+ const provider = new FakeProvider({
22
+ script: [
23
+ // Parent's only message — kick off the child.
24
+ toolUseReply(
25
+ 'dispatch_agent',
26
+ {
27
+ agents: [
28
+ {
29
+ prompt: 'Read /etc/config and report its version',
30
+ label: 'reader',
31
+ },
32
+ ],
33
+ },
34
+ 'p1',
35
+ ),
36
+ // Child's iteration 1: call Read.
37
+ toolUseReply('Read', { file_path: '/etc/config' }, 'c1'),
38
+ // Child's iteration 2: summarize.
39
+ textReply('Version is 1.2.3'),
40
+ // Parent's iteration 2: end turn with summary.
41
+ textReply('reader reported: Version is 1.2.3'),
42
+ ],
43
+ });
44
+ const session = createFakeSession({ provider });
45
+ session.pluginHost.registerStatic(defaultModePlugin);
46
+ session.modes.setActive('default');
47
+ session.tools.register(
48
+ defineTool({
49
+ name: 'Read',
50
+ description: 'read file',
51
+ inputSchema: z.object({ file_path: z.string() }),
52
+ handler: () => 'version=1.2.3',
53
+ }),
54
+ );
55
+ // Register the dispatch_agent tool so the parent can invoke it.
56
+ session.tools.register(dispatchAgentTool);
57
+
58
+ const events = await collectTurn(session, 'use a sub-agent to fetch the version');
59
+
60
+ // Parent log should carry subagent_started + subagent_completed envelopes.
61
+ const started = events.find(
62
+ (e) => e.type === 'plugin_event' && e.subtype === 'subagent_started',
63
+ );
64
+ expect(started).toBeDefined();
65
+ if (started?.type === 'plugin_event') {
66
+ const payload = started.payload as { label: string; mode: string };
67
+ expect(payload.label).toBe('reader');
68
+ expect(payload.mode).toBe('default');
69
+ }
70
+
71
+ const completed = events.find(
72
+ (e) => e.type === 'plugin_event' && e.subtype === 'subagent_completed',
73
+ );
74
+ expect(completed).toBeDefined();
75
+ if (completed?.type === 'plugin_event') {
76
+ const payload = completed.payload as { label: string; text: string };
77
+ expect(payload.label).toBe('reader');
78
+ expect(payload.text).toContain('1.2.3');
79
+ }
80
+ });
81
+
82
+ it('streams child tool calls to the parent in real time', async () => {
83
+ const provider = new FakeProvider({
84
+ script: [
85
+ toolUseReply(
86
+ 'dispatch_agent',
87
+ { agents: [{ prompt: 'Read a file', label: 'a' }] },
88
+ 'p1',
89
+ ),
90
+ // Child iteration: tool call + text wrap.
91
+ toolUseReply('Read', { file_path: '/x' }, 'c1'),
92
+ textReply('done'),
93
+ // Parent wrap-up.
94
+ textReply('child finished'),
95
+ ],
96
+ });
97
+ const session = createFakeSession({ provider });
98
+ session.pluginHost.registerStatic(defaultModePlugin);
99
+ session.modes.setActive('default');
100
+ session.tools.register(
101
+ defineTool({
102
+ name: 'Read',
103
+ description: 'read',
104
+ inputSchema: z.object({ file_path: z.string() }),
105
+ handler: () => 'contents',
106
+ }),
107
+ );
108
+ session.tools.register(dispatchAgentTool);
109
+
110
+ const events = await collectTurn(session, 'fan out');
111
+
112
+ // We expect at least one subagent_tool_call event mirroring the child's Read invocation.
113
+ const childToolCall = events.find(
114
+ (e) => e.type === 'plugin_event' && e.subtype === 'subagent_tool_call',
115
+ );
116
+ expect(childToolCall).toBeDefined();
117
+ if (childToolCall?.type === 'plugin_event') {
118
+ const payload = childToolCall.payload as { name: string; label: string };
119
+ expect(payload.name).toBe('Read');
120
+ expect(payload.label).toBe('a');
121
+ }
122
+
123
+ // And a tool_result mirror.
124
+ const childToolResult = events.find(
125
+ (e) => e.type === 'plugin_event' && e.subtype === 'subagent_tool_result',
126
+ );
127
+ expect(childToolResult).toBeDefined();
128
+ });
129
+
130
+ it('spawnAll runs multiple children and returns results in input order', async () => {
131
+ // 3 children, each replies once. Parent kicks them off in one dispatch_agent call.
132
+ const provider = new FakeProvider({
133
+ script: [
134
+ toolUseReply(
135
+ 'dispatch_agent',
136
+ {
137
+ agents: [
138
+ { prompt: 'task 1', label: 'one' },
139
+ { prompt: 'task 2', label: 'two' },
140
+ { prompt: 'task 3', label: 'three' },
141
+ ],
142
+ },
143
+ 'p1',
144
+ ),
145
+ // The FakeProvider replies to requests in script order; with 3 parallel
146
+ // children all making their first request roughly together, the order is
147
+ // deterministic-enough for this test: each child gets a textReply ending its turn.
148
+ textReply('done 1'),
149
+ textReply('done 2'),
150
+ textReply('done 3'),
151
+ textReply('all done'),
152
+ ],
153
+ });
154
+ const session = createFakeSession({ provider });
155
+ session.pluginHost.registerStatic(defaultModePlugin);
156
+ session.modes.setActive('default');
157
+ session.tools.register(dispatchAgentTool);
158
+
159
+ const events = await collectTurn(session, 'spawn three');
160
+
161
+ const completed = events.filter(
162
+ (e) => e.type === 'plugin_event' && e.subtype === 'subagent_completed',
163
+ );
164
+ expect(completed).toHaveLength(3);
165
+ const labels = completed
166
+ .map((e) => (e.type === 'plugin_event' ? (e.payload as { label: string }).label : ''))
167
+ .sort();
168
+ expect(labels).toEqual(['one', 'three', 'two']);
169
+ });
170
+ });
171
+
172
+ describe('subagents — model override validation', () => {
173
+ it('falls back to the parent model (with a warning) on a hallucinated model id', async () => {
174
+ const provider = new FakeProvider({
175
+ script: [
176
+ toolUseReply(
177
+ 'dispatch_agent',
178
+ // Training-era id the calling LLM tends to invent.
179
+ { agents: [{ prompt: 'task', label: 'kid', model: 'claude-3-5-sonnet' }] },
180
+ 'p1',
181
+ ),
182
+ textReply('child done'),
183
+ textReply('parent done'),
184
+ ],
185
+ });
186
+ const session = createFakeSession({ provider });
187
+ session.pluginHost.registerStatic(defaultModePlugin);
188
+ session.modes.setActive('default');
189
+ session.tools.register(dispatchAgentTool);
190
+
191
+ const events = await collectTurn(session, 'spawn with a bogus model');
192
+
193
+ const warning = events.find(
194
+ (e) => e.type === 'plugin_event' && e.subtype === 'subagent_warning',
195
+ );
196
+ expect(warning).toBeDefined();
197
+ if (warning?.type === 'plugin_event') {
198
+ expect((warning.payload as { message: string }).message).toBe(
199
+ 'unknown model "claude-3-5-sonnet" — falling back to parent model "fake-model"',
200
+ );
201
+ }
202
+ // Request order: parent iteration 1, child, parent wrap-up. The child's
203
+ // provider request must carry the parent's model, not the invented one.
204
+ expect(provider.received[1]?.model).toBe('fake-model');
205
+ });
206
+
207
+ it('honors a model override the provider actually lists', async () => {
208
+ const models = [
209
+ { id: 'fake-model', contextWindow: 200_000 },
210
+ { id: 'cheap-model', contextWindow: 200_000 },
211
+ ];
212
+ const provider = new FakeProvider({
213
+ models,
214
+ script: [
215
+ toolUseReply(
216
+ 'dispatch_agent',
217
+ { agents: [{ prompt: 'task', label: 'kid', model: 'cheap-model' }] },
218
+ 'p1',
219
+ ),
220
+ textReply('child done'),
221
+ textReply('parent done'),
222
+ ],
223
+ });
224
+ const session = createFakeSession({ provider });
225
+ session.pluginHost.registerStatic(defaultModePlugin);
226
+ session.modes.setActive('default');
227
+ session.tools.register(dispatchAgentTool);
228
+
229
+ const events = await collectTurn(session, 'spawn cheap');
230
+
231
+ expect(
232
+ events.find((e) => e.type === 'plugin_event' && e.subtype === 'subagent_warning'),
233
+ ).toBeUndefined();
234
+ expect(provider.received[1]?.model).toBe('cheap-model');
235
+ });
236
+ });
237
+
238
+ describe('subagents — fan-out failure containment', () => {
239
+ it('degrades a spawnAll rejection to per-child error results instead of crashing the tool', async () => {
240
+ // The core spawner uses Promise.all; a single child's setup throw rejects
241
+ // the whole batch and discards siblings. The tool must not propagate that
242
+ // raw rejection to the loop — it should hand back one error per spec.
243
+ const spawner: SubagentSpawner = {
244
+ spawn: async (): Promise<SubagentResult> => {
245
+ throw new Error('should not be called');
246
+ },
247
+ spawnAll: async () => {
248
+ throw new Error('provider lookup failed');
249
+ },
250
+ };
251
+
252
+ const out = (await dispatchAgentTool.handler(
253
+ {
254
+ agents: [
255
+ { prompt: 'task one', label: 'one' },
256
+ { prompt: 'task two', label: 'two' },
257
+ ],
258
+ },
259
+ ctxWith(spawner),
260
+ )) as DispatchResults;
261
+
262
+ expect(out.results).toHaveLength(2);
263
+ expect(out.results.map((r) => r.label)).toEqual(['one', 'two']);
264
+ for (const r of out.results) {
265
+ expect(r.error).toBe('provider lookup failed');
266
+ expect(r.stopReason).toBe('error');
267
+ expect(r.text).toBe('');
268
+ }
269
+ });
270
+
271
+ it('still returns successful results in input order on the happy path', async () => {
272
+ const spawner: SubagentSpawner = {
273
+ spawn: async (): Promise<SubagentResult> => {
274
+ throw new Error('unused');
275
+ },
276
+ spawnAll: async (specs) =>
277
+ specs.map(
278
+ (s, i): SubagentResult => ({
279
+ label: s.label ?? `k${i}`,
280
+ childSessionId: `sess-${i}` as SubagentResult['childSessionId'],
281
+ text: `done ${i}`,
282
+ stopReason: 'end_turn',
283
+ }),
284
+ ),
285
+ };
286
+
287
+ const out = (await dispatchAgentTool.handler(
288
+ { agents: [{ prompt: 'a', label: 'x' }, { prompt: 'b', label: 'y' }] },
289
+ ctxWith(spawner),
290
+ )) as DispatchResults;
291
+
292
+ expect(out.results.map((r) => r.label)).toEqual(['x', 'y']);
293
+ expect(out.results.every((r) => r.error === undefined)).toBe(true);
294
+ });
295
+ });
296
+
297
+ describe('subagents — input bounds', () => {
298
+ const tool = buildDispatchAgentTool({ getAgent: () => undefined });
299
+
300
+ it('rejects an over-long prompt at the schema boundary (before any child is spawned)', () => {
301
+ // The bound is enforced by the loop's input validation; assert the cap
302
+ // exists so a regression that drops it is caught here.
303
+ const schema = tool.inputSchema;
304
+ const huge = 'x'.repeat(20_001);
305
+ expect(schema.safeParse({ agents: [{ prompt: huge }] }).success).toBe(false);
306
+ // A bounded prompt still parses.
307
+ expect(schema.safeParse({ agents: [{ prompt: 'short' }] }).success).toBe(true);
308
+ });
309
+
310
+ it('rejects an over-long systemPrompt at the schema boundary', () => {
311
+ const sys = 'y'.repeat(8_001);
312
+ expect(
313
+ tool.inputSchema.safeParse({ agents: [{ prompt: 'ok', systemPrompt: sys }] }).success,
314
+ ).toBe(false);
315
+ });
316
+
317
+ it('rejects more than 8 agents in one batch', () => {
318
+ const agents = Array.from({ length: 9 }, (_, i) => ({ prompt: `t${i}` }));
319
+ const parsed = tool.inputSchema.safeParse({ agents });
320
+ expect(parsed.success).toBe(false);
321
+ });
322
+
323
+ it('rejects a batch whose aggregate payload exceeds the budget even when each spec is within its field caps', () => {
324
+ // 8 prompts each just under the 20K per-field cap parse field-by-field, but
325
+ // ~144K chars dispatched as 8 concurrent completions is the worst-case spike
326
+ // the aggregate budget exists to refuse — before any session is spawned.
327
+ const big = 'x'.repeat(18_000);
328
+ const agents = Array.from({ length: 8 }, () => ({ prompt: big }));
329
+ const parsed = tool.inputSchema.safeParse({ agents });
330
+ expect(parsed.success).toBe(false);
331
+ if (!parsed.success) {
332
+ expect(parsed.error.issues.some((i) => /aggregate limit/.test(i.message))).toBe(true);
333
+ }
334
+ });
335
+
336
+ it('accepts a realistically-sized multi-agent batch under the aggregate budget', () => {
337
+ // Five agents with substantial-but-reasonable prompts must still parse.
338
+ const agents = Array.from({ length: 5 }, (_, i) => ({
339
+ prompt: `research subtopic ${i}: ` + 'detail '.repeat(200),
340
+ systemPrompt: 'be concise '.repeat(50),
341
+ }));
342
+ expect(tool.inputSchema.safeParse({ agents }).success).toBe(true);
343
+ });
344
+ });
@@ -0,0 +1,255 @@
1
+ import {
2
+ defineTool,
3
+ MoxxyError,
4
+ z,
5
+ type AgentDef,
6
+ type SubagentResult,
7
+ type SubagentSpec,
8
+ } from '@moxxy/sdk';
9
+
10
+ /** Upper bound on a single child prompt. Children seed their session with this
11
+ * verbatim and send it to the provider; an unbounded string at this trust
12
+ * boundary lets a runaway/injected model fan out N multi-megabyte completions. */
13
+ const MAX_PROMPT_CHARS = 20_000;
14
+ /** Upper bound on a per-child system-prompt override. */
15
+ const MAX_SYSTEM_PROMPT_CHARS = 8_000;
16
+ /**
17
+ * Aggregate text budget across the WHOLE batch (sum of every spec's prompt +
18
+ * systemPrompt). The per-field caps bound one child, but 8 specs each at the
19
+ * field ceiling still total ~224K chars, dispatched as 8 *concurrent*
20
+ * completions — a sudden cost/context/memory spike reachable from untrusted
21
+ * model output. This caps the simultaneous fan-out payload below that
22
+ * worst case while staying generously above any real multi-agent call.
23
+ */
24
+ const MAX_BATCH_TEXT_CHARS = 60_000;
25
+ /** This tool's own name — never re-granted to children by default so a model
26
+ * can't drive an unbounded recursive fan-out (8^N sessions). */
27
+ const DISPATCH_AGENT_TOOL_NAME = 'dispatch_agent';
28
+
29
+ const agentSpecSchema = z.object({
30
+ prompt: z
31
+ .string()
32
+ .min(1)
33
+ .max(MAX_PROMPT_CHARS)
34
+ .describe('The task the sub-agent should perform. Phrase as a focused, self-contained request.'),
35
+ agentType: z
36
+ .string()
37
+ .optional()
38
+ .describe(
39
+ 'Named agent kind to spawn (e.g. "researcher", "code-reviewer"). Looked ' +
40
+ 'up in the agent registry contributed by installed plugins. Omit, or ' +
41
+ 'pass "default", for a generic tool-use agent. Unknown types fall back ' +
42
+ 'to default — the request never fails over a missing kind. List of ' +
43
+ 'currently-registered kinds is visible via the /agents command.',
44
+ ),
45
+ label: z
46
+ .string()
47
+ .max(60)
48
+ .optional()
49
+ .describe('Short label shown in progress events (e.g. "research-deps", "lint-fix-A").'),
50
+ systemPrompt: z
51
+ .string()
52
+ .max(MAX_SYSTEM_PROMPT_CHARS)
53
+ .optional()
54
+ .describe(
55
+ 'Override the kind\'s system prompt. Use to set persona, constraints, ' +
56
+ 'or hand off upstream artifacts the child needs as context.',
57
+ ),
58
+ model: z
59
+ .string()
60
+ .optional()
61
+ .describe(
62
+ 'Model id override; defaults to the kind\'s model, then the parent\'s. ' +
63
+ 'Omit unless the user explicitly requested a specific model — do NOT ' +
64
+ 'invent model ids. Unknown ids fall back to the parent\'s model with a warning.',
65
+ ),
66
+ mode: z
67
+ .string()
68
+ .optional()
69
+ .describe(
70
+ 'Loop strategy override. Valid values: "default", "goal", ' +
71
+ '"research". OMIT for the kind\'s default — do NOT invent names.',
72
+ ),
73
+ allowedTools: z
74
+ .array(z.string())
75
+ .optional()
76
+ .describe('Restrict the child to these tool names. Overrides the kind\'s allowlist if set.'),
77
+ });
78
+
79
+ // NOTE: `maxIterations` is intentionally absent from the model-facing
80
+ // schema. Models tend to hallucinate small numbers (4, 5, 10) when
81
+ // given a free integer field, which causes legitimate research tasks
82
+ // to fail with `loop exceeded maxIterations (4)`. The cap belongs on
83
+ // the AgentDef (per-kind, set by the plugin author) or the spawner
84
+ // default (50), not on the per-call payload.
85
+
86
+ export type AgentSpecInput = z.infer<typeof agentSpecSchema>;
87
+
88
+ export interface DispatchAgentDeps {
89
+ /** Live lookup against the session's agent registry. Closure-bound at
90
+ * plugin construction so handler reads see fresh state. */
91
+ readonly getAgent: (name: string) => AgentDef | undefined;
92
+ /**
93
+ * Live snapshot of the parent's tool names. When wired, a child that
94
+ * neither the caller nor its kind restricts is defaulted to the parent's
95
+ * tools MINUS `dispatch_agent`, so a model can't drive an unbounded
96
+ * recursive fan-out (8^N concurrent sessions / fork-bomb). Omit (the
97
+ * default) to preserve full unrestricted inheritance — useful for
98
+ * standalone tests/scripts that don't wire a session.
99
+ */
100
+ readonly getToolNames?: () => ReadonlyArray<string>;
101
+ }
102
+
103
+ /** Built-in "default" kind — surfaced when the model omits agentType or
104
+ * passes an unknown one. Never registered in the AgentRegistry so
105
+ * plugins can override it cleanly via `replace()` if they want. */
106
+ export const DEFAULT_AGENT: AgentDef = {
107
+ name: 'default',
108
+ description:
109
+ 'Generic tool-use loop. Inherits the parent\'s full tool registry; no system prompt override.',
110
+ };
111
+
112
+ export function buildDispatchAgentTool(deps: DispatchAgentDeps) {
113
+ return defineTool({
114
+ name: 'dispatch_agent',
115
+ description:
116
+ 'Spawn one or more focused sub-agents in parallel. Use when a task fans out ' +
117
+ 'into independent subtasks (multi-source research, per-file refactor, ' +
118
+ 'multi-perspective review). Each child runs in isolation and returns its ' +
119
+ 'final message; children stream their progress so you see what each one is ' +
120
+ 'doing in real time. Pass `agentType` to pick a specialized kind from the ' +
121
+ 'agent registry (see /agents); omit for the default generic agent. Unknown ' +
122
+ 'kinds fall back to the default instead of erroring.',
123
+ inputSchema: z.object({
124
+ agents: z
125
+ .array(agentSpecSchema)
126
+ .min(1)
127
+ .max(8)
128
+ .superRefine((specs, ctx) => {
129
+ // Bound the *aggregate* concurrent fan-out payload, not just each
130
+ // child. Reject the whole batch before any session is spawned.
131
+ let total = 0;
132
+ for (const s of specs) total += s.prompt.length + (s.systemPrompt?.length ?? 0);
133
+ if (total > MAX_BATCH_TEXT_CHARS) {
134
+ ctx.addIssue({
135
+ code: z.ZodIssueCode.custom,
136
+ message:
137
+ `dispatch_agent batch payload too large: ${total} chars across ${specs.length} ` +
138
+ `agent(s) exceeds the ${MAX_BATCH_TEXT_CHARS}-char aggregate limit. Spawn fewer ` +
139
+ `agents per call or shorten the prompts/systemPrompts.`,
140
+ });
141
+ }
142
+ })
143
+ .describe('Specs for the agents to spawn. Run in parallel; results returned in order.'),
144
+ }),
145
+ handler: async (input, ctx) => {
146
+ if (!ctx.subagents) {
147
+ throw new MoxxyError({
148
+ code: 'INTERNAL',
149
+ message:
150
+ 'dispatch_agent: no subagent spawner available — this tool must be invoked from a run-turn loop.',
151
+ hint: 'Invoke dispatch_agent from within a run-turn loop so the subagent spawner is wired into the tool context.',
152
+ });
153
+ }
154
+ const agents = input.agents as AgentSpecInput[];
155
+ const specs: SubagentSpec[] = agents.map((s, i) =>
156
+ resolveSpec(s, deps, { index: i, total: agents.length }),
157
+ );
158
+ // The core spawner runs children with Promise.all, which rejects on the
159
+ // FIRST child's setup throw (model/log/provider lookup before the
160
+ // per-child try) and discards every sibling's result. Degrade to
161
+ // per-child errors here so one child's failure doesn't abort the batch
162
+ // and orphan the rest — the model still gets the specs that succeeded.
163
+ let results: ReadonlyArray<SubagentResult>;
164
+ try {
165
+ results = await ctx.subagents.spawnAll(specs);
166
+ } catch (err) {
167
+ const message = err instanceof Error ? err.message : String(err);
168
+ return {
169
+ results: specs.map((s) => ({
170
+ label: s.label ?? s.agentType ?? 'default',
171
+ childSessionId: '',
172
+ text: '',
173
+ stopReason: 'error',
174
+ error: message,
175
+ })),
176
+ };
177
+ }
178
+ return {
179
+ results: results.map((r) => ({
180
+ label: r.label,
181
+ childSessionId: String(r.childSessionId),
182
+ text: r.text,
183
+ stopReason: r.stopReason,
184
+ ...(r.error ? { error: r.error.message } : {}),
185
+ })),
186
+ };
187
+ },
188
+ });
189
+ }
190
+
191
+ /** Position of this spec within a multi-agent batch — used to disambiguate
192
+ * fallback labels and (informationally) reason about fan-out width. */
193
+ export interface ResolveSpecPosition {
194
+ readonly index: number;
195
+ readonly total: number;
196
+ }
197
+
198
+ /**
199
+ * Merge a model-supplied spec with the registered agent kind. Caller
200
+ * fields win over kind defaults; omitted caller fields fall back to
201
+ * the kind, which falls back to the built-in DEFAULT.
202
+ */
203
+ export function resolveSpec(
204
+ input: AgentSpecInput,
205
+ deps: DispatchAgentDeps,
206
+ position?: ResolveSpecPosition,
207
+ ): SubagentSpec {
208
+ const requested = input.agentType ?? 'default';
209
+ const def = deps.getAgent(requested) ?? DEFAULT_AGENT;
210
+ const systemPrompt = input.systemPrompt ?? def.systemPrompt;
211
+ const model = input.model ?? def.model;
212
+ const mode = input.mode ?? def.mode;
213
+ // maxIterations only comes from the AgentDef now (the input schema
214
+ // doesn't expose it — see comment in agentSpecSchema above).
215
+ const allowedTools = resolveAllowedTools(input, def, deps);
216
+ // When the caller omits `label`, multiple agents of the same kind would all
217
+ // collapse to the kind's name (e.g. five "default" labels), making the live
218
+ // progress display and the returned results indistinguishable. Suffix a
219
+ // 1-based index in that multi-agent case; keep explicit/single labels clean.
220
+ const fallbackLabel =
221
+ position && position.total > 1 ? `${def.name}-${position.index + 1}` : def.name;
222
+ const merged: SubagentSpec = {
223
+ prompt: input.prompt,
224
+ label: input.label ?? fallbackLabel,
225
+ // The requested kind, surfaced in subagent_* payloads for group rendering.
226
+ agentType: requested,
227
+ ...(systemPrompt !== undefined && { systemPrompt }),
228
+ ...(model !== undefined && { model }),
229
+ ...(mode !== undefined && { mode }),
230
+ ...(def.maxIterations !== undefined && { maxIterations: def.maxIterations }),
231
+ ...(allowedTools !== undefined && { allowedTools }),
232
+ };
233
+ return merged;
234
+ }
235
+
236
+ /**
237
+ * Decide the child's tool allowlist. Caller `allowedTools` wins, then the
238
+ * kind's, then — when a parent tool snapshot is wired (`deps.getToolNames`)
239
+ * and neither restricts — the parent's tools MINUS `dispatch_agent`. That
240
+ * last default is the recursion cut: without it, an unrestricted child
241
+ * inherits `dispatch_agent` and each level can spawn another full batch,
242
+ * giving 8^depth concurrent sessions reachable from untrusted model output.
243
+ * A kind that genuinely needs to recurse can re-grant `dispatch_agent`
244
+ * explicitly via its `allowedTools`.
245
+ */
246
+ function resolveAllowedTools(
247
+ input: AgentSpecInput,
248
+ def: AgentDef,
249
+ deps: DispatchAgentDeps,
250
+ ): ReadonlyArray<string> | undefined {
251
+ const explicit = input.allowedTools ?? def.allowedTools;
252
+ if (explicit !== undefined) return explicit;
253
+ if (!deps.getToolNames) return undefined; // not wired → preserve full inheritance
254
+ return deps.getToolNames().filter((n) => n !== DISPATCH_AGENT_TOOL_NAME);
255
+ }
package/src/index.ts ADDED
@@ -0,0 +1,86 @@
1
+ import {
2
+ definePlugin,
3
+ type AgentDef,
4
+ type LifecycleHooks,
5
+ type NamedRegistry,
6
+ type Plugin,
7
+ } from '@moxxy/sdk';
8
+ import { buildDispatchAgentTool, type DispatchAgentDeps } from './dispatch-agent.js';
9
+
10
+ export { buildDispatchAgentTool, type DispatchAgentDeps } from './dispatch-agent.js';
11
+
12
+ export interface BuildSubagentsPluginOpts {
13
+ /**
14
+ * How the tool resolves an `agentType` name → AgentDef at handler
15
+ * time. Pass a closure that reads from your session's agent registry:
16
+ * `(name) => session.agents.get(name)`.
17
+ *
18
+ * Defaults to "no agents registered" (always falls back to the
19
+ * built-in default kind). Useful for standalone tests / scripts that
20
+ * don't want to wire a session.
21
+ */
22
+ readonly getAgent?: (name: string) => AgentDef | undefined;
23
+ /**
24
+ * Live snapshot of the parent's tool names, e.g.
25
+ * `() => session.tools.list().map((t) => t.name)`. When provided, a child
26
+ * that neither the caller nor its kind restricts is defaulted to the
27
+ * parent's tools MINUS `dispatch_agent` — cutting unbounded recursive
28
+ * fan-out (8^N sessions). Omit to keep full unrestricted inheritance.
29
+ */
30
+ readonly getToolNames?: () => ReadonlyArray<string>;
31
+ }
32
+
33
+ /**
34
+ * `@moxxy/plugin-subagents` — adds the dispatch_agent tool + the
35
+ * auto-detection skill ("dispatch-agents") that triggers on fan-out
36
+ * patterns. Without this plugin the model can't spawn subagents — the
37
+ * normal single-loop flow runs as usual.
38
+ *
39
+ * Other plugins can ship `AgentDef` kinds via their own
40
+ * `PluginSpec.agents`. This plugin's tool resolves them at runtime, so
41
+ * a freshly-installed agent kind becomes available the next time the
42
+ * model calls dispatch_agent — no restart needed.
43
+ */
44
+ export function buildSubagentsPlugin(
45
+ opts: BuildSubagentsPluginOpts = {},
46
+ hooks?: LifecycleHooks,
47
+ ): Plugin {
48
+ const deps: DispatchAgentDeps = {
49
+ getAgent: opts.getAgent ?? (() => undefined),
50
+ ...(opts.getToolNames ? { getToolNames: opts.getToolNames } : {}),
51
+ };
52
+ return definePlugin({
53
+ name: '@moxxy/plugin-subagents',
54
+ version: '0.0.0',
55
+ ...(hooks ? { hooks } : {}),
56
+ tools: [buildDispatchAgentTool(deps)],
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Discovery-loadable default export: resolves the `agents` + `tools` registries
62
+ * from the inter-plugin service registry in `onInit` (the host publishes them),
63
+ * so `dispatch_agent` looks up agent kinds + the live parent-tool snapshot from
64
+ * the session without a host-injected closure. Both reads are lazy (at tool-call
65
+ * time, after all registration), and degrade to the standalone defaults if the
66
+ * host hasn't published them.
67
+ */
68
+ export const subagentsPlugin: Plugin = (() => {
69
+ let agents: NamedRegistry<AgentDef> | null = null;
70
+ let tools: NamedRegistry<{ readonly name: string }> | null = null;
71
+ const hooks: LifecycleHooks = {
72
+ onInit: (ctx) => {
73
+ agents = ctx.services.get<NamedRegistry<AgentDef>>('agents') ?? null;
74
+ tools = ctx.services.get<NamedRegistry<{ readonly name: string }>>('tools') ?? null;
75
+ },
76
+ };
77
+ return buildSubagentsPlugin(
78
+ {
79
+ getAgent: (name) => agents?.get(name),
80
+ getToolNames: () => (tools ? tools.list().map((t) => t.name) : []),
81
+ },
82
+ hooks,
83
+ );
84
+ })();
85
+
86
+ export default subagentsPlugin;