@mlx-node/agent 0.0.12 → 0.0.15

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.
Files changed (61) hide show
  1. package/dist/catalog.d.ts +10 -1
  2. package/dist/catalog.d.ts.map +1 -1
  3. package/dist/catalog.js +11 -2
  4. package/dist/delegate.d.ts +29 -0
  5. package/dist/delegate.d.ts.map +1 -0
  6. package/dist/delegate.js +106 -0
  7. package/dist/extensions/delegation.d.ts +15 -0
  8. package/dist/extensions/delegation.d.ts.map +1 -0
  9. package/dist/extensions/delegation.js +93 -0
  10. package/dist/paths.d.ts +6 -0
  11. package/dist/paths.d.ts.map +1 -1
  12. package/dist/paths.js +16 -0
  13. package/dist/provider/chat-config.d.ts +6 -5
  14. package/dist/provider/chat-config.d.ts.map +1 -1
  15. package/dist/provider/chat-config.js +21 -7
  16. package/dist/provider/index.d.ts.map +1 -1
  17. package/dist/provider/index.js +8 -1
  18. package/dist/provider/model-host.d.ts +1 -1
  19. package/dist/provider/model-host.d.ts.map +1 -1
  20. package/dist/provider/model-host.js +25 -7
  21. package/dist/provider/models.d.ts +3 -14
  22. package/dist/provider/models.d.ts.map +1 -1
  23. package/dist/provider/models.js +17 -239
  24. package/dist/provider/stream-adapter.d.ts +2 -2
  25. package/dist/provider/stream-adapter.d.ts.map +1 -1
  26. package/dist/provider/stream-adapter.js +8 -5
  27. package/dist/run-agent.d.ts +4 -0
  28. package/dist/run-agent.d.ts.map +1 -1
  29. package/dist/run-agent.js +8 -2
  30. package/dist/types.d.ts +1 -1
  31. package/dist/types.d.ts.map +1 -1
  32. package/package.json +23 -5
  33. package/src/catalog.ts +194 -0
  34. package/src/cold-tier.ts +152 -0
  35. package/src/delegate.ts +136 -0
  36. package/src/extensions/approval-detail.ts +57 -0
  37. package/src/extensions/delegation.ts +109 -0
  38. package/src/extensions/local-image-input.ts +132 -0
  39. package/src/extensions/permission-gate.ts +347 -0
  40. package/src/extensions/subagent.ts +743 -0
  41. package/src/extensions/terminal-title.ts +53 -0
  42. package/src/extensions/trace-notice.ts +37 -0
  43. package/src/index.ts +23 -0
  44. package/src/paths.ts +36 -0
  45. package/src/provider/chat-config.ts +132 -0
  46. package/src/provider/convert-messages.ts +273 -0
  47. package/src/provider/error-coercion.ts +36 -0
  48. package/src/provider/events.ts +341 -0
  49. package/src/provider/index.ts +255 -0
  50. package/src/provider/metrics-trace.ts +380 -0
  51. package/src/provider/mlx-identity.ts +16 -0
  52. package/src/provider/model-host.ts +276 -0
  53. package/src/provider/model-registry-filter.ts +336 -0
  54. package/src/provider/models.ts +48 -0
  55. package/src/provider/performance-status.ts +112 -0
  56. package/src/provider/reasoning-tag-buffer.ts +67 -0
  57. package/src/provider/stream-adapter.ts +515 -0
  58. package/src/provider/tool-call-buffer.ts +82 -0
  59. package/src/provider/warm-reuse.ts +125 -0
  60. package/src/run-agent.ts +178 -0
  61. package/src/types.ts +10 -0
@@ -0,0 +1,743 @@
1
+ /**
2
+ * MLX port of pi's official `examples/extensions/subagent` extension.
3
+ *
4
+ * The upstream example starts a fresh pi process per task. MLX instead creates
5
+ * an in-process `AgentSession` for each task so all delegated sessions reuse
6
+ * the parent's registered provider and its single `MlxModelHost`. Each task
7
+ * still has isolated conversation/compaction state and its own cwd/tools:
8
+ *
9
+ * - at most four task loops run concurrently; the host serializes inference,
10
+ * - sessions inherit the parent's current local model/model registry,
11
+ * - context files and skills load, while extensions/prompts/themes do not,
12
+ * - the parent permission gate approves delegated tool access once up front.
13
+ *
14
+ * Upstream source: @earendil-works/pi-coding-agent 0.80.6,
15
+ * examples/extensions/subagent (MIT).
16
+ */
17
+
18
+ import * as fs from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import * as path from 'node:path';
21
+
22
+ import type { Message } from '@earendil-works/pi-ai';
23
+ import { StringEnum } from '@earendil-works/pi-ai';
24
+ import type { ExtensionAPI, ExtensionContext, InlineExtension } from '@earendil-works/pi-coding-agent';
25
+ import { Type } from 'typebox';
26
+
27
+ import { sanitizeApprovalDetail } from './approval-detail.js';
28
+
29
+ const MAX_PARALLEL_TASKS = 8;
30
+ /**
31
+ * Tool loops can overlap; the shared `MlxModelHost` serializes model calls.
32
+ *
33
+ * Exported because it is one half of a cross-language invariant. Every live
34
+ * loop is a distinct native GDN cache owner, so this fleet demands
35
+ * `MAX_CONCURRENCY + 1` checkpoint slots (root session plus one per loop), and
36
+ * the native store supplies `gdnPrefixCheckpointLimit()` of them.
37
+ * `__test__/gdn-checkpoint-capacity.test.ts` holds the two together.
38
+ */
39
+ export const MAX_CONCURRENCY = 4;
40
+ const PER_TASK_OUTPUT_CAP = 50 * 1024;
41
+
42
+ type AgentScope = 'user' | 'project' | 'both';
43
+ type AgentSource = 'builtin' | 'user' | 'project' | 'unknown';
44
+ export type SubagentMode = 'single' | 'parallel' | 'chain';
45
+
46
+ export interface SubagentConfig {
47
+ name: string;
48
+ description: string;
49
+ tools?: string[];
50
+ model?: string;
51
+ systemPrompt: string;
52
+ source: Exclude<AgentSource, 'unknown'>;
53
+ filePath: string;
54
+ }
55
+
56
+ interface UsageStats {
57
+ input: number;
58
+ output: number;
59
+ cacheRead: number;
60
+ cacheWrite: number;
61
+ cost: number;
62
+ contextTokens: number;
63
+ turns: number;
64
+ }
65
+
66
+ interface SingleResult {
67
+ agent: string;
68
+ agentSource: AgentSource;
69
+ task: string;
70
+ exitCode: number;
71
+ messages: Message[];
72
+ stderr: string;
73
+ usage: UsageStats;
74
+ model?: string;
75
+ stopReason?: string;
76
+ errorMessage?: string;
77
+ step?: number;
78
+ }
79
+
80
+ interface SubagentDetails {
81
+ mode: SubagentMode;
82
+ agentScope: AgentScope;
83
+ projectAgentsDir: string | null;
84
+ results: SingleResult[];
85
+ }
86
+
87
+ export interface InProcessSubagentSession {
88
+ subscribe(listener: (event: unknown) => void): () => void;
89
+ prompt(text: string): Promise<void>;
90
+ abort(): Promise<void>;
91
+ dispose(): void;
92
+ }
93
+
94
+ export interface SubagentSessionCreateOptions {
95
+ cwd: string;
96
+ model: NonNullable<ExtensionContext['model']>;
97
+ /**
98
+ * Parent's registered in-process mlx provider config. Registering it on the
99
+ * subagent's fresh runtime reuses the parent's `streamSimple` closure, which
100
+ * is bound to the single shared `MlxModelHost` — so subagent inference stays
101
+ * serialized on the one GPU-resident model. `ProviderConfigInput` is not
102
+ * exported at the package root, so type it structurally from the facade
103
+ * method's return type (survives a future pi rename of the type).
104
+ */
105
+ mlxProviderConfig: NonNullable<ReturnType<ExtensionContext['modelRegistry']['getRegisteredProviderConfig']>>;
106
+ tools?: string[];
107
+ systemPrompt: string;
108
+ }
109
+
110
+ export interface SubagentExtensionOptions {
111
+ /** Test/programmatic seam. Production uses pi's in-process SDK. */
112
+ createSession?: (options: SubagentSessionCreateOptions) => Promise<InProcessSubagentSession>;
113
+ }
114
+
115
+ export interface SubagentCompactionSettings {
116
+ enabled: boolean;
117
+ reserveTokens: number;
118
+ keepRecentTokens: number;
119
+ }
120
+
121
+ const COMPACTION_RESERVE_WINDOW_FRACTION = 0.25;
122
+ const COMPACTION_KEEP_RECENT_WINDOW_FRACTION = 0.5;
123
+ const COMPACTION_TOTAL_WINDOW_FRACTION = 0.75;
124
+
125
+ /**
126
+ * Fit pi's compaction budgets to the model's effective physical context.
127
+ *
128
+ * Pi's defaults (16,384 reserved + 20,000 recent) assume a context larger
129
+ * than some dynamically sized MLX KV pools. Preserve explicitly smaller user
130
+ * values, but keep the summary reserve at most 25%, retained history at most
131
+ * 50%, and their combined budget at most 75% of the usable window.
132
+ */
133
+ export function scaleSubagentCompactionSettings(
134
+ contextWindow: number,
135
+ current: SubagentCompactionSettings,
136
+ ): SubagentCompactionSettings {
137
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) {
138
+ throw new RangeError(`Subagent model reported an invalid context window: ${contextWindow}`);
139
+ }
140
+
141
+ const window = Math.floor(contextWindow);
142
+ const reserveCap = Math.floor(window * COMPACTION_RESERVE_WINDOW_FRACTION);
143
+ const keepRecentCap = Math.floor(window * COMPACTION_KEEP_RECENT_WINDOW_FRACTION);
144
+ const totalCap = Math.floor(window * COMPACTION_TOTAL_WINDOW_FRACTION);
145
+ const capConfiguredValue = (value: number, cap: number): number => {
146
+ if (!Number.isFinite(value)) return cap;
147
+ return Math.min(Math.max(0, Math.floor(value)), cap);
148
+ };
149
+
150
+ const reserveTokens = capConfiguredValue(current.reserveTokens, reserveCap);
151
+ const keepRecentTokens = Math.min(
152
+ capConfiguredValue(current.keepRecentTokens, keepRecentCap),
153
+ Math.max(0, totalCap - reserveTokens),
154
+ );
155
+ return { enabled: current.enabled, reserveTokens, keepRecentTokens };
156
+ }
157
+
158
+ const BUILTIN_AGENTS: readonly SubagentConfig[] = [
159
+ {
160
+ name: 'scout',
161
+ description: 'Fast codebase recon that returns compressed context for handoff to other agents',
162
+ tools: ['read', 'grep', 'find', 'ls', 'bash'],
163
+ systemPrompt: `You are a scout. Quickly investigate a codebase and return structured findings that another agent can use without re-reading everything.
164
+
165
+ Follow imports, read critical sections, and report exact file paths and line ranges. Summarize the architecture, key types/functions, and where the next agent should start. Do not modify files.`,
166
+ source: 'builtin',
167
+ filePath: '<builtin:scout>',
168
+ },
169
+ {
170
+ name: 'planner',
171
+ description: 'Creates implementation plans from context and requirements',
172
+ tools: ['read', 'grep', 'find', 'ls'],
173
+ systemPrompt: `You are a planning specialist. Produce a concrete implementation plan with the goal, numbered steps, files to modify, new files, and risks. You must not make changes.`,
174
+ source: 'builtin',
175
+ filePath: '<builtin:planner>',
176
+ },
177
+ {
178
+ name: 'reviewer',
179
+ description: 'Code review specialist for quality and security analysis',
180
+ tools: ['read', 'grep', 'find', 'ls', 'bash'],
181
+ systemPrompt: `You are a senior code reviewer. Review the relevant diff and code for correctness, security, and maintainability. Keep bash read-only. Report concrete findings with file paths and line numbers, then give a concise verdict. Do not modify files.`,
182
+ source: 'builtin',
183
+ filePath: '<builtin:reviewer>',
184
+ },
185
+ {
186
+ name: 'worker',
187
+ description: 'General-purpose subagent with full capabilities and isolated context',
188
+ systemPrompt: `You are a worker agent with full capabilities. Work autonomously to complete the delegated task. Report what changed, exact files changed, tests run, and anything the parent agent must know.`,
189
+ source: 'builtin',
190
+ filePath: '<builtin:worker>',
191
+ },
192
+ ];
193
+
194
+ function emptyUsage(): UsageStats {
195
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
196
+ }
197
+
198
+ function agentDir(): string {
199
+ const configured = process.env['PI_CODING_AGENT_DIR'];
200
+ if (!configured) return path.join(homedir(), '.mlx-node', 'agent');
201
+ if (configured === '~') return homedir();
202
+ if (configured.startsWith('~/')) return path.join(homedir(), configured.slice(2));
203
+ return configured;
204
+ }
205
+
206
+ function parseAgentFile(filePath: string, source: 'user' | 'project'): SubagentConfig | undefined {
207
+ let content: string;
208
+ try {
209
+ content = fs.readFileSync(filePath, 'utf8');
210
+ } catch {
211
+ return undefined;
212
+ }
213
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(content);
214
+ if (!match) return undefined;
215
+ const frontmatter: Record<string, string> = {};
216
+ for (const line of match[1]!.split(/\r?\n/)) {
217
+ const separator = line.indexOf(':');
218
+ if (separator <= 0) continue;
219
+ frontmatter[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
220
+ }
221
+ if (!frontmatter['name'] || !frontmatter['description']) return undefined;
222
+ const tools = frontmatter['tools']
223
+ ?.split(',')
224
+ .map((tool) => tool.trim())
225
+ .filter(Boolean);
226
+ return {
227
+ name: frontmatter['name'],
228
+ description: frontmatter['description'],
229
+ tools: tools?.length ? tools : undefined,
230
+ model: frontmatter['model'] || undefined,
231
+ systemPrompt: match[2]!,
232
+ source,
233
+ filePath,
234
+ };
235
+ }
236
+
237
+ function loadAgentsFromDir(dir: string, source: 'user' | 'project'): SubagentConfig[] {
238
+ let entries: fs.Dirent[];
239
+ try {
240
+ entries = fs.readdirSync(dir, { withFileTypes: true });
241
+ } catch {
242
+ return [];
243
+ }
244
+ return entries
245
+ .filter((entry) => entry.name.endsWith('.md') && (entry.isFile() || entry.isSymbolicLink()))
246
+ .map((entry) => parseAgentFile(path.join(dir, entry.name), source))
247
+ .filter((agent): agent is SubagentConfig => agent !== undefined);
248
+ }
249
+
250
+ function findProjectAgentsDir(cwd: string): string | null {
251
+ let current = path.resolve(cwd);
252
+ while (true) {
253
+ const candidate = path.join(current, '.pi', 'agents');
254
+ try {
255
+ if (fs.statSync(candidate).isDirectory()) return candidate;
256
+ } catch {
257
+ // Continue toward the filesystem root.
258
+ }
259
+ const parent = path.dirname(current);
260
+ if (parent === current) return null;
261
+ current = parent;
262
+ }
263
+ }
264
+
265
+ export function discoverSubagents(
266
+ cwd: string,
267
+ scope: AgentScope,
268
+ ): {
269
+ agents: SubagentConfig[];
270
+ projectAgentsDir: string | null;
271
+ } {
272
+ const projectAgentsDir = findProjectAgentsDir(cwd);
273
+ const users = scope === 'project' ? [] : loadAgentsFromDir(path.join(agentDir(), 'agents'), 'user');
274
+ const projects = scope === 'user' || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, 'project');
275
+ const agents = new Map<string, SubagentConfig>();
276
+ if (scope !== 'project') for (const agent of BUILTIN_AGENTS) agents.set(agent.name, agent);
277
+ for (const agent of users) agents.set(agent.name, agent);
278
+ for (const agent of projects) agents.set(agent.name, agent);
279
+ return { agents: [...agents.values()], projectAgentsDir };
280
+ }
281
+
282
+ function getFinalOutput(messages: Message[]): string {
283
+ for (let i = messages.length - 1; i >= 0; i--) {
284
+ const message = messages[i];
285
+ if (message?.role !== 'assistant' || !Array.isArray(message.content)) continue;
286
+ const text = message.content.find((part) => part.type === 'text');
287
+ if (text?.type === 'text') return text.text;
288
+ }
289
+ return '';
290
+ }
291
+
292
+ function isFailed(result: SingleResult): boolean {
293
+ return result.exitCode !== 0 || result.stopReason === 'error' || result.stopReason === 'aborted';
294
+ }
295
+
296
+ function resultOutput(result: SingleResult): string {
297
+ return (
298
+ result.errorMessage || (isFailed(result) ? result.stderr : '') || getFinalOutput(result.messages) || '(no output)'
299
+ );
300
+ }
301
+
302
+ function truncateOutput(output: string): string {
303
+ const bytes = Buffer.byteLength(output);
304
+ if (bytes <= PER_TASK_OUTPUT_CAP) return output;
305
+ let truncated = output.slice(0, PER_TASK_OUTPUT_CAP);
306
+ while (Buffer.byteLength(truncated) > PER_TASK_OUTPUT_CAP) truncated = truncated.slice(0, -1);
307
+ return `${truncated}\n\n[Output truncated; full output remains in tool details.]`;
308
+ }
309
+
310
+ function normalizedModelId(model: string): string | undefined {
311
+ if (model.startsWith('mlx/')) return model.slice('mlx/'.length) || undefined;
312
+ // A bare name is a local discovered model id. A provider/name pair is not.
313
+ if (!model.includes('/')) return model;
314
+ return undefined;
315
+ }
316
+
317
+ interface SubagentRequestShape {
318
+ agent?: string;
319
+ task?: string;
320
+ tasks?: unknown[];
321
+ chain?: unknown[];
322
+ }
323
+
324
+ /**
325
+ * Select the mode with the same precedence used by execution. Permission UI
326
+ * imports this helper so a stray top-level field can never hide the queued
327
+ * agents that will actually receive delegated tool access.
328
+ */
329
+ export function normalizeSubagentMode(input: SubagentRequestShape): {
330
+ mode: SubagentMode;
331
+ modeCount: number;
332
+ } {
333
+ const hasSingle = Boolean(input.agent && input.task);
334
+ const hasParallel = Boolean(input.tasks?.length);
335
+ const hasChain = Boolean(input.chain?.length);
336
+ return {
337
+ mode: hasChain ? 'chain' : hasParallel ? 'parallel' : 'single',
338
+ modeCount: Number(hasSingle) + Number(hasParallel) + Number(hasChain),
339
+ };
340
+ }
341
+
342
+ async function mapWithConcurrencyLimit<T, R>(items: T[], fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
343
+ const results = Array.from<R>({ length: items.length });
344
+ let next = 0;
345
+ const workers = Array.from({ length: Math.min(MAX_CONCURRENCY, items.length) }, async () => {
346
+ while (next < items.length) {
347
+ const index = next++;
348
+ results[index] = await fn(items[index]!, index);
349
+ }
350
+ });
351
+ await Promise.all(workers);
352
+ return results;
353
+ }
354
+
355
+ export async function createProductionSession(
356
+ options: SubagentSessionCreateOptions,
357
+ ): Promise<InProcessSubagentSession> {
358
+ // `runAgent()` seeds pi's config environment before the extension can execute,
359
+ // so defer the runtime import until now instead of importing pi at module load.
360
+ const { createAgentSession, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager } =
361
+ await import('@earendil-works/pi-coding-agent');
362
+ const agentDir = getAgentDir();
363
+ const settingsManager = SettingsManager.create(options.cwd, agentDir);
364
+ settingsManager.applyOverrides({
365
+ compaction: scaleSubagentCompactionSettings(options.model.contextWindow, settingsManager.getCompactionSettings()),
366
+ });
367
+ const resourceLoader = new DefaultResourceLoader({
368
+ cwd: options.cwd,
369
+ agentDir,
370
+ settingsManager,
371
+ // Subagents keep project/user context files and skills, but cannot load the
372
+ // subagent extension recursively or introduce unrelated prompt/theme state.
373
+ noExtensions: true,
374
+ noPromptTemplates: true,
375
+ noThemes: true,
376
+ appendSystemPromptOverride: (base) => (options.systemPrompt.trim() ? [...base, options.systemPrompt] : base),
377
+ });
378
+ await resourceLoader.reload();
379
+
380
+ // 0.81.1: `createAgentSession` takes a `ModelRuntime`, not a `ModelRegistry`.
381
+ // Build a fresh runtime that carries the parent's in-process mlx provider. The
382
+ // config's `streamSimple` closure is bound to the single shared `MlxModelHost`,
383
+ // so all subagent inference stays serialized on the one GPU-resident model.
384
+ // `create` does no network (`allowModelNetwork:false`, also the default — set
385
+ // explicitly to keep the offline invariant local to this call, independent of
386
+ // the process-wide PI_OFFLINE seed in `runAgent`), and reuses the parent's
387
+ // `authPath`/`modelsPath`; `registerProvider` marks mlx configured (apiKey
388
+ // present) so streaming works immediately, dispatched by provider id.
389
+ const modelRuntime = await ModelRuntime.create({
390
+ authPath: path.join(agentDir, 'auth.json'),
391
+ modelsPath: path.join(agentDir, 'models.json'),
392
+ allowModelNetwork: false,
393
+ });
394
+ modelRuntime.registerProvider('mlx', options.mlxProviderConfig);
395
+
396
+ const { session } = await createAgentSession({
397
+ cwd: options.cwd,
398
+ agentDir,
399
+ model: options.model,
400
+ modelRuntime,
401
+ tools: options.tools,
402
+ resourceLoader,
403
+ sessionManager: SessionManager.inMemory(options.cwd),
404
+ settingsManager,
405
+ });
406
+ return session;
407
+ }
408
+
409
+ async function runSingleAgent(
410
+ options: SubagentExtensionOptions,
411
+ defaultCwd: string,
412
+ agents: SubagentConfig[],
413
+ agentName: string,
414
+ task: string,
415
+ cwd: string | undefined,
416
+ context: ExtensionContext,
417
+ step: number | undefined,
418
+ signal: AbortSignal | undefined,
419
+ onUpdate: ((result: { content: { type: 'text'; text: string }[]; details: SubagentDetails }) => void) | undefined,
420
+ makeDetails: (results: SingleResult[]) => SubagentDetails,
421
+ ): Promise<SingleResult> {
422
+ const agent = agents.find((candidate) => candidate.name === agentName);
423
+ if (!agent) {
424
+ return {
425
+ agent: agentName,
426
+ agentSource: 'unknown',
427
+ task,
428
+ exitCode: 1,
429
+ messages: [],
430
+ stderr: `Unknown agent: ${agentName}. Available: ${agents.map((a) => a.name).join(', ') || 'none'}`,
431
+ usage: emptyUsage(),
432
+ step,
433
+ };
434
+ }
435
+
436
+ const requestedModelId = agent.model ? normalizedModelId(agent.model) : undefined;
437
+ if (agent.model && !requestedModelId) {
438
+ return {
439
+ agent: agent.name,
440
+ agentSource: agent.source,
441
+ task,
442
+ exitCode: 1,
443
+ messages: [],
444
+ stderr: `Agent ${agent.name} requested non-local model ${agent.model}; mlx subagents only accept mlx/<model>.`,
445
+ usage: emptyUsage(),
446
+ step,
447
+ };
448
+ }
449
+
450
+ const model = requestedModelId ? context.modelRegistry.find('mlx', requestedModelId) : context.model;
451
+ if (!model || model.provider !== 'mlx') {
452
+ const requested = requestedModelId ? `mlx/${requestedModelId}` : 'the parent mlx model';
453
+ return {
454
+ agent: agent.name,
455
+ agentSource: agent.source,
456
+ task,
457
+ exitCode: 1,
458
+ messages: [],
459
+ stderr: `Agent ${agent.name} could not resolve ${requested} in the parent model registry.`,
460
+ usage: emptyUsage(),
461
+ step,
462
+ };
463
+ }
464
+
465
+ // The subagent runs on a fresh runtime (no mlx provider extension), so it must
466
+ // inherit the parent's exact registered mlx config to reuse the shared host.
467
+ // Fail closed if the parent has no 'mlx' provider — never fall back to an
468
+ // unconfigured/cloud provider.
469
+ const mlxProviderConfig = context.modelRegistry.getRegisteredProviderConfig('mlx');
470
+ if (!mlxProviderConfig) {
471
+ return {
472
+ agent: agent.name,
473
+ agentSource: agent.source,
474
+ task,
475
+ exitCode: 1,
476
+ messages: [],
477
+ stderr: `Agent ${agent.name} could not inherit the parent mlx provider (no 'mlx' provider registered).`,
478
+ usage: emptyUsage(),
479
+ step,
480
+ };
481
+ }
482
+
483
+ const result: SingleResult = {
484
+ agent: agent.name,
485
+ agentSource: agent.source,
486
+ task,
487
+ exitCode: 0,
488
+ messages: [],
489
+ stderr: '',
490
+ usage: emptyUsage(),
491
+ model: `mlx/${model.id}`,
492
+ step,
493
+ };
494
+ const emitUpdate = () =>
495
+ onUpdate?.({
496
+ content: [{ type: 'text', text: getFinalOutput(result.messages) || '(running...)' }],
497
+ details: makeDetails([result]),
498
+ });
499
+
500
+ let session: InProcessSubagentSession | undefined;
501
+ let unsubscribe: (() => void) | undefined;
502
+ let aborted = signal?.aborted ?? false;
503
+ let abortPromise: Promise<void> | undefined;
504
+ const abort = () => {
505
+ aborted = true;
506
+ if (session) abortPromise = session.abort().catch(() => undefined);
507
+ };
508
+ if (!signal?.aborted) signal?.addEventListener('abort', abort, { once: true });
509
+
510
+ try {
511
+ session = await (options.createSession ?? createProductionSession)({
512
+ cwd: cwd ?? defaultCwd,
513
+ model,
514
+ mlxProviderConfig,
515
+ tools: agent.tools,
516
+ systemPrompt: agent.systemPrompt,
517
+ });
518
+ if (aborted) {
519
+ abort();
520
+ await abortPromise;
521
+ result.exitCode = 1;
522
+ result.stopReason = 'aborted';
523
+ result.errorMessage = 'Subagent was aborted';
524
+ return result;
525
+ }
526
+
527
+ unsubscribe = session.subscribe((rawEvent) => {
528
+ const event = rawEvent as { type?: unknown; message?: unknown };
529
+ if (event.type !== 'message_end' || !event.message) return;
530
+ const message = event.message as Message;
531
+ result.messages.push(message);
532
+ if (message.role === 'assistant') {
533
+ result.usage.turns++;
534
+ const usage = message.usage;
535
+ result.usage.input += usage?.input ?? 0;
536
+ result.usage.output += usage?.output ?? 0;
537
+ result.usage.cacheRead += usage?.cacheRead ?? 0;
538
+ result.usage.cacheWrite += usage?.cacheWrite ?? 0;
539
+ result.usage.cost += usage?.cost?.total ?? 0;
540
+ result.usage.contextTokens = usage?.totalTokens ?? 0;
541
+ result.model = message.provider && message.model ? `${message.provider}/${message.model}` : result.model;
542
+ result.stopReason = message.stopReason;
543
+ result.errorMessage = message.errorMessage;
544
+ }
545
+ emitUpdate();
546
+ });
547
+
548
+ try {
549
+ await session.prompt(`Task: ${task}`);
550
+ await abortPromise;
551
+ } catch (error) {
552
+ result.exitCode = 1;
553
+ result.stderr = error instanceof Error ? error.message : String(error);
554
+ }
555
+ if (aborted) {
556
+ result.exitCode = 1;
557
+ result.stopReason = 'aborted';
558
+ result.errorMessage = 'Subagent was aborted';
559
+ } else if (result.stopReason === 'error') {
560
+ result.exitCode = 1;
561
+ }
562
+ return result;
563
+ } catch (error) {
564
+ result.exitCode = 1;
565
+ result.stderr = error instanceof Error ? error.message : String(error);
566
+ if (aborted) {
567
+ result.stopReason = 'aborted';
568
+ result.errorMessage = 'Subagent was aborted';
569
+ }
570
+ return result;
571
+ } finally {
572
+ signal?.removeEventListener('abort', abort);
573
+ unsubscribe?.();
574
+ session?.dispose();
575
+ }
576
+ }
577
+
578
+ const TaskItem = Type.Object({
579
+ agent: Type.String({ description: 'Name of the agent to invoke' }),
580
+ task: Type.String({ description: 'Task to delegate' }),
581
+ cwd: Type.Optional(Type.String({ description: 'Working directory for the isolated session' })),
582
+ });
583
+
584
+ const Params = Type.Object({
585
+ agent: Type.Optional(Type.String()),
586
+ task: Type.Optional(Type.String()),
587
+ cwd: Type.Optional(Type.String()),
588
+ tasks: Type.Optional(Type.Array(TaskItem)),
589
+ chain: Type.Optional(Type.Array(TaskItem)),
590
+ agentScope: Type.Optional(StringEnum(['user', 'project', 'both'] as const, { default: 'user' })),
591
+ });
592
+
593
+ export function createSubagentExtension(options: SubagentExtensionOptions = {}): InlineExtension {
594
+ return {
595
+ name: 'mlx-subagent',
596
+ factory: (pi: ExtensionAPI) => {
597
+ pi.registerTool({
598
+ name: 'subagent',
599
+ label: 'Subagent',
600
+ description:
601
+ 'Delegate one task, a sequential chain, or concurrent tasks to isolated in-process mlx agent sessions. ' +
602
+ 'Built-ins: scout, planner, reviewer, worker. Sessions share one model host and KV pool.',
603
+ promptSnippet: 'Delegate isolated research, planning, review, or implementation with the subagent tool.',
604
+ promptGuidelines: [
605
+ 'Use subagents for bounded work that benefits from an isolated context.',
606
+ 'A tasks array runs up to four independent tool loops concurrently; shared mlx inference remains serialized.',
607
+ ],
608
+ parameters: Params,
609
+ executionMode: 'sequential',
610
+ async execute(_id, params, signal, onUpdate, ctx) {
611
+ const scope: AgentScope = params.agentScope ?? 'user';
612
+ const discovery = discoverSubagents(ctx.cwd, scope);
613
+ const { mode, modeCount } = normalizeSubagentMode(params);
614
+ const makeDetails = (results: SingleResult[]): SubagentDetails => ({
615
+ mode,
616
+ agentScope: scope,
617
+ projectAgentsDir: discovery.projectAgentsDir,
618
+ results,
619
+ });
620
+ if (modeCount !== 1) {
621
+ return {
622
+ content: [{ type: 'text', text: 'Provide exactly one mode: agent+task, tasks, or chain.' }],
623
+ details: makeDetails([]),
624
+ isError: true,
625
+ };
626
+ }
627
+
628
+ const requested = new Set<string>();
629
+ if (mode === 'single' && params.agent) requested.add(params.agent);
630
+ if (mode === 'parallel') for (const item of params.tasks ?? []) requested.add(item.agent);
631
+ if (mode === 'chain') for (const item of params.chain ?? []) requested.add(item.agent);
632
+ const projectAgents = [...requested]
633
+ .map((name) => discovery.agents.find((agent) => agent.name === name))
634
+ .filter((agent): agent is SubagentConfig => agent?.source === 'project');
635
+ if (projectAgents.length) {
636
+ if (!ctx.hasUI) {
637
+ return {
638
+ content: [{ type: 'text', text: 'Project-local subagents require interactive confirmation.' }],
639
+ details: makeDetails([]),
640
+ isError: true,
641
+ };
642
+ }
643
+ const approved = await ctx.ui.confirm(
644
+ 'Run project-local agents?',
645
+ sanitizeApprovalDetail(
646
+ `Agents: ${projectAgents.map((agent) => agent.name).join(', ')}\nSource: ${discovery.projectAgentsDir}`,
647
+ ),
648
+ );
649
+ if (!approved) {
650
+ return {
651
+ content: [{ type: 'text', text: 'Canceled by user.' }],
652
+ details: makeDetails([]),
653
+ isError: true,
654
+ };
655
+ }
656
+ }
657
+
658
+ const run = (
659
+ agent: string,
660
+ task: string,
661
+ cwd: string | undefined,
662
+ step: number | undefined,
663
+ update = onUpdate,
664
+ ) =>
665
+ runSingleAgent(
666
+ options,
667
+ ctx.cwd,
668
+ discovery.agents,
669
+ agent,
670
+ task,
671
+ cwd,
672
+ ctx,
673
+ step,
674
+ signal,
675
+ update,
676
+ makeDetails,
677
+ );
678
+
679
+ if (params.chain?.length) {
680
+ const results: SingleResult[] = [];
681
+ let previous = '';
682
+ for (let i = 0; i < params.chain.length; i++) {
683
+ const item = params.chain[i]!;
684
+ const result = await run(
685
+ item.agent,
686
+ item.task.replace(/\{previous\}/g, () => previous),
687
+ item.cwd,
688
+ i + 1,
689
+ );
690
+ results.push(result);
691
+ if (isFailed(result)) {
692
+ return {
693
+ content: [{ type: 'text', text: `Chain stopped at step ${i + 1}: ${resultOutput(result)}` }],
694
+ details: makeDetails(results),
695
+ isError: true,
696
+ };
697
+ }
698
+ previous = getFinalOutput(result.messages);
699
+ }
700
+ return { content: [{ type: 'text', text: previous || '(no output)' }], details: makeDetails(results) };
701
+ }
702
+
703
+ if (params.tasks?.length) {
704
+ if (params.tasks.length > MAX_PARALLEL_TASKS) {
705
+ return {
706
+ content: [
707
+ { type: 'text', text: `Too many tasks (${params.tasks.length}); max is ${MAX_PARALLEL_TASKS}.` },
708
+ ],
709
+ details: makeDetails([]),
710
+ isError: true,
711
+ };
712
+ }
713
+ const results = await mapWithConcurrencyLimit(params.tasks, (item) =>
714
+ run(item.agent, item.task, item.cwd, undefined, undefined),
715
+ );
716
+ const summaries = results.map(
717
+ (result) =>
718
+ `### [${result.agent}] ${isFailed(result) ? 'failed' : 'completed'}\n\n${truncateOutput(resultOutput(result))}`,
719
+ );
720
+ const success = results.filter((result) => !isFailed(result)).length;
721
+ return {
722
+ content: [
723
+ {
724
+ type: 'text',
725
+ text: `Concurrent tasks: ${success}/${results.length} succeeded\n\n${summaries.join('\n\n---\n\n')}`,
726
+ },
727
+ ],
728
+ details: makeDetails(results),
729
+ isError: success !== results.length,
730
+ };
731
+ }
732
+
733
+ const result = await run(params.agent!, params.task!, params.cwd, undefined);
734
+ return {
735
+ content: [{ type: 'text', text: resultOutput(result) }],
736
+ details: makeDetails([result]),
737
+ isError: isFailed(result),
738
+ };
739
+ },
740
+ });
741
+ },
742
+ };
743
+ }