skyloom 1.14.6 → 1.15.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 (157) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/publish.yml +74 -0
  3. package/CONVERSION_PLAN.md +191 -191
  4. package/README.md +523 -220
  5. package/config/default.yaml +46 -43
  6. package/config/models.yaml +928 -155
  7. package/config/providers.yaml +109 -6
  8. package/dist/agents/snow.d.ts +2 -0
  9. package/dist/agents/snow.d.ts.map +1 -1
  10. package/dist/agents/snow.js +36 -5
  11. package/dist/agents/snow.js.map +1 -1
  12. package/dist/cli/loom_chat.d.ts.map +1 -1
  13. package/dist/cli/loom_chat.js +207 -1
  14. package/dist/cli/loom_chat.js.map +1 -1
  15. package/dist/cli/main.js +190 -40
  16. package/dist/cli/main.js.map +1 -1
  17. package/dist/cli/tui.d.ts.map +1 -1
  18. package/dist/cli/tui.js +6 -31
  19. package/dist/cli/tui.js.map +1 -1
  20. package/dist/core/agent.d.ts +6 -4
  21. package/dist/core/agent.d.ts.map +1 -1
  22. package/dist/core/agent.js +61 -20
  23. package/dist/core/agent.js.map +1 -1
  24. package/dist/core/catalog.d.ts.map +1 -1
  25. package/dist/core/catalog.js +30 -9
  26. package/dist/core/catalog.js.map +1 -1
  27. package/dist/core/commands.d.ts +110 -0
  28. package/dist/core/commands.d.ts.map +1 -0
  29. package/dist/core/commands.js +633 -0
  30. package/dist/core/commands.js.map +1 -0
  31. package/dist/core/concurrency.d.ts +38 -0
  32. package/dist/core/concurrency.d.ts.map +1 -0
  33. package/dist/core/concurrency.js +65 -0
  34. package/dist/core/concurrency.js.map +1 -0
  35. package/dist/core/factory.js +16 -16
  36. package/dist/core/file_checkpoint.d.ts +9 -0
  37. package/dist/core/file_checkpoint.d.ts.map +1 -1
  38. package/dist/core/file_checkpoint.js +33 -1
  39. package/dist/core/file_checkpoint.js.map +1 -1
  40. package/dist/core/llm.d.ts.map +1 -1
  41. package/dist/core/llm.js +66 -13
  42. package/dist/core/llm.js.map +1 -1
  43. package/dist/core/memory.js +51 -51
  44. package/dist/core/schemas.d.ts +16 -0
  45. package/dist/core/schemas.d.ts.map +1 -1
  46. package/dist/core/schemas.js +32 -0
  47. package/dist/core/schemas.js.map +1 -1
  48. package/dist/core/security.d.ts.map +1 -1
  49. package/dist/core/security.js +27 -0
  50. package/dist/core/security.js.map +1 -1
  51. package/dist/core/skymd.js +14 -14
  52. package/dist/core/trace.d.ts +105 -0
  53. package/dist/core/trace.d.ts.map +1 -0
  54. package/dist/core/trace.js +213 -0
  55. package/dist/core/trace.js.map +1 -0
  56. package/dist/tools/builtin.d.ts +2 -6
  57. package/dist/tools/builtin.d.ts.map +1 -1
  58. package/dist/tools/builtin.js +180 -125
  59. package/dist/tools/builtin.js.map +1 -1
  60. package/dist/tools/extra.d.ts +13 -0
  61. package/dist/tools/extra.d.ts.map +1 -0
  62. package/dist/tools/extra.js +827 -0
  63. package/dist/tools/extra.js.map +1 -0
  64. package/dist/tools/guards.d.ts +12 -0
  65. package/dist/tools/guards.d.ts.map +1 -0
  66. package/dist/tools/guards.js +143 -0
  67. package/dist/tools/guards.js.map +1 -0
  68. package/dist/tools/model_tool.d.ts.map +1 -1
  69. package/dist/tools/model_tool.js +24 -4
  70. package/dist/tools/model_tool.js.map +1 -1
  71. package/dist/web/markdown.d.ts +32 -0
  72. package/dist/web/markdown.d.ts.map +1 -0
  73. package/dist/web/markdown.js +202 -0
  74. package/dist/web/markdown.js.map +1 -0
  75. package/dist/web/server.d.ts +4 -0
  76. package/dist/web/server.d.ts.map +1 -1
  77. package/dist/web/server.js +14 -582
  78. package/dist/web/server.js.map +1 -1
  79. package/dist/web/ui.d.ts +31 -0
  80. package/dist/web/ui.d.ts.map +1 -0
  81. package/dist/web/ui.js +1009 -0
  82. package/dist/web/ui.js.map +1 -0
  83. package/docs/AESTHETIC_DESIGN.md +152 -152
  84. package/docs/OPTIMIZATION_PLAN.md +178 -178
  85. package/package.json +68 -68
  86. package/src/agents/snow.ts +38 -5
  87. package/src/cli/commands_md.ts +112 -112
  88. package/src/cli/input_macros.ts +83 -83
  89. package/src/cli/loom.ts +1041 -1041
  90. package/src/cli/loom_chat.ts +772 -603
  91. package/src/cli/main.ts +853 -723
  92. package/src/cli/tui.ts +264 -289
  93. package/src/core/agent/guard.ts +133 -133
  94. package/src/core/agent/task.ts +100 -100
  95. package/src/core/agent.ts +1630 -1590
  96. package/src/core/agent_helpers.ts +500 -500
  97. package/src/core/bus.ts +221 -221
  98. package/src/core/cache.ts +153 -153
  99. package/src/core/catalog.ts +199 -178
  100. package/src/core/circuit_breaker.ts +119 -119
  101. package/src/core/commands.ts +704 -0
  102. package/src/core/concurrency.ts +73 -0
  103. package/src/core/config.ts +365 -365
  104. package/src/core/constants.ts +95 -95
  105. package/src/core/factory.ts +656 -656
  106. package/src/core/file_checkpoint.ts +163 -136
  107. package/src/core/hooks.ts +126 -126
  108. package/src/core/llm.ts +972 -915
  109. package/src/core/logger.ts +143 -143
  110. package/src/core/mcp.ts +1001 -1001
  111. package/src/core/memory.ts +1201 -1201
  112. package/src/core/middleware.ts +350 -350
  113. package/src/core/model_config.ts +159 -159
  114. package/src/core/pipelines.ts +424 -424
  115. package/src/core/schemas.ts +319 -282
  116. package/src/core/security.ts +27 -0
  117. package/src/core/semantic.ts +211 -211
  118. package/src/core/skill.ts +384 -384
  119. package/src/core/skymd.ts +143 -143
  120. package/src/core/theme.ts +65 -65
  121. package/src/core/tool.ts +457 -457
  122. package/src/core/trace.ts +236 -0
  123. package/src/core/verify.ts +71 -71
  124. package/src/plugins/loader.ts +91 -91
  125. package/src/skills/loader.ts +75 -75
  126. package/src/tools/builtin.ts +571 -493
  127. package/src/tools/computer.ts +279 -279
  128. package/src/tools/extra.ts +662 -0
  129. package/src/tools/guards.ts +82 -0
  130. package/src/tools/model_tool.ts +93 -74
  131. package/src/tools/todo.ts +76 -76
  132. package/src/web/markdown.ts +193 -0
  133. package/src/web/server.ts +117 -693
  134. package/src/web/ui.ts +949 -0
  135. package/tests/agent.test.ts +211 -159
  136. package/tests/agent_helpers.test.ts +48 -48
  137. package/tests/catalog.test.ts +86 -86
  138. package/tests/checkpoint_commands.test.ts +124 -124
  139. package/tests/claude_compat.test.ts +110 -110
  140. package/tests/commands.test.ts +103 -0
  141. package/tests/concurrency.test.ts +102 -0
  142. package/tests/config.test.ts +41 -41
  143. package/tests/extra_tools.test.ts +212 -0
  144. package/tests/fence_plugin.test.ts +52 -52
  145. package/tests/guard.test.ts +75 -75
  146. package/tests/loom.test.ts +337 -337
  147. package/tests/memory.test.ts +170 -170
  148. package/tests/model_config.test.ts +109 -109
  149. package/tests/skymd.test.ts +146 -146
  150. package/tests/ssrf.test.ts +38 -38
  151. package/tests/structured_retry.test.ts +87 -0
  152. package/tests/task.test.ts +60 -60
  153. package/tests/todo_toolstats.test.ts +94 -94
  154. package/tests/trace.test.ts +128 -0
  155. package/tests/tui.test.ts +67 -67
  156. package/tests/web.test.ts +169 -0
  157. package/tsconfig.json +38 -38
package/src/core/tool.ts CHANGED
@@ -1,457 +1,457 @@
1
- /**
2
- * Tool registration and execution framework with retry support
3
- */
4
-
5
- import { EventEmitter } from "events";
6
- import { getLogger } from "./logger";
7
- import { CircuitBreaker } from "./circuit_breaker";
8
-
9
- const log = getLogger("tool");
10
-
11
- /**
12
- * Tool parameter definition
13
- */
14
- export interface ToolParameter {
15
- name: string;
16
- type: "string" | "number" | "boolean" | "array" | "object";
17
- description: string;
18
- required?: boolean;
19
- default?: unknown;
20
- enum?: string[];
21
- }
22
-
23
- /**
24
- * Tool handler function
25
- */
26
- export type ToolHandler = (params: Record<string, unknown>) => Promise<string>;
27
-
28
- /**
29
- * Tool definition
30
- */
31
- export interface ToolDefinition {
32
- name: string;
33
- description: string;
34
- parameters?: ToolParameter[];
35
- handler?: ToolHandler;
36
- maxRetries?: number;
37
- retryDelay?: number;
38
- dangerous?: boolean;
39
- cacheable?: boolean;
40
- timeout?: number;
41
- }
42
-
43
- /**
44
- * Tool execution result
45
- */
46
- export interface ToolResult {
47
- success: boolean;
48
- result: string;
49
- error?: string;
50
- retries?: number;
51
- duration?: number;
52
- }
53
-
54
- const CACHE_MAXSIZE = 128;
55
- const DEFAULT_TIMEOUT = 30000; // 30 seconds
56
- const DEFAULT_RETRIES = 2;
57
- const DEFAULT_RETRY_DELAY = 0.5; // seconds
58
-
59
- /**
60
- * Tool result cache
61
- */
62
- class ToolResultStore {
63
- private store: Map<string, Map<string, string>> = new Map();
64
-
65
- get(toolName: string, key: string): string | undefined {
66
- const bucket = this.store.get(toolName);
67
- if (!bucket) return undefined;
68
-
69
- const value = bucket.get(key);
70
- if (value) {
71
- // Move to end (LRU)
72
- bucket.delete(key);
73
- bucket.set(key, value);
74
- }
75
- return value;
76
- }
77
-
78
- set(toolName: string, key: string, value: string): void {
79
- let bucket = this.store.get(toolName);
80
- if (!bucket) {
81
- bucket = new Map();
82
- this.store.set(toolName, bucket);
83
- }
84
-
85
- bucket.set(key, value);
86
-
87
- // Evict oldest if over limit
88
- if (bucket.size > CACHE_MAXSIZE) {
89
- const firstKey = bucket.keys().next().value;
90
- if (firstKey !== undefined) bucket.delete(firstKey);
91
- }
92
- }
93
-
94
- clear(toolName?: string): void {
95
- if (toolName) {
96
- this.store.delete(toolName);
97
- } else {
98
- this.store.clear();
99
- }
100
- }
101
- }
102
-
103
- const resultStore = new ToolResultStore();
104
-
105
- /**
106
- * Type coercion for tool parameters
107
- */
108
- function coerceValue(value: unknown, targetType: string): [boolean, unknown] {
109
- if (value === null || value === undefined) {
110
- return [true, value];
111
- }
112
-
113
- // Already correct type
114
- if (targetType === "string" && typeof value === "string") {
115
- return [true, value];
116
- }
117
- if (targetType === "number" && typeof value === "number") {
118
- return [true, value];
119
- }
120
- if (targetType === "boolean" && typeof value === "boolean") {
121
- return [true, value];
122
- }
123
- if (targetType === "array" && Array.isArray(value)) {
124
- return [true, value];
125
- }
126
- if (targetType === "object" && typeof value === "object") {
127
- return [true, value];
128
- }
129
-
130
- // Lenient coercion from string
131
- if (typeof value === "string") {
132
- const stripped = value.trim();
133
-
134
- if (targetType === "integer" || targetType === "number") {
135
- const num = parseInt(stripped, 10);
136
- if (!isNaN(num)) return [true, num];
137
- const float = parseFloat(stripped);
138
- if (!isNaN(float)) return [true, float];
139
- return [false, value];
140
- }
141
-
142
- if (targetType === "boolean") {
143
- const lower = stripped.toLowerCase();
144
- if (["true", "1", "yes", "y"].includes(lower)) return [true, true];
145
- if (["false", "0", "no", "n"].includes(lower)) return [true, false];
146
- return [false, value];
147
- }
148
-
149
- if (targetType === "array") {
150
- if (stripped.includes(",")) {
151
- return [true, stripped.split(",").map((s) => s.trim())];
152
- }
153
- return [true, [value]];
154
- }
155
- }
156
-
157
- return [false, value];
158
- }
159
-
160
- /**
161
- * Tool registry and executor
162
- */
163
- export class ToolRegistry extends EventEmitter {
164
- private tools: Map<string, ToolDefinition> = new Map();
165
- private breakers: Map<string, CircuitBreaker> = new Map();
166
- /** Per-tool runtime stats for the /tools observability command. */
167
- private stats: Map<string, { calls: number; failures: number; totalMs: number; cacheHits: number }> = new Map();
168
-
169
- private bumpStats(name: string, opts: { ms?: number; failed?: boolean; cacheHit?: boolean }): void {
170
- const s = this.stats.get(name) || { calls: 0, failures: 0, totalMs: 0, cacheHits: 0 };
171
- if (opts.cacheHit) s.cacheHits += 1;
172
- else {
173
- s.calls += 1;
174
- if (opts.failed) s.failures += 1;
175
- s.totalMs += opts.ms ?? 0;
176
- }
177
- this.stats.set(name, s);
178
- }
179
-
180
- /** Runtime stats per tool (only tools that were actually called), busiest first. */
181
- getStats(): Array<{ name: string; calls: number; failures: number; avgMs: number; cacheHits: number; breaker: string }> {
182
- return [...this.stats.entries()]
183
- .map(([name, s]) => ({
184
- name,
185
- calls: s.calls,
186
- failures: s.failures,
187
- avgMs: s.calls > 0 ? Math.round(s.totalMs / s.calls) : 0,
188
- cacheHits: s.cacheHits,
189
- breaker: this.breakers.get(name)?.getState() ?? 'closed',
190
- }))
191
- .sort((a, b) => b.calls - a.calls);
192
- }
193
-
194
- /**
195
- * Register a tool
196
- */
197
- register(def: ToolDefinition): void {
198
- if (!def.name || !def.description) {
199
- throw new Error("Tool must have name and description");
200
- }
201
-
202
- this.tools.set(def.name, def);
203
-
204
- // Create circuit breaker for the tool
205
- if (!this.breakers.has(def.name)) {
206
- this.breakers.set(
207
- def.name,
208
- new CircuitBreaker({
209
- name: `tool_${def.name}`,
210
- failureThreshold: 5,
211
- resetTimeout: 60000,
212
- })
213
- );
214
- }
215
-
216
- log.info("Tool registered", { tool: def.name });
217
- this.emit("registered", def.name);
218
- }
219
-
220
- /**
221
- * Unregister a tool
222
- */
223
- unregister(toolName: string): void {
224
- this.tools.delete(toolName);
225
- this.emit("unregistered", toolName);
226
- log.info("Tool unregistered", { tool: toolName });
227
- }
228
-
229
- /**
230
- * Get a tool definition
231
- */
232
- get(toolName: string): ToolDefinition | undefined {
233
- return this.tools.get(toolName);
234
- }
235
-
236
- /**
237
- * List all registered tools
238
- */
239
- list(): ToolDefinition[] {
240
- return Array.from(this.tools.values());
241
- }
242
-
243
- /**
244
- * Check if tool is registered
245
- */
246
- has(toolName: string): boolean {
247
- return this.tools.has(toolName);
248
- }
249
-
250
- /**
251
- * Validate tool parameters
252
- */
253
- validateParameters(toolName: string, params: Record<string, unknown>): [boolean, string] {
254
- const tool = this.tools.get(toolName);
255
- if (!tool) {
256
- return [false, `Tool ${toolName} not found`];
257
- }
258
-
259
- if (!tool.parameters) {
260
- return [true, ""];
261
- }
262
-
263
- for (const param of tool.parameters) {
264
- if (param.required && !(param.name in params)) {
265
- return [false, `Missing required parameter: ${param.name}`];
266
- }
267
-
268
- if (param.name in params) {
269
- const [valid] = coerceValue(params[param.name], param.type);
270
- if (!valid) {
271
- return [false, `Invalid type for parameter ${param.name}: expected ${param.type}`];
272
- }
273
- }
274
- }
275
-
276
- return [true, ""];
277
- }
278
-
279
- /**
280
- * Execute a tool with retry support
281
- */
282
- async execute(toolName: string, params: Record<string, unknown>): Promise<ToolResult> {
283
- const tool = this.tools.get(toolName);
284
- if (!tool) {
285
- return {
286
- success: false,
287
- result: "",
288
- error: `Tool ${toolName} not found`,
289
- };
290
- }
291
-
292
- // Check circuit breaker
293
- const breaker = this.breakers.get(toolName);
294
- if (breaker && !breaker.canExecute()) {
295
- return {
296
- success: false,
297
- result: "",
298
- error: `Tool ${toolName} is temporarily unavailable (circuit breaker open)`,
299
- };
300
- }
301
-
302
- // Check cache
303
- if (tool.cacheable) {
304
- const cacheKey = JSON.stringify(params);
305
- const cached = resultStore.get(toolName, cacheKey);
306
- if (cached) {
307
- log.debug("Tool cache hit", { tool: toolName });
308
- this.bumpStats(toolName, { cacheHit: true });
309
- return {
310
- success: true,
311
- result: cached,
312
- };
313
- }
314
- }
315
-
316
- // Validate parameters
317
- const [valid, error] = this.validateParameters(toolName, params);
318
- if (!valid) {
319
- return {
320
- success: false,
321
- result: "",
322
- error,
323
- };
324
- }
325
-
326
- // Execute with retries
327
- const maxRetries = tool.maxRetries ?? DEFAULT_RETRIES;
328
- const retryDelay = (tool.retryDelay ?? DEFAULT_RETRY_DELAY) * 1000;
329
- const timeout = tool.timeout ?? DEFAULT_TIMEOUT;
330
-
331
- let lastError: Error | null = null;
332
- let retries = 0;
333
-
334
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
335
- try {
336
- if (attempt > 0) {
337
- await new Promise((resolve) => setTimeout(resolve, retryDelay * attempt));
338
- }
339
-
340
- if (!tool.handler) {
341
- throw new Error(`No handler for tool ${toolName}`);
342
- }
343
-
344
- const startTime = Date.now();
345
-
346
- // Execute with timeout
347
- const promise = tool.handler(params);
348
- const timeoutPromise = new Promise<string>((_, reject) =>
349
- setTimeout(() => reject(new Error("Tool execution timeout")), timeout)
350
- );
351
-
352
- const result = await Promise.race([promise, timeoutPromise]);
353
-
354
- const duration = Date.now() - startTime;
355
-
356
- // Cache result
357
- if (tool.cacheable) {
358
- const cacheKey = JSON.stringify(params);
359
- resultStore.set(toolName, cacheKey, result);
360
- }
361
-
362
- breaker?.recordSuccess();
363
-
364
- log.info("Tool executed successfully", {
365
- tool: toolName,
366
- duration,
367
- retries: attempt,
368
- });
369
-
370
- this.bumpStats(toolName, { ms: duration });
371
- return {
372
- success: true,
373
- result,
374
- duration,
375
- retries: attempt,
376
- };
377
- } catch (error) {
378
- lastError = error as Error;
379
- retries = attempt;
380
-
381
- if (attempt < maxRetries) {
382
- log.warn("Tool execution failed, retrying", {
383
- tool: toolName,
384
- attempt: attempt + 1,
385
- error: lastError.message,
386
- });
387
- }
388
- }
389
- }
390
-
391
- breaker?.recordFailure();
392
- this.bumpStats(toolName, { failed: true });
393
-
394
- log.error("Tool execution failed after retries", {
395
- tool: toolName,
396
- retries,
397
- error: lastError?.message,
398
- });
399
-
400
- return {
401
- success: false,
402
- result: "",
403
- error: lastError?.message || "Tool execution failed",
404
- retries,
405
- };
406
- }
407
-
408
- /**
409
- * Get all tools (alias for list, used by agent code)
410
- */
411
- getTools(): ToolDefinition[] {
412
- return this.list();
413
- }
414
-
415
- /**
416
- * List all registered tool names
417
- */
418
- listNames(): string[] {
419
- return Array.from(this.tools.keys());
420
- }
421
-
422
- /**
423
- * Merge tools from another registry into this one.
424
- */
425
- merge(other: ToolRegistry): void {
426
- for (const tool of other.list()) {
427
- this.tools.set(tool.name, tool);
428
- }
429
- }
430
-
431
- /**
432
- * Clear result cache for a tool or all tools
433
- */
434
- clearCache(toolName?: string): void {
435
- resultStore.clear(toolName);
436
- if (toolName) {
437
- log.info("Tool cache cleared", { tool: toolName });
438
- } else {
439
- log.info("All tool caches cleared");
440
- }
441
- }
442
- }
443
-
444
- /**
445
- * Global tool registry
446
- */
447
- let globalRegistry: ToolRegistry | null = null;
448
-
449
- /**
450
- * Get the global tool registry
451
- */
452
- export function getToolRegistry(): ToolRegistry {
453
- if (!globalRegistry) {
454
- globalRegistry = new ToolRegistry();
455
- }
456
- return globalRegistry;
457
- }
1
+ /**
2
+ * Tool registration and execution framework with retry support
3
+ */
4
+
5
+ import { EventEmitter } from "events";
6
+ import { getLogger } from "./logger";
7
+ import { CircuitBreaker } from "./circuit_breaker";
8
+
9
+ const log = getLogger("tool");
10
+
11
+ /**
12
+ * Tool parameter definition
13
+ */
14
+ export interface ToolParameter {
15
+ name: string;
16
+ type: "string" | "number" | "boolean" | "array" | "object";
17
+ description: string;
18
+ required?: boolean;
19
+ default?: unknown;
20
+ enum?: string[];
21
+ }
22
+
23
+ /**
24
+ * Tool handler function
25
+ */
26
+ export type ToolHandler = (params: Record<string, unknown>) => Promise<string>;
27
+
28
+ /**
29
+ * Tool definition
30
+ */
31
+ export interface ToolDefinition {
32
+ name: string;
33
+ description: string;
34
+ parameters?: ToolParameter[];
35
+ handler?: ToolHandler;
36
+ maxRetries?: number;
37
+ retryDelay?: number;
38
+ dangerous?: boolean;
39
+ cacheable?: boolean;
40
+ timeout?: number;
41
+ }
42
+
43
+ /**
44
+ * Tool execution result
45
+ */
46
+ export interface ToolResult {
47
+ success: boolean;
48
+ result: string;
49
+ error?: string;
50
+ retries?: number;
51
+ duration?: number;
52
+ }
53
+
54
+ const CACHE_MAXSIZE = 128;
55
+ const DEFAULT_TIMEOUT = 30000; // 30 seconds
56
+ const DEFAULT_RETRIES = 2;
57
+ const DEFAULT_RETRY_DELAY = 0.5; // seconds
58
+
59
+ /**
60
+ * Tool result cache
61
+ */
62
+ class ToolResultStore {
63
+ private store: Map<string, Map<string, string>> = new Map();
64
+
65
+ get(toolName: string, key: string): string | undefined {
66
+ const bucket = this.store.get(toolName);
67
+ if (!bucket) return undefined;
68
+
69
+ const value = bucket.get(key);
70
+ if (value) {
71
+ // Move to end (LRU)
72
+ bucket.delete(key);
73
+ bucket.set(key, value);
74
+ }
75
+ return value;
76
+ }
77
+
78
+ set(toolName: string, key: string, value: string): void {
79
+ let bucket = this.store.get(toolName);
80
+ if (!bucket) {
81
+ bucket = new Map();
82
+ this.store.set(toolName, bucket);
83
+ }
84
+
85
+ bucket.set(key, value);
86
+
87
+ // Evict oldest if over limit
88
+ if (bucket.size > CACHE_MAXSIZE) {
89
+ const firstKey = bucket.keys().next().value;
90
+ if (firstKey !== undefined) bucket.delete(firstKey);
91
+ }
92
+ }
93
+
94
+ clear(toolName?: string): void {
95
+ if (toolName) {
96
+ this.store.delete(toolName);
97
+ } else {
98
+ this.store.clear();
99
+ }
100
+ }
101
+ }
102
+
103
+ const resultStore = new ToolResultStore();
104
+
105
+ /**
106
+ * Type coercion for tool parameters
107
+ */
108
+ function coerceValue(value: unknown, targetType: string): [boolean, unknown] {
109
+ if (value === null || value === undefined) {
110
+ return [true, value];
111
+ }
112
+
113
+ // Already correct type
114
+ if (targetType === "string" && typeof value === "string") {
115
+ return [true, value];
116
+ }
117
+ if (targetType === "number" && typeof value === "number") {
118
+ return [true, value];
119
+ }
120
+ if (targetType === "boolean" && typeof value === "boolean") {
121
+ return [true, value];
122
+ }
123
+ if (targetType === "array" && Array.isArray(value)) {
124
+ return [true, value];
125
+ }
126
+ if (targetType === "object" && typeof value === "object") {
127
+ return [true, value];
128
+ }
129
+
130
+ // Lenient coercion from string
131
+ if (typeof value === "string") {
132
+ const stripped = value.trim();
133
+
134
+ if (targetType === "integer" || targetType === "number") {
135
+ const num = parseInt(stripped, 10);
136
+ if (!isNaN(num)) return [true, num];
137
+ const float = parseFloat(stripped);
138
+ if (!isNaN(float)) return [true, float];
139
+ return [false, value];
140
+ }
141
+
142
+ if (targetType === "boolean") {
143
+ const lower = stripped.toLowerCase();
144
+ if (["true", "1", "yes", "y"].includes(lower)) return [true, true];
145
+ if (["false", "0", "no", "n"].includes(lower)) return [true, false];
146
+ return [false, value];
147
+ }
148
+
149
+ if (targetType === "array") {
150
+ if (stripped.includes(",")) {
151
+ return [true, stripped.split(",").map((s) => s.trim())];
152
+ }
153
+ return [true, [value]];
154
+ }
155
+ }
156
+
157
+ return [false, value];
158
+ }
159
+
160
+ /**
161
+ * Tool registry and executor
162
+ */
163
+ export class ToolRegistry extends EventEmitter {
164
+ private tools: Map<string, ToolDefinition> = new Map();
165
+ private breakers: Map<string, CircuitBreaker> = new Map();
166
+ /** Per-tool runtime stats for the /tools observability command. */
167
+ private stats: Map<string, { calls: number; failures: number; totalMs: number; cacheHits: number }> = new Map();
168
+
169
+ private bumpStats(name: string, opts: { ms?: number; failed?: boolean; cacheHit?: boolean }): void {
170
+ const s = this.stats.get(name) || { calls: 0, failures: 0, totalMs: 0, cacheHits: 0 };
171
+ if (opts.cacheHit) s.cacheHits += 1;
172
+ else {
173
+ s.calls += 1;
174
+ if (opts.failed) s.failures += 1;
175
+ s.totalMs += opts.ms ?? 0;
176
+ }
177
+ this.stats.set(name, s);
178
+ }
179
+
180
+ /** Runtime stats per tool (only tools that were actually called), busiest first. */
181
+ getStats(): Array<{ name: string; calls: number; failures: number; avgMs: number; cacheHits: number; breaker: string }> {
182
+ return [...this.stats.entries()]
183
+ .map(([name, s]) => ({
184
+ name,
185
+ calls: s.calls,
186
+ failures: s.failures,
187
+ avgMs: s.calls > 0 ? Math.round(s.totalMs / s.calls) : 0,
188
+ cacheHits: s.cacheHits,
189
+ breaker: this.breakers.get(name)?.getState() ?? 'closed',
190
+ }))
191
+ .sort((a, b) => b.calls - a.calls);
192
+ }
193
+
194
+ /**
195
+ * Register a tool
196
+ */
197
+ register(def: ToolDefinition): void {
198
+ if (!def.name || !def.description) {
199
+ throw new Error("Tool must have name and description");
200
+ }
201
+
202
+ this.tools.set(def.name, def);
203
+
204
+ // Create circuit breaker for the tool
205
+ if (!this.breakers.has(def.name)) {
206
+ this.breakers.set(
207
+ def.name,
208
+ new CircuitBreaker({
209
+ name: `tool_${def.name}`,
210
+ failureThreshold: 5,
211
+ resetTimeout: 60000,
212
+ })
213
+ );
214
+ }
215
+
216
+ log.info("Tool registered", { tool: def.name });
217
+ this.emit("registered", def.name);
218
+ }
219
+
220
+ /**
221
+ * Unregister a tool
222
+ */
223
+ unregister(toolName: string): void {
224
+ this.tools.delete(toolName);
225
+ this.emit("unregistered", toolName);
226
+ log.info("Tool unregistered", { tool: toolName });
227
+ }
228
+
229
+ /**
230
+ * Get a tool definition
231
+ */
232
+ get(toolName: string): ToolDefinition | undefined {
233
+ return this.tools.get(toolName);
234
+ }
235
+
236
+ /**
237
+ * List all registered tools
238
+ */
239
+ list(): ToolDefinition[] {
240
+ return Array.from(this.tools.values());
241
+ }
242
+
243
+ /**
244
+ * Check if tool is registered
245
+ */
246
+ has(toolName: string): boolean {
247
+ return this.tools.has(toolName);
248
+ }
249
+
250
+ /**
251
+ * Validate tool parameters
252
+ */
253
+ validateParameters(toolName: string, params: Record<string, unknown>): [boolean, string] {
254
+ const tool = this.tools.get(toolName);
255
+ if (!tool) {
256
+ return [false, `Tool ${toolName} not found`];
257
+ }
258
+
259
+ if (!tool.parameters) {
260
+ return [true, ""];
261
+ }
262
+
263
+ for (const param of tool.parameters) {
264
+ if (param.required && !(param.name in params)) {
265
+ return [false, `Missing required parameter: ${param.name}`];
266
+ }
267
+
268
+ if (param.name in params) {
269
+ const [valid] = coerceValue(params[param.name], param.type);
270
+ if (!valid) {
271
+ return [false, `Invalid type for parameter ${param.name}: expected ${param.type}`];
272
+ }
273
+ }
274
+ }
275
+
276
+ return [true, ""];
277
+ }
278
+
279
+ /**
280
+ * Execute a tool with retry support
281
+ */
282
+ async execute(toolName: string, params: Record<string, unknown>): Promise<ToolResult> {
283
+ const tool = this.tools.get(toolName);
284
+ if (!tool) {
285
+ return {
286
+ success: false,
287
+ result: "",
288
+ error: `Tool ${toolName} not found`,
289
+ };
290
+ }
291
+
292
+ // Check circuit breaker
293
+ const breaker = this.breakers.get(toolName);
294
+ if (breaker && !breaker.canExecute()) {
295
+ return {
296
+ success: false,
297
+ result: "",
298
+ error: `Tool ${toolName} is temporarily unavailable (circuit breaker open)`,
299
+ };
300
+ }
301
+
302
+ // Check cache
303
+ if (tool.cacheable) {
304
+ const cacheKey = JSON.stringify(params);
305
+ const cached = resultStore.get(toolName, cacheKey);
306
+ if (cached) {
307
+ log.debug("Tool cache hit", { tool: toolName });
308
+ this.bumpStats(toolName, { cacheHit: true });
309
+ return {
310
+ success: true,
311
+ result: cached,
312
+ };
313
+ }
314
+ }
315
+
316
+ // Validate parameters
317
+ const [valid, error] = this.validateParameters(toolName, params);
318
+ if (!valid) {
319
+ return {
320
+ success: false,
321
+ result: "",
322
+ error,
323
+ };
324
+ }
325
+
326
+ // Execute with retries
327
+ const maxRetries = tool.maxRetries ?? DEFAULT_RETRIES;
328
+ const retryDelay = (tool.retryDelay ?? DEFAULT_RETRY_DELAY) * 1000;
329
+ const timeout = tool.timeout ?? DEFAULT_TIMEOUT;
330
+
331
+ let lastError: Error | null = null;
332
+ let retries = 0;
333
+
334
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
335
+ try {
336
+ if (attempt > 0) {
337
+ await new Promise((resolve) => setTimeout(resolve, retryDelay * attempt));
338
+ }
339
+
340
+ if (!tool.handler) {
341
+ throw new Error(`No handler for tool ${toolName}`);
342
+ }
343
+
344
+ const startTime = Date.now();
345
+
346
+ // Execute with timeout
347
+ const promise = tool.handler(params);
348
+ const timeoutPromise = new Promise<string>((_, reject) =>
349
+ setTimeout(() => reject(new Error("Tool execution timeout")), timeout)
350
+ );
351
+
352
+ const result = await Promise.race([promise, timeoutPromise]);
353
+
354
+ const duration = Date.now() - startTime;
355
+
356
+ // Cache result
357
+ if (tool.cacheable) {
358
+ const cacheKey = JSON.stringify(params);
359
+ resultStore.set(toolName, cacheKey, result);
360
+ }
361
+
362
+ breaker?.recordSuccess();
363
+
364
+ log.info("Tool executed successfully", {
365
+ tool: toolName,
366
+ duration,
367
+ retries: attempt,
368
+ });
369
+
370
+ this.bumpStats(toolName, { ms: duration });
371
+ return {
372
+ success: true,
373
+ result,
374
+ duration,
375
+ retries: attempt,
376
+ };
377
+ } catch (error) {
378
+ lastError = error as Error;
379
+ retries = attempt;
380
+
381
+ if (attempt < maxRetries) {
382
+ log.warn("Tool execution failed, retrying", {
383
+ tool: toolName,
384
+ attempt: attempt + 1,
385
+ error: lastError.message,
386
+ });
387
+ }
388
+ }
389
+ }
390
+
391
+ breaker?.recordFailure();
392
+ this.bumpStats(toolName, { failed: true });
393
+
394
+ log.error("Tool execution failed after retries", {
395
+ tool: toolName,
396
+ retries,
397
+ error: lastError?.message,
398
+ });
399
+
400
+ return {
401
+ success: false,
402
+ result: "",
403
+ error: lastError?.message || "Tool execution failed",
404
+ retries,
405
+ };
406
+ }
407
+
408
+ /**
409
+ * Get all tools (alias for list, used by agent code)
410
+ */
411
+ getTools(): ToolDefinition[] {
412
+ return this.list();
413
+ }
414
+
415
+ /**
416
+ * List all registered tool names
417
+ */
418
+ listNames(): string[] {
419
+ return Array.from(this.tools.keys());
420
+ }
421
+
422
+ /**
423
+ * Merge tools from another registry into this one.
424
+ */
425
+ merge(other: ToolRegistry): void {
426
+ for (const tool of other.list()) {
427
+ this.tools.set(tool.name, tool);
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Clear result cache for a tool or all tools
433
+ */
434
+ clearCache(toolName?: string): void {
435
+ resultStore.clear(toolName);
436
+ if (toolName) {
437
+ log.info("Tool cache cleared", { tool: toolName });
438
+ } else {
439
+ log.info("All tool caches cleared");
440
+ }
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Global tool registry
446
+ */
447
+ let globalRegistry: ToolRegistry | null = null;
448
+
449
+ /**
450
+ * Get the global tool registry
451
+ */
452
+ export function getToolRegistry(): ToolRegistry {
453
+ if (!globalRegistry) {
454
+ globalRegistry = new ToolRegistry();
455
+ }
456
+ return globalRegistry;
457
+ }