@sooneocean/agw 1.4.0 → 1.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sooneocean/agw",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Agent Gateway — multi-agent task router for Claude, Codex, Gemini with combos, DAG, DSL",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,60 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { TemplateEngine, TaskTemplate, InstantiateRequest } from '../services/template-engine.js';
3
+ import type { TaskExecutor } from '../services/task-executor.js';
4
+ import type { LlmRouter } from '../../router/llm-router.js';
5
+ import type { AgentManager } from '../services/agent-manager.js';
6
+
7
+ export function registerTemplateRoutes(
8
+ app: FastifyInstance,
9
+ templateEngine: TemplateEngine,
10
+ executor: TaskExecutor,
11
+ router: LlmRouter,
12
+ agentManager: AgentManager,
13
+ ): void {
14
+ // List templates
15
+ app.get<{ Querystring: { tag?: string } }>('/templates', async (request) => {
16
+ return templateEngine.list(request.query.tag);
17
+ });
18
+
19
+ // Get template
20
+ app.get<{ Params: { id: string } }>('/templates/:id', async (request, reply) => {
21
+ const t = templateEngine.get(request.params.id);
22
+ if (!t) return reply.status(404).send({ error: 'Template not found' });
23
+ return t;
24
+ });
25
+
26
+ // Register custom template
27
+ app.post<{ Body: TaskTemplate }>('/templates', async (request, reply) => {
28
+ templateEngine.register(request.body);
29
+ return reply.status(201).send(request.body);
30
+ });
31
+
32
+ // Delete template
33
+ app.delete<{ Params: { id: string } }>('/templates/:id', async (request, reply) => {
34
+ const deleted = templateEngine.unregister(request.params.id);
35
+ if (!deleted) return reply.status(404).send({ error: 'Template not found' });
36
+ return { deleted: true };
37
+ });
38
+
39
+ // Instantiate and execute a template
40
+ app.post<{ Body: InstantiateRequest }>('/templates/execute', async (request, reply) => {
41
+ try {
42
+ const { prompt, agent, priority } = templateEngine.instantiate(request.body);
43
+ const availableAgents = agentManager.getAvailableAgents();
44
+
45
+ const task = await executor.execute(
46
+ {
47
+ prompt,
48
+ preferredAgent: agent ?? request.body.overrides?.agent,
49
+ workingDirectory: request.body.overrides?.workingDirectory,
50
+ priority,
51
+ },
52
+ async (p) => router.route(p, availableAgents, agent),
53
+ );
54
+
55
+ return reply.status(201).send(task);
56
+ } catch (err) {
57
+ return reply.status(400).send({ error: (err as Error).message });
58
+ }
59
+ });
60
+ }
@@ -0,0 +1,18 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { WebhookManager, WebhookConfig } from '../services/webhook-manager.js';
3
+
4
+ export function registerWebhookRoutes(app: FastifyInstance, webhookManager: WebhookManager): void {
5
+ app.get('/webhooks', async () => {
6
+ return webhookManager.getWebhooks();
7
+ });
8
+
9
+ app.post<{ Body: WebhookConfig }>('/webhooks', async (request, reply) => {
10
+ webhookManager.addWebhook(request.body);
11
+ return reply.status(201).send({ registered: true, url: request.body.url });
12
+ });
13
+
14
+ app.delete<{ Body: { url: string } }>('/webhooks', async (request) => {
15
+ webhookManager.removeWebhook(request.body.url);
16
+ return { removed: true };
17
+ });
18
+ }
@@ -10,22 +10,26 @@ import { AuditRepo } from '../store/audit-repo.js';
10
10
  import { CostRepo } from '../store/cost-repo.js';
11
11
  import { WorkflowRepo } from '../store/workflow-repo.js';
12
12
  import { ComboRepo } from '../store/combo-repo.js';
13
+ import { MemoryRepo } from '../store/memory-repo.js';
13
14
  import { AgentManager } from './services/agent-manager.js';
14
15
  import { TaskExecutor } from './services/task-executor.js';
15
16
  import { WorkflowExecutor } from './services/workflow-executor.js';
16
17
  import { ComboExecutor } from './services/combo-executor.js';
17
18
  import { LlmRouter } from '../router/llm-router.js';
19
+ import { MetricsCollector } from './services/metrics.js';
20
+ import { CircuitBreakerRegistry } from './services/circuit-breaker.js';
21
+ import { TemplateEngine } from './services/template-engine.js';
22
+ import { WebhookManager } from './services/webhook-manager.js';
18
23
  import { registerAuthMiddleware } from './middleware/auth.js';
19
24
  import { registerAgentRoutes } from './routes/agents.js';
20
25
  import { registerTaskRoutes } from './routes/tasks.js';
21
26
  import { registerWorkflowRoutes } from './routes/workflows.js';
22
27
  import { registerCostRoutes } from './routes/costs.js';
23
28
  import { registerComboRoutes } from './routes/combos.js';
24
- import { MemoryRepo } from '../store/memory-repo.js';
25
29
  import { registerMemoryRoutes } from './routes/memory.js';
26
30
  import { registerHealthRoutes } from './routes/health.js';
27
- import { MetricsCollector } from './services/metrics.js';
28
- import { CircuitBreakerRegistry } from './services/circuit-breaker.js';
31
+ import { registerTemplateRoutes } from './routes/templates.js';
32
+ import { registerWebhookRoutes } from './routes/webhooks.js';
29
33
 
30
34
  interface ServerOptions {
31
35
  dbPath?: string;
@@ -58,6 +62,9 @@ export async function buildServer(options: ServerOptions = {}): Promise<FastifyI
58
62
  const comboExecutor = new ComboExecutor(comboRepo, auditRepo, executor, agentManager);
59
63
  const metrics = new MetricsCollector();
60
64
  const cbRegistry = new CircuitBreakerRegistry();
65
+ const templateEngine = new TemplateEngine();
66
+ templateEngine.seedDefaults();
67
+ const webhookManager = new WebhookManager();
61
68
 
62
69
  const app = Fastify({
63
70
  logger: false,
@@ -73,6 +80,8 @@ export async function buildServer(options: ServerOptions = {}): Promise<FastifyI
73
80
  registerComboRoutes(app, comboExecutor, config);
74
81
  registerMemoryRoutes(app, memoryRepo);
75
82
  registerHealthRoutes(app, metrics, agentManager, cbRegistry, taskRepo, costRepo, config);
83
+ registerTemplateRoutes(app, templateEngine, executor, router, agentManager);
84
+ registerWebhookRoutes(app, webhookManager);
76
85
 
77
86
  app.register(import('./routes/ui.js'));
78
87
 
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Condition Engine — evaluates conditions on agent output to control combo flow.
3
+ *
4
+ * Conditions:
5
+ * contains:"APPROVED" — output contains string
6
+ * !contains:"ERROR" — output does NOT contain string
7
+ * exitCode:0 — task exit code equals value
8
+ * length>100 — output length greater than threshold
9
+ * matches:/pattern/i — output matches regex
10
+ * always — always true (default)
11
+ */
12
+
13
+ export interface Condition {
14
+ type: 'contains' | 'not-contains' | 'exitCode' | 'length-gt' | 'length-lt' | 'matches' | 'always';
15
+ value?: string | number;
16
+ }
17
+
18
+ export interface ConditionalBranch {
19
+ condition: Condition;
20
+ thenStep: number; // step index to jump to
21
+ elseStep?: number; // step index if condition is false
22
+ }
23
+
24
+ export function parseCondition(expr: string): Condition {
25
+ if (expr === 'always') return { type: 'always' };
26
+
27
+ const containsMatch = expr.match(/^contains:"(.+)"$/);
28
+ if (containsMatch) return { type: 'contains', value: containsMatch[1] };
29
+
30
+ const notContainsMatch = expr.match(/^!contains:"(.+)"$/);
31
+ if (notContainsMatch) return { type: 'not-contains', value: notContainsMatch[1] };
32
+
33
+ const exitCodeMatch = expr.match(/^exitCode:(\d+)$/);
34
+ if (exitCodeMatch) return { type: 'exitCode', value: parseInt(exitCodeMatch[1], 10) };
35
+
36
+ const lengthGtMatch = expr.match(/^length>(\d+)$/);
37
+ if (lengthGtMatch) return { type: 'length-gt', value: parseInt(lengthGtMatch[1], 10) };
38
+
39
+ const lengthLtMatch = expr.match(/^length<(\d+)$/);
40
+ if (lengthLtMatch) return { type: 'length-lt', value: parseInt(lengthLtMatch[1], 10) };
41
+
42
+ const matchesMatch = expr.match(/^matches:\/(.+)\/([gimsuy]*)$/);
43
+ if (matchesMatch) return { type: 'matches', value: matchesMatch[1] + (matchesMatch[2] ? `/${matchesMatch[2]}` : '') };
44
+
45
+ throw new Error(`Invalid condition: ${expr}`);
46
+ }
47
+
48
+ export function evaluateCondition(condition: Condition, output: string, exitCode: number = 0): boolean {
49
+ switch (condition.type) {
50
+ case 'always':
51
+ return true;
52
+
53
+ case 'contains':
54
+ return output.includes(String(condition.value));
55
+
56
+ case 'not-contains':
57
+ return !output.includes(String(condition.value));
58
+
59
+ case 'exitCode':
60
+ return exitCode === condition.value;
61
+
62
+ case 'length-gt':
63
+ return output.length > (condition.value as number);
64
+
65
+ case 'length-lt':
66
+ return output.length < (condition.value as number);
67
+
68
+ case 'matches': {
69
+ const parts = String(condition.value).split('/');
70
+ const pattern = parts[0];
71
+ const flags = parts[1] ?? '';
72
+ return new RegExp(pattern, flags).test(output);
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Task Template Engine — reusable parameterized task definitions.
3
+ *
4
+ * Templates use {{param.name}} syntax for user-supplied values.
5
+ * Templates can be stored, listed, and instantiated.
6
+ *
7
+ * Example:
8
+ * { id: "code-review", prompt: "Review {{param.file}} for {{param.criteria}}", agent: "claude" }
9
+ * Instantiate with: { templateId: "code-review", params: { file: "auth.ts", criteria: "security" } }
10
+ */
11
+
12
+ export interface TaskTemplate {
13
+ id: string;
14
+ name: string;
15
+ description: string;
16
+ prompt: string; // template with {{param.name}} placeholders
17
+ agent?: string; // default agent
18
+ priority?: number;
19
+ params: TemplateParam[]; // parameter definitions
20
+ tags?: string[];
21
+ }
22
+
23
+ export interface TemplateParam {
24
+ name: string;
25
+ description: string;
26
+ required: boolean;
27
+ default?: string;
28
+ }
29
+
30
+ export interface InstantiateRequest {
31
+ templateId: string;
32
+ params: Record<string, string>;
33
+ overrides?: {
34
+ agent?: string;
35
+ priority?: number;
36
+ workingDirectory?: string;
37
+ };
38
+ }
39
+
40
+ export class TemplateEngine {
41
+ private templates = new Map<string, TaskTemplate>();
42
+
43
+ register(template: TaskTemplate): void {
44
+ this.templates.set(template.id, template);
45
+ }
46
+
47
+ unregister(id: string): boolean {
48
+ return this.templates.delete(id);
49
+ }
50
+
51
+ get(id: string): TaskTemplate | undefined {
52
+ return this.templates.get(id);
53
+ }
54
+
55
+ list(tag?: string): TaskTemplate[] {
56
+ const all = Array.from(this.templates.values());
57
+ if (tag) return all.filter(t => t.tags?.includes(tag));
58
+ return all;
59
+ }
60
+
61
+ instantiate(request: InstantiateRequest): { prompt: string; agent?: string; priority?: number } {
62
+ const template = this.templates.get(request.templateId);
63
+ if (!template) throw new Error(`Template not found: ${request.templateId}`);
64
+
65
+ // Validate required params
66
+ for (const param of template.params) {
67
+ if (param.required && !(request.params[param.name] ?? param.default)) {
68
+ throw new Error(`Missing required parameter: ${param.name}`);
69
+ }
70
+ }
71
+
72
+ // Interpolate {{param.name}} with provided values
73
+ let prompt = template.prompt;
74
+ for (const param of template.params) {
75
+ const value = request.params[param.name] ?? param.default ?? '';
76
+ prompt = prompt.replace(new RegExp(`\\{\\{param\\.${param.name}\\}\\}`, 'g'), value);
77
+ }
78
+
79
+ return {
80
+ prompt,
81
+ agent: request.overrides?.agent ?? template.agent,
82
+ priority: request.overrides?.priority ?? template.priority,
83
+ };
84
+ }
85
+
86
+ /** Seed built-in templates */
87
+ seedDefaults(): void {
88
+ this.register({
89
+ id: 'code-review',
90
+ name: 'Code Review',
91
+ description: 'Review a file for quality, security, and correctness',
92
+ prompt: 'Review {{param.file}} for {{param.criteria}}. Focus on actionable issues.',
93
+ agent: 'claude',
94
+ priority: 4,
95
+ params: [
96
+ { name: 'file', description: 'File path to review', required: true },
97
+ { name: 'criteria', description: 'Review criteria', required: false, default: 'quality, security, correctness' },
98
+ ],
99
+ tags: ['review', 'quality'],
100
+ });
101
+
102
+ this.register({
103
+ id: 'implement-feature',
104
+ name: 'Implement Feature',
105
+ description: 'Implement a new feature with tests',
106
+ prompt: 'Implement the following feature: {{param.description}}\n\nRequirements:\n{{param.requirements}}\n\nInclude unit tests.',
107
+ agent: 'codex',
108
+ priority: 3,
109
+ params: [
110
+ { name: 'description', description: 'Feature description', required: true },
111
+ { name: 'requirements', description: 'Detailed requirements', required: false, default: 'Follow existing patterns' },
112
+ ],
113
+ tags: ['implementation', 'feature'],
114
+ });
115
+
116
+ this.register({
117
+ id: 'explain-code',
118
+ name: 'Explain Code',
119
+ description: 'Explain what a piece of code does',
120
+ prompt: 'Explain this code in {{param.detail_level}} detail:\n\n{{param.code}}',
121
+ agent: 'claude',
122
+ priority: 2,
123
+ params: [
124
+ { name: 'code', description: 'Code to explain', required: true },
125
+ { name: 'detail_level', description: 'Detail level (brief/moderate/deep)', required: false, default: 'moderate' },
126
+ ],
127
+ tags: ['explanation', 'documentation'],
128
+ });
129
+
130
+ this.register({
131
+ id: 'debug-issue',
132
+ name: 'Debug Issue',
133
+ description: 'Diagnose and fix a bug',
134
+ prompt: 'Debug this issue: {{param.issue}}\n\nError message: {{param.error}}\n\nRelevant file: {{param.file}}',
135
+ agent: 'claude',
136
+ priority: 5,
137
+ params: [
138
+ { name: 'issue', description: 'Description of the issue', required: true },
139
+ { name: 'error', description: 'Error message or stack trace', required: false, default: 'N/A' },
140
+ { name: 'file', description: 'Relevant file path', required: false, default: 'N/A' },
141
+ ],
142
+ tags: ['debugging', 'fix'],
143
+ });
144
+ }
145
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Webhook Manager — sends HTTP POST notifications on AGW events.
3
+ *
4
+ * Config example:
5
+ * webhooks: [
6
+ * { url: "https://slack.com/...", events: ["task.completed", "combo.completed"], secret: "hmac-key" }
7
+ * ]
8
+ */
9
+
10
+ import { createHmac } from 'node:crypto';
11
+
12
+ export interface WebhookConfig {
13
+ url: string;
14
+ events: string[]; // event types to subscribe to, or ["*"] for all
15
+ secret?: string; // HMAC-SHA256 signing key
16
+ headers?: Record<string, string>;
17
+ retries?: number;
18
+ timeoutMs?: number;
19
+ }
20
+
21
+ export interface WebhookPayload {
22
+ event: string;
23
+ timestamp: string;
24
+ data: Record<string, unknown>;
25
+ }
26
+
27
+ export class WebhookManager {
28
+ private hooks: WebhookConfig[] = [];
29
+
30
+ addWebhook(config: WebhookConfig): void {
31
+ this.hooks.push(config);
32
+ }
33
+
34
+ removeWebhook(url: string): void {
35
+ this.hooks = this.hooks.filter(h => h.url !== url);
36
+ }
37
+
38
+ getWebhooks(): WebhookConfig[] {
39
+ return this.hooks.map(h => ({ ...h, secret: h.secret ? '***' : undefined }));
40
+ }
41
+
42
+ async emit(event: string, data: Record<string, unknown>): Promise<void> {
43
+ const payload: WebhookPayload = {
44
+ event,
45
+ timestamp: new Date().toISOString(),
46
+ data,
47
+ };
48
+ const body = JSON.stringify(payload);
49
+
50
+ const matching = this.hooks.filter(h =>
51
+ h.events.includes('*') || h.events.includes(event)
52
+ );
53
+
54
+ const deliveries = matching.map(hook => this.deliver(hook, body));
55
+ await Promise.allSettled(deliveries);
56
+ }
57
+
58
+ private async deliver(hook: WebhookConfig, body: string): Promise<void> {
59
+ const maxRetries = hook.retries ?? 2;
60
+ const timeout = hook.timeoutMs ?? 10_000;
61
+
62
+ const headers: Record<string, string> = {
63
+ 'Content-Type': 'application/json',
64
+ 'User-Agent': 'AGW-Webhook/1.5',
65
+ ...hook.headers,
66
+ };
67
+
68
+ if (hook.secret) {
69
+ const signature = createHmac('sha256', hook.secret).update(body).digest('hex');
70
+ headers['X-AGW-Signature'] = `sha256=${signature}`;
71
+ }
72
+
73
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
74
+ try {
75
+ const controller = new AbortController();
76
+ const timer = setTimeout(() => controller.abort(), timeout);
77
+
78
+ const res = await fetch(hook.url, {
79
+ method: 'POST',
80
+ headers,
81
+ body,
82
+ signal: controller.signal,
83
+ });
84
+
85
+ clearTimeout(timer);
86
+
87
+ if (res.ok) return;
88
+ if (res.status >= 400 && res.status < 500) return; // Don't retry client errors
89
+ } catch {
90
+ // Retry on network errors
91
+ }
92
+
93
+ if (attempt < maxRetries) {
94
+ await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
95
+ }
96
+ }
97
+ }
98
+ }