@sooneocean/agw 1.4.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.
Files changed (59) hide show
  1. package/README.md +116 -0
  2. package/bin/agw.ts +5 -0
  3. package/package.json +59 -0
  4. package/src/agents/base-adapter.ts +113 -0
  5. package/src/agents/claude-adapter.ts +29 -0
  6. package/src/agents/codex-adapter.ts +29 -0
  7. package/src/agents/gemini-adapter.ts +29 -0
  8. package/src/cli/commands/agents.ts +55 -0
  9. package/src/cli/commands/combo.ts +130 -0
  10. package/src/cli/commands/costs.ts +33 -0
  11. package/src/cli/commands/daemon.ts +110 -0
  12. package/src/cli/commands/history.ts +29 -0
  13. package/src/cli/commands/run.ts +59 -0
  14. package/src/cli/commands/status.ts +29 -0
  15. package/src/cli/commands/workflow.ts +73 -0
  16. package/src/cli/error-handler.ts +8 -0
  17. package/src/cli/http-client.ts +68 -0
  18. package/src/cli/index.ts +28 -0
  19. package/src/config.ts +68 -0
  20. package/src/daemon/middleware/auth.ts +35 -0
  21. package/src/daemon/middleware/rate-limiter.ts +63 -0
  22. package/src/daemon/middleware/tenant.ts +64 -0
  23. package/src/daemon/middleware/workspace.ts +40 -0
  24. package/src/daemon/routes/agents.ts +13 -0
  25. package/src/daemon/routes/combos.ts +103 -0
  26. package/src/daemon/routes/costs.ts +9 -0
  27. package/src/daemon/routes/health.ts +62 -0
  28. package/src/daemon/routes/memory.ts +32 -0
  29. package/src/daemon/routes/tasks.ts +157 -0
  30. package/src/daemon/routes/ui.ts +18 -0
  31. package/src/daemon/routes/workflows.ts +73 -0
  32. package/src/daemon/server.ts +91 -0
  33. package/src/daemon/services/agent-learning.ts +77 -0
  34. package/src/daemon/services/agent-manager.ts +71 -0
  35. package/src/daemon/services/auto-scaler.ts +77 -0
  36. package/src/daemon/services/circuit-breaker.ts +95 -0
  37. package/src/daemon/services/combo-executor.ts +300 -0
  38. package/src/daemon/services/dag-executor.ts +136 -0
  39. package/src/daemon/services/metrics.ts +35 -0
  40. package/src/daemon/services/stream-aggregator.ts +64 -0
  41. package/src/daemon/services/task-executor.ts +184 -0
  42. package/src/daemon/services/task-queue.ts +75 -0
  43. package/src/daemon/services/workflow-executor.ts +150 -0
  44. package/src/daemon/services/ws-manager.ts +90 -0
  45. package/src/dsl/parser.ts +124 -0
  46. package/src/plugins/plugin-loader.ts +72 -0
  47. package/src/router/keyword-router.ts +63 -0
  48. package/src/router/llm-router.ts +93 -0
  49. package/src/store/agent-repo.ts +57 -0
  50. package/src/store/audit-repo.ts +25 -0
  51. package/src/store/combo-repo.ts +99 -0
  52. package/src/store/cost-repo.ts +55 -0
  53. package/src/store/db.ts +137 -0
  54. package/src/store/memory-repo.ts +46 -0
  55. package/src/store/task-repo.ts +127 -0
  56. package/src/store/workflow-repo.ts +69 -0
  57. package/src/types.ts +208 -0
  58. package/tsconfig.json +17 -0
  59. package/ui/index.html +272 -0
@@ -0,0 +1,71 @@
1
+ import type { UnifiedAgent, AgentDescriptor, AppConfig } from '../../types.js';
2
+ import { AgentRepo } from '../../store/agent-repo.js';
3
+ import { AuditRepo } from '../../store/audit-repo.js';
4
+ import { ClaudeAdapter } from '../../agents/claude-adapter.js';
5
+ import { CodexAdapter } from '../../agents/codex-adapter.js';
6
+ import { GeminiAdapter } from '../../agents/gemini-adapter.js';
7
+
8
+ export class AgentManager {
9
+ private adapters: Map<string, UnifiedAgent> = new Map();
10
+
11
+ constructor(
12
+ private agentRepo: AgentRepo,
13
+ private auditRepo: AuditRepo,
14
+ private config: AppConfig,
15
+ ) {
16
+ this.initAdapters();
17
+ }
18
+
19
+ private initAdapters(): void {
20
+ const timeout = this.config.defaultTimeout;
21
+ const maxBuffer = 10 * 1024 * 1024; // 10 MB
22
+
23
+ const agentConfigs = this.config.agents;
24
+
25
+ if (agentConfigs.claude?.enabled !== false) {
26
+ this.adapters.set('claude', new ClaudeAdapter(timeout, maxBuffer, agentConfigs.claude?.args, agentConfigs.claude?.command));
27
+ }
28
+ if (agentConfigs.codex?.enabled !== false) {
29
+ this.adapters.set('codex', new CodexAdapter(timeout, maxBuffer, agentConfigs.codex?.args, agentConfigs.codex?.command));
30
+ }
31
+ if (agentConfigs.gemini?.enabled !== false) {
32
+ this.adapters.set('gemini', new GeminiAdapter(timeout, maxBuffer, agentConfigs.gemini?.args, agentConfigs.gemini?.command));
33
+ }
34
+
35
+ // Sync enabled state to DB
36
+ for (const [id, conf] of Object.entries(agentConfigs)) {
37
+ this.agentRepo.setEnabled(id, conf.enabled);
38
+ }
39
+ }
40
+
41
+ async runHealthChecks(): Promise<void> {
42
+ // M5: Run all health checks in parallel instead of serial
43
+ const checks = Array.from(this.adapters.entries()).map(async ([id, adapter]) => {
44
+ const available = await adapter.healthCheck();
45
+ this.agentRepo.setAvailability(id, available);
46
+ this.auditRepo.log(null, 'agent.health', { agentId: id, available });
47
+ });
48
+ await Promise.allSettled(checks);
49
+ }
50
+
51
+ async checkAgent(id: string): Promise<boolean> {
52
+ const adapter = this.adapters.get(id);
53
+ if (!adapter) return false;
54
+ const available = await adapter.healthCheck();
55
+ this.agentRepo.setAvailability(id, available);
56
+ this.auditRepo.log(null, 'agent.health', { agentId: id, available });
57
+ return available;
58
+ }
59
+
60
+ getAdapter(id: string): UnifiedAgent | undefined {
61
+ return this.adapters.get(id);
62
+ }
63
+
64
+ listAgents(): AgentDescriptor[] {
65
+ return this.agentRepo.listAll();
66
+ }
67
+
68
+ getAvailableAgents(): AgentDescriptor[] {
69
+ return this.agentRepo.listAvailable();
70
+ }
71
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Auto-Scaler — dynamically adjusts per-agent concurrency limits
3
+ * based on queue depth, error rates, and response times.
4
+ */
5
+
6
+ export interface ScaleConfig {
7
+ minConcurrency: number;
8
+ maxConcurrency: number;
9
+ scaleUpThreshold: number; // queue depth to trigger scale up
10
+ scaleDownThreshold: number; // queue depth to trigger scale down
11
+ cooldownMs: number; // minimum time between scale changes
12
+ errorRateThreshold: number; // error rate (0-1) to trigger scale down
13
+ }
14
+
15
+ const DEFAULTS: ScaleConfig = {
16
+ minConcurrency: 1,
17
+ maxConcurrency: 10,
18
+ scaleUpThreshold: 3,
19
+ scaleDownThreshold: 0,
20
+ cooldownMs: 30_000,
21
+ errorRateThreshold: 0.5,
22
+ };
23
+
24
+ export interface ScaleDecision {
25
+ agentId: string;
26
+ action: 'scale-up' | 'scale-down' | 'hold';
27
+ currentConcurrency: number;
28
+ newConcurrency: number;
29
+ reason: string;
30
+ }
31
+
32
+ export class AutoScaler {
33
+ private config: ScaleConfig;
34
+ private lastScaleTime = new Map<string, number>();
35
+ private concurrencyMap = new Map<string, number>();
36
+
37
+ constructor(config?: Partial<ScaleConfig>) {
38
+ this.config = { ...DEFAULTS, ...config };
39
+ }
40
+
41
+ getConcurrency(agentId: string): number {
42
+ return this.concurrencyMap.get(agentId) ?? this.config.minConcurrency;
43
+ }
44
+
45
+ evaluate(agentId: string, queueDepth: number, errorRate: number): ScaleDecision {
46
+ const now = Date.now();
47
+ const current = this.getConcurrency(agentId);
48
+ const lastScale = this.lastScaleTime.get(agentId) ?? 0;
49
+ const inCooldown = (now - lastScale) < this.config.cooldownMs;
50
+
51
+ // Scale down on high error rate
52
+ if (errorRate >= this.config.errorRateThreshold && current > this.config.minConcurrency && !inCooldown) {
53
+ const newVal = Math.max(this.config.minConcurrency, current - 1);
54
+ this.concurrencyMap.set(agentId, newVal);
55
+ this.lastScaleTime.set(agentId, now);
56
+ return { agentId, action: 'scale-down', currentConcurrency: current, newConcurrency: newVal, reason: `error rate ${(errorRate * 100).toFixed(0)}% exceeds threshold` };
57
+ }
58
+
59
+ // Scale up on queue pressure
60
+ if (queueDepth >= this.config.scaleUpThreshold && current < this.config.maxConcurrency && !inCooldown) {
61
+ const newVal = Math.min(this.config.maxConcurrency, current + 1);
62
+ this.concurrencyMap.set(agentId, newVal);
63
+ this.lastScaleTime.set(agentId, now);
64
+ return { agentId, action: 'scale-up', currentConcurrency: current, newConcurrency: newVal, reason: `queue depth ${queueDepth} exceeds threshold` };
65
+ }
66
+
67
+ // Scale down when queue is empty
68
+ if (queueDepth <= this.config.scaleDownThreshold && current > this.config.minConcurrency && !inCooldown) {
69
+ const newVal = Math.max(this.config.minConcurrency, current - 1);
70
+ this.concurrencyMap.set(agentId, newVal);
71
+ this.lastScaleTime.set(agentId, now);
72
+ return { agentId, action: 'scale-down', currentConcurrency: current, newConcurrency: newVal, reason: 'queue empty' };
73
+ }
74
+
75
+ return { agentId, action: 'hold', currentConcurrency: current, newConcurrency: current, reason: inCooldown ? 'cooldown' : 'stable' };
76
+ }
77
+ }
@@ -0,0 +1,95 @@
1
+ type State = 'closed' | 'open' | 'half-open';
2
+
3
+ export interface CircuitBreakerOptions {
4
+ failureThreshold: number; // failures before opening
5
+ resetTimeout: number; // ms before trying half-open
6
+ maxRetries: number; // retries per execution
7
+ retryDelay: number; // ms between retries
8
+ }
9
+
10
+ const DEFAULTS: CircuitBreakerOptions = {
11
+ failureThreshold: 3,
12
+ resetTimeout: 30_000,
13
+ maxRetries: 2,
14
+ retryDelay: 1_000,
15
+ };
16
+
17
+ export class CircuitBreaker {
18
+ private state: State = 'closed';
19
+ private failures = 0;
20
+ private lastFailure = 0;
21
+ private options: CircuitBreakerOptions;
22
+
23
+ constructor(public readonly name: string, options?: Partial<CircuitBreakerOptions>) {
24
+ this.options = { ...DEFAULTS, ...options };
25
+ }
26
+
27
+ getState(): State { return this.state; }
28
+ getFailures(): number { return this.failures; }
29
+
30
+ async execute<T>(fn: () => Promise<T>): Promise<T> {
31
+ if (this.state === 'open') {
32
+ if (Date.now() - this.lastFailure >= this.options.resetTimeout) {
33
+ this.state = 'half-open';
34
+ } else {
35
+ throw new Error(`Circuit breaker [${this.name}] is OPEN — agent temporarily unavailable`);
36
+ }
37
+ }
38
+
39
+ let lastError: Error | undefined;
40
+ for (let attempt = 0; attempt <= this.options.maxRetries; attempt++) {
41
+ try {
42
+ const result = await fn();
43
+ this.onSuccess();
44
+ return result;
45
+ } catch (err) {
46
+ lastError = err as Error;
47
+ if (attempt < this.options.maxRetries) {
48
+ await new Promise(r => setTimeout(r, this.options.retryDelay * (attempt + 1)));
49
+ }
50
+ }
51
+ }
52
+
53
+ this.onFailure();
54
+ throw lastError!;
55
+ }
56
+
57
+ private onSuccess(): void {
58
+ this.failures = 0;
59
+ this.state = 'closed';
60
+ }
61
+
62
+ private onFailure(): void {
63
+ this.failures++;
64
+ this.lastFailure = Date.now();
65
+ if (this.failures >= this.options.failureThreshold) {
66
+ this.state = 'open';
67
+ }
68
+ }
69
+
70
+ reset(): void {
71
+ this.state = 'closed';
72
+ this.failures = 0;
73
+ }
74
+
75
+ toJSON() {
76
+ return { name: this.name, state: this.state, failures: this.failures };
77
+ }
78
+ }
79
+
80
+ export class CircuitBreakerRegistry {
81
+ private breakers = new Map<string, CircuitBreaker>();
82
+
83
+ getOrCreate(name: string, options?: Partial<CircuitBreakerOptions>): CircuitBreaker {
84
+ let cb = this.breakers.get(name);
85
+ if (!cb) {
86
+ cb = new CircuitBreaker(name, options);
87
+ this.breakers.set(name, cb);
88
+ }
89
+ return cb;
90
+ }
91
+
92
+ getAll(): CircuitBreaker[] {
93
+ return Array.from(this.breakers.values());
94
+ }
95
+ }
@@ -0,0 +1,300 @@
1
+ import { nanoid } from 'nanoid';
2
+ import { EventEmitter } from 'node:events';
3
+ import type { CreateComboRequest, ComboDescriptor, CreateTaskRequest, ComboPreset } from '../../types.js';
4
+ import { ComboRepo } from '../../store/combo-repo.js';
5
+ import { AuditRepo } from '../../store/audit-repo.js';
6
+ import type { TaskExecutor } from './task-executor.js';
7
+ import type { AgentManager } from './agent-manager.js';
8
+
9
+ // Interpolate template variables: {{input}}, {{prev}}, {{step.N}}, {{all}}
10
+ function interpolate(template: string, context: { input: string; prev?: string; stepResults: Record<number, string> }): string {
11
+ let result = template.replace(/\{\{input\}\}/g, context.input);
12
+ if (context.prev !== undefined) {
13
+ result = result.replace(/\{\{prev\}\}/g, context.prev);
14
+ }
15
+ // {{step.0}}, {{step.1}}, etc.
16
+ result = result.replace(/\{\{step\.(\d+)\}\}/g, (_match, idx) => {
17
+ return context.stepResults[parseInt(idx, 10)] ?? `[step ${idx} not yet available]`;
18
+ });
19
+ // {{all}} — all step results concatenated
20
+ result = result.replace(/\{\{all\}\}/g, () => {
21
+ return Object.entries(context.stepResults)
22
+ .sort(([a], [b]) => parseInt(a) - parseInt(b))
23
+ .map(([idx, out]) => `--- Step ${idx} ---\n${out}`)
24
+ .join('\n\n');
25
+ });
26
+ return result;
27
+ }
28
+
29
+ // Built-in presets
30
+ export const COMBO_PRESETS: ComboPreset[] = [
31
+ {
32
+ id: 'analyze-implement-review',
33
+ name: 'Analyze → Implement → Review',
34
+ description: 'Claude analyzes the problem, Codex implements, Claude reviews the result',
35
+ pattern: 'pipeline',
36
+ steps: [
37
+ { agent: 'claude', role: 'analyzer', prompt: 'Analyze this task and produce a clear technical plan:\n\n{{input}}' },
38
+ { agent: 'codex', role: 'implementer', prompt: 'Implement the following plan:\n\n{{prev}}' },
39
+ { agent: 'claude', role: 'reviewer', prompt: 'Review this implementation for correctness, security, and quality. The original request was:\n\n{{input}}\n\nThe implementation output:\n\n{{prev}}' },
40
+ ],
41
+ },
42
+ {
43
+ id: 'multi-perspective',
44
+ name: 'Multi-Perspective Analysis',
45
+ description: 'All agents analyze independently, Claude synthesizes',
46
+ pattern: 'map-reduce',
47
+ steps: [
48
+ { agent: 'claude', role: 'analyst-1', prompt: 'Analyze this from an architecture and security perspective:\n\n{{input}}' },
49
+ { agent: 'codex', role: 'analyst-2', prompt: 'Analyze this from an implementation and performance perspective:\n\n{{input}}' },
50
+ { agent: 'claude', role: 'synthesizer', prompt: 'Synthesize these independent analyses into a unified recommendation:\n\nAnalysis 1 (Architecture/Security):\n{{step.0}}\n\nAnalysis 2 (Implementation/Performance):\n{{step.1}}\n\nOriginal question:\n{{input}}' },
51
+ ],
52
+ },
53
+ {
54
+ id: 'code-review-loop',
55
+ name: 'Implement + Review Loop',
56
+ description: 'Codex implements, Claude reviews, iterates until approved',
57
+ pattern: 'review-loop',
58
+ steps: [
59
+ { agent: 'codex', role: 'implementer', prompt: '{{input}}\n\n{{prev}}' },
60
+ { agent: 'claude', role: 'reviewer', prompt: 'Review this code. If it\'s acceptable, respond with exactly "APPROVED". If not, explain what needs to change.\n\nOriginal request: {{input}}\n\nImplementation:\n{{prev}}' },
61
+ ],
62
+ maxIterations: 3,
63
+ },
64
+ {
65
+ id: 'debate',
66
+ name: 'Agent Debate',
67
+ description: 'Two agents debate, then a judge synthesizes the best answer',
68
+ pattern: 'debate',
69
+ steps: [
70
+ { agent: 'claude', role: 'debater-1', prompt: 'Take a strong position on this topic and argue for it:\n\n{{input}}' },
71
+ { agent: 'codex', role: 'debater-2', prompt: 'Take the opposite position from this argument and counter it:\n\nOriginal topic: {{input}}\n\nFirst position:\n{{step.0}}' },
72
+ { agent: 'claude', role: 'judge', prompt: 'You are a neutral judge. Evaluate both positions and synthesize the strongest answer:\n\nOriginal question: {{input}}\n\nPosition A:\n{{step.0}}\n\nPosition B:\n{{step.1}}' },
73
+ ],
74
+ },
75
+ ];
76
+
77
+ export class ComboExecutor extends EventEmitter {
78
+ constructor(
79
+ private comboRepo: ComboRepo,
80
+ private auditRepo: AuditRepo,
81
+ private taskExecutor: TaskExecutor,
82
+ private agentManager: AgentManager,
83
+ ) {
84
+ super();
85
+ }
86
+
87
+ getPresets(): ComboPreset[] {
88
+ return COMBO_PRESETS;
89
+ }
90
+
91
+ start(request: CreateComboRequest): string {
92
+ const comboId = nanoid(12);
93
+ const createdAt = new Date().toISOString();
94
+
95
+ this.comboRepo.create({
96
+ comboId,
97
+ name: request.name,
98
+ pattern: request.pattern,
99
+ steps: request.steps,
100
+ input: request.input,
101
+ status: 'pending',
102
+ maxIterations: request.maxIterations,
103
+ workingDirectory: request.workingDirectory,
104
+ priority: request.priority,
105
+ createdAt,
106
+ });
107
+ this.auditRepo.log(null, 'combo.created', {
108
+ comboId, name: request.name, pattern: request.pattern, stepCount: request.steps.length,
109
+ });
110
+
111
+ this.runCombo(comboId, request).catch((err) => {
112
+ this.emit('combo:error', comboId, err);
113
+ });
114
+
115
+ return comboId;
116
+ }
117
+
118
+ private async runCombo(comboId: string, request: CreateComboRequest): Promise<void> {
119
+ this.comboRepo.updateStatus(comboId, 'running');
120
+
121
+ try {
122
+ switch (request.pattern) {
123
+ case 'pipeline':
124
+ await this.executePipeline(comboId, request);
125
+ break;
126
+ case 'map-reduce':
127
+ await this.executeMapReduce(comboId, request);
128
+ break;
129
+ case 'review-loop':
130
+ await this.executeReviewLoop(comboId, request);
131
+ break;
132
+ case 'debate':
133
+ await this.executeDebate(comboId, request);
134
+ break;
135
+ }
136
+ this.comboRepo.updateStatus(comboId, 'completed');
137
+ this.auditRepo.log(null, 'combo.completed', { comboId });
138
+ this.emit('combo:done', comboId);
139
+ } catch (err) {
140
+ this.comboRepo.updateStatus(comboId, 'failed');
141
+ this.auditRepo.log(null, 'combo.failed', { comboId, error: (err as Error).message });
142
+ this.emit('combo:done', comboId);
143
+ }
144
+ }
145
+
146
+ /** Pipeline: each step's output feeds into the next step's prompt */
147
+ private async executePipeline(comboId: string, request: CreateComboRequest): Promise<void> {
148
+ const stepResults: Record<number, string> = {};
149
+ let prev = '';
150
+
151
+ for (let i = 0; i < request.steps.length; i++) {
152
+ const step = request.steps[i];
153
+ const prompt = interpolate(step.prompt, { input: request.input, prev, stepResults });
154
+
155
+ this.auditRepo.log(null, 'combo.step', { comboId, stepIndex: i, agent: step.agent, role: step.role });
156
+
157
+ const task = await this.executeStep(comboId, step.agent, prompt, request);
158
+ const output = task.result?.stdout ?? '';
159
+ stepResults[i] = output;
160
+ prev = output;
161
+
162
+ this.comboRepo.addTaskId(comboId, task.taskId);
163
+ this.comboRepo.setStepResult(comboId, i, output);
164
+
165
+ if (task.status === 'failed') {
166
+ throw new Error(`Pipeline step ${i} (${step.role ?? step.agent}) failed`);
167
+ }
168
+ }
169
+
170
+ this.comboRepo.setFinalOutput(comboId, prev);
171
+ }
172
+
173
+ /** Map-Reduce: all steps except last run in parallel, last step gets all results */
174
+ private async executeMapReduce(comboId: string, request: CreateComboRequest): Promise<void> {
175
+ const mapSteps = request.steps.slice(0, -1);
176
+ const reduceStep = request.steps[request.steps.length - 1];
177
+ const stepResults: Record<number, string> = {};
178
+
179
+ // Map phase: run all map steps in parallel
180
+ this.auditRepo.log(null, 'combo.step', { comboId, phase: 'map', count: mapSteps.length });
181
+
182
+ const mapPromises = mapSteps.map(async (step, i) => {
183
+ const prompt = interpolate(step.prompt, { input: request.input, stepResults });
184
+ const task = await this.executeStep(comboId, step.agent, prompt, request);
185
+ const output = task.result?.stdout ?? '';
186
+ stepResults[i] = output;
187
+ this.comboRepo.addTaskId(comboId, task.taskId);
188
+ this.comboRepo.setStepResult(comboId, i, output);
189
+ return task;
190
+ });
191
+
192
+ const mapResults = await Promise.all(mapPromises);
193
+ const failed = mapResults.filter(t => t.status === 'failed');
194
+ if (failed.length > 0) {
195
+ throw new Error(`Map phase: ${failed.length} step(s) failed`);
196
+ }
197
+
198
+ // Reduce phase: synthesize all map results
199
+ this.auditRepo.log(null, 'combo.step', { comboId, phase: 'reduce', agent: reduceStep.agent });
200
+
201
+ const reducePrompt = interpolate(reduceStep.prompt, { input: request.input, stepResults });
202
+ const reduceTask = await this.executeStep(comboId, reduceStep.agent, reducePrompt, request);
203
+ const reduceIdx = request.steps.length - 1;
204
+ const finalOutput = reduceTask.result?.stdout ?? '';
205
+
206
+ stepResults[reduceIdx] = finalOutput;
207
+ this.comboRepo.addTaskId(comboId, reduceTask.taskId);
208
+ this.comboRepo.setStepResult(comboId, reduceIdx, finalOutput);
209
+ this.comboRepo.setFinalOutput(comboId, finalOutput);
210
+
211
+ if (reduceTask.status === 'failed') {
212
+ throw new Error('Reduce step failed');
213
+ }
214
+ }
215
+
216
+ /** Review Loop: step[0] implements, step[1] reviews, iterate until APPROVED or max iterations */
217
+ private async executeReviewLoop(comboId: string, request: CreateComboRequest): Promise<void> {
218
+ const implStep = request.steps[0];
219
+ const reviewStep = request.steps[1];
220
+ const maxIter = request.maxIterations ?? 3;
221
+ const stepResults: Record<number, string> = {};
222
+ let prev = '';
223
+ let approved = false;
224
+
225
+ for (let iter = 0; iter < maxIter; iter++) {
226
+ this.comboRepo.incrementIterations(comboId);
227
+ this.auditRepo.log(null, 'combo.iteration', { comboId, iteration: iter + 1, maxIterations: maxIter });
228
+
229
+ // Implementation step
230
+ const implPrompt = interpolate(implStep.prompt, { input: request.input, prev, stepResults });
231
+ const implTask = await this.executeStep(comboId, implStep.agent, implPrompt, request);
232
+ const implOutput = implTask.result?.stdout ?? '';
233
+ this.comboRepo.addTaskId(comboId, implTask.taskId);
234
+
235
+ if (implTask.status === 'failed') {
236
+ throw new Error(`Implementation failed on iteration ${iter + 1}`);
237
+ }
238
+
239
+ // Review step
240
+ const reviewPrompt = interpolate(reviewStep.prompt, { input: request.input, prev: implOutput, stepResults });
241
+ const reviewTask = await this.executeStep(comboId, reviewStep.agent, reviewPrompt, request);
242
+ const reviewOutput = reviewTask.result?.stdout ?? '';
243
+ this.comboRepo.addTaskId(comboId, reviewTask.taskId);
244
+
245
+ if (reviewTask.status === 'failed') {
246
+ throw new Error(`Review failed on iteration ${iter + 1}`);
247
+ }
248
+
249
+ // Check for approval
250
+ if (reviewOutput.toUpperCase().includes('APPROVED')) {
251
+ approved = true;
252
+ stepResults[0] = implOutput;
253
+ stepResults[1] = reviewOutput;
254
+ this.comboRepo.setStepResult(comboId, 0, implOutput);
255
+ this.comboRepo.setStepResult(comboId, 1, reviewOutput);
256
+ this.comboRepo.setFinalOutput(comboId, implOutput);
257
+ break;
258
+ }
259
+
260
+ // Not approved — feed review feedback as prev for next iteration
261
+ prev = `Previous review feedback:\n${reviewOutput}\n\nPlease address the above issues.`;
262
+ }
263
+
264
+ if (!approved) {
265
+ // Max iterations reached without approval — use last implementation
266
+ const combo = this.comboRepo.getById(comboId);
267
+ this.comboRepo.setFinalOutput(comboId, `[Max iterations (${maxIter}) reached without approval]\n\n${stepResults[0] ?? ''}`);
268
+ }
269
+ }
270
+
271
+ /** Debate: step[0] argues, step[1] counters, step[2] judges */
272
+ private async executeDebate(comboId: string, request: CreateComboRequest): Promise<void> {
273
+ // Same as pipeline but with semantic labeling
274
+ await this.executePipeline(comboId, request);
275
+ }
276
+
277
+ private async executeStep(
278
+ comboId: string,
279
+ agentId: string,
280
+ prompt: string,
281
+ request: CreateComboRequest,
282
+ ) {
283
+ const taskRequest: CreateTaskRequest = {
284
+ prompt,
285
+ preferredAgent: agentId,
286
+ workingDirectory: request.workingDirectory,
287
+ priority: request.priority,
288
+ };
289
+
290
+ return this.taskExecutor.execute(taskRequest);
291
+ }
292
+
293
+ getCombo(comboId: string): ComboDescriptor | undefined {
294
+ return this.comboRepo.getById(comboId);
295
+ }
296
+
297
+ listCombos(limit: number = 20, offset: number = 0): ComboDescriptor[] {
298
+ return this.comboRepo.list(limit, offset);
299
+ }
300
+ }
@@ -0,0 +1,136 @@
1
+ import { nanoid } from 'nanoid';
2
+ import { EventEmitter } from 'node:events';
3
+
4
+ export interface DagNode {
5
+ id: string;
6
+ prompt: string;
7
+ agent?: string;
8
+ dependsOn: string[]; // node IDs this node depends on
9
+ }
10
+
11
+ export interface DagResult {
12
+ dagId: string;
13
+ status: 'completed' | 'failed';
14
+ nodeResults: Record<string, { output: string; exitCode: number; durationMs: number }>;
15
+ executionOrder: string[];
16
+ totalDurationMs: number;
17
+ }
18
+
19
+ type ExecuteFn = (nodeId: string, prompt: string, agent?: string) => Promise<{ stdout: string; exitCode: number; durationMs: number }>;
20
+
21
+ export class DagExecutor extends EventEmitter {
22
+ /**
23
+ * Execute a DAG of tasks respecting dependencies.
24
+ * Nodes without dependencies run in parallel.
25
+ * Node prompts can reference {{node.ID}} for dependency outputs.
26
+ */
27
+ async execute(nodes: DagNode[], executeFn: ExecuteFn): Promise<DagResult> {
28
+ const dagId = nanoid(12);
29
+ const start = Date.now();
30
+ const nodeResults: Record<string, { output: string; exitCode: number; durationMs: number }> = {};
31
+ const executionOrder: string[] = [];
32
+
33
+ // Validate: no cycles, all dependencies exist
34
+ const nodeMap = new Map(nodes.map(n => [n.id, n]));
35
+ for (const node of nodes) {
36
+ for (const dep of node.dependsOn) {
37
+ if (!nodeMap.has(dep)) throw new Error(`Node ${node.id} depends on unknown node ${dep}`);
38
+ }
39
+ }
40
+ if (this.hasCycle(nodes)) throw new Error('DAG contains a cycle');
41
+
42
+ const completed = new Set<string>();
43
+ const failed = new Set<string>();
44
+ const pending = new Set(nodes.map(n => n.id));
45
+
46
+ while (pending.size > 0) {
47
+ // Find all nodes whose dependencies are satisfied
48
+ const ready = nodes.filter(n =>
49
+ pending.has(n.id) &&
50
+ n.dependsOn.every(d => completed.has(d)) &&
51
+ !n.dependsOn.some(d => failed.has(d))
52
+ );
53
+
54
+ // Check for nodes blocked by failed dependencies
55
+ const blocked = nodes.filter(n =>
56
+ pending.has(n.id) &&
57
+ n.dependsOn.some(d => failed.has(d))
58
+ );
59
+ for (const b of blocked) {
60
+ pending.delete(b.id);
61
+ failed.add(b.id);
62
+ nodeResults[b.id] = { output: 'Skipped: dependency failed', exitCode: 1, durationMs: 0 };
63
+ }
64
+
65
+ if (ready.length === 0 && pending.size > 0 && blocked.length === 0) {
66
+ throw new Error('DAG execution deadlocked');
67
+ }
68
+ if (ready.length === 0) break;
69
+
70
+ // Execute ready nodes in parallel
71
+ const results = await Promise.allSettled(
72
+ ready.map(async (node) => {
73
+ const prompt = this.interpolateNodePrompt(node.prompt, nodeResults);
74
+ this.emit('dag:node:start', dagId, node.id);
75
+ const result = await executeFn(node.id, prompt, node.agent);
76
+ return { nodeId: node.id, ...result };
77
+ })
78
+ );
79
+
80
+ for (const r of results) {
81
+ if (r.status === 'fulfilled') {
82
+ const { nodeId, stdout, exitCode, durationMs } = r.value;
83
+ nodeResults[nodeId] = { output: stdout, exitCode, durationMs };
84
+ executionOrder.push(nodeId);
85
+ pending.delete(nodeId);
86
+ if (exitCode === 0) completed.add(nodeId);
87
+ else failed.add(nodeId);
88
+ this.emit('dag:node:done', dagId, nodeId, exitCode);
89
+ } else {
90
+ // Should not happen since executeFn catches errors, but handle anyway
91
+ const nodeId = ready[results.indexOf(r)].id;
92
+ nodeResults[nodeId] = { output: r.reason?.message ?? 'Unknown error', exitCode: 1, durationMs: 0 };
93
+ pending.delete(nodeId);
94
+ failed.add(nodeId);
95
+ }
96
+ }
97
+ }
98
+
99
+ return {
100
+ dagId,
101
+ status: failed.size === 0 ? 'completed' : 'failed',
102
+ nodeResults,
103
+ executionOrder,
104
+ totalDurationMs: Date.now() - start,
105
+ };
106
+ }
107
+
108
+ private interpolateNodePrompt(prompt: string, nodeResults: Record<string, { output: string }>): string {
109
+ return prompt.replace(/\{\{node\.([a-zA-Z0-9_-]+)\}\}/g, (_match, nodeId) => {
110
+ return nodeResults[nodeId]?.output ?? `[node ${nodeId} not available]`;
111
+ });
112
+ }
113
+
114
+ private hasCycle(nodes: DagNode[]): boolean {
115
+ const visited = new Set<string>();
116
+ const recStack = new Set<string>();
117
+ const adj = new Map<string, string[]>();
118
+ for (const n of nodes) adj.set(n.id, n.dependsOn);
119
+
120
+ const dfs = (id: string): boolean => {
121
+ visited.add(id);
122
+ recStack.add(id);
123
+ for (const dep of adj.get(id) ?? []) {
124
+ if (!visited.has(dep) && dfs(dep)) return true;
125
+ if (recStack.has(dep)) return true;
126
+ }
127
+ recStack.delete(id);
128
+ return false;
129
+ };
130
+
131
+ for (const n of nodes) {
132
+ if (!visited.has(n.id) && dfs(n.id)) return true;
133
+ }
134
+ return false;
135
+ }
136
+ }