@kadi.build/core 0.15.5 → 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.
- package/README.md +139 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +13 -4
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/react-loop.d.ts +251 -0
- package/dist/react-loop.d.ts.map +1 -0
- package/dist/react-loop.js +499 -0
- package/dist/react-loop.js.map +1 -0
- package/dist/types.d.ts +8 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/client.ts +12 -4
- package/src/index.ts +20 -0
- package/src/react-loop.ts +792 -0
- package/src/types.ts +8 -2
|
@@ -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
|
+
}
|