skyloom 1.14.8 → 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.
- package/.github/workflows/ci.yml +2 -2
- package/.github/workflows/publish.yml +51 -4
- package/CONVERSION_PLAN.md +191 -191
- package/config/default.yaml +46 -43
- package/config/models.yaml +928 -155
- package/config/providers.yaml +109 -6
- package/dist/agents/snow.d.ts +2 -0
- package/dist/agents/snow.d.ts.map +1 -1
- package/dist/agents/snow.js +36 -5
- package/dist/agents/snow.js.map +1 -1
- package/dist/cli/loom_chat.d.ts.map +1 -1
- package/dist/cli/loom_chat.js +207 -1
- package/dist/cli/loom_chat.js.map +1 -1
- package/dist/cli/main.js +190 -40
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/tui.d.ts.map +1 -1
- package/dist/cli/tui.js +6 -31
- package/dist/cli/tui.js.map +1 -1
- package/dist/core/agent.d.ts +6 -4
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +61 -20
- package/dist/core/agent.js.map +1 -1
- package/dist/core/catalog.d.ts.map +1 -1
- package/dist/core/catalog.js +30 -9
- package/dist/core/catalog.js.map +1 -1
- package/dist/core/commands.d.ts +110 -0
- package/dist/core/commands.d.ts.map +1 -0
- package/dist/core/commands.js +633 -0
- package/dist/core/commands.js.map +1 -0
- package/dist/core/concurrency.d.ts +38 -0
- package/dist/core/concurrency.d.ts.map +1 -0
- package/dist/core/concurrency.js +65 -0
- package/dist/core/concurrency.js.map +1 -0
- package/dist/core/factory.js +16 -16
- package/dist/core/file_checkpoint.d.ts +9 -0
- package/dist/core/file_checkpoint.d.ts.map +1 -1
- package/dist/core/file_checkpoint.js +33 -1
- package/dist/core/file_checkpoint.js.map +1 -1
- package/dist/core/llm.d.ts.map +1 -1
- package/dist/core/llm.js +66 -13
- package/dist/core/llm.js.map +1 -1
- package/dist/core/memory.js +51 -51
- package/dist/core/schemas.d.ts +16 -0
- package/dist/core/schemas.d.ts.map +1 -1
- package/dist/core/schemas.js +32 -0
- package/dist/core/schemas.js.map +1 -1
- package/dist/core/security.d.ts.map +1 -1
- package/dist/core/security.js +27 -0
- package/dist/core/security.js.map +1 -1
- package/dist/core/skymd.js +14 -14
- package/dist/core/trace.d.ts +105 -0
- package/dist/core/trace.d.ts.map +1 -0
- package/dist/core/trace.js +213 -0
- package/dist/core/trace.js.map +1 -0
- package/dist/tools/builtin.d.ts +2 -6
- package/dist/tools/builtin.d.ts.map +1 -1
- package/dist/tools/builtin.js +18 -111
- package/dist/tools/builtin.js.map +1 -1
- package/dist/tools/extra.d.ts +13 -0
- package/dist/tools/extra.d.ts.map +1 -0
- package/dist/tools/extra.js +827 -0
- package/dist/tools/extra.js.map +1 -0
- package/dist/tools/guards.d.ts +12 -0
- package/dist/tools/guards.d.ts.map +1 -0
- package/dist/tools/guards.js +143 -0
- package/dist/tools/guards.js.map +1 -0
- package/dist/tools/model_tool.d.ts.map +1 -1
- package/dist/tools/model_tool.js +24 -4
- package/dist/tools/model_tool.js.map +1 -1
- package/dist/web/markdown.d.ts +32 -0
- package/dist/web/markdown.d.ts.map +1 -0
- package/dist/web/markdown.js +202 -0
- package/dist/web/markdown.js.map +1 -0
- package/dist/web/server.d.ts +4 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +14 -582
- package/dist/web/server.js.map +1 -1
- package/dist/web/ui.d.ts +31 -0
- package/dist/web/ui.d.ts.map +1 -0
- package/dist/web/ui.js +1009 -0
- package/dist/web/ui.js.map +1 -0
- package/docs/AESTHETIC_DESIGN.md +152 -152
- package/docs/OPTIMIZATION_PLAN.md +178 -178
- package/package.json +1 -1
- package/src/agents/snow.ts +38 -5
- package/src/cli/commands_md.ts +112 -112
- package/src/cli/input_macros.ts +83 -83
- package/src/cli/loom.ts +1041 -1041
- package/src/cli/loom_chat.ts +772 -603
- package/src/cli/main.ts +853 -723
- package/src/cli/tui.ts +264 -289
- package/src/core/agent/guard.ts +133 -133
- package/src/core/agent/task.ts +100 -100
- package/src/core/agent.ts +1630 -1590
- package/src/core/agent_helpers.ts +500 -500
- package/src/core/bus.ts +221 -221
- package/src/core/cache.ts +153 -153
- package/src/core/catalog.ts +199 -178
- package/src/core/circuit_breaker.ts +119 -119
- package/src/core/commands.ts +704 -0
- package/src/core/concurrency.ts +73 -0
- package/src/core/config.ts +365 -365
- package/src/core/constants.ts +95 -95
- package/src/core/factory.ts +656 -656
- package/src/core/file_checkpoint.ts +163 -136
- package/src/core/hooks.ts +126 -126
- package/src/core/llm.ts +972 -915
- package/src/core/logger.ts +143 -143
- package/src/core/mcp.ts +1001 -1001
- package/src/core/memory.ts +1201 -1201
- package/src/core/middleware.ts +350 -350
- package/src/core/model_config.ts +159 -159
- package/src/core/pipelines.ts +424 -424
- package/src/core/schemas.ts +319 -282
- package/src/core/security.ts +27 -0
- package/src/core/semantic.ts +211 -211
- package/src/core/skill.ts +384 -384
- package/src/core/skymd.ts +143 -143
- package/src/core/theme.ts +65 -65
- package/src/core/tool.ts +457 -457
- package/src/core/trace.ts +236 -0
- package/src/core/verify.ts +71 -71
- package/src/plugins/loader.ts +91 -91
- package/src/skills/loader.ts +75 -75
- package/src/tools/builtin.ts +571 -642
- package/src/tools/computer.ts +279 -279
- package/src/tools/extra.ts +662 -0
- package/src/tools/guards.ts +82 -0
- package/src/tools/model_tool.ts +93 -74
- package/src/tools/todo.ts +76 -76
- package/src/web/markdown.ts +193 -0
- package/src/web/server.ts +117 -693
- package/src/web/ui.ts +949 -0
- package/tests/agent.test.ts +211 -159
- package/tests/agent_helpers.test.ts +48 -48
- package/tests/catalog.test.ts +86 -86
- package/tests/checkpoint_commands.test.ts +124 -124
- package/tests/claude_compat.test.ts +110 -110
- package/tests/commands.test.ts +103 -0
- package/tests/concurrency.test.ts +102 -0
- package/tests/config.test.ts +41 -41
- package/tests/extra_tools.test.ts +212 -0
- package/tests/fence_plugin.test.ts +52 -52
- package/tests/guard.test.ts +75 -75
- package/tests/loom.test.ts +337 -337
- package/tests/memory.test.ts +170 -170
- package/tests/model_config.test.ts +109 -109
- package/tests/skymd.test.ts +146 -146
- package/tests/ssrf.test.ts +38 -38
- package/tests/structured_retry.test.ts +87 -0
- package/tests/task.test.ts +60 -60
- package/tests/todo_toolstats.test.ts +94 -94
- package/tests/trace.test.ts +128 -0
- package/tests/tui.test.ts +67 -67
- package/tests/web.test.ts +169 -0
- 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
|
+
}
|