@haoyiyin/workflow 0.2.11 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/agents/contracts/implementer.d.ts +29 -0
- package/dist/src/agents/contracts/implementer.d.ts.map +1 -0
- package/dist/src/agents/contracts/implementer.js +94 -0
- package/dist/src/agents/contracts/implementer.js.map +1 -0
- package/dist/src/agents/contracts/index.d.ts +11 -0
- package/dist/src/agents/contracts/index.d.ts.map +1 -0
- package/dist/src/agents/contracts/index.js +11 -0
- package/dist/src/agents/contracts/index.js.map +1 -0
- package/dist/src/agents/contracts/planner.d.ts +25 -0
- package/dist/src/agents/contracts/planner.d.ts.map +1 -0
- package/dist/src/agents/contracts/planner.js +107 -0
- package/dist/src/agents/contracts/planner.js.map +1 -0
- package/dist/src/agents/contracts/router.d.ts +24 -0
- package/dist/src/agents/contracts/router.d.ts.map +1 -0
- package/dist/src/agents/contracts/router.js +137 -0
- package/dist/src/agents/contracts/router.js.map +1 -0
- package/dist/src/agents/contracts/verifier.d.ts +27 -0
- package/dist/src/agents/contracts/verifier.d.ts.map +1 -0
- package/dist/src/agents/contracts/verifier.js +115 -0
- package/dist/src/agents/contracts/verifier.js.map +1 -0
- package/dist/src/agents/dispatcher.d.ts +94 -51
- package/dist/src/agents/dispatcher.d.ts.map +1 -1
- package/dist/src/agents/dispatcher.js +207 -164
- package/dist/src/agents/dispatcher.js.map +1 -1
- package/dist/src/persistence/index.d.ts +4 -2
- package/dist/src/persistence/index.d.ts.map +1 -1
- package/dist/src/persistence/index.js +4 -1
- package/dist/src/persistence/index.js.map +1 -1
- package/dist/src/persistence/plan-md.d.ts +3 -2
- package/dist/src/persistence/plan-md.d.ts.map +1 -1
- package/dist/src/persistence/plan-md.js +47 -15
- package/dist/src/persistence/plan-md.js.map +1 -1
- package/dist/src/persistence/state-md.d.ts +2 -0
- package/dist/src/persistence/state-md.d.ts.map +1 -1
- package/dist/src/persistence/state-md.js +40 -22
- package/dist/src/persistence/state-md.js.map +1 -1
- package/dist/src/persistence/types.d.ts +35 -39
- package/dist/src/persistence/types.d.ts.map +1 -1
- package/dist/src/router/namespace/core/intent-router.d.ts +24 -0
- package/dist/src/router/namespace/core/intent-router.d.ts.map +1 -0
- package/dist/src/router/namespace/core/intent-router.js +190 -0
- package/dist/src/router/namespace/core/intent-router.js.map +1 -0
- package/dist/src/router/namespace/core/lifecycle-router.d.ts +28 -0
- package/dist/src/router/namespace/core/lifecycle-router.d.ts.map +1 -0
- package/dist/src/router/namespace/core/lifecycle-router.js +132 -0
- package/dist/src/router/namespace/core/lifecycle-router.js.map +1 -0
- package/dist/src/router/namespace/core/state-router.d.ts +32 -0
- package/dist/src/router/namespace/core/state-router.d.ts.map +1 -0
- package/dist/src/router/namespace/core/state-router.js +157 -0
- package/dist/src/router/namespace/core/state-router.js.map +1 -0
- package/dist/src/router/namespace/domain/code-router.d.ts +26 -0
- package/dist/src/router/namespace/domain/code-router.d.ts.map +1 -0
- package/dist/src/router/namespace/domain/code-router.js +171 -0
- package/dist/src/router/namespace/domain/code-router.js.map +1 -0
- package/dist/src/router/namespace/domain/debug-router.d.ts +25 -0
- package/dist/src/router/namespace/domain/debug-router.d.ts.map +1 -0
- package/dist/src/router/namespace/domain/debug-router.js +139 -0
- package/dist/src/router/namespace/domain/debug-router.js.map +1 -0
- package/dist/src/router/namespace/domain/plan-router.d.ts +29 -0
- package/dist/src/router/namespace/domain/plan-router.d.ts.map +1 -0
- package/dist/src/router/namespace/domain/plan-router.js +160 -0
- package/dist/src/router/namespace/domain/plan-router.js.map +1 -0
- package/dist/src/router/namespace/domain/review-router.d.ts +24 -0
- package/dist/src/router/namespace/domain/review-router.d.ts.map +1 -0
- package/dist/src/router/namespace/domain/review-router.js +116 -0
- package/dist/src/router/namespace/domain/review-router.js.map +1 -0
- package/dist/src/router/namespace/index.d.ts +19 -0
- package/dist/src/router/namespace/index.d.ts.map +1 -0
- package/dist/src/router/namespace/index.js +22 -0
- package/dist/src/router/namespace/index.js.map +1 -0
- package/dist/src/router/namespace/registry.d.ts +67 -0
- package/dist/src/router/namespace/registry.d.ts.map +1 -0
- package/dist/src/router/namespace/registry.js +197 -0
- package/dist/src/router/namespace/registry.js.map +1 -0
- package/dist/src/router/namespace/types.d.ts +124 -0
- package/dist/src/router/namespace/types.d.ts.map +1 -0
- package/dist/src/router/namespace/types.js +20 -0
- package/dist/src/router/namespace/types.js.map +1 -0
- package/dist/src/router/namespace/utility/fallback-router.d.ts +28 -0
- package/dist/src/router/namespace/utility/fallback-router.d.ts.map +1 -0
- package/dist/src/router/namespace/utility/fallback-router.js +88 -0
- package/dist/src/router/namespace/utility/fallback-router.js.map +1 -0
- package/dist/src/router/namespace/utility/quick-task-router.d.ts +28 -0
- package/dist/src/router/namespace/utility/quick-task-router.d.ts.map +1 -0
- package/dist/src/router/namespace/utility/quick-task-router.js +99 -0
- package/dist/src/router/namespace/utility/quick-task-router.js.map +1 -0
- package/dist/src/router/namespace/utility/research-router.d.ts +24 -0
- package/dist/src/router/namespace/utility/research-router.d.ts.map +1 -0
- package/dist/src/router/namespace/utility/research-router.js +84 -0
- package/dist/src/router/namespace/utility/research-router.js.map +1 -0
- package/dist/src/skills/agents-md/index.js +2 -2
- package/dist/src/skills/agents-md/index.js.map +1 -1
- package/dist/src/skills/execute-plan/index.d.ts +45 -65
- package/dist/src/skills/execute-plan/index.d.ts.map +1 -1
- package/dist/src/skills/execute-plan/index.js +325 -551
- package/dist/src/skills/execute-plan/index.js.map +1 -1
- package/dist/src/skills/index.d.ts +1 -0
- package/dist/src/skills/index.d.ts.map +1 -1
- package/dist/src/skills/index.js +1 -0
- package/dist/src/skills/index.js.map +1 -1
- package/dist/src/skills/quick-task/index.d.ts +4 -4
- package/dist/src/skills/quick-task/index.js +1 -1
- package/dist/src/skills/quick-task/index.js.map +1 -1
- package/dist/src/skills/review-diff/index.d.ts +6 -6
- package/dist/src/skills/review-diff/index.js +1 -1
- package/dist/src/skills/review-diff/index.js.map +1 -1
- package/dist/src/skills/router/index.d.ts +101 -0
- package/dist/src/skills/router/index.d.ts.map +1 -0
- package/dist/src/skills/router/index.js +450 -0
- package/dist/src/skills/router/index.js.map +1 -0
- package/dist/src/skills/router/types.d.ts +79 -0
- package/dist/src/skills/router/types.d.ts.map +1 -0
- package/dist/src/skills/router/types.js +8 -0
- package/dist/src/skills/router/types.js.map +1 -0
- package/dist/src/skills/systematic-debugging/index.js +1 -1
- package/dist/src/skills/systematic-debugging/index.js.map +1 -1
- package/dist/src/skills/tdd/index.d.ts +14 -14
- package/dist/src/skills/tdd/index.js +1 -1
- package/dist/src/skills/tdd/index.js.map +1 -1
- package/dist/src/skills/to-plan/index-enhanced.d.ts +4 -4
- package/dist/src/skills/to-plan/index-enhanced.d.ts.map +1 -1
- package/dist/src/skills/to-plan/index-enhanced.js +3 -5
- package/dist/src/skills/to-plan/index-enhanced.js.map +1 -1
- package/dist/src/skills/to-plan/index.d.ts +24 -91
- package/dist/src/skills/to-plan/index.d.ts.map +1 -1
- package/dist/src/skills/to-plan/index.js +214 -409
- package/dist/src/skills/to-plan/index.js.map +1 -1
- package/package.json +3 -5
- package/src/agents/contracts/implementer.ts +122 -0
- package/src/agents/contracts/index.ts +27 -0
- package/src/agents/contracts/planner.ts +129 -0
- package/src/agents/contracts/router.ts +168 -0
- package/src/agents/contracts/verifier.ts +137 -0
- package/src/agents/dispatcher.ts +387 -362
- package/src/persistence/index.ts +10 -4
- package/src/persistence/plan-md.ts +52 -18
- package/src/persistence/state-md.ts +45 -23
- package/src/persistence/types.ts +37 -40
- package/src/router/namespace/README.md +127 -0
- package/src/router/namespace/core/intent-router.ts +221 -0
- package/src/router/namespace/core/lifecycle-router.ts +156 -0
- package/src/router/namespace/core/state-router.ts +192 -0
- package/src/router/namespace/domain/code-router.ts +202 -0
- package/src/router/namespace/domain/debug-router.ts +167 -0
- package/src/router/namespace/domain/plan-router.ts +196 -0
- package/src/router/namespace/domain/review-router.ts +142 -0
- package/src/router/namespace/index.ts +84 -0
- package/src/router/namespace/registry.ts +242 -0
- package/src/router/namespace/types.ts +182 -0
- package/src/router/namespace/utility/fallback-router.ts +107 -0
- package/src/router/namespace/utility/quick-task-router.ts +121 -0
- package/src/router/namespace/utility/research-router.ts +105 -0
- package/src/skills/agents-md/index.ts +2 -2
- package/src/skills/execute-plan/index.ts +419 -673
- package/src/skills/index.ts +1 -0
- package/src/skills/quick-task/index.ts +1 -1
- package/src/skills/review-diff/index.ts +1 -1
- package/src/skills/router/SKILL.md +181 -0
- package/src/skills/router/index.ts +577 -0
- package/src/skills/router/types.ts +90 -0
- package/src/skills/systematic-debugging/index.ts +1 -1
- package/src/skills/tdd/index.ts +1 -1
- package/src/skills/to-plan/index-enhanced.ts +3 -5
- package/src/skills/to-plan/index.ts +231 -502
package/src/agents/dispatcher.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Subagent Dispatcher - Spawns isolated subagents
|
|
2
|
+
* Subagent Dispatcher - Spawns isolated subagents with fresh context
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
132
|
-
await rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
|
121
|
+
await rm(workDir, { recursive: true, force: true }).catch(() => undefined)
|
|
133
122
|
|
|
134
|
-
|
|
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
|
|
202
|
-
private readonly
|
|
203
|
-
private readonly
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
this.logger = logger
|
|
208
|
-
this.
|
|
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
|
|
225
|
+
* Dispatch a single subagent with fresh context.
|
|
217
226
|
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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=${
|
|
261
|
-
)
|
|
278
|
+
`[Dispatcher] Subagent ${id} completed: status=${result.status} duration=${duration}ms tokens=${result.tokensUsed || estimatedTokens}`
|
|
279
|
+
)
|
|
262
280
|
|
|
263
281
|
return {
|
|
264
|
-
|
|
265
|
-
role: config.role,
|
|
266
|
-
status,
|
|
267
|
-
output: executorResult.output,
|
|
268
|
-
artifacts,
|
|
269
|
-
tokensUsed: executorResult.tokensUsed,
|
|
282
|
+
...result,
|
|
270
283
|
duration,
|
|
271
|
-
|
|
272
|
-
};
|
|
284
|
+
}
|
|
273
285
|
} catch (error) {
|
|
274
|
-
const duration = Date.now() - startTime
|
|
275
|
-
const errorMessage =
|
|
276
|
-
|
|
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:
|
|
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
|
-
*
|
|
300
|
-
*
|
|
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
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
*
|
|
382
|
+
* Reset token budget (for testing or new milestone)
|
|
379
383
|
*/
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
394
|
+
* Build a fresh prompt with ONLY necessary context.
|
|
396
395
|
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
|
|
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
|
|
402
|
-
|
|
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
|
-
//
|
|
428
|
+
// Subagent execution
|
|
424
429
|
// -----------------------------------------------------------------------
|
|
425
430
|
|
|
426
431
|
/**
|
|
427
|
-
*
|
|
428
|
-
*
|
|
432
|
+
* Execute a subagent process.
|
|
433
|
+
* This is the actual implementation that the resilient dispatcher wraps.
|
|
429
434
|
*/
|
|
430
|
-
private
|
|
431
|
-
promise: Promise<T>,
|
|
432
|
-
timeoutMs: number,
|
|
435
|
+
private async executeSubagent(
|
|
433
436
|
id: string,
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
-
|
|
446
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
477
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
474
|
+
const stdout = Buffer.concat(outputChunks).toString('utf-8').trim()
|
|
475
|
+
const stderr = Buffer.concat(errorChunks).toString('utf-8').trim()
|
|
490
476
|
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
*
|
|
614
|
-
*
|
|
619
|
+
* Validate subagent output against an optional schema.
|
|
620
|
+
* Returns an array of validation error messages (empty = valid).
|
|
615
621
|
*/
|
|
616
|
-
function
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
664
|
-
|
|
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
|
-
|
|
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 }
|