opc-agent 0.6.0 → 0.8.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 (53) hide show
  1. package/dist/channels/email.d.ts +69 -0
  2. package/dist/channels/email.js +118 -0
  3. package/dist/channels/slack.d.ts +62 -0
  4. package/dist/channels/slack.js +107 -0
  5. package/dist/channels/web.d.ts +7 -1
  6. package/dist/channels/web.js +165 -4
  7. package/dist/channels/wechat.d.ts +62 -0
  8. package/dist/channels/wechat.js +104 -0
  9. package/dist/core/auth.d.ts +13 -0
  10. package/dist/core/auth.js +41 -0
  11. package/dist/core/compose.d.ts +35 -0
  12. package/dist/core/compose.js +49 -0
  13. package/dist/core/orchestrator.d.ts +68 -0
  14. package/dist/core/orchestrator.js +145 -0
  15. package/dist/index.d.ts +23 -0
  16. package/dist/index.js +34 -1
  17. package/dist/schema/oad.d.ts +69 -0
  18. package/dist/schema/oad.js +7 -1
  19. package/dist/skills/document.d.ts +27 -0
  20. package/dist/skills/document.js +80 -0
  21. package/dist/skills/http.d.ts +8 -0
  22. package/dist/skills/http.js +34 -0
  23. package/dist/skills/scheduler.d.ts +21 -0
  24. package/dist/skills/scheduler.js +70 -0
  25. package/dist/skills/webhook-trigger.d.ts +17 -0
  26. package/dist/skills/webhook-trigger.js +49 -0
  27. package/dist/tools/calculator.d.ts +7 -0
  28. package/dist/tools/calculator.js +70 -0
  29. package/dist/tools/datetime.d.ts +7 -0
  30. package/dist/tools/datetime.js +159 -0
  31. package/dist/tools/json-transform.d.ts +7 -0
  32. package/dist/tools/json-transform.js +184 -0
  33. package/dist/tools/text-analysis.d.ts +8 -0
  34. package/dist/tools/text-analysis.js +113 -0
  35. package/package.json +1 -1
  36. package/src/channels/email.ts +177 -0
  37. package/src/channels/slack.ts +160 -0
  38. package/src/channels/web.ts +175 -4
  39. package/src/channels/wechat.ts +149 -0
  40. package/src/core/auth.ts +57 -0
  41. package/src/core/compose.ts +77 -0
  42. package/src/core/orchestrator.ts +215 -0
  43. package/src/index.ts +27 -0
  44. package/src/schema/oad.ts +7 -0
  45. package/src/skills/document.ts +100 -0
  46. package/src/skills/http.ts +35 -0
  47. package/src/skills/scheduler.ts +80 -0
  48. package/src/skills/webhook-trigger.ts +59 -0
  49. package/src/tools/calculator.ts +73 -0
  50. package/src/tools/datetime.ts +149 -0
  51. package/src/tools/json-transform.ts +187 -0
  52. package/src/tools/text-analysis.ts +116 -0
  53. package/tests/v070.test.ts +76 -0
@@ -4,6 +4,59 @@ import type { Message } from '../core/types';
4
4
  import { BaseChannel } from './index';
5
5
  import { KnowledgeBase } from '../core/knowledge';
6
6
  import { createProvider, type LLMProvider } from '../providers';
7
+ import { createAuthMiddleware, type AuthConfig } from '../core/auth';
8
+
9
+ const AGENT_TEMPLATES = [
10
+ { id: 'customer-service', name: 'Customer Service', description: 'Handle support tickets, FAQs, and customer inquiries', icon: '🎧', category: 'Business' },
11
+ { id: 'code-reviewer', name: 'Code Reviewer', description: 'Review PRs, suggest improvements, check for bugs', icon: '🔍', category: 'Engineering' },
12
+ { id: 'content-writer', name: 'Content Writer', description: 'Write blogs, social media posts, and marketing copy', icon: '✍️', category: 'Marketing' },
13
+ { id: 'executive-assistant', name: 'Executive Assistant', description: 'Schedule management, email drafting, meeting prep', icon: '📋', category: 'Business' },
14
+ { id: 'knowledge-base', name: 'Knowledge Base', description: 'RAG-powered Q&A over your documents', icon: '📚', category: 'Knowledge' },
15
+ { id: 'project-manager', name: 'Project Manager', description: 'Track tasks, milestones, and team coordination', icon: '📊', category: 'Business' },
16
+ { id: 'sales-assistant', name: 'Sales Assistant', description: 'Lead qualification, outreach drafting, CRM updates', icon: '💼', category: 'Sales' },
17
+ { id: 'financial-advisor', name: 'Financial Advisor', description: 'Budget analysis, financial planning, cost optimization', icon: '💰', category: 'Finance' },
18
+ { id: 'hr-recruiter', name: 'HR Recruiter', description: 'Resume screening, interview scheduling, candidate comms', icon: '👥', category: 'HR' },
19
+ { id: 'legal-assistant', name: 'Legal Assistant', description: 'Contract review, compliance checks, legal research', icon: '⚖️', category: 'Legal' },
20
+ ];
21
+
22
+ const TEMPLATES_HTML = `<!DOCTYPE html>
23
+ <html lang="en">
24
+ <head>
25
+ <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
26
+ <title>Agent Templates</title>
27
+ <style>
28
+ *{margin:0;padding:0;box-sizing:border-box}
29
+ body{background:#0a0a0f;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px}
30
+ h1{font-size:28px;margin-bottom:8px;color:#fff}
31
+ .sub{color:#888;margin-bottom:32px;font-size:14px}
32
+ nav{margin-bottom:24px}
33
+ nav a{color:#818cf8;text-decoration:none;margin-right:16px;font-size:14px}
34
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}
35
+ .card{background:#12121a;border:1px solid #1e1e2e;border-radius:12px;padding:24px;cursor:pointer;transition:all .2s}
36
+ .card:hover{border-color:#818cf8;transform:translateY(-2px)}
37
+ .card .icon{font-size:32px;margin-bottom:12px}
38
+ .card h3{font-size:16px;color:#fff;margin-bottom:8px}
39
+ .card p{font-size:13px;color:#888;line-height:1.5}
40
+ .card .cat{font-size:11px;color:#818cf8;text-transform:uppercase;letter-spacing:1px;margin-top:12px}
41
+ .btn{display:inline-block;background:#2563eb;color:#fff;border:none;border-radius:8px;padding:8px 16px;font-size:13px;cursor:pointer;margin-top:12px}
42
+ .btn:hover{background:#1d4ed8}
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <nav><a href="/">← Chat</a><a href="/dashboard">Dashboard</a><a href="/templates">Templates</a></nav>
47
+ <h1>🧩 Agent Templates</h1>
48
+ <p class="sub">Create a new agent from a pre-built template in one click.</p>
49
+ <div class="grid" id="grid"></div>
50
+ <script>
51
+ fetch('/api/templates').then(r=>r.json()).then(d=>{
52
+ const g=document.getElementById('grid');
53
+ d.templates.forEach(t=>{
54
+ g.innerHTML+=\`<div class="card"><div class="icon">\${t.icon}</div><h3>\${t.name}</h3><p>\${t.description}</p><div class="cat">\${t.category}</div><button class="btn" onclick="alert('Creating agent from template: '+'\${t.id}'+'\\\\nRun: opc init --template \${t.id}')">Use Template</button></div>\`;
55
+ });
56
+ });
57
+ </script>
58
+ </body>
59
+ </html>`;
7
60
 
8
61
  const CHAT_HTML = `<!DOCTYPE html>
9
62
  <html lang="en">
@@ -157,8 +210,12 @@ export class WebChannel extends BaseChannel {
157
210
  private streamHandler: ((msg: Message, res: Response) => Promise<void>) | null = null;
158
211
  private agentName: string = 'OPC Agent';
159
212
  private currentProvider: string = 'openai';
160
- private stats = { sessions: 0, messages: 0, totalResponseMs: 0, tokenUsage: 0, knowledgeFiles: 0, startedAt: Date.now() };
213
+ private stats = { sessions: 0, messages: 0, totalResponseMs: 0, tokenUsage: 0, knowledgeFiles: 0, startedAt: Date.now(), errors: 0 };
161
214
  private eventHandlers: Map<string, Function[]> = new Map();
215
+ private conversations: Map<string, Message[]> = new Map();
216
+ private requestCount = 0;
217
+ private llmLatencySum = 0;
218
+ private llmCalls = 0;
162
219
 
163
220
  private emit(event: string, data: any): void {
164
221
  const handlers = this.eventHandlers.get(event) ?? [];
@@ -175,15 +232,23 @@ export class WebChannel extends BaseChannel {
175
232
  this.stats.messages++;
176
233
  this.stats.totalResponseMs += responseMs;
177
234
  this.stats.tokenUsage += tokens;
235
+ this.requestCount++;
236
+ this.llmLatencySum += responseMs;
237
+ this.llmCalls++;
178
238
  }
179
239
 
240
+ trackError(): void { this.stats.errors++; }
241
+
180
242
  trackSession(): void { this.stats.sessions++; }
181
243
 
182
- constructor(port: number = 3000) {
244
+ constructor(port: number = 3000, authConfig?: AuthConfig) {
183
245
  super();
184
246
  this.port = port;
185
247
  this.app = express();
186
- this.app.use(express.json());
248
+ this.app.use(express.json({ limit: '10mb' }));
249
+ if (authConfig && authConfig.apiKeys.length > 0) {
250
+ this.app.use(createAuthMiddleware(authConfig));
251
+ }
187
252
  this.setupRoutes();
188
253
  }
189
254
 
@@ -211,6 +276,7 @@ export class WebChannel extends BaseChannel {
211
276
  // Streaming chat endpoint
212
277
  this.app.post('/api/chat', async (req: Request, res: Response) => {
213
278
  const { message, sessionId } = req.body;
279
+ const sid = sessionId ?? 'default';
214
280
  if (!message) {
215
281
  res.status(400).json({ error: 'message is required' });
216
282
  return;
@@ -221,9 +287,13 @@ export class WebChannel extends BaseChannel {
221
287
  role: 'user',
222
288
  content: message,
223
289
  timestamp: Date.now(),
224
- metadata: { sessionId: sessionId ?? 'default' },
290
+ metadata: { sessionId: sid },
225
291
  };
226
292
 
293
+ // Track conversation
294
+ if (!this.conversations.has(sid)) this.conversations.set(sid, []);
295
+ this.conversations.get(sid)!.push(msg);
296
+
227
297
  if (this.streamHandler) {
228
298
  try {
229
299
  await this.streamHandler(msg, res);
@@ -301,6 +371,107 @@ export class WebChannel extends BaseChannel {
301
371
  } catch { res.json({ totalEntries: 0, sources: [] }); }
302
372
  });
303
373
 
374
+ // --- Health Check (detailed) ---
375
+ this.app.get('/api/health', (_req: Request, res: Response) => {
376
+ const uptimeMs = Date.now() - this.stats.startedAt;
377
+ res.json({
378
+ status: 'ok',
379
+ timestamp: Date.now(),
380
+ uptime: uptimeMs,
381
+ uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`,
382
+ version: '0.7.0',
383
+ agent: this.agentName,
384
+ stats: {
385
+ sessions: this.stats.sessions,
386
+ messages: this.stats.messages,
387
+ errors: this.stats.errors,
388
+ avgResponseMs: this.stats.messages > 0 ? Math.round(this.stats.totalResponseMs / this.stats.messages) : 0,
389
+ },
390
+ memory: {
391
+ rss: process.memoryUsage().rss,
392
+ heapUsed: process.memoryUsage().heapUsed,
393
+ },
394
+ });
395
+ });
396
+
397
+ // --- Prometheus Metrics ---
398
+ this.app.get('/api/metrics', (_req: Request, res: Response) => {
399
+ const uptimeMs = Date.now() - this.stats.startedAt;
400
+ const avgLatency = this.llmCalls > 0 ? this.llmLatencySum / this.llmCalls : 0;
401
+ const mem = process.memoryUsage();
402
+ res.type('text/plain').send(
403
+ `# HELP opc_uptime_seconds Agent uptime in seconds\n` +
404
+ `# TYPE opc_uptime_seconds gauge\n` +
405
+ `opc_uptime_seconds ${(uptimeMs / 1000).toFixed(1)}\n` +
406
+ `# HELP opc_requests_total Total requests\n` +
407
+ `# TYPE opc_requests_total counter\n` +
408
+ `opc_requests_total ${this.requestCount}\n` +
409
+ `# HELP opc_messages_total Total messages processed\n` +
410
+ `# TYPE opc_messages_total counter\n` +
411
+ `opc_messages_total ${this.stats.messages}\n` +
412
+ `# HELP opc_errors_total Total errors\n` +
413
+ `# TYPE opc_errors_total counter\n` +
414
+ `opc_errors_total ${this.stats.errors}\n` +
415
+ `# HELP opc_llm_latency_avg_ms Average LLM response latency\n` +
416
+ `# TYPE opc_llm_latency_avg_ms gauge\n` +
417
+ `opc_llm_latency_avg_ms ${avgLatency.toFixed(1)}\n` +
418
+ `# HELP opc_sessions_total Total sessions\n` +
419
+ `# TYPE opc_sessions_total counter\n` +
420
+ `opc_sessions_total ${this.stats.sessions}\n` +
421
+ `# HELP opc_token_usage_total Total token usage\n` +
422
+ `# TYPE opc_token_usage_total counter\n` +
423
+ `opc_token_usage_total ${this.stats.tokenUsage}\n` +
424
+ `# HELP process_resident_memory_bytes Resident memory size\n` +
425
+ `# TYPE process_resident_memory_bytes gauge\n` +
426
+ `process_resident_memory_bytes ${mem.rss}\n`
427
+ );
428
+ });
429
+
430
+ // --- Conversation tracking & export ---
431
+ this.app.get('/api/conversations/export', (req: Request, res: Response) => {
432
+ const sessionId = req.query.sessionId as string;
433
+ const format = (req.query.format as string) ?? 'json';
434
+
435
+ const messages = sessionId ? (this.conversations.get(sessionId) ?? []) : Array.from(this.conversations.values()).flat();
436
+
437
+ if (format === 'markdown') {
438
+ const md = messages.map(m => `**${m.role}** (${new Date(m.timestamp).toISOString()}):\n${m.content}`).join('\n\n---\n\n');
439
+ res.type('text/markdown').send(md);
440
+ } else if (format === 'csv') {
441
+ const header = 'id,role,content,timestamp\n';
442
+ const rows = messages.map(m => `"${m.id}","${m.role}","${m.content.replace(/"/g, '""')}",${m.timestamp}`).join('\n');
443
+ res.type('text/csv').send(header + rows);
444
+ } else {
445
+ res.json({ sessionId: sessionId ?? 'all', messages, count: messages.length });
446
+ }
447
+ });
448
+
449
+ // --- Document Upload ---
450
+ this.app.post('/api/documents/upload', async (req: Request, res: Response) => {
451
+ try {
452
+ const { content, filename, mimeType } = req.body;
453
+ if (!content || !filename) {
454
+ res.status(400).json({ error: 'content and filename are required' });
455
+ return;
456
+ }
457
+ const kb = new KnowledgeBase('.');
458
+ const result = await kb.addText(content, filename);
459
+ this.stats.knowledgeFiles++;
460
+ res.json({ ok: true, filename, chunks: result.chunks, chars: content.length });
461
+ } catch (err) {
462
+ res.status(500).json({ error: err instanceof Error ? err.message : 'Upload failed' });
463
+ }
464
+ });
465
+
466
+ // --- Agent Templates Gallery ---
467
+ this.app.get('/api/templates', (_req: Request, res: Response) => {
468
+ res.json({ templates: AGENT_TEMPLATES });
469
+ });
470
+
471
+ this.app.get('/templates', (_req: Request, res: Response) => {
472
+ res.type('html').send(TEMPLATES_HTML);
473
+ });
474
+
304
475
  // Legacy endpoint
305
476
  this.app.post('/chat', async (req: Request, res: Response) => {
306
477
  if (!this.handler) {
@@ -0,0 +1,149 @@
1
+ import { BaseChannel } from './index';
2
+ import type { Message } from '../core/types';
3
+
4
+ /**
5
+ * WeChat Channel (Stub) — v0.8.0
6
+ * WeChat Official Account message handling, template messages, QR code login.
7
+ */
8
+
9
+ export interface WeChatChannelConfig {
10
+ /** WeChat Official Account AppID */
11
+ appId: string;
12
+ /** WeChat Official Account AppSecret */
13
+ appSecret: string;
14
+ /** Verification token for message validation */
15
+ token: string;
16
+ /** AES encoding key for encrypted messages */
17
+ encodingAESKey?: string;
18
+ /** HTTP server port (default: 3002) */
19
+ port?: number;
20
+ }
21
+
22
+ export interface WeChatMessage {
23
+ toUserName: string;
24
+ fromUserName: string;
25
+ createTime: number;
26
+ msgType: 'text' | 'image' | 'voice' | 'video' | 'event';
27
+ content?: string;
28
+ msgId?: string;
29
+ event?: string;
30
+ eventKey?: string;
31
+ }
32
+
33
+ export interface TemplateMessageData {
34
+ toUser: string;
35
+ templateId: string;
36
+ url?: string;
37
+ data: Record<string, { value: string; color?: string }>;
38
+ }
39
+
40
+ export class WeChatChannel extends BaseChannel {
41
+ type = 'wechat';
42
+ private config: WeChatChannelConfig;
43
+ private accessToken: string | null = null;
44
+ private tokenExpiry = 0;
45
+
46
+ constructor(config: WeChatChannelConfig) {
47
+ super();
48
+ this.config = config;
49
+ }
50
+
51
+ async start(): Promise<void> {
52
+ // TODO: Start HTTP server to receive WeChat push messages
53
+ // 1. Verify signature on GET requests
54
+ // 2. Parse XML messages on POST requests
55
+ // 3. Route to handler and reply with XML
56
+ }
57
+
58
+ async stop(): Promise<void> {
59
+ // TODO: Stop HTTP server
60
+ }
61
+
62
+ /** Get or refresh access token */
63
+ async getAccessToken(): Promise<string> {
64
+ if (this.accessToken && Date.now() < this.tokenExpiry) {
65
+ return this.accessToken;
66
+ }
67
+
68
+ // TODO: Implement token refresh
69
+ // const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${this.config.appId}&secret=${this.config.appSecret}`;
70
+ // const res = await fetch(url);
71
+ // const data = await res.json();
72
+ // this.accessToken = data.access_token;
73
+ // this.tokenExpiry = Date.now() + (data.expires_in - 300) * 1000;
74
+
75
+ return this.accessToken ?? '';
76
+ }
77
+
78
+ /** Handle incoming WeChat message */
79
+ async handleMessage(wxMsg: WeChatMessage): Promise<string> {
80
+ if (wxMsg.msgType === 'event') {
81
+ return this.handleEvent(wxMsg);
82
+ }
83
+
84
+ const message = this.wechatToMessage(wxMsg);
85
+ if (this.handler) {
86
+ const reply = await this.handler(message);
87
+ return reply.content;
88
+ }
89
+ return '';
90
+ }
91
+
92
+ /** Handle WeChat events (subscribe, scan, etc.) */
93
+ private handleEvent(wxMsg: WeChatMessage): string {
94
+ switch (wxMsg.event) {
95
+ case 'subscribe':
96
+ return 'Welcome! How can I help you?';
97
+ case 'SCAN':
98
+ return `QR code scanned: ${wxMsg.eventKey}`;
99
+ default:
100
+ return '';
101
+ }
102
+ }
103
+
104
+ /** Convert WeChat message to internal Message */
105
+ private wechatToMessage(wxMsg: WeChatMessage): Message {
106
+ return {
107
+ id: wxMsg.msgId ?? `wx-${wxMsg.createTime}`,
108
+ role: 'user',
109
+ content: wxMsg.content ?? '',
110
+ timestamp: wxMsg.createTime * 1000,
111
+ metadata: {
112
+ channel: 'wechat',
113
+ fromUser: wxMsg.fromUserName,
114
+ toUser: wxMsg.toUserName,
115
+ msgType: wxMsg.msgType,
116
+ },
117
+ };
118
+ }
119
+
120
+ /** Send template message */
121
+ async sendTemplateMessage(data: TemplateMessageData): Promise<boolean> {
122
+ // TODO: Implement
123
+ // const token = await this.getAccessToken();
124
+ // const url = `https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=${token}`;
125
+ // const res = await fetch(url, {
126
+ // method: 'POST',
127
+ // body: JSON.stringify({
128
+ // touser: data.toUser,
129
+ // template_id: data.templateId,
130
+ // url: data.url,
131
+ // data: data.data,
132
+ // }),
133
+ // });
134
+ void data;
135
+ return true;
136
+ }
137
+
138
+ /** Generate QR code for login (stub) */
139
+ async generateLoginQR(): Promise<{ ticket: string; url: string; expireSeconds: number }> {
140
+ // TODO: Implement with WeChat QR code API
141
+ // const token = await this.getAccessToken();
142
+ // POST to https://api.weixin.qq.com/cgi-bin/qrcode/create
143
+ return {
144
+ ticket: 'stub-ticket',
145
+ url: 'https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=stub-ticket',
146
+ expireSeconds: 300,
147
+ };
148
+ }
149
+ }
@@ -0,0 +1,57 @@
1
+ import type { Request, Response, NextFunction } from 'express';
2
+
3
+ export interface AuthConfig {
4
+ apiKeys: string[];
5
+ sessionIsolation?: boolean;
6
+ }
7
+
8
+ export interface AuthSession {
9
+ apiKey: string;
10
+ userId: string;
11
+ createdAt: number;
12
+ }
13
+
14
+ const sessions = new Map<string, AuthSession>();
15
+
16
+ export function createAuthMiddleware(config: AuthConfig) {
17
+ return (req: Request, res: Response, next: NextFunction): void => {
18
+ // Skip auth for non-API routes and health/metrics
19
+ if (!req.path.startsWith('/api/') || req.path === '/api/health' || req.path === '/api/metrics') {
20
+ next();
21
+ return;
22
+ }
23
+
24
+ const apiKey = req.headers['x-api-key'] as string
25
+ ?? req.headers['authorization']?.replace(/^Bearer\s+/i, '')
26
+ ?? (req.query as any).apiKey;
27
+
28
+ if (!apiKey || !config.apiKeys.includes(apiKey)) {
29
+ res.status(401).json({ error: 'Unauthorized. Provide a valid API key via X-API-Key header, Bearer token, or ?apiKey query.' });
30
+ return;
31
+ }
32
+
33
+ // Derive userId from API key for session isolation
34
+ const userId = `user_${hashKey(apiKey)}`;
35
+ if (!sessions.has(apiKey)) {
36
+ sessions.set(apiKey, { apiKey, userId, createdAt: Date.now() });
37
+ }
38
+
39
+ // Attach user info to request
40
+ (req as any).userId = userId;
41
+ (req as any).sessionPrefix = config.sessionIsolation ? `${userId}:` : '';
42
+
43
+ next();
44
+ };
45
+ }
46
+
47
+ function hashKey(key: string): string {
48
+ let h = 0;
49
+ for (let i = 0; i < key.length; i++) {
50
+ h = ((h << 5) - h + key.charCodeAt(i)) | 0;
51
+ }
52
+ return Math.abs(h).toString(36);
53
+ }
54
+
55
+ export function getActiveSessions(): AuthSession[] {
56
+ return Array.from(sessions.values());
57
+ }
@@ -0,0 +1,77 @@
1
+ import type { AgentContext, Message } from './types';
2
+
3
+ /**
4
+ * Agent Composition — v0.8.0
5
+ * Combine multiple agents into a pipeline: Agent A output → Agent B input.
6
+ * Configurable in OAD: `compose: [agent-a, agent-b]`
7
+ */
8
+
9
+ export type AgentHandler = (context: AgentContext, message: Message) => Promise<Message>;
10
+
11
+ export interface ComposableAgent {
12
+ id: string;
13
+ name: string;
14
+ handler: AgentHandler;
15
+ }
16
+
17
+ export interface ComposeOptions {
18
+ /** Stop pipeline if any agent returns empty content */
19
+ stopOnEmpty?: boolean;
20
+ /** Transform output between agents */
21
+ transform?: (output: Message, nextAgentId: string) => Message;
22
+ /** Timeout per agent in ms */
23
+ timeoutMs?: number;
24
+ }
25
+
26
+ export class AgentPipeline {
27
+ private agents: ComposableAgent[] = [];
28
+ private options: ComposeOptions;
29
+
30
+ constructor(agents: ComposableAgent[], options: ComposeOptions = {}) {
31
+ this.agents = agents;
32
+ this.options = options;
33
+ }
34
+
35
+ /** Run the pipeline sequentially: each agent's output becomes the next agent's input */
36
+ async execute(context: AgentContext, initialMessage: Message): Promise<Message> {
37
+ let currentMessage = initialMessage;
38
+
39
+ for (const agent of this.agents) {
40
+ if (this.options.stopOnEmpty && !currentMessage.content.trim()) {
41
+ break;
42
+ }
43
+
44
+ // Apply transform if provided
45
+ if (this.options.transform) {
46
+ currentMessage = this.options.transform(currentMessage, agent.id);
47
+ }
48
+
49
+ if (this.options.timeoutMs) {
50
+ const result = await Promise.race([
51
+ agent.handler(context, currentMessage),
52
+ new Promise<never>((_, reject) =>
53
+ setTimeout(() => reject(new Error(`Agent ${agent.id} timed out`)), this.options.timeoutMs)
54
+ ),
55
+ ]);
56
+ currentMessage = result;
57
+ } else {
58
+ currentMessage = await agent.handler(context, currentMessage);
59
+ }
60
+ }
61
+
62
+ return currentMessage;
63
+ }
64
+
65
+ /** Get the pipeline agent IDs in order */
66
+ getAgentIds(): string[] {
67
+ return this.agents.map((a) => a.id);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Create a pipeline from an array of composable agents.
73
+ * Usage in OAD: `compose: [agent-a, agent-b, agent-c]`
74
+ */
75
+ export function compose(agents: ComposableAgent[], options?: ComposeOptions): AgentPipeline {
76
+ return new AgentPipeline(agents, options);
77
+ }