@kadi.build/core 0.15.4 → 0.15.6

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.
@@ -0,0 +1,792 @@
1
+ /**
2
+ * ReActLoop — SDK Primitive for Agent Execution
3
+ *
4
+ * The ReAct (Reason + Act) loop is the core agent execution primitive.
5
+ * It handles: tool routing, model calls, turn management, context compaction,
6
+ * event hooks, stop conditions, steering, and event emission.
7
+ *
8
+ * It does NOT handle: planning, sub-agents, filesystem management, or skills.
9
+ * Those are engine ability concerns.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * import { KadiClient, ReActLoop } from '@kadi.build/core';
14
+ *
15
+ * const client = new KadiClient({ name: 'my-agent' });
16
+ * await client.connect();
17
+ *
18
+ * const loop = new ReActLoop({
19
+ * client,
20
+ * model: { routing: 'broker', model: 'claude-sonnet-4-6' },
21
+ * tools: { broker: true },
22
+ * maxTurns: 50,
23
+ * systemPrompt: 'You are a research assistant...',
24
+ * });
25
+ *
26
+ * const result = await loop.run('Analyze the test results');
27
+ * ```
28
+ *
29
+ * @see docs/kadi-harness-spec-v5.md §8 (ReActLoop)
30
+ */
31
+
32
+ import { EventEmitter } from 'events';
33
+ import type { KadiClient } from './client.js';
34
+ import type { ToolDefinition } from './types.js';
35
+
36
+ // ═══════════════════════════════════════════════════════════════
37
+ // TYPES
38
+ // ═══════════════════════════════════════════════════════════════
39
+
40
+ /** Model routing and configuration */
41
+ export interface ModelConfig {
42
+ /** How to route model calls: 'broker' (via model-provider) or 'direct' */
43
+ routing: 'broker' | 'direct';
44
+ /** Provider name (used when routing: 'direct') */
45
+ provider?: string;
46
+ /** Model identifier (e.g. 'claude-sonnet-4-6') */
47
+ model: string;
48
+ /** Sampling temperature */
49
+ temperature?: number;
50
+ /** Max tokens for model response */
51
+ maxTokens?: number;
52
+ }
53
+
54
+ /** Tool routing configuration */
55
+ export interface ToolsConfig {
56
+ /** Include all tools from connected broker networks */
57
+ broker?: boolean;
58
+ /** Local tools to include by name */
59
+ native?: string[];
60
+ /** Additional custom tool definitions with handlers */
61
+ custom?: CustomTool[];
62
+ /** Pre-configured tool router (from bridge) */
63
+ router?: ToolRouter;
64
+ /** Pre-resolved tool definitions (bypass discovery) */
65
+ definitions?: ToolDefinition[];
66
+ }
67
+
68
+ /** A custom tool with definition and handler */
69
+ export interface CustomTool {
70
+ definition: ToolDefinition;
71
+ handler: (params: unknown) => Promise<unknown>;
72
+ }
73
+
74
+ /** Tool router interface — resolves and executes tool calls */
75
+ export interface ToolRouter {
76
+ resolve(toolName: string): Promise<ToolDefinition | undefined>;
77
+ execute(toolName: string, params: unknown): Promise<unknown>;
78
+ }
79
+
80
+ /** Event emission configuration */
81
+ export interface EventsConfig {
82
+ /** Whether to emit events */
83
+ enabled?: boolean;
84
+ /** Which network to emit events on */
85
+ network?: string;
86
+ /** Event channel prefix (default: 'kadi.agent') */
87
+ prefix?: string;
88
+ }
89
+
90
+ /** Lifecycle hooks */
91
+ export interface ReActHooks {
92
+ /** Called before each LLM call */
93
+ beforeTurn?: (turn: TurnInfo) => Promise<void>;
94
+ /** Called after each tool execution */
95
+ afterTurn?: (turn: TurnInfo, result: TurnResult) => Promise<void>;
96
+ /** Intercept/modify tool calls before execution */
97
+ onToolCall?: (toolName: string, params: unknown) => Promise<unknown | void>;
98
+ /** Handle errors — return 'retry', 'skip', or 'abort' */
99
+ onError?: (error: Error, turn: TurnInfo) => Promise<'retry' | 'skip' | 'abort'>;
100
+ /** Return false to stop the loop */
101
+ shouldContinue?: (turn: TurnInfo, result: TurnResult) => Promise<boolean>;
102
+ }
103
+
104
+ /** Full configuration for the ReActLoop */
105
+ export interface ReActConfig {
106
+ /** KadiClient instance for broker communication */
107
+ client?: KadiClient;
108
+
109
+ /** Model configuration */
110
+ model: ModelConfig;
111
+
112
+ /** Tools available to the loop */
113
+ tools?: ToolsConfig;
114
+
115
+ /** Maximum number of turns (default: 100) */
116
+ maxTurns?: number;
117
+
118
+ /** Maximum wall-clock time (e.g. '2h', '30m', '500ms') */
119
+ timeout?: string;
120
+
121
+ /** System prompt prepended to context */
122
+ systemPrompt?: string;
123
+
124
+ /** Context compaction strategy (default: 'auto') */
125
+ compaction?: 'auto' | 'manual' | 'off';
126
+
127
+ /** Token threshold for compaction trigger (default: 128000) */
128
+ maxContextTokens?: number;
129
+
130
+ /** Model for compaction summaries (null = same model) */
131
+ summaryModel?: string;
132
+
133
+ /** Lifecycle hooks */
134
+ hooks?: ReActHooks;
135
+
136
+ /** Event emission configuration */
137
+ events?: EventsConfig;
138
+
139
+ /** Auth context */
140
+ auth?: {
141
+ token?: string;
142
+ };
143
+
144
+ /** Model call function — override for testing or custom providers */
145
+ modelCall?: (messages: Message[], tools: ToolDefinition[]) => Promise<ModelResponse>;
146
+ }
147
+
148
+ // ═══════════════════════════════════════════════════════════════
149
+ // MESSAGE & RESPONSE TYPES
150
+ // ═══════════════════════════════════════════════════════════════
151
+
152
+ /** A message in the conversation context */
153
+ export interface Message {
154
+ role: 'system' | 'user' | 'assistant' | 'tool';
155
+ content: string;
156
+ /** Tool call ID (for tool results) */
157
+ toolCallId?: string;
158
+ /** Tool calls requested by the model */
159
+ toolCalls?: ToolCall[];
160
+ }
161
+
162
+ /** A tool call from the model */
163
+ export interface ToolCall {
164
+ id: string;
165
+ name: string;
166
+ arguments: unknown;
167
+ }
168
+
169
+ /** Response from a model call */
170
+ export interface ModelResponse {
171
+ /** Text content of the response */
172
+ content?: string;
173
+ /** Tool calls requested by the model */
174
+ toolCalls?: ToolCall[];
175
+ /** Estimated tokens used */
176
+ tokensUsed?: number;
177
+ /** Whether the model considers the task done */
178
+ done?: boolean;
179
+ }
180
+
181
+ /** Information about the current turn */
182
+ export interface TurnInfo {
183
+ /** Turn number (1-indexed) */
184
+ number: number;
185
+ /** When this turn started */
186
+ startedAt: Date;
187
+ /** Cumulative tokens used so far */
188
+ tokensUsed: number;
189
+ /** Cumulative tool calls so far */
190
+ toolCalls: number;
191
+ }
192
+
193
+ /** Result of a single turn */
194
+ export interface TurnResult {
195
+ /** Tool that was called (if any) */
196
+ toolName?: string;
197
+ /** Parameters passed to the tool */
198
+ toolParams?: unknown;
199
+ /** Result returned by the tool */
200
+ toolResult?: unknown;
201
+ /** Text response from the model */
202
+ modelResponse?: string;
203
+ /** Tokens used in this turn */
204
+ tokensUsed: number;
205
+ /** Whether the loop is complete */
206
+ completed: boolean;
207
+ /** Reason for completion (if completed) */
208
+ reason?: StopReason;
209
+ }
210
+
211
+ /** Reasons the loop can stop */
212
+ export type StopReason = 'max_turns' | 'timeout' | 'should_continue' | 'model_done' | 'error' | 'cancelled';
213
+
214
+ /** Final result of the loop execution */
215
+ export interface ReActResult {
216
+ /** How the loop ended */
217
+ status: 'completed' | 'cancelled' | 'error' | 'max_turns' | 'timeout';
218
+ /** Total turns executed */
219
+ turns: number;
220
+ /** Total tokens used */
221
+ totalTokens: number;
222
+ /** Artifacts produced during execution */
223
+ artifacts: unknown[];
224
+ /** Last model response text */
225
+ lastResponse?: string;
226
+ /** Error that caused the loop to stop (if applicable) */
227
+ error?: Error;
228
+ }
229
+
230
+ /** Handle for controlling a running loop */
231
+ export interface LoopHandle {
232
+ /** Inject a steering message into the loop */
233
+ steer(message: string): Promise<void>;
234
+ /** Cancel the loop */
235
+ cancel(): Promise<void>;
236
+ /** Promise that resolves when the loop completes */
237
+ completion: Promise<ReActResult>;
238
+ /** Listen for loop events */
239
+ on(event: string, handler: (...args: unknown[]) => void): void;
240
+ }
241
+
242
+ // ═══════════════════════════════════════════════════════════════
243
+ // CONSTANTS
244
+ // ═══════════════════════════════════════════════════════════════
245
+
246
+ const DEFAULT_MAX_TURNS = 100;
247
+ const DEFAULT_MAX_CONTEXT_TOKENS = 128000;
248
+ const DEFAULT_COMPACTION = 'auto';
249
+ const DEFAULT_EVENT_PREFIX = 'kadi.agent';
250
+ /** Compaction triggers at this fraction of maxContextTokens */
251
+ const COMPACTION_THRESHOLD = 0.8;
252
+ /** Rough estimate: 1 token ≈ 4 characters */
253
+ const CHARS_PER_TOKEN = 4;
254
+
255
+ // ═══════════════════════════════════════════════════════════════
256
+ // HELPER: Parse timeout string to ms
257
+ // ═══════════════════════════════════════════════════════════════
258
+
259
+ function parseTimeout(timeout: string): number {
260
+ const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h)$/.exec(timeout.trim());
261
+ if (!match || !match[1] || !match[2]) {
262
+ throw new Error(`Invalid timeout format: "${timeout}". Use e.g. '500ms', '30s', '2m', '2h'`);
263
+ }
264
+ const value = parseFloat(match[1]);
265
+ switch (match[2]) {
266
+ case 'ms': return value;
267
+ case 's': return value * 1000;
268
+ case 'm': return value * 60_000;
269
+ case 'h': return value * 3_600_000;
270
+ default: return value;
271
+ }
272
+ }
273
+
274
+ /** Estimate token count from message content */
275
+ function estimateTokens(messages: Message[]): number {
276
+ let chars = 0;
277
+ for (const msg of messages) {
278
+ chars += msg.content.length;
279
+ if (msg.toolCalls) {
280
+ chars += JSON.stringify(msg.toolCalls).length;
281
+ }
282
+ }
283
+ return Math.ceil(chars / CHARS_PER_TOKEN);
284
+ }
285
+
286
+ // ═══════════════════════════════════════════════════════════════
287
+ // REACT LOOP CLASS
288
+ // ═══════════════════════════════════════════════════════════════
289
+
290
+ /**
291
+ * The ReAct (Reason + Act) loop — core agent execution primitive.
292
+ *
293
+ * Runs a loop: call model → if tool call → execute tool → feed result → repeat.
294
+ * Stops when: max turns reached, timeout, shouldContinue returns false,
295
+ * model indicates done (no tool call), or an error causes abort.
296
+ */
297
+ export class ReActLoop {
298
+ private readonly config: Required<Pick<ReActConfig, 'maxTurns' | 'compaction' | 'maxContextTokens'>> & ReActConfig;
299
+ private readonly emitter = new EventEmitter();
300
+
301
+ // Custom tool handlers indexed by name
302
+ private readonly customTools = new Map<string, (params: unknown) => Promise<unknown>>();
303
+
304
+ constructor(config: ReActConfig) {
305
+ // Validate required fields
306
+ if (!config.model) {
307
+ throw new Error('ReActConfig.model is required');
308
+ }
309
+ if (!config.model.model) {
310
+ throw new Error('ReActConfig.model.model is required');
311
+ }
312
+ if (!config.model.routing) {
313
+ throw new Error('ReActConfig.model.routing is required');
314
+ }
315
+
316
+ this.config = {
317
+ ...config,
318
+ maxTurns: config.maxTurns ?? DEFAULT_MAX_TURNS,
319
+ compaction: config.compaction ?? DEFAULT_COMPACTION,
320
+ maxContextTokens: config.maxContextTokens ?? DEFAULT_MAX_CONTEXT_TOKENS,
321
+ };
322
+
323
+ // Register custom tools
324
+ if (config.tools?.custom) {
325
+ for (const tool of config.tools.custom) {
326
+ this.customTools.set(tool.definition.name, tool.handler);
327
+ }
328
+ }
329
+ }
330
+
331
+ // ─────────────────────────────────────────────────────────────
332
+ // PUBLIC API
333
+ // ─────────────────────────────────────────────────────────────
334
+
335
+ /**
336
+ * Run the loop synchronously (blocks until complete).
337
+ *
338
+ * @param prompt - User prompt to start the loop with
339
+ * @returns Final result with status, turn count, and artifacts
340
+ */
341
+ async run(prompt: string): Promise<ReActResult> {
342
+ return this.executeLoop(prompt);
343
+ }
344
+
345
+ /**
346
+ * Start the loop in interactive mode (supports steering).
347
+ *
348
+ * @param prompt - User prompt to start the loop with
349
+ * @returns Handle for steering, cancellation, and completion
350
+ */
351
+ start(prompt: string): LoopHandle {
352
+ const steeringQueue: string[] = [];
353
+ let cancelled = false;
354
+
355
+ const completion = this.executeLoop(prompt, {
356
+ getSteeringMessages: () => {
357
+ const messages = [...steeringQueue];
358
+ steeringQueue.length = 0;
359
+ return messages;
360
+ },
361
+ isCancelled: () => cancelled,
362
+ });
363
+
364
+ return {
365
+ steer: async (message: string) => {
366
+ steeringQueue.push(message);
367
+ },
368
+ cancel: async () => {
369
+ cancelled = true;
370
+ },
371
+ completion,
372
+ on: (event: string, handler: (...args: unknown[]) => void) => {
373
+ this.emitter.on(event, handler);
374
+ },
375
+ };
376
+ }
377
+
378
+ // ─────────────────────────────────────────────────────────────
379
+ // CORE LOOP
380
+ // ─────────────────────────────────────────────────────────────
381
+
382
+ private async executeLoop(
383
+ prompt: string,
384
+ control?: {
385
+ getSteeringMessages?: () => string[];
386
+ isCancelled?: () => boolean;
387
+ }
388
+ ): Promise<ReActResult> {
389
+ const messages: Message[] = [];
390
+ const artifacts: unknown[] = [];
391
+ let totalTokens = 0;
392
+ let totalToolCalls = 0;
393
+ let lastResponse: string | undefined;
394
+ let timeoutMs: number | undefined;
395
+
396
+ // Build initial context
397
+ if (this.config.systemPrompt) {
398
+ messages.push({ role: 'system', content: this.config.systemPrompt });
399
+ }
400
+ messages.push({ role: 'user', content: prompt });
401
+
402
+ // Parse timeout
403
+ if (this.config.timeout) {
404
+ timeoutMs = parseTimeout(this.config.timeout);
405
+ }
406
+ const startTime = Date.now();
407
+
408
+ // Resolve available tool definitions
409
+ const toolDefs = await this.resolveToolDefinitions();
410
+
411
+ // ─── Main loop ──────────────────────────────────────────
412
+
413
+ for (let turn = 1; turn <= this.config.maxTurns; turn++) {
414
+ // Check cancellation
415
+ if (control?.isCancelled?.()) {
416
+ return this.buildResult('cancelled', turn - 1, totalTokens, artifacts, lastResponse);
417
+ }
418
+
419
+ // Check timeout
420
+ if (timeoutMs && (Date.now() - startTime) >= timeoutMs) {
421
+ this.emitEvent('timeout', { turn: turn - 1, totalTokens });
422
+ return this.buildResult('timeout', turn - 1, totalTokens, artifacts, lastResponse);
423
+ }
424
+
425
+ // Inject steering messages
426
+ const steeringMsgs = control?.getSteeringMessages?.() ?? [];
427
+ for (const msg of steeringMsgs) {
428
+ messages.push({ role: 'user', content: msg });
429
+ }
430
+
431
+ // Build turn info
432
+ const turnInfo: TurnInfo = {
433
+ number: turn,
434
+ startedAt: new Date(),
435
+ tokensUsed: totalTokens,
436
+ toolCalls: totalToolCalls,
437
+ };
438
+
439
+ // beforeTurn hook
440
+ if (this.config.hooks?.beforeTurn) {
441
+ await this.config.hooks.beforeTurn(turnInfo);
442
+ }
443
+
444
+ // ─── Compaction check ──────────────────────────────
445
+ if (this.config.compaction === 'auto') {
446
+ const estimatedTokens = estimateTokens(messages);
447
+ if (estimatedTokens > this.config.maxContextTokens * COMPACTION_THRESHOLD) {
448
+ await this.compactContext(messages);
449
+ this.emitEvent('compaction', { turn, estimatedTokens });
450
+ }
451
+ }
452
+
453
+ // ─── Call model ────────────────────────────────────
454
+ let modelResponse: ModelResponse;
455
+ try {
456
+ modelResponse = await this.callModel(messages, toolDefs);
457
+ } catch (err) {
458
+ const error = err instanceof Error ? err : new Error(String(err));
459
+ const action = await this.handleError(error, turnInfo);
460
+ if (action === 'retry') continue;
461
+ if (action === 'skip') {
462
+ const skipResult: TurnResult = {
463
+ modelResponse: undefined,
464
+ tokensUsed: 0,
465
+ completed: false,
466
+ };
467
+ if (this.config.hooks?.afterTurn) {
468
+ await this.config.hooks.afterTurn(turnInfo, skipResult);
469
+ }
470
+ continue;
471
+ }
472
+ // abort
473
+ this.emitEvent('error', { turn, error: error.message });
474
+ return this.buildResult('error', turn, totalTokens, artifacts, lastResponse, error);
475
+ }
476
+
477
+ totalTokens += modelResponse.tokensUsed ?? 0;
478
+ lastResponse = modelResponse.content;
479
+
480
+ // ─── No tool calls → model is done ─────────────────
481
+ if (!modelResponse.toolCalls || modelResponse.toolCalls.length === 0) {
482
+ // Model returned text only — task is done
483
+ if (modelResponse.content) {
484
+ messages.push({ role: 'assistant', content: modelResponse.content });
485
+ }
486
+
487
+ const turnResult: TurnResult = {
488
+ modelResponse: modelResponse.content,
489
+ tokensUsed: modelResponse.tokensUsed ?? 0,
490
+ completed: true,
491
+ reason: 'model_done',
492
+ };
493
+
494
+ this.emitEvent('turn', { turn, result: turnResult });
495
+
496
+ if (this.config.hooks?.afterTurn) {
497
+ await this.config.hooks.afterTurn(turnInfo, turnResult);
498
+ }
499
+
500
+ return this.buildResult('completed', turn, totalTokens, artifacts, lastResponse);
501
+ }
502
+
503
+ // ─── Execute tool calls ────────────────────────────
504
+ // Add assistant message with tool calls
505
+ messages.push({
506
+ role: 'assistant',
507
+ content: modelResponse.content ?? '',
508
+ toolCalls: modelResponse.toolCalls,
509
+ });
510
+
511
+ for (const toolCall of modelResponse.toolCalls) {
512
+ totalToolCalls++;
513
+
514
+ // onToolCall hook — can modify params
515
+ let params = toolCall.arguments;
516
+ if (this.config.hooks?.onToolCall) {
517
+ const modified = await this.config.hooks.onToolCall(toolCall.name, params);
518
+ if (modified !== undefined) {
519
+ params = modified;
520
+ }
521
+ }
522
+
523
+ // Execute the tool
524
+ let toolResult: unknown;
525
+ try {
526
+ toolResult = await this.executeTool(toolCall.name, params);
527
+ } catch (err) {
528
+ const error = err instanceof Error ? err : new Error(String(err));
529
+ const action = await this.handleError(error, turnInfo);
530
+ if (action === 'retry') {
531
+ // Re-run this turn
532
+ break;
533
+ }
534
+ if (action === 'skip') {
535
+ toolResult = { error: error.message };
536
+ } else {
537
+ // abort
538
+ this.emitEvent('error', { turn, error: error.message, toolName: toolCall.name });
539
+ return this.buildResult('error', turn, totalTokens, artifacts, lastResponse, error);
540
+ }
541
+ }
542
+
543
+ // Feed tool result back to context
544
+ messages.push({
545
+ role: 'tool',
546
+ content: typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult),
547
+ toolCallId: toolCall.id,
548
+ });
549
+
550
+ const turnResult: TurnResult = {
551
+ toolName: toolCall.name,
552
+ toolParams: params,
553
+ toolResult,
554
+ modelResponse: modelResponse.content,
555
+ tokensUsed: modelResponse.tokensUsed ?? 0,
556
+ completed: false,
557
+ };
558
+
559
+ this.emitEvent('turn', { turn, result: turnResult });
560
+
561
+ if (this.config.hooks?.afterTurn) {
562
+ await this.config.hooks.afterTurn(turnInfo, turnResult);
563
+ }
564
+
565
+ // shouldContinue hook
566
+ if (this.config.hooks?.shouldContinue) {
567
+ const shouldContinue = await this.config.hooks.shouldContinue(turnInfo, turnResult);
568
+ if (!shouldContinue) {
569
+ return this.buildResult('completed', turn, totalTokens, artifacts, lastResponse);
570
+ }
571
+ }
572
+ }
573
+ }
574
+
575
+ // Exceeded maxTurns
576
+ this.emitEvent('max_turns', { maxTurns: this.config.maxTurns, totalTokens });
577
+ return this.buildResult('max_turns', this.config.maxTurns, totalTokens, artifacts, lastResponse);
578
+ }
579
+
580
+ // ─────────────────────────────────────────────────────────────
581
+ // MODEL CALLS
582
+ // ─────────────────────────────────────────────────────────────
583
+
584
+ private async callModel(messages: Message[], tools: ToolDefinition[]): Promise<ModelResponse> {
585
+ // If a custom modelCall is provided (for testing or direct providers), use it
586
+ if (this.config.modelCall) {
587
+ return this.config.modelCall(messages, tools);
588
+ }
589
+
590
+ // Broker-routed model call via model-provider ability
591
+ if (this.config.model.routing === 'broker' && this.config.client) {
592
+ return this.config.client.invokeRemote<ModelResponse>('model-call', {
593
+ model: this.config.model.model,
594
+ messages,
595
+ tools,
596
+ temperature: this.config.model.temperature,
597
+ maxTokens: this.config.model.maxTokens,
598
+ });
599
+ }
600
+
601
+ throw new Error(
602
+ 'No model call method available. Provide config.modelCall, or configure ' +
603
+ 'config.model.routing = "broker" with a connected KadiClient.'
604
+ );
605
+ }
606
+
607
+ // ─────────────────────────────────────────────────────────────
608
+ // TOOL ROUTING
609
+ // ─────────────────────────────────────────────────────────────
610
+
611
+ /**
612
+ * Resolve available tool definitions.
613
+ * Priority: custom → native → broker → definitions
614
+ */
615
+ private async resolveToolDefinitions(): Promise<ToolDefinition[]> {
616
+ const toolDefs: ToolDefinition[] = [];
617
+ const seenNames = new Set<string>();
618
+
619
+ // Custom tools
620
+ if (this.config.tools?.custom) {
621
+ for (const tool of this.config.tools.custom) {
622
+ if (!seenNames.has(tool.definition.name)) {
623
+ toolDefs.push(tool.definition);
624
+ seenNames.add(tool.definition.name);
625
+ }
626
+ }
627
+ }
628
+
629
+ // Pre-resolved definitions
630
+ if (this.config.tools?.definitions) {
631
+ for (const def of this.config.tools.definitions) {
632
+ if (!seenNames.has(def.name)) {
633
+ toolDefs.push(def);
634
+ seenNames.add(def.name);
635
+ }
636
+ }
637
+ }
638
+
639
+ return toolDefs;
640
+ }
641
+
642
+ /**
643
+ * Execute a tool by name.
644
+ * Routing priority: router → custom → broker
645
+ */
646
+ private async executeTool(toolName: string, params: unknown): Promise<unknown> {
647
+ // 1. Pre-configured router (from bridge)
648
+ if (this.config.tools?.router) {
649
+ const def = await this.config.tools.router.resolve(toolName);
650
+ if (def) {
651
+ return this.config.tools.router.execute(toolName, params);
652
+ }
653
+ }
654
+
655
+ // 2. Custom tools
656
+ const customHandler = this.customTools.get(toolName);
657
+ if (customHandler) {
658
+ return customHandler(params);
659
+ }
660
+
661
+ // 3. Broker (remote) tools
662
+ if (this.config.client && this.config.tools?.broker) {
663
+ return this.config.client.invokeRemote(toolName, params);
664
+ }
665
+
666
+ throw new Error(`Tool "${toolName}" not found in any configured tool source`);
667
+ }
668
+
669
+ // ─────────────────────────────────────────────────────────────
670
+ // CONTEXT COMPACTION
671
+ // ─────────────────────────────────────────────────────────────
672
+
673
+ /**
674
+ * Compact the message history by summarizing older messages.
675
+ * Preserves the system prompt and most recent messages.
676
+ */
677
+ private async compactContext(messages: Message[]): Promise<void> {
678
+ // Keep system prompt (first message if role is 'system')
679
+ const firstMsg = messages[0];
680
+ const hasSystemPrompt = firstMsg !== undefined && firstMsg.role === 'system';
681
+ const systemPrompt = hasSystemPrompt ? firstMsg : null;
682
+
683
+ // Keep the most recent messages (last 4)
684
+ const keepRecent = 4;
685
+ const recentStart = Math.max(hasSystemPrompt ? 1 : 0, messages.length - keepRecent);
686
+ const recentMessages = messages.slice(recentStart);
687
+
688
+ // Messages to summarize (everything between system prompt and recent)
689
+ const startIdx = hasSystemPrompt ? 1 : 0;
690
+ const toSummarize = messages.slice(startIdx, recentStart);
691
+
692
+ if (toSummarize.length === 0) return; // Nothing to compact
693
+
694
+ // Build summary prompt
695
+ const summaryText = toSummarize
696
+ .map(m => `[${m.role}]: ${m.content}`)
697
+ .join('\n');
698
+
699
+ const summaryPrompt: Message[] = [
700
+ {
701
+ role: 'system',
702
+ content: 'Summarize the following conversation history concisely, preserving key facts, tool results, and decisions:',
703
+ },
704
+ {
705
+ role: 'user',
706
+ content: summaryText,
707
+ },
708
+ ];
709
+
710
+ let summary: string;
711
+ try {
712
+ const response = await this.callModel(summaryPrompt, []);
713
+ summary = response.content ?? 'Unable to summarize conversation.';
714
+ } catch {
715
+ // If summarization fails, create a simple summary
716
+ summary = `[Compacted ${toSummarize.length} messages]`;
717
+ }
718
+
719
+ // Rebuild messages array
720
+ messages.length = 0;
721
+ if (systemPrompt) {
722
+ messages.push(systemPrompt);
723
+ }
724
+ messages.push({
725
+ role: 'assistant',
726
+ content: `[Context Summary]: ${summary}`,
727
+ });
728
+ messages.push(...recentMessages);
729
+ }
730
+
731
+ // ─────────────────────────────────────────────────────────────
732
+ // ERROR HANDLING
733
+ // ─────────────────────────────────────────────────────────────
734
+
735
+ private async handleError(error: Error, turnInfo: TurnInfo): Promise<'retry' | 'skip' | 'abort'> {
736
+ if (this.config.hooks?.onError) {
737
+ return this.config.hooks.onError(error, turnInfo);
738
+ }
739
+ return 'abort';
740
+ }
741
+
742
+ // ─────────────────────────────────────────────────────────────
743
+ // EVENT EMISSION
744
+ // ─────────────────────────────────────────────────────────────
745
+
746
+ private emitEvent(event: string, data: unknown): void {
747
+ const prefix = this.config.events?.prefix ?? DEFAULT_EVENT_PREFIX;
748
+ const fullEvent = `${prefix}.${event}`;
749
+
750
+ // Local event emission (only if there are listeners — avoids Node's
751
+ // special 'error' event behavior which throws if unhandled)
752
+ if (this.emitter.listenerCount(event) > 0) {
753
+ this.emitter.emit(event, data);
754
+ }
755
+ if (this.emitter.listenerCount(fullEvent) > 0) {
756
+ this.emitter.emit(fullEvent, data);
757
+ }
758
+
759
+ // Broker event emission (if configured)
760
+ if (this.config.events?.enabled && this.config.client) {
761
+ const network = this.config.events.network;
762
+ this.config.client.publish(fullEvent, data, { network }).catch(() => {
763
+ // Ignore publish errors — events are best-effort
764
+ });
765
+ }
766
+ }
767
+
768
+ // ─────────────────────────────────────────────────────────────
769
+ // RESULT BUILDER
770
+ // ─────────────────────────────────────────────────────────────
771
+
772
+ private buildResult(
773
+ status: ReActResult['status'],
774
+ turns: number,
775
+ totalTokens: number,
776
+ artifacts: unknown[],
777
+ lastResponse?: string,
778
+ error?: Error,
779
+ ): ReActResult {
780
+ const result: ReActResult = {
781
+ status,
782
+ turns,
783
+ totalTokens,
784
+ artifacts,
785
+ lastResponse,
786
+ error,
787
+ };
788
+
789
+ this.emitEvent('completed', result);
790
+ return result;
791
+ }
792
+ }