@haoyiyin/workflow 0.2.11 → 0.3.1

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 (175) hide show
  1. package/dist/src/agents/contracts/implementer.d.ts +29 -0
  2. package/dist/src/agents/contracts/implementer.d.ts.map +1 -0
  3. package/dist/src/agents/contracts/implementer.js +94 -0
  4. package/dist/src/agents/contracts/implementer.js.map +1 -0
  5. package/dist/src/agents/contracts/index.d.ts +11 -0
  6. package/dist/src/agents/contracts/index.d.ts.map +1 -0
  7. package/dist/src/agents/contracts/index.js +11 -0
  8. package/dist/src/agents/contracts/index.js.map +1 -0
  9. package/dist/src/agents/contracts/planner.d.ts +25 -0
  10. package/dist/src/agents/contracts/planner.d.ts.map +1 -0
  11. package/dist/src/agents/contracts/planner.js +107 -0
  12. package/dist/src/agents/contracts/planner.js.map +1 -0
  13. package/dist/src/agents/contracts/router.d.ts +24 -0
  14. package/dist/src/agents/contracts/router.d.ts.map +1 -0
  15. package/dist/src/agents/contracts/router.js +137 -0
  16. package/dist/src/agents/contracts/router.js.map +1 -0
  17. package/dist/src/agents/contracts/verifier.d.ts +27 -0
  18. package/dist/src/agents/contracts/verifier.d.ts.map +1 -0
  19. package/dist/src/agents/contracts/verifier.js +115 -0
  20. package/dist/src/agents/contracts/verifier.js.map +1 -0
  21. package/dist/src/agents/dispatcher.d.ts +94 -51
  22. package/dist/src/agents/dispatcher.d.ts.map +1 -1
  23. package/dist/src/agents/dispatcher.js +207 -164
  24. package/dist/src/agents/dispatcher.js.map +1 -1
  25. package/dist/src/persistence/index.d.ts +4 -2
  26. package/dist/src/persistence/index.d.ts.map +1 -1
  27. package/dist/src/persistence/index.js +4 -1
  28. package/dist/src/persistence/index.js.map +1 -1
  29. package/dist/src/persistence/plan-md.d.ts +3 -2
  30. package/dist/src/persistence/plan-md.d.ts.map +1 -1
  31. package/dist/src/persistence/plan-md.js +47 -15
  32. package/dist/src/persistence/plan-md.js.map +1 -1
  33. package/dist/src/persistence/state-md.d.ts +2 -0
  34. package/dist/src/persistence/state-md.d.ts.map +1 -1
  35. package/dist/src/persistence/state-md.js +40 -22
  36. package/dist/src/persistence/state-md.js.map +1 -1
  37. package/dist/src/persistence/types.d.ts +35 -39
  38. package/dist/src/persistence/types.d.ts.map +1 -1
  39. package/dist/src/pi-extension.d.ts +4 -3
  40. package/dist/src/pi-extension.d.ts.map +1 -1
  41. package/dist/src/pi-extension.js +36 -67
  42. package/dist/src/pi-extension.js.map +1 -1
  43. package/dist/src/router/namespace/core/intent-router.d.ts +24 -0
  44. package/dist/src/router/namespace/core/intent-router.d.ts.map +1 -0
  45. package/dist/src/router/namespace/core/intent-router.js +190 -0
  46. package/dist/src/router/namespace/core/intent-router.js.map +1 -0
  47. package/dist/src/router/namespace/core/lifecycle-router.d.ts +28 -0
  48. package/dist/src/router/namespace/core/lifecycle-router.d.ts.map +1 -0
  49. package/dist/src/router/namespace/core/lifecycle-router.js +132 -0
  50. package/dist/src/router/namespace/core/lifecycle-router.js.map +1 -0
  51. package/dist/src/router/namespace/core/state-router.d.ts +32 -0
  52. package/dist/src/router/namespace/core/state-router.d.ts.map +1 -0
  53. package/dist/src/router/namespace/core/state-router.js +157 -0
  54. package/dist/src/router/namespace/core/state-router.js.map +1 -0
  55. package/dist/src/router/namespace/domain/code-router.d.ts +26 -0
  56. package/dist/src/router/namespace/domain/code-router.d.ts.map +1 -0
  57. package/dist/src/router/namespace/domain/code-router.js +171 -0
  58. package/dist/src/router/namespace/domain/code-router.js.map +1 -0
  59. package/dist/src/router/namespace/domain/debug-router.d.ts +25 -0
  60. package/dist/src/router/namespace/domain/debug-router.d.ts.map +1 -0
  61. package/dist/src/router/namespace/domain/debug-router.js +139 -0
  62. package/dist/src/router/namespace/domain/debug-router.js.map +1 -0
  63. package/dist/src/router/namespace/domain/plan-router.d.ts +29 -0
  64. package/dist/src/router/namespace/domain/plan-router.d.ts.map +1 -0
  65. package/dist/src/router/namespace/domain/plan-router.js +160 -0
  66. package/dist/src/router/namespace/domain/plan-router.js.map +1 -0
  67. package/dist/src/router/namespace/domain/review-router.d.ts +24 -0
  68. package/dist/src/router/namespace/domain/review-router.d.ts.map +1 -0
  69. package/dist/src/router/namespace/domain/review-router.js +116 -0
  70. package/dist/src/router/namespace/domain/review-router.js.map +1 -0
  71. package/dist/src/router/namespace/index.d.ts +19 -0
  72. package/dist/src/router/namespace/index.d.ts.map +1 -0
  73. package/dist/src/router/namespace/index.js +22 -0
  74. package/dist/src/router/namespace/index.js.map +1 -0
  75. package/dist/src/router/namespace/registry.d.ts +67 -0
  76. package/dist/src/router/namespace/registry.d.ts.map +1 -0
  77. package/dist/src/router/namespace/registry.js +197 -0
  78. package/dist/src/router/namespace/registry.js.map +1 -0
  79. package/dist/src/router/namespace/types.d.ts +124 -0
  80. package/dist/src/router/namespace/types.d.ts.map +1 -0
  81. package/dist/src/router/namespace/types.js +20 -0
  82. package/dist/src/router/namespace/types.js.map +1 -0
  83. package/dist/src/router/namespace/utility/fallback-router.d.ts +28 -0
  84. package/dist/src/router/namespace/utility/fallback-router.d.ts.map +1 -0
  85. package/dist/src/router/namespace/utility/fallback-router.js +88 -0
  86. package/dist/src/router/namespace/utility/fallback-router.js.map +1 -0
  87. package/dist/src/router/namespace/utility/quick-task-router.d.ts +28 -0
  88. package/dist/src/router/namespace/utility/quick-task-router.d.ts.map +1 -0
  89. package/dist/src/router/namespace/utility/quick-task-router.js +99 -0
  90. package/dist/src/router/namespace/utility/quick-task-router.js.map +1 -0
  91. package/dist/src/router/namespace/utility/research-router.d.ts +24 -0
  92. package/dist/src/router/namespace/utility/research-router.d.ts.map +1 -0
  93. package/dist/src/router/namespace/utility/research-router.js +84 -0
  94. package/dist/src/router/namespace/utility/research-router.js.map +1 -0
  95. package/dist/src/skills/agents-md/index.js +2 -2
  96. package/dist/src/skills/agents-md/index.js.map +1 -1
  97. package/dist/src/skills/execute-plan/index.d.ts +45 -65
  98. package/dist/src/skills/execute-plan/index.d.ts.map +1 -1
  99. package/dist/src/skills/execute-plan/index.js +325 -551
  100. package/dist/src/skills/execute-plan/index.js.map +1 -1
  101. package/dist/src/skills/index.d.ts +1 -0
  102. package/dist/src/skills/index.d.ts.map +1 -1
  103. package/dist/src/skills/index.js +1 -0
  104. package/dist/src/skills/index.js.map +1 -1
  105. package/dist/src/skills/quick-task/index.d.ts +4 -4
  106. package/dist/src/skills/quick-task/index.js +1 -1
  107. package/dist/src/skills/quick-task/index.js.map +1 -1
  108. package/dist/src/skills/review-diff/index.d.ts +6 -6
  109. package/dist/src/skills/review-diff/index.js +1 -1
  110. package/dist/src/skills/review-diff/index.js.map +1 -1
  111. package/dist/src/skills/router/index.d.ts +101 -0
  112. package/dist/src/skills/router/index.d.ts.map +1 -0
  113. package/dist/src/skills/router/index.js +450 -0
  114. package/dist/src/skills/router/index.js.map +1 -0
  115. package/dist/src/skills/router/types.d.ts +79 -0
  116. package/dist/src/skills/router/types.d.ts.map +1 -0
  117. package/dist/src/skills/router/types.js +8 -0
  118. package/dist/src/skills/router/types.js.map +1 -0
  119. package/dist/src/skills/systematic-debugging/index.js +1 -1
  120. package/dist/src/skills/systematic-debugging/index.js.map +1 -1
  121. package/dist/src/skills/tdd/index.d.ts +14 -14
  122. package/dist/src/skills/tdd/index.js +1 -1
  123. package/dist/src/skills/tdd/index.js.map +1 -1
  124. package/dist/src/skills/to-plan/index-enhanced.d.ts +4 -4
  125. package/dist/src/skills/to-plan/index-enhanced.d.ts.map +1 -1
  126. package/dist/src/skills/to-plan/index-enhanced.js +3 -5
  127. package/dist/src/skills/to-plan/index-enhanced.js.map +1 -1
  128. package/dist/src/skills/to-plan/index.d.ts +24 -91
  129. package/dist/src/skills/to-plan/index.d.ts.map +1 -1
  130. package/dist/src/skills/to-plan/index.js +214 -409
  131. package/dist/src/skills/to-plan/index.js.map +1 -1
  132. package/package.json +3 -5
  133. package/scripts/postinstall.js +42 -47
  134. package/src/agents/contracts/implementer.ts +122 -0
  135. package/src/agents/contracts/index.ts +27 -0
  136. package/src/agents/contracts/planner.ts +129 -0
  137. package/src/agents/contracts/router.ts +168 -0
  138. package/src/agents/contracts/verifier.ts +137 -0
  139. package/src/agents/dispatcher.ts +387 -362
  140. package/src/persistence/index.ts +10 -4
  141. package/src/persistence/plan-md.ts +52 -18
  142. package/src/persistence/state-md.ts +45 -23
  143. package/src/persistence/types.ts +37 -40
  144. package/src/pi-extension.ts +38 -76
  145. package/src/router/namespace/README.md +127 -0
  146. package/src/router/namespace/core/intent-router.ts +221 -0
  147. package/src/router/namespace/core/lifecycle-router.ts +156 -0
  148. package/src/router/namespace/core/state-router.ts +192 -0
  149. package/src/router/namespace/domain/code-router.ts +202 -0
  150. package/src/router/namespace/domain/debug-router.ts +167 -0
  151. package/src/router/namespace/domain/plan-router.ts +196 -0
  152. package/src/router/namespace/domain/review-router.ts +142 -0
  153. package/src/router/namespace/index.ts +84 -0
  154. package/src/router/namespace/registry.ts +242 -0
  155. package/src/router/namespace/types.ts +182 -0
  156. package/src/router/namespace/utility/fallback-router.ts +107 -0
  157. package/src/router/namespace/utility/quick-task-router.ts +121 -0
  158. package/src/router/namespace/utility/research-router.ts +105 -0
  159. package/src/skills/agents-md/index.ts +2 -2
  160. package/src/skills/execute-plan/index.ts +419 -673
  161. package/src/skills/index.ts +1 -0
  162. package/src/skills/quick-task/index.ts +1 -1
  163. package/src/skills/review-diff/index.ts +1 -1
  164. package/src/skills/router/SKILL.md +81 -0
  165. package/src/skills/router/index.ts +577 -0
  166. package/src/skills/router/types.ts +90 -0
  167. package/src/skills/systematic-debugging/index.ts +1 -1
  168. package/src/skills/tdd/index.ts +1 -1
  169. package/src/skills/to-plan/index-enhanced.ts +3 -5
  170. package/src/skills/to-plan/index.ts +231 -502
  171. package/dist/src/extension/classifier.d.ts +0 -18
  172. package/dist/src/extension/classifier.d.ts.map +0 -1
  173. package/dist/src/extension/classifier.js +0 -143
  174. package/dist/src/extension/classifier.js.map +0 -1
  175. package/src/extension/classifier.ts +0 -160
@@ -1,13 +1,14 @@
1
1
  /**
2
- * Subagent Dispatcher - Spawns isolated subagents to do all real work
2
+ * Subagent Dispatcher - Spawns isolated subagents with fresh context
3
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.
4
+ * Key principle: Each subagent gets ONLY the context it needs.
5
+ * - No history pollution from main agent
6
+ * - Minimal, focused prompts
7
+ * - Token budget enforcement
8
+ * - Resilient dispatch with circuit breaker and retry
8
9
  */
9
10
 
10
- import { nanoid } from 'nanoid';
11
+ import { nanoid } from 'nanoid'
11
12
  import type {
12
13
  SubagentConfig,
13
14
  SubagentContract,
@@ -15,81 +16,70 @@ import type {
15
16
  SubagentArtifact,
16
17
  PermissionSet,
17
18
  SubagentRole,
18
- } from './types.js';
19
- import type { Logger } from '../types.js';
19
+ } from './types.js'
20
+ import { TokenBudget, BudgetExceededError } from './token-budget.js'
21
+ import { ResilientDispatcher } from './resilience.js'
22
+ import type { Logger } from '../types.js'
20
23
 
21
24
  // ---------------------------------------------------------------------------
22
- // Executor abstraction
25
+ // Executor Types (re-exported for index.ts compatibility)
23
26
  // ---------------------------------------------------------------------------
24
27
 
25
28
  export interface ExecutorOptions {
26
- id: string;
27
- model?: string;
28
- timeout?: number;
29
- workDir?: string;
29
+ id: string
30
+ model?: string
31
+ timeout?: number
32
+ workDir?: string
30
33
  }
31
34
 
32
35
  export interface ExecutorResult {
33
- output: string;
34
- tokensUsed: number;
35
- errors: string[];
36
+ output: string
37
+ tokensUsed: number
38
+ errors: string[]
36
39
  }
37
40
 
38
41
  export interface SubagentExecutor {
39
- execute(prompt: string, options: ExecutorOptions): Promise<ExecutorResult>;
40
- abort(id: string): Promise<void>;
42
+ execute(prompt: string, options: ExecutorOptions): Promise<ExecutorResult>
43
+ abort(id: string): Promise<void>
41
44
  }
42
45
 
43
- // ---------------------------------------------------------------------------
44
- // Default executor (process-based)
45
- // ---------------------------------------------------------------------------
46
-
47
46
  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>;
47
+ command: string
48
+ args: string[]
49
+ env: Record<string, string>
54
50
  }
55
51
 
56
52
  const DEFAULT_PROCESS_CONFIG: ProcessExecutorConfig = {
57
53
  command: 'claude',
58
54
  args: ['--print', '--output-format', 'json'],
59
55
  env: {},
60
- };
56
+ }
61
57
 
62
58
  /**
63
59
  * 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
60
  */
68
61
  export function createProcessExecutor(
69
- config: Partial<ProcessExecutorConfig> = {},
62
+ config: Partial<ProcessExecutorConfig> = {}
70
63
  ): SubagentExecutor {
71
64
  const resolved: ProcessExecutorConfig = {
72
65
  ...DEFAULT_PROCESS_CONFIG,
73
66
  ...config,
74
- };
67
+ }
75
68
 
76
- const active = new Map<
77
- string,
78
- ReturnType<typeof import('child_process').spawn>
79
- >();
69
+ const active = new Map<string, ReturnType<typeof import('child_process').spawn>>()
80
70
 
81
71
  return {
82
72
  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');
73
+ const { spawn } = await import('child_process')
74
+ const { writeFile, mkdir, rm } = await import('fs/promises')
75
+ const { join } = await import('path')
76
+ const { tmpdir } = await import('os')
87
77
 
88
- const workDir = options.workDir ?? join(tmpdir(), `yi-subagent-${options.id}`);
89
- await mkdir(workDir, { recursive: true });
78
+ const workDir = options.workDir ?? join(tmpdir(), `yi-subagent-${options.id}`)
79
+ await mkdir(workDir, { recursive: true })
90
80
 
91
- const promptPath = join(workDir, 'prompt.md');
92
- await writeFile(promptPath, prompt, 'utf-8');
81
+ const promptPath = join(workDir, 'prompt.md')
82
+ await writeFile(promptPath, prompt, 'utf-8')
93
83
 
94
84
  const child = spawn(
95
85
  resolved.command,
@@ -98,69 +88,68 @@ export function createProcessExecutor(
98
88
  env: { ...process.env, ...resolved.env },
99
89
  stdio: ['ignore', 'pipe', 'pipe'],
100
90
  timeout: options.timeout ? options.timeout * 1000 : undefined,
101
- },
102
- );
91
+ }
92
+ )
103
93
 
104
- active.set(options.id, child);
94
+ active.set(options.id, child)
105
95
 
106
- const outputChunks: Buffer[] = [];
107
- const errorChunks: Buffer[] = [];
96
+ const outputChunks: Buffer[] = []
97
+ const errorChunks: Buffer[] = []
108
98
 
109
- child.stdout?.on('data', (chunk: Buffer) => outputChunks.push(chunk));
110
- child.stderr?.on('data', (chunk: Buffer) => errorChunks.push(chunk));
99
+ child.stdout?.on('data', (chunk: Buffer) => outputChunks.push(chunk))
100
+ child.stderr?.on('data', (chunk: Buffer) => errorChunks.push(chunk))
111
101
 
112
102
  try {
113
103
  const exitCode = await new Promise<number | null>((resolve, reject) => {
114
- child.on('close', resolve);
115
- child.on('error', reject);
116
- });
104
+ child.on('close', resolve)
105
+ child.on('error', reject)
106
+ })
117
107
 
118
- active.delete(options.id);
108
+ active.delete(options.id)
119
109
 
120
- const stdout = Buffer.concat(outputChunks).toString('utf-8').trim();
121
- const stderr = Buffer.concat(errorChunks).toString('utf-8').trim();
110
+ const stdout = Buffer.concat(outputChunks).toString('utf-8').trim()
111
+ const stderr = Buffer.concat(errorChunks).toString('utf-8').trim()
122
112
 
123
- const errors: string[] = [];
113
+ const errors: string[] = []
124
114
  if (exitCode !== 0) {
125
- errors.push(`Subprocess exited with code ${exitCode}`);
115
+ errors.push(`Subprocess exited with code ${exitCode}`)
126
116
  }
127
117
  if (stderr) {
128
- errors.push(stderr);
118
+ errors.push(stderr)
129
119
  }
130
120
 
131
- // Best-effort cleanup
132
- await rm(workDir, { recursive: true, force: true }).catch(() => undefined);
121
+ await rm(workDir, { recursive: true, force: true }).catch(() => undefined)
133
122
 
134
- // Rough token estimate: ~4 chars per token
135
- const tokensUsed = Math.ceil((prompt.length + stdout.length) / 4);
123
+ const tokensUsed = Math.ceil((prompt.length + stdout.length) / 4)
136
124
 
137
- return { output: stdout, tokensUsed, errors };
125
+ return { output: stdout, tokensUsed, errors }
138
126
  } catch (err) {
139
- active.delete(options.id);
140
- await rm(workDir, { recursive: true, force: true }).catch(() => undefined);
127
+ active.delete(options.id)
128
+ await rm(workDir, { recursive: true, force: true }).catch(() => undefined)
141
129
  return {
142
130
  output: '',
143
131
  tokensUsed: Math.ceil(prompt.length / 4),
144
132
  errors: [String(err)],
145
- };
133
+ }
146
134
  }
147
135
  },
148
136
 
149
137
  async abort(id: string): Promise<void> {
150
- const child = active.get(id);
138
+ const child = active.get(id)
151
139
  if (child) {
152
- child.kill('SIGTERM');
153
- active.delete(id);
140
+ child.kill('SIGTERM')
141
+ active.delete(id)
154
142
  }
155
143
  },
156
- };
144
+ }
157
145
  }
158
146
 
159
147
  // ---------------------------------------------------------------------------
160
148
  // Constants
161
149
  // ---------------------------------------------------------------------------
162
150
 
163
- const DEFAULT_TIMEOUT_SECONDS = 300;
151
+ const DEFAULT_TIMEOUT_SECONDS = 300
152
+ const DEFAULT_TOKEN_BUDGET = 32000
164
153
 
165
154
  // ---------------------------------------------------------------------------
166
155
  // Role-specific system instructions
@@ -183,7 +172,7 @@ const ROLE_INSTRUCTIONS: Record<SubagentRole, string> = {
183
172
  'You are a GENERAL subagent. Complete the assigned task within your permissions.',
184
173
  researcher:
185
174
  'You are a RESEARCHER subagent. Your job is to research topics and provide findings.',
186
- };
175
+ }
187
176
 
188
177
  const PERMISSION_LABELS: Record<keyof PermissionSet, string> = {
189
178
  readFiles: 'Read Files',
@@ -191,21 +180,41 @@ const PERMISSION_LABELS: Record<keyof PermissionSet, string> = {
191
180
  runCommands: 'Run Commands',
192
181
  writeFiles: 'Write Files',
193
182
  gitOperations: 'Git Operations',
194
- };
183
+ }
195
184
 
196
185
  // ---------------------------------------------------------------------------
197
186
  // Subagent Dispatcher
198
187
  // ---------------------------------------------------------------------------
199
188
 
189
+ export interface SubagentDispatcherConfig {
190
+ logger: Logger
191
+ tokenBudget?: { limit: number; warningThreshold: number }
192
+ resilience?: {
193
+ circuitBreaker?: { failureThreshold?: number; successThreshold?: number; timeout?: number }
194
+ retryPolicy?: { maxRetries?: number; baseDelay?: number; maxDelay?: number }
195
+ }
196
+ }
197
+
198
+ export interface DispatchConfig {
199
+ config: SubagentConfig
200
+ contract: SubagentContract
201
+ fallback?: () => unknown
202
+ }
203
+
200
204
  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();
205
+ private readonly logger: Logger
206
+ private readonly tokenBudget: TokenBudget
207
+ private readonly resilientDispatcher: ResilientDispatcher
208
+ private readonly activeIds: Set<string>
209
+
210
+ constructor(config: SubagentDispatcherConfig) {
211
+ this.logger = config.logger
212
+ this.tokenBudget = new TokenBudget(config.tokenBudget)
213
+ this.resilientDispatcher = new ResilientDispatcher({
214
+ circuitBreaker: config.resilience?.circuitBreaker,
215
+ retryPolicy: config.resilience?.retryPolicy,
216
+ })
217
+ this.activeIds = new Set()
209
218
  }
210
219
 
211
220
  // -----------------------------------------------------------------------
@@ -213,70 +222,72 @@ export class SubagentDispatcher {
213
222
  // -----------------------------------------------------------------------
214
223
 
215
224
  /**
216
- * Dispatch a single subagent with the given config and contract.
225
+ * Dispatch a single subagent with fresh context.
217
226
  *
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.
227
+ * Steps:
228
+ * 1. Check token budget
229
+ * 2. Build fresh prompt (no history pollution)
230
+ * 3. Spawn subagent via resilient dispatcher
231
+ * 4. Parse and validate output
232
+ * 5. Return structured result
221
233
  */
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})`);
234
+ async dispatch(config: SubagentConfig, contract: SubagentContract): Promise<SubagentResult> {
235
+ const id = nanoid()
236
+ const startTime = Date.now()
237
+ const timeoutSeconds = config.timeout ?? DEFAULT_TIMEOUT_SECONDS
238
+
239
+ this.activeIds.add(id)
240
+ this.logger.info(`[Dispatcher] Dispatching ${id} (role=${config.role})`)
241
+
242
+ // Check token budget
243
+ const prompt = this.buildFreshPrompt(config, contract)
244
+ const estimatedTokens = TokenBudget.estimateTokens(prompt)
245
+
246
+ if (!this.tokenBudget.canAfford(estimatedTokens)) {
247
+ this.activeIds.delete(id)
248
+ throw new BudgetExceededError(
249
+ `Cannot afford ${estimatedTokens} tokens. Remaining: ${this.tokenBudget.getRemaining()}`
250
+ )
251
+ }
232
252
 
233
253
  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';
254
+ // Execute via resilient dispatcher (handles circuit breaker + retry)
255
+ const result = await this.resilientDispatcher.dispatch<SubagentResult>(
256
+ async () => {
257
+ return await this.executeSubagent(id, config, contract, prompt, timeoutSeconds)
258
+ },
259
+ {
260
+ fallback: () => ({
261
+ id,
262
+ role: config.role,
263
+ status: 'failure',
264
+ output: '',
265
+ artifacts: [],
266
+ tokensUsed: 0,
267
+ duration: Date.now() - startTime,
268
+ errors: ['Fallback executed due to circuit breaker or retry exhaustion'],
269
+ }),
270
+ }
271
+ )
256
272
 
257
- const artifacts = extractArtifacts(executorResult.output);
273
+ // Record actual token usage
274
+ this.tokenBudget.recordUsage(result.tokensUsed || estimatedTokens)
258
275
 
276
+ const duration = Date.now() - startTime
259
277
  this.logger.info(
260
- `[Dispatcher] Subagent ${id} completed: status=${status} duration=${duration}ms tokens=${executorResult.tokensUsed}`,
261
- );
278
+ `[Dispatcher] Subagent ${id} completed: status=${result.status} duration=${duration}ms tokens=${result.tokensUsed || estimatedTokens}`
279
+ )
262
280
 
263
281
  return {
264
- id,
265
- role: config.role,
266
- status,
267
- output: executorResult.output,
268
- artifacts,
269
- tokensUsed: executorResult.tokensUsed,
282
+ ...result,
270
283
  duration,
271
- errors: allErrors,
272
- };
284
+ }
273
285
  } 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');
286
+ const duration = Date.now() - startTime
287
+ const errorMessage = error instanceof Error ? error.message : String(error)
288
+ const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('Timeout')
278
289
 
279
- this.logger.error(`[Dispatcher] Subagent ${id} failed: ${errorMessage}`);
290
+ this.logger.error(`[Dispatcher] Subagent ${id} failed: ${errorMessage}`)
280
291
 
281
292
  return {
282
293
  id,
@@ -284,227 +295,221 @@ export class SubagentDispatcher {
284
295
  status: isTimeout ? 'timeout' : 'failure',
285
296
  output: '',
286
297
  artifacts: [],
287
- tokensUsed: 0,
298
+ tokensUsed: estimatedTokens,
288
299
  duration,
289
300
  errors: [errorMessage],
290
- };
301
+ }
291
302
  } finally {
292
- this.activeIds.delete(id);
303
+ this.activeIds.delete(id)
293
304
  }
294
305
  }
295
306
 
296
307
  /**
297
308
  * Dispatch multiple subagents in parallel.
298
309
  *
299
- * All subagents run concurrently. Use when tasks are independent
300
- * of each other (e.g. exploring multiple directories at once).
310
+ * Each subagent receives ONLY its own contract - no shared context pollution.
311
+ * Results are returned in the same order as configs.
301
312
  */
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
- );
313
+ async dispatchParallel(configs: DispatchConfig[]): Promise<SubagentResult[]> {
314
+ this.logger.info(`[Dispatcher] Dispatching ${configs.length} subagents in parallel`)
315
+
316
+ // Check total budget before dispatching
317
+ const totalEstimatedTokens = configs.reduce((sum, { contract }) => {
318
+ const prompt = this.buildFreshPrompt({ role: 'general' }, contract)
319
+ return sum + TokenBudget.estimateTokens(prompt)
320
+ }, 0)
321
+
322
+ if (!this.tokenBudget.canAfford(totalEstimatedTokens)) {
323
+ throw new BudgetExceededError(
324
+ `Parallel dispatch requires ${totalEstimatedTokens} tokens. Remaining: ${this.tokenBudget.getRemaining()}`
325
+ )
310
326
  }
311
327
 
312
- this.logger.info(
313
- `[Dispatcher] Dispatching ${configs.length} subagents in parallel`,
314
- );
328
+ // Dispatch all in parallel
329
+ const promises = configs.map(({ config, contract }) =>
330
+ this.dispatch(config, contract).catch((error) => {
331
+ const errorMessage = error instanceof Error ? error.message : String(error)
332
+ return {
333
+ id: nanoid(),
334
+ role: config.role,
335
+ status: 'failure' as const,
336
+ output: '',
337
+ artifacts: [],
338
+ tokensUsed: 0,
339
+ duration: 0,
340
+ errors: [errorMessage],
341
+ }
342
+ })
343
+ )
315
344
 
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
- );
345
+ const results = await Promise.all(promises)
320
346
 
321
347
  const counts = {
322
348
  success: results.filter((r) => r.status === 'success').length,
323
349
  failure: results.filter((r) => r.status === 'failure').length,
324
350
  timeout: results.filter((r) => r.status === 'timeout').length,
325
- };
351
+ }
326
352
 
327
353
  this.logger.info(
328
- `[Dispatcher] Parallel batch complete: ${counts.success} success, ${counts.failure} failure, ${counts.timeout} timeout`,
329
- );
354
+ `[Dispatcher] Parallel batch complete: ${counts.success} success, ${counts.failure} failure, ${counts.timeout} timeout`
355
+ )
330
356
 
331
- return results;
357
+ return results
332
358
  }
333
359
 
334
360
  /**
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).
361
+ * Get current health status including token usage and circuit state
341
362
  */
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
- );
363
+ getHealth(): {
364
+ tokenUsage: { used: number; limit: number; percentage: number; remaining: number }
365
+ circuitState: string
366
+ activeSubagents: number
367
+ } {
368
+ const resilientHealth = this.resilientDispatcher.getHealth()
369
+ const tokenUsage = this.tokenBudget.getUsage()
370
+
371
+ return {
372
+ tokenUsage: {
373
+ ...tokenUsage,
374
+ remaining: this.tokenBudget.getRemaining(),
375
+ },
376
+ circuitState: resilientHealth.circuitState,
377
+ activeSubagents: this.activeIds.size,
350
378
  }
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
379
  }
376
380
 
377
381
  /**
378
- * Abort a running subagent by its ID.
382
+ * Reset token budget (for testing or new milestone)
379
383
  */
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}`);
384
+ resetBudget(): void {
385
+ this.tokenBudget.reset()
386
+ this.logger.info('[Dispatcher] Token budget reset')
388
387
  }
389
388
 
390
389
  // -----------------------------------------------------------------------
391
- // Prompt construction
390
+ // Prompt construction - FRESH CONTEXT ONLY
392
391
  // -----------------------------------------------------------------------
393
392
 
394
393
  /**
395
- * Build a structured subagent prompt from config and contract.
394
+ * Build a fresh prompt with ONLY necessary context.
396
395
  *
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.
396
+ * Excludes:
397
+ * - Main agent conversation history
398
+ * - Previous subagent outputs (unless explicitly passed)
399
+ * - Internal reasoning
400
+ *
401
+ * Includes:
402
+ * - Role instruction
403
+ * - Permissions
404
+ * - File scope
405
+ * - Token budget
406
+ * - Output schema
407
+ * - Task description
400
408
  */
401
- private buildPrompt(
402
- config: SubagentConfig,
403
- contract: SubagentContract,
404
- ): string {
405
- const sections: string[] = [];
409
+ private buildFreshPrompt(config: SubagentConfig, contract: SubagentContract): string {
410
+ const sections: string[] = []
406
411
 
407
- sections.push(buildRoleHeader(config.role));
408
- sections.push(buildPermissionsBlock(contract.permissions));
409
- sections.push(buildFileScopeBlock(contract.owns, contract.reads));
412
+ sections.push(buildRoleHeader(config.role))
413
+ sections.push(buildPermissionsBlock(contract.permissions))
414
+ sections.push(buildFileScopeBlock(contract.owns, contract.reads))
410
415
 
411
416
  if (config.isolation === 'worktree') {
412
- sections.push(buildIsolationBlock());
417
+ sections.push(buildIsolationBlock())
413
418
  }
414
419
 
415
- sections.push(buildBudgetBlock(config.tokenBudget));
416
- sections.push(buildOutputSchemaBlock(contract.outputSchema));
417
- sections.push(buildTaskBlock(contract.prompt));
420
+ sections.push(buildBudgetBlock(config.tokenBudget))
421
+ sections.push(buildOutputSchemaBlock(contract.outputSchema))
422
+ sections.push(buildTaskBlock(contract.prompt))
418
423
 
419
- return sections.join('\n\n---\n\n');
424
+ return sections.join('\n\n---\n\n')
420
425
  }
421
426
 
422
427
  // -----------------------------------------------------------------------
423
- // Timeout handling
428
+ // Subagent execution
424
429
  // -----------------------------------------------------------------------
425
430
 
426
431
  /**
427
- * Wraps a promise with a timeout. If the timeout fires first the
428
- * subagent is aborted and the promise rejects.
432
+ * Execute a subagent process.
433
+ * This is the actual implementation that the resilient dispatcher wraps.
429
434
  */
430
- private withTimeout<T>(
431
- promise: Promise<T>,
432
- timeoutMs: number,
435
+ private async executeSubagent(
433
436
  id: string,
434
- ): Promise<T> {
435
- if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) {
436
- return promise;
437
- }
437
+ config: SubagentConfig,
438
+ contract: SubagentContract,
439
+ prompt: string,
440
+ timeoutSeconds: number
441
+ ): Promise<SubagentResult> {
442
+ const { spawn } = await import('child_process')
443
+ const { writeFile, mkdir, rm } = await import('fs/promises')
444
+ const { join } = await import('path')
445
+ const { tmpdir } = await import('os')
438
446
 
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);
447
+ const workDir = join(tmpdir(), `yi-subagent-${id}`)
448
+ await mkdir(workDir, { recursive: true })
444
449
 
445
- promise
446
- .then((result) => {
447
- clearTimeout(timer);
448
- resolve(result);
449
- })
450
- .catch((error) => {
451
- clearTimeout(timer);
452
- reject(error);
453
- });
454
- });
455
- }
450
+ const promptPath = join(workDir, 'prompt.md')
451
+ await writeFile(promptPath, prompt, 'utf-8')
456
452
 
457
- // -----------------------------------------------------------------------
458
- // Output validation
459
- // -----------------------------------------------------------------------
453
+ return new Promise((resolve, reject) => {
454
+ const child = spawn(
455
+ 'claude',
456
+ ['--print', '--output-format', 'json', '--input-file', promptPath],
457
+ {
458
+ env: { ...process.env, CLAUDE_SUBAGENT: '1' },
459
+ stdio: ['ignore', 'pipe', 'pipe'],
460
+ timeout: timeoutSeconds * 1000,
461
+ }
462
+ )
460
463
 
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
- }
464
+ const outputChunks: Buffer[] = []
465
+ const errorChunks: Buffer[] = []
475
466
 
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();
467
+ child.stdout?.on('data', (chunk: Buffer) => outputChunks.push(chunk))
468
+ child.stderr?.on('data', (chunk: Buffer) => errorChunks.push(chunk))
479
469
 
480
- let parsed: unknown;
481
- try {
482
- parsed = JSON.parse(jsonText);
483
- } catch {
484
- return ['Output is not valid JSON'];
485
- }
470
+ child.on('close', async (exitCode) => {
471
+ // Cleanup
472
+ await rm(workDir, { recursive: true, force: true }).catch(() => undefined)
486
473
 
487
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
488
- return ['Output must be a JSON object'];
489
- }
474
+ const stdout = Buffer.concat(outputChunks).toString('utf-8').trim()
475
+ const stderr = Buffer.concat(errorChunks).toString('utf-8').trim()
490
476
 
491
- const obj = parsed as Record<string, unknown>;
492
- const errors: string[] = [];
477
+ const errors: string[] = []
478
+ if (exitCode !== 0) {
479
+ errors.push(`Subprocess exited with code ${exitCode}`)
480
+ }
481
+ if (stderr) {
482
+ errors.push(stderr)
483
+ }
493
484
 
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
- }
485
+ // Parse output and validate
486
+ const tokensUsed = Math.ceil((prompt.length + stdout.length) / 4)
487
+ const artifacts = extractArtifacts(stdout)
488
+ const status: SubagentResult['status'] = errors.length > 0 ? 'failure' : 'success'
506
489
 
507
- return errors;
490
+ // Validate against schema if provided
491
+ const validationErrors = validateOutput(stdout, contract.outputSchema)
492
+ if (validationErrors.length > 0) {
493
+ errors.push(...validationErrors)
494
+ }
495
+
496
+ resolve({
497
+ id,
498
+ role: config.role,
499
+ status: validationErrors.length > 0 ? 'failure' : status,
500
+ output: stdout,
501
+ artifacts,
502
+ tokensUsed,
503
+ duration: 0, // Will be set by caller
504
+ errors: [...errors, ...validationErrors],
505
+ })
506
+ })
507
+
508
+ child.on('error', async (error) => {
509
+ await rm(workDir, { recursive: true, force: true }).catch(() => undefined)
510
+ reject(error)
511
+ })
512
+ })
508
513
  }
509
514
  }
510
515
 
@@ -513,45 +518,42 @@ export class SubagentDispatcher {
513
518
  // ---------------------------------------------------------------------------
514
519
 
515
520
  function buildRoleHeader(role: SubagentRole): string {
516
- const instruction = ROLE_INSTRUCTIONS[role] ?? ROLE_INSTRUCTIONS.general;
517
- return `# Role: ${role}\n\n${instruction}`;
521
+ const instruction = ROLE_INSTRUCTIONS[role] ?? ROLE_INSTRUCTIONS.general
522
+ return `# Role: ${role}\n\n${instruction}`
518
523
  }
519
524
 
520
525
  function buildPermissionsBlock(permissions: PermissionSet): string {
521
- const lines: string[] = ['## Permissions'];
526
+ const lines: string[] = ['## Permissions']
522
527
  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}`);
528
+ const label = PERMISSION_LABELS[key]
529
+ const allowed = permissions[key] ? 'ALLOWED' : 'DENIED'
530
+ lines.push(`- ${label}: ${allowed}`)
526
531
  }
527
- return lines.join('\n');
532
+ return lines.join('\n')
528
533
  }
529
534
 
530
- function buildFileScopeBlock(
531
- owns: readonly string[],
532
- reads: readonly string[],
533
- ): string {
534
- const lines: string[] = ['## File Scope'];
535
+ function buildFileScopeBlock(owns: readonly string[], reads: readonly string[]): string {
536
+ const lines: string[] = ['## File Scope']
535
537
 
536
- lines.push('\n### Owned Files (may modify)');
538
+ lines.push('\n### Owned Files (may modify)')
537
539
  if (owns.length === 0) {
538
- lines.push('- None');
540
+ lines.push('- None')
539
541
  } else {
540
542
  for (const file of owns) {
541
- lines.push(`- ${file}`);
543
+ lines.push(`- ${file}`)
542
544
  }
543
545
  }
544
546
 
545
- lines.push('\n### Read-only Files');
547
+ lines.push('\n### Read-only Files')
546
548
  if (reads.length === 0) {
547
- lines.push('- None');
549
+ lines.push('- None')
548
550
  } else {
549
551
  for (const file of reads) {
550
- lines.push(`- ${file}`);
552
+ lines.push(`- ${file}`)
551
553
  }
552
554
  }
553
555
 
554
- return lines.join('\n');
556
+ return lines.join('\n')
555
557
  }
556
558
 
557
559
  function buildIsolationBlock(): string {
@@ -564,33 +566,33 @@ function buildIsolationBlock(): string {
564
566
  '- Run tests within the worktree',
565
567
  '- Commit your changes before exiting',
566
568
  '- Never modify files outside the worktree',
567
- ].join('\n');
569
+ ].join('\n')
568
570
  }
569
571
 
570
572
  function buildBudgetBlock(tokenBudget?: number): string {
571
- const budget = tokenBudget ?? 32000;
573
+ const budget = tokenBudget ?? DEFAULT_TOKEN_BUDGET
572
574
  return [
573
575
  '## Token Budget',
574
576
  '',
575
577
  `You have a budget of **${budget.toLocaleString()} tokens**.`,
576
578
  'Stay within this limit. Be concise. Prefer essential information.',
577
- ].join('\n');
579
+ ].join('\n')
578
580
  }
579
581
 
580
582
  function buildOutputSchemaBlock(schema?: Record<string, unknown>): string {
581
583
  if (!schema || Object.keys(schema).length === 0) {
582
- return '## Output Format\n\nProvide your response as plain text or markdown.';
584
+ return '## Output Format\n\nProvide your response as plain text or markdown.'
583
585
  }
584
586
 
585
- const keys = Object.keys(schema);
587
+ const keys = Object.keys(schema)
586
588
  const lines: string[] = [
587
589
  '## Required Output Format',
588
590
  '',
589
591
  'Your response MUST be a JSON object with these fields:',
590
- ];
592
+ ]
591
593
 
592
594
  for (const key of keys) {
593
- lines.push(`- \`${key}\`: ${String(schema[key])}`);
595
+ lines.push(`- \`${key}\`: ${String(schema[key])}`)
594
596
  }
595
597
 
596
598
  lines.push(
@@ -599,60 +601,84 @@ function buildOutputSchemaBlock(schema?: Record<string, unknown>): string {
599
601
  '',
600
602
  '```json',
601
603
  `{ ${keys.map((k) => `"${k}": ...`).join(', ')} }`,
602
- '```',
603
- );
604
+ '```'
605
+ )
604
606
 
605
- return lines.join('\n');
607
+ return lines.join('\n')
606
608
  }
607
609
 
608
610
  function buildTaskBlock(prompt: string): string {
609
- return `## Task\n\n${prompt}`;
611
+ return `## Task\n\n${prompt}`
610
612
  }
611
613
 
614
+ // ---------------------------------------------------------------------------
615
+ // Output validation
616
+ // ---------------------------------------------------------------------------
617
+
612
618
  /**
613
- * Augment a contract with output from previous subagents.
614
- * Used by dispatchSequential to provide pipeline context.
619
+ * Validate subagent output against an optional schema.
620
+ * Returns an array of validation error messages (empty = valid).
615
621
  */
616
- function augmentWithPreviousResults(
617
- contract: SubagentContract,
618
- previousResults: readonly SubagentResult[],
619
- ): SubagentContract {
620
- if (previousResults.length === 0) {
621
- return contract;
622
+ function validateOutput(output: string, schema?: Record<string, unknown>): string[] {
623
+ if (!schema || Object.keys(schema).length === 0) {
624
+ return []
622
625
  }
623
626
 
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');
627
+ // Try to extract a JSON block from the output
628
+ const jsonMatch = output.match(/```json\s*([\s\S]*?)\s*```/)
629
+ const jsonText = jsonMatch ? jsonMatch[1].trim() : output.trim()
630
630
 
631
- return {
632
- ...contract,
633
- prompt: `${contract.prompt}\n\n## Context from Previous Steps\n\n${context}`,
634
- };
631
+ let parsed: unknown
632
+ try {
633
+ parsed = JSON.parse(jsonText)
634
+ } catch {
635
+ return ['Output is not valid JSON']
636
+ }
637
+
638
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
639
+ return ['Output must be a JSON object']
640
+ }
641
+
642
+ const obj = parsed as Record<string, unknown>
643
+ const errors: string[] = []
644
+
645
+ for (const [key, expectedType] of Object.entries(schema)) {
646
+ if (!(key in obj)) {
647
+ errors.push(`Missing required field: "${key}"`)
648
+ continue
649
+ }
650
+ const actualType = typeof obj[key]
651
+ if (actualType !== expectedType) {
652
+ errors.push(`Field "${key}": expected type ${String(expectedType)}, got ${actualType}`)
653
+ }
654
+ }
655
+
656
+ return errors
635
657
  }
636
658
 
659
+ // ---------------------------------------------------------------------------
660
+ // Artifact extraction
661
+ // ---------------------------------------------------------------------------
662
+
637
663
  /**
638
664
  * Extract structured artifacts from subagent output text.
639
665
  * Recognises fenced code blocks (JSON, diff) and converts them
640
666
  * to SubagentArtifact records.
641
667
  */
642
668
  function extractArtifacts(output: string): SubagentArtifact[] {
643
- const artifacts: SubagentArtifact[] = [];
669
+ const artifacts: SubagentArtifact[] = []
644
670
 
645
- const jsonBlocks = output.matchAll(/```json\s*([\s\S]*?)\s*```/g);
671
+ const jsonBlocks = output.matchAll(/```json\s*([\s\S]*?)\s*```/g)
646
672
  for (const match of jsonBlocks) {
647
- artifacts.push({ type: 'report', content: match[1].trim() });
673
+ artifacts.push({ type: 'report', content: match[1].trim() })
648
674
  }
649
675
 
650
- const diffBlocks = output.matchAll(/```diff\s*([\s\S]*?)\s*```/g);
676
+ const diffBlocks = output.matchAll(/```diff\s*([\s\S]*?)\s*```/g)
651
677
  for (const match of diffBlocks) {
652
- artifacts.push({ type: 'diff', content: match[1].trim() });
678
+ artifacts.push({ type: 'diff', content: match[1].trim() })
653
679
  }
654
680
 
655
- return artifacts;
681
+ return artifacts
656
682
  }
657
683
 
658
684
  // ---------------------------------------------------------------------------
@@ -660,21 +686,20 @@ function extractArtifacts(output: string): SubagentArtifact[] {
660
686
  // ---------------------------------------------------------------------------
661
687
 
662
688
  export interface DispatcherOptions {
663
- executor?: SubagentExecutor;
664
- processConfig?: Partial<ProcessExecutorConfig>;
689
+ logger: Logger
690
+ tokenBudget?: { limit: number; warningThreshold: number }
691
+ resilience?: {
692
+ circuitBreaker?: { failureThreshold?: number; successThreshold?: number; timeout?: number }
693
+ retryPolicy?: { maxRetries?: number; baseDelay?: number; maxDelay?: number }
694
+ }
665
695
  }
666
696
 
667
697
  /**
668
698
  * 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
699
  */
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);
700
+ export function createDispatcher(options: DispatcherOptions): SubagentDispatcher {
701
+ return new SubagentDispatcher(options)
680
702
  }
703
+
704
+ // Re-export for convenience
705
+ export { TokenBudget, BudgetExceededError, ResilientDispatcher }