@haoyiyin/workflow 0.2.0 → 0.2.3
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/package.json +15 -10
- package/scripts/postinstall.js +2 -2
- package/src/agents/contracts.ts +559 -0
- package/src/agents/dispatcher-enhanced.ts +350 -0
- package/src/agents/dispatcher.ts +680 -0
- package/src/agents/index.ts +48 -0
- package/src/agents/resilience.ts +255 -0
- package/src/agents/token-budget.ts +83 -0
- package/src/agents/types.ts +73 -0
- package/src/guard/main-agent.ts +245 -0
- package/src/hooks/builtin/index.ts +8 -0
- package/src/hooks/builtin/on-error.ts +23 -0
- package/src/hooks/builtin/post-execute.ts +40 -0
- package/src/hooks/builtin/post-plan.ts +23 -0
- package/src/hooks/builtin/pre-execute.ts +30 -0
- package/src/hooks/builtin/pre-plan.ts +26 -0
- package/src/hooks/index.ts +7 -0
- package/src/hooks/loader.ts +98 -0
- package/src/hooks/manager.ts +99 -0
- package/src/hooks/types-enhanced.ts +38 -0
- package/src/hooks/types.ts +35 -0
- package/src/index.ts +127 -0
- package/src/persistence/index.ts +17 -0
- package/src/persistence/plan-md.ts +141 -0
- package/src/persistence/state-md.ts +167 -0
- package/src/persistence/types.ts +89 -0
- package/src/router/classifier.ts +610 -0
- package/src/router/guard.ts +483 -0
- package/src/router/index.ts +22 -0
- package/src/router/router.ts +108 -0
- package/src/router/types.ts +127 -0
- package/src/skills/agents-md/SKILL.md +45 -0
- package/src/skills/agents-md/index.ts +33 -0
- package/src/skills/execute-plan/SKILL.md +60 -0
- package/src/skills/execute-plan/index.ts +970 -0
- package/src/skills/index.ts +13 -0
- package/src/skills/quick-task/SKILL.md +54 -0
- package/src/skills/quick-task/index.ts +346 -0
- package/src/skills/registry.ts +59 -0
- package/src/skills/review-diff/SKILL.md +53 -0
- package/src/skills/review-diff/index.ts +394 -0
- package/src/skills/skill.ts +59 -0
- package/src/skills/systematic-debugging/SKILL.md +56 -0
- package/src/skills/systematic-debugging/index.ts +404 -0
- package/src/skills/tdd/SKILL.md +52 -0
- package/src/skills/tdd/index.ts +409 -0
- package/src/skills/to-plan/SKILL.md +56 -0
- package/src/skills/to-plan/index-enhanced.ts +551 -0
- package/src/skills/to-plan/index.ts +586 -0
- package/src/skills/types.ts +47 -0
- package/src/state/cleanup.ts +118 -0
- package/src/state/index.ts +8 -0
- package/src/state/manager.ts +96 -0
- package/src/state/persistence.ts +77 -0
- package/src/state/types.ts +30 -0
- package/src/state/validator.ts +78 -0
- package/src/types.ts +102 -0
- package/src/utils/compress.ts +347 -0
- package/src/utils/git.ts +82 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/logger.ts +23 -0
- package/src/utils/paths.ts +55 -0
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent Dispatcher - Spawns isolated subagents to do all real work
|
|
3
|
+
*
|
|
4
|
+
* Constructs prompts from contracts, manages timeouts, validates outputs,
|
|
5
|
+
* and collects results. Uses a pluggable executor backend so the dispatch
|
|
6
|
+
* mechanism can be swapped (process, HTTP, in-process) without changing
|
|
7
|
+
* the orchestration logic.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { nanoid } from 'nanoid';
|
|
11
|
+
import type {
|
|
12
|
+
SubagentConfig,
|
|
13
|
+
SubagentContract,
|
|
14
|
+
SubagentResult,
|
|
15
|
+
SubagentArtifact,
|
|
16
|
+
PermissionSet,
|
|
17
|
+
SubagentRole,
|
|
18
|
+
} from './types.js';
|
|
19
|
+
import type { Logger } from '../types.js';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Executor abstraction
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export interface ExecutorOptions {
|
|
26
|
+
id: string;
|
|
27
|
+
model?: string;
|
|
28
|
+
timeout?: number;
|
|
29
|
+
workDir?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ExecutorResult {
|
|
33
|
+
output: string;
|
|
34
|
+
tokensUsed: number;
|
|
35
|
+
errors: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SubagentExecutor {
|
|
39
|
+
execute(prompt: string, options: ExecutorOptions): Promise<ExecutorResult>;
|
|
40
|
+
abort(id: string): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Default executor (process-based)
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
export interface ProcessExecutorConfig {
|
|
48
|
+
/** CLI command to invoke (default: "claude") */
|
|
49
|
+
command: string;
|
|
50
|
+
/** CLI arguments appended after the prompt */
|
|
51
|
+
args: string[];
|
|
52
|
+
/** Environment variables to forward */
|
|
53
|
+
env: Record<string, string>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const DEFAULT_PROCESS_CONFIG: ProcessExecutorConfig = {
|
|
57
|
+
command: 'claude',
|
|
58
|
+
args: ['--print', '--output-format', 'json'],
|
|
59
|
+
env: {},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Creates a SubagentExecutor that spawns a subprocess for each dispatch.
|
|
64
|
+
*
|
|
65
|
+
* The executor writes the prompt to a temp file, invokes the configured
|
|
66
|
+
* CLI command with `--input-file`, collects stdout/stderr, and cleans up.
|
|
67
|
+
*/
|
|
68
|
+
export function createProcessExecutor(
|
|
69
|
+
config: Partial<ProcessExecutorConfig> = {},
|
|
70
|
+
): SubagentExecutor {
|
|
71
|
+
const resolved: ProcessExecutorConfig = {
|
|
72
|
+
...DEFAULT_PROCESS_CONFIG,
|
|
73
|
+
...config,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const active = new Map<
|
|
77
|
+
string,
|
|
78
|
+
ReturnType<typeof import('child_process').spawn>
|
|
79
|
+
>();
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
async execute(prompt: string, options: ExecutorOptions): Promise<ExecutorResult> {
|
|
83
|
+
const { spawn } = await import('child_process');
|
|
84
|
+
const { writeFile, mkdir, rm } = await import('fs/promises');
|
|
85
|
+
const { join } = await import('path');
|
|
86
|
+
const { tmpdir } = await import('os');
|
|
87
|
+
|
|
88
|
+
const workDir = options.workDir ?? join(tmpdir(), `yi-subagent-${options.id}`);
|
|
89
|
+
await mkdir(workDir, { recursive: true });
|
|
90
|
+
|
|
91
|
+
const promptPath = join(workDir, 'prompt.md');
|
|
92
|
+
await writeFile(promptPath, prompt, 'utf-8');
|
|
93
|
+
|
|
94
|
+
const child = spawn(
|
|
95
|
+
resolved.command,
|
|
96
|
+
[...resolved.args, '--input-file', promptPath],
|
|
97
|
+
{
|
|
98
|
+
env: { ...process.env, ...resolved.env },
|
|
99
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
100
|
+
timeout: options.timeout ? options.timeout * 1000 : undefined,
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
active.set(options.id, child);
|
|
105
|
+
|
|
106
|
+
const outputChunks: Buffer[] = [];
|
|
107
|
+
const errorChunks: Buffer[] = [];
|
|
108
|
+
|
|
109
|
+
child.stdout?.on('data', (chunk: Buffer) => outputChunks.push(chunk));
|
|
110
|
+
child.stderr?.on('data', (chunk: Buffer) => errorChunks.push(chunk));
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const exitCode = await new Promise<number | null>((resolve, reject) => {
|
|
114
|
+
child.on('close', resolve);
|
|
115
|
+
child.on('error', reject);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
active.delete(options.id);
|
|
119
|
+
|
|
120
|
+
const stdout = Buffer.concat(outputChunks).toString('utf-8').trim();
|
|
121
|
+
const stderr = Buffer.concat(errorChunks).toString('utf-8').trim();
|
|
122
|
+
|
|
123
|
+
const errors: string[] = [];
|
|
124
|
+
if (exitCode !== 0) {
|
|
125
|
+
errors.push(`Subprocess exited with code ${exitCode}`);
|
|
126
|
+
}
|
|
127
|
+
if (stderr) {
|
|
128
|
+
errors.push(stderr);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Best-effort cleanup
|
|
132
|
+
await rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
|
133
|
+
|
|
134
|
+
// Rough token estimate: ~4 chars per token
|
|
135
|
+
const tokensUsed = Math.ceil((prompt.length + stdout.length) / 4);
|
|
136
|
+
|
|
137
|
+
return { output: stdout, tokensUsed, errors };
|
|
138
|
+
} catch (err) {
|
|
139
|
+
active.delete(options.id);
|
|
140
|
+
await rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
|
141
|
+
return {
|
|
142
|
+
output: '',
|
|
143
|
+
tokensUsed: Math.ceil(prompt.length / 4),
|
|
144
|
+
errors: [String(err)],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
async abort(id: string): Promise<void> {
|
|
150
|
+
const child = active.get(id);
|
|
151
|
+
if (child) {
|
|
152
|
+
child.kill('SIGTERM');
|
|
153
|
+
active.delete(id);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Constants
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
const DEFAULT_TIMEOUT_SECONDS = 300;
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Role-specific system instructions
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
const ROLE_INSTRUCTIONS: Record<SubagentRole, string> = {
|
|
170
|
+
explorer:
|
|
171
|
+
'You are an EXPLORER subagent. Your job is to search and read the codebase. You CANNOT write or modify any files.',
|
|
172
|
+
implementer:
|
|
173
|
+
'You are an IMPLEMENTER subagent. Your job is to write code, run tests, and verify your changes compile and pass.',
|
|
174
|
+
reviewer:
|
|
175
|
+
'You are a REVIEWER subagent. Your job is to read code changes and produce a structured review report. You CANNOT write files.',
|
|
176
|
+
planner:
|
|
177
|
+
'You are a PLANNER subagent. Your job is to analyse requirements and create implementation plans.',
|
|
178
|
+
debugger:
|
|
179
|
+
'You are a DEBUGGER subagent. Your job is to analyse failures, identify root causes, and propose fixes.',
|
|
180
|
+
verifier:
|
|
181
|
+
'You are a VERIFIER subagent. Your job is to verify that implemented changes satisfy the original requirements. You CANNOT write files.',
|
|
182
|
+
general:
|
|
183
|
+
'You are a GENERAL subagent. Complete the assigned task within your permissions.',
|
|
184
|
+
researcher:
|
|
185
|
+
'You are a RESEARCHER subagent. Your job is to research topics and provide findings.',
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const PERMISSION_LABELS: Record<keyof PermissionSet, string> = {
|
|
189
|
+
readFiles: 'Read Files',
|
|
190
|
+
searchCode: 'Search Code',
|
|
191
|
+
runCommands: 'Run Commands',
|
|
192
|
+
writeFiles: 'Write Files',
|
|
193
|
+
gitOperations: 'Git Operations',
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Subagent Dispatcher
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
export class SubagentDispatcher {
|
|
201
|
+
private readonly executor: SubagentExecutor;
|
|
202
|
+
private readonly logger: Logger;
|
|
203
|
+
private readonly activeIds: Set<string>;
|
|
204
|
+
|
|
205
|
+
constructor(executor: SubagentExecutor, logger: Logger) {
|
|
206
|
+
this.executor = executor;
|
|
207
|
+
this.logger = logger;
|
|
208
|
+
this.activeIds = new Set();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// -----------------------------------------------------------------------
|
|
212
|
+
// Public API
|
|
213
|
+
// -----------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Dispatch a single subagent with the given config and contract.
|
|
217
|
+
*
|
|
218
|
+
* Constructs a structured prompt from the contract, sends it to the
|
|
219
|
+
* executor, validates the output against the optional output schema,
|
|
220
|
+
* and returns a typed SubagentResult.
|
|
221
|
+
*/
|
|
222
|
+
async dispatch(
|
|
223
|
+
config: SubagentConfig,
|
|
224
|
+
contract: SubagentContract,
|
|
225
|
+
): Promise<SubagentResult> {
|
|
226
|
+
const id = nanoid();
|
|
227
|
+
const startTime = Date.now();
|
|
228
|
+
const timeoutSeconds = config.timeout ?? DEFAULT_TIMEOUT_SECONDS;
|
|
229
|
+
|
|
230
|
+
this.activeIds.add(id);
|
|
231
|
+
this.logger.info(`[Dispatcher] Dispatching ${id} (role=${config.role})`);
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const prompt = this.buildPrompt(config, contract);
|
|
235
|
+
|
|
236
|
+
const executorResult = await this.withTimeout(
|
|
237
|
+
this.executor.execute(prompt, {
|
|
238
|
+
id,
|
|
239
|
+
model: config.model,
|
|
240
|
+
timeout: timeoutSeconds,
|
|
241
|
+
}),
|
|
242
|
+
timeoutSeconds * 1000,
|
|
243
|
+
id,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const duration = Date.now() - startTime;
|
|
247
|
+
|
|
248
|
+
const validationErrors = this.validateOutput(
|
|
249
|
+
executorResult.output,
|
|
250
|
+
contract.outputSchema,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const allErrors = [...executorResult.errors, ...validationErrors];
|
|
254
|
+
const status: SubagentResult['status'] =
|
|
255
|
+
allErrors.length > 0 ? 'failure' : 'success';
|
|
256
|
+
|
|
257
|
+
const artifacts = extractArtifacts(executorResult.output);
|
|
258
|
+
|
|
259
|
+
this.logger.info(
|
|
260
|
+
`[Dispatcher] Subagent ${id} completed: status=${status} duration=${duration}ms tokens=${executorResult.tokensUsed}`,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
id,
|
|
265
|
+
role: config.role,
|
|
266
|
+
status,
|
|
267
|
+
output: executorResult.output,
|
|
268
|
+
artifacts,
|
|
269
|
+
tokensUsed: executorResult.tokensUsed,
|
|
270
|
+
duration,
|
|
271
|
+
errors: allErrors,
|
|
272
|
+
};
|
|
273
|
+
} catch (error) {
|
|
274
|
+
const duration = Date.now() - startTime;
|
|
275
|
+
const errorMessage =
|
|
276
|
+
error instanceof Error ? error.message : String(error);
|
|
277
|
+
const isTimeout = errorMessage.includes('timed out');
|
|
278
|
+
|
|
279
|
+
this.logger.error(`[Dispatcher] Subagent ${id} failed: ${errorMessage}`);
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
id,
|
|
283
|
+
role: config.role,
|
|
284
|
+
status: isTimeout ? 'timeout' : 'failure',
|
|
285
|
+
output: '',
|
|
286
|
+
artifacts: [],
|
|
287
|
+
tokensUsed: 0,
|
|
288
|
+
duration,
|
|
289
|
+
errors: [errorMessage],
|
|
290
|
+
};
|
|
291
|
+
} finally {
|
|
292
|
+
this.activeIds.delete(id);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Dispatch multiple subagents in parallel.
|
|
298
|
+
*
|
|
299
|
+
* All subagents run concurrently. Use when tasks are independent
|
|
300
|
+
* of each other (e.g. exploring multiple directories at once).
|
|
301
|
+
*/
|
|
302
|
+
async dispatchParallel(
|
|
303
|
+
configs: readonly SubagentConfig[],
|
|
304
|
+
contracts: readonly SubagentContract[],
|
|
305
|
+
): Promise<SubagentResult[]> {
|
|
306
|
+
if (configs.length !== contracts.length) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
`configs.length (${configs.length}) must equal contracts.length (${contracts.length})`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
this.logger.info(
|
|
313
|
+
`[Dispatcher] Dispatching ${configs.length} subagents in parallel`,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const pairs = configs.map((config, i) => ({ config, contract: contracts[i] }));
|
|
317
|
+
const results = await Promise.all(
|
|
318
|
+
pairs.map(({ config, contract }) => this.dispatch(config, contract)),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const counts = {
|
|
322
|
+
success: results.filter((r) => r.status === 'success').length,
|
|
323
|
+
failure: results.filter((r) => r.status === 'failure').length,
|
|
324
|
+
timeout: results.filter((r) => r.status === 'timeout').length,
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
this.logger.info(
|
|
328
|
+
`[Dispatcher] Parallel batch complete: ${counts.success} success, ${counts.failure} failure, ${counts.timeout} timeout`,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
return results;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Dispatch multiple subagents sequentially.
|
|
336
|
+
*
|
|
337
|
+
* Each subagent receives the output of all previous subagents as
|
|
338
|
+
* additional context. The pipeline stops early if a subagent fails
|
|
339
|
+
* or times out. Use when tasks form a pipeline (e.g. explore ->
|
|
340
|
+
* implement -> review -> verify).
|
|
341
|
+
*/
|
|
342
|
+
async dispatchSequential(
|
|
343
|
+
configs: readonly SubagentConfig[],
|
|
344
|
+
contracts: readonly SubagentContract[],
|
|
345
|
+
): Promise<SubagentResult[]> {
|
|
346
|
+
if (configs.length !== contracts.length) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`configs.length (${configs.length}) must equal contracts.length (${contracts.length})`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
this.logger.info(
|
|
353
|
+
`[Dispatcher] Dispatching ${configs.length} subagents sequentially`,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const results: SubagentResult[] = [];
|
|
357
|
+
|
|
358
|
+
for (let i = 0; i < configs.length; i++) {
|
|
359
|
+
const augmentedContract = augmentWithPreviousResults(
|
|
360
|
+
contracts[i],
|
|
361
|
+
results,
|
|
362
|
+
);
|
|
363
|
+
const result = await this.dispatch(configs[i], augmentedContract);
|
|
364
|
+
results.push(result);
|
|
365
|
+
|
|
366
|
+
if (result.status === 'failure' || result.status === 'timeout') {
|
|
367
|
+
this.logger.warn(
|
|
368
|
+
`[Dispatcher] Stopping sequential pipeline at index ${i} (${result.status})`,
|
|
369
|
+
);
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return results;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Abort a running subagent by its ID.
|
|
379
|
+
*/
|
|
380
|
+
async abort(id: string): Promise<void> {
|
|
381
|
+
if (!this.activeIds.has(id)) {
|
|
382
|
+
this.logger.warn(`[Dispatcher] Cannot abort ${id}: not active`);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
await this.executor.abort(id);
|
|
386
|
+
this.activeIds.delete(id);
|
|
387
|
+
this.logger.info(`[Dispatcher] Aborted subagent ${id}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// -----------------------------------------------------------------------
|
|
391
|
+
// Prompt construction
|
|
392
|
+
// -----------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Build a structured subagent prompt from config and contract.
|
|
396
|
+
*
|
|
397
|
+
* The prompt follows the Claude Code agent tool pattern: explicit
|
|
398
|
+
* permissions, owned/read file scopes, isolation instructions,
|
|
399
|
+
* token budget, output format requirements, and the task itself.
|
|
400
|
+
*/
|
|
401
|
+
private buildPrompt(
|
|
402
|
+
config: SubagentConfig,
|
|
403
|
+
contract: SubagentContract,
|
|
404
|
+
): string {
|
|
405
|
+
const sections: string[] = [];
|
|
406
|
+
|
|
407
|
+
sections.push(buildRoleHeader(config.role));
|
|
408
|
+
sections.push(buildPermissionsBlock(contract.permissions));
|
|
409
|
+
sections.push(buildFileScopeBlock(contract.owns, contract.reads));
|
|
410
|
+
|
|
411
|
+
if (config.isolation === 'worktree') {
|
|
412
|
+
sections.push(buildIsolationBlock());
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
sections.push(buildBudgetBlock(config.tokenBudget));
|
|
416
|
+
sections.push(buildOutputSchemaBlock(contract.outputSchema));
|
|
417
|
+
sections.push(buildTaskBlock(contract.prompt));
|
|
418
|
+
|
|
419
|
+
return sections.join('\n\n---\n\n');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// -----------------------------------------------------------------------
|
|
423
|
+
// Timeout handling
|
|
424
|
+
// -----------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Wraps a promise with a timeout. If the timeout fires first the
|
|
428
|
+
* subagent is aborted and the promise rejects.
|
|
429
|
+
*/
|
|
430
|
+
private withTimeout<T>(
|
|
431
|
+
promise: Promise<T>,
|
|
432
|
+
timeoutMs: number,
|
|
433
|
+
id: string,
|
|
434
|
+
): Promise<T> {
|
|
435
|
+
if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) {
|
|
436
|
+
return promise;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return new Promise<T>((resolve, reject) => {
|
|
440
|
+
const timer = setTimeout(() => {
|
|
441
|
+
this.executor.abort(id).catch(() => undefined);
|
|
442
|
+
reject(new Error(`Subagent ${id} timed out after ${timeoutMs}ms`));
|
|
443
|
+
}, timeoutMs);
|
|
444
|
+
|
|
445
|
+
promise
|
|
446
|
+
.then((result) => {
|
|
447
|
+
clearTimeout(timer);
|
|
448
|
+
resolve(result);
|
|
449
|
+
})
|
|
450
|
+
.catch((error) => {
|
|
451
|
+
clearTimeout(timer);
|
|
452
|
+
reject(error);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// -----------------------------------------------------------------------
|
|
458
|
+
// Output validation
|
|
459
|
+
// -----------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Validate subagent output against an optional schema.
|
|
463
|
+
* The schema is a Record<string, unknown> where values are type strings
|
|
464
|
+
* like "string", "number", "boolean", "object".
|
|
465
|
+
*
|
|
466
|
+
* Returns an array of validation error messages (empty = valid).
|
|
467
|
+
*/
|
|
468
|
+
private validateOutput(
|
|
469
|
+
output: string,
|
|
470
|
+
schema?: Record<string, unknown>,
|
|
471
|
+
): string[] {
|
|
472
|
+
if (!schema || Object.keys(schema).length === 0) {
|
|
473
|
+
return [];
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Try to extract a JSON block from the output
|
|
477
|
+
const jsonMatch = output.match(/```json\s*([\s\S]*?)\s*```/);
|
|
478
|
+
const jsonText = jsonMatch ? jsonMatch[1].trim() : output.trim();
|
|
479
|
+
|
|
480
|
+
let parsed: unknown;
|
|
481
|
+
try {
|
|
482
|
+
parsed = JSON.parse(jsonText);
|
|
483
|
+
} catch {
|
|
484
|
+
return ['Output is not valid JSON'];
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
488
|
+
return ['Output must be a JSON object'];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const obj = parsed as Record<string, unknown>;
|
|
492
|
+
const errors: string[] = [];
|
|
493
|
+
|
|
494
|
+
for (const [key, expectedType] of Object.entries(schema)) {
|
|
495
|
+
if (!(key in obj)) {
|
|
496
|
+
errors.push(`Missing required field: "${key}"`);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
const actualType = typeof obj[key];
|
|
500
|
+
if (actualType !== expectedType) {
|
|
501
|
+
errors.push(
|
|
502
|
+
`Field "${key}": expected type ${String(expectedType)}, got ${actualType}`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return errors;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
// Prompt builder helpers (pure functions)
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
function buildRoleHeader(role: SubagentRole): string {
|
|
516
|
+
const instruction = ROLE_INSTRUCTIONS[role] ?? ROLE_INSTRUCTIONS.general;
|
|
517
|
+
return `# Role: ${role}\n\n${instruction}`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function buildPermissionsBlock(permissions: PermissionSet): string {
|
|
521
|
+
const lines: string[] = ['## Permissions'];
|
|
522
|
+
for (const key of Object.keys(permissions) as (keyof PermissionSet)[]) {
|
|
523
|
+
const label = PERMISSION_LABELS[key];
|
|
524
|
+
const allowed = permissions[key] ? 'ALLOWED' : 'DENIED';
|
|
525
|
+
lines.push(`- ${label}: ${allowed}`);
|
|
526
|
+
}
|
|
527
|
+
return lines.join('\n');
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function buildFileScopeBlock(
|
|
531
|
+
owns: readonly string[],
|
|
532
|
+
reads: readonly string[],
|
|
533
|
+
): string {
|
|
534
|
+
const lines: string[] = ['## File Scope'];
|
|
535
|
+
|
|
536
|
+
lines.push('\n### Owned Files (may modify)');
|
|
537
|
+
if (owns.length === 0) {
|
|
538
|
+
lines.push('- None');
|
|
539
|
+
} else {
|
|
540
|
+
for (const file of owns) {
|
|
541
|
+
lines.push(`- ${file}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
lines.push('\n### Read-only Files');
|
|
546
|
+
if (reads.length === 0) {
|
|
547
|
+
lines.push('- None');
|
|
548
|
+
} else {
|
|
549
|
+
for (const file of reads) {
|
|
550
|
+
lines.push(`- ${file}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return lines.join('\n');
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function buildIsolationBlock(): string {
|
|
558
|
+
return [
|
|
559
|
+
'## Isolation: Worktree',
|
|
560
|
+
'',
|
|
561
|
+
'You are running in an isolated git worktree. All changes you make are',
|
|
562
|
+
'contained within this worktree. You MUST:',
|
|
563
|
+
'- Create and modify files only within the worktree',
|
|
564
|
+
'- Run tests within the worktree',
|
|
565
|
+
'- Commit your changes before exiting',
|
|
566
|
+
'- Never modify files outside the worktree',
|
|
567
|
+
].join('\n');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function buildBudgetBlock(tokenBudget?: number): string {
|
|
571
|
+
const budget = tokenBudget ?? 32000;
|
|
572
|
+
return [
|
|
573
|
+
'## Token Budget',
|
|
574
|
+
'',
|
|
575
|
+
`You have a budget of **${budget.toLocaleString()} tokens**.`,
|
|
576
|
+
'Stay within this limit. Be concise. Prefer essential information.',
|
|
577
|
+
].join('\n');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function buildOutputSchemaBlock(schema?: Record<string, unknown>): string {
|
|
581
|
+
if (!schema || Object.keys(schema).length === 0) {
|
|
582
|
+
return '## Output Format\n\nProvide your response as plain text or markdown.';
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const keys = Object.keys(schema);
|
|
586
|
+
const lines: string[] = [
|
|
587
|
+
'## Required Output Format',
|
|
588
|
+
'',
|
|
589
|
+
'Your response MUST be a JSON object with these fields:',
|
|
590
|
+
];
|
|
591
|
+
|
|
592
|
+
for (const key of keys) {
|
|
593
|
+
lines.push(`- \`${key}\`: ${String(schema[key])}`);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
lines.push(
|
|
597
|
+
'',
|
|
598
|
+
'Wrap your output in a fenced JSON block:',
|
|
599
|
+
'',
|
|
600
|
+
'```json',
|
|
601
|
+
`{ ${keys.map((k) => `"${k}": ...`).join(', ')} }`,
|
|
602
|
+
'```',
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
return lines.join('\n');
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function buildTaskBlock(prompt: string): string {
|
|
609
|
+
return `## Task\n\n${prompt}`;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Augment a contract with output from previous subagents.
|
|
614
|
+
* Used by dispatchSequential to provide pipeline context.
|
|
615
|
+
*/
|
|
616
|
+
function augmentWithPreviousResults(
|
|
617
|
+
contract: SubagentContract,
|
|
618
|
+
previousResults: readonly SubagentResult[],
|
|
619
|
+
): SubagentContract {
|
|
620
|
+
if (previousResults.length === 0) {
|
|
621
|
+
return contract;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const context = previousResults
|
|
625
|
+
.map(
|
|
626
|
+
(r) =>
|
|
627
|
+
`### Previous Subagent (role=${r.role}, status=${r.status})\n\n${r.output || '(no output)'}`,
|
|
628
|
+
)
|
|
629
|
+
.join('\n\n');
|
|
630
|
+
|
|
631
|
+
return {
|
|
632
|
+
...contract,
|
|
633
|
+
prompt: `${contract.prompt}\n\n## Context from Previous Steps\n\n${context}`,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Extract structured artifacts from subagent output text.
|
|
639
|
+
* Recognises fenced code blocks (JSON, diff) and converts them
|
|
640
|
+
* to SubagentArtifact records.
|
|
641
|
+
*/
|
|
642
|
+
function extractArtifacts(output: string): SubagentArtifact[] {
|
|
643
|
+
const artifacts: SubagentArtifact[] = [];
|
|
644
|
+
|
|
645
|
+
const jsonBlocks = output.matchAll(/```json\s*([\s\S]*?)\s*```/g);
|
|
646
|
+
for (const match of jsonBlocks) {
|
|
647
|
+
artifacts.push({ type: 'report', content: match[1].trim() });
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const diffBlocks = output.matchAll(/```diff\s*([\s\S]*?)\s*```/g);
|
|
651
|
+
for (const match of diffBlocks) {
|
|
652
|
+
artifacts.push({ type: 'diff', content: match[1].trim() });
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return artifacts;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
// Factory
|
|
660
|
+
// ---------------------------------------------------------------------------
|
|
661
|
+
|
|
662
|
+
export interface DispatcherOptions {
|
|
663
|
+
executor?: SubagentExecutor;
|
|
664
|
+
processConfig?: Partial<ProcessExecutorConfig>;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Create a SubagentDispatcher with sensible defaults.
|
|
669
|
+
*
|
|
670
|
+
* If no executor is provided, a process-based executor is created
|
|
671
|
+
* using `createProcessExecutor` which spawns a CLI command.
|
|
672
|
+
*/
|
|
673
|
+
export function createDispatcher(
|
|
674
|
+
logger: Logger,
|
|
675
|
+
options: DispatcherOptions = {},
|
|
676
|
+
): SubagentDispatcher {
|
|
677
|
+
const executor =
|
|
678
|
+
options.executor ?? createProcessExecutor(options.processConfig);
|
|
679
|
+
return new SubagentDispatcher(executor, logger);
|
|
680
|
+
}
|