skyloom 1.16.2 → 1.17.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/README.md +15 -3
- package/dist/cli/loom_chat.d.ts.map +1 -1
- package/dist/cli/loom_chat.js +17 -0
- package/dist/cli/loom_chat.js.map +1 -1
- package/dist/cli/main.js +37 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/core/agent.d.ts +2 -0
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +13 -0
- package/dist/core/agent.js.map +1 -1
- package/dist/core/bgproc.d.ts +59 -0
- package/dist/core/bgproc.d.ts.map +1 -0
- package/dist/core/bgproc.js +135 -0
- package/dist/core/bgproc.js.map +1 -0
- package/dist/core/commands.d.ts.map +1 -1
- package/dist/core/commands.js +20 -0
- package/dist/core/commands.js.map +1 -1
- package/dist/core/diagnostics.d.ts +39 -0
- package/dist/core/diagnostics.d.ts.map +1 -0
- package/dist/core/diagnostics.js +206 -0
- package/dist/core/diagnostics.js.map +1 -0
- package/dist/core/diff.d.ts +31 -0
- package/dist/core/diff.d.ts.map +1 -0
- package/dist/core/diff.js +82 -0
- package/dist/core/diff.js.map +1 -0
- package/dist/core/envcontext.d.ts +25 -0
- package/dist/core/envcontext.d.ts.map +1 -0
- package/dist/core/envcontext.js +112 -0
- package/dist/core/envcontext.js.map +1 -0
- package/dist/core/factory.d.ts +2 -0
- package/dist/core/factory.d.ts.map +1 -1
- package/dist/core/factory.js +35 -2
- package/dist/core/factory.js.map +1 -1
- package/dist/core/sandbox.d.ts +1 -0
- package/dist/core/sandbox.d.ts.map +1 -1
- package/dist/core/sandbox.js +1 -0
- package/dist/core/sandbox.js.map +1 -1
- package/dist/core/security.d.ts +22 -2
- package/dist/core/security.d.ts.map +1 -1
- package/dist/core/security.js +54 -24
- package/dist/core/security.js.map +1 -1
- package/dist/core/skill.d.ts +4 -0
- package/dist/core/skill.d.ts.map +1 -1
- package/dist/core/skill.js +1 -0
- package/dist/core/skill.js.map +1 -1
- package/dist/core/subagent.d.ts +75 -0
- package/dist/core/subagent.d.ts.map +1 -0
- package/dist/core/subagent.js +287 -0
- package/dist/core/subagent.js.map +1 -0
- package/dist/core/tool.d.ts +15 -1
- package/dist/core/tool.d.ts.map +1 -1
- package/dist/core/tool.js +88 -30
- package/dist/core/tool.js.map +1 -1
- package/dist/plugins/loader.d.ts +49 -8
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +129 -16
- package/dist/plugins/loader.js.map +1 -1
- package/dist/tools/builtin.d.ts.map +1 -1
- package/dist/tools/builtin.js +118 -13
- package/dist/tools/builtin.js.map +1 -1
- package/dist/tools/spawn.d.ts +23 -0
- package/dist/tools/spawn.d.ts.map +1 -0
- package/dist/tools/spawn.js +77 -0
- package/dist/tools/spawn.js.map +1 -0
- package/docs/OPTIMIZATION_PLAN.md +21 -4
- package/package.json +1 -1
- package/src/cli/loom_chat.ts +11 -0
- package/src/cli/main.ts +31 -1
- package/src/core/agent.ts +13 -0
- package/src/core/bgproc.ts +153 -0
- package/src/core/commands.ts +20 -0
- package/src/core/diagnostics.ts +178 -0
- package/src/core/diff.ts +98 -0
- package/src/core/envcontext.ts +79 -0
- package/src/core/factory.ts +31 -2
- package/src/core/sandbox.ts +1 -1
- package/src/core/security.ts +62 -21
- package/src/core/skill.ts +1 -1
- package/src/core/subagent.ts +272 -0
- package/src/core/tool.ts +86 -31
- package/src/plugins/loader.ts +145 -18
- package/src/tools/builtin.ts +107 -13
- package/src/tools/spawn.ts +92 -0
- package/tests/bgproc.test.ts +65 -0
- package/tests/diagnostics.test.ts +86 -0
- package/tests/edit_diff.test.ts +102 -0
- package/tests/envcontext.test.ts +67 -0
- package/tests/plugins.test.ts +84 -0
- package/tests/security.test.ts +87 -0
- package/tests/subagent.test.ts +211 -0
- package/tests/tool.test.ts +76 -0
package/src/core/tool.ts
CHANGED
|
@@ -144,15 +144,22 @@ function coerceValue(value: unknown, targetType: string): [boolean, unknown] {
|
|
|
144
144
|
return [true, value];
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
// string target accepts scalars (number/boolean) by stringifying them —
|
|
148
|
+
// the model sometimes sends 5 where a string id is expected.
|
|
149
|
+
if (targetType === "string" && (typeof value === "number" || typeof value === "boolean")) {
|
|
150
|
+
return [true, String(value)];
|
|
151
|
+
}
|
|
152
|
+
|
|
147
153
|
// Lenient coercion from string
|
|
148
154
|
if (typeof value === "string") {
|
|
149
155
|
const stripped = value.trim();
|
|
150
156
|
|
|
151
157
|
if (targetType === "integer" || targetType === "number") {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
158
|
+
// Number() handles ints and floats uniformly; parseInt truncated floats
|
|
159
|
+
// ("3.5" -> 3), silently corrupting numeric args.
|
|
160
|
+
if (stripped === "") return [false, value];
|
|
161
|
+
const num = Number(stripped);
|
|
162
|
+
if (!isNaN(num)) return [true, targetType === "integer" ? Math.trunc(num) : num];
|
|
156
163
|
return [false, value];
|
|
157
164
|
}
|
|
158
165
|
|
|
@@ -164,16 +171,35 @@ function coerceValue(value: unknown, targetType: string): [boolean, unknown] {
|
|
|
164
171
|
}
|
|
165
172
|
|
|
166
173
|
if (targetType === "array") {
|
|
174
|
+
// The model often sends a JSON-encoded array string for array params.
|
|
175
|
+
if (stripped.startsWith("[")) {
|
|
176
|
+
try { const parsed = JSON.parse(stripped); if (Array.isArray(parsed)) return [true, parsed]; } catch { /* fall through */ }
|
|
177
|
+
}
|
|
167
178
|
if (stripped.includes(",")) {
|
|
168
179
|
return [true, stripped.split(",").map((s) => s.trim())];
|
|
169
180
|
}
|
|
170
181
|
return [true, [value]];
|
|
171
182
|
}
|
|
183
|
+
|
|
184
|
+
if (targetType === "object") {
|
|
185
|
+
try {
|
|
186
|
+
const parsed = JSON.parse(stripped);
|
|
187
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return [true, parsed];
|
|
188
|
+
} catch { /* not JSON */ }
|
|
189
|
+
return [false, value];
|
|
190
|
+
}
|
|
172
191
|
}
|
|
173
192
|
|
|
174
193
|
return [false, value];
|
|
175
194
|
}
|
|
176
195
|
|
|
196
|
+
/** Describe a value's runtime type for an error message. */
|
|
197
|
+
function describeType(v: unknown): string {
|
|
198
|
+
if (v === null) return "null";
|
|
199
|
+
if (Array.isArray(v)) return "array";
|
|
200
|
+
return typeof v;
|
|
201
|
+
}
|
|
202
|
+
|
|
177
203
|
/**
|
|
178
204
|
* Tool registry and executor
|
|
179
205
|
*/
|
|
@@ -265,32 +291,59 @@ export class ToolRegistry extends EventEmitter {
|
|
|
265
291
|
}
|
|
266
292
|
|
|
267
293
|
/**
|
|
268
|
-
* Validate tool
|
|
294
|
+
* Validate tool inputs against the declared schema AND return coerced args:
|
|
295
|
+
* required-field presence, per-param type coercion (string<->number/bool,
|
|
296
|
+
* JSON-string -> array/object), and enum membership. Returning the coerced
|
|
297
|
+
* params means the handler receives clean, typed values (the model often
|
|
298
|
+
* sends "5" / "true" / a JSON string), and rejecting malformed input before
|
|
299
|
+
* execution gives the model a precise, actionable error to retry against.
|
|
269
300
|
*/
|
|
270
|
-
|
|
301
|
+
validateAndCoerce(
|
|
302
|
+
toolName: string,
|
|
303
|
+
params: Record<string, unknown>,
|
|
304
|
+
): { ok: boolean; error?: string; params?: Record<string, unknown> } {
|
|
271
305
|
const tool = this.tools.get(toolName);
|
|
272
|
-
if (!tool) {
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
if (!tool.parameters) {
|
|
277
|
-
return [true, ""];
|
|
278
|
-
}
|
|
306
|
+
if (!tool) return { ok: false, error: `Tool ${toolName} not found` };
|
|
307
|
+
if (!tool.parameters || tool.parameters.length === 0) return { ok: true, params };
|
|
279
308
|
|
|
309
|
+
const out: Record<string, unknown> = { ...params };
|
|
280
310
|
for (const param of tool.parameters) {
|
|
281
|
-
|
|
282
|
-
|
|
311
|
+
const has = param.name in params && params[param.name] !== null && params[param.name] !== undefined;
|
|
312
|
+
if (!has) {
|
|
313
|
+
if (param.required) {
|
|
314
|
+
return { ok: false, error: `Missing required parameter '${param.name}' (expected ${param.type}).` };
|
|
315
|
+
}
|
|
316
|
+
continue;
|
|
283
317
|
}
|
|
284
318
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
319
|
+
const [valid, coerced] = coerceValue(params[param.name], param.type);
|
|
320
|
+
if (!valid) {
|
|
321
|
+
return {
|
|
322
|
+
ok: false,
|
|
323
|
+
error: `Invalid type for parameter '${param.name}': expected ${param.type}, got ${describeType(params[param.name])}.`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (param.enum && param.enum.length > 0 && !param.enum.includes(String(coerced))) {
|
|
328
|
+
return {
|
|
329
|
+
ok: false,
|
|
330
|
+
error: `Invalid value for parameter '${param.name}': '${String(coerced)}' is not allowed. Valid values: ${param.enum.join(", ")}.`,
|
|
331
|
+
};
|
|
290
332
|
}
|
|
333
|
+
|
|
334
|
+
out[param.name] = coerced;
|
|
291
335
|
}
|
|
292
336
|
|
|
293
|
-
return
|
|
337
|
+
return { ok: true, params: out };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Validate tool parameters (legacy boolean/message form; delegates to
|
|
342
|
+
* validateAndCoerce).
|
|
343
|
+
*/
|
|
344
|
+
validateParameters(toolName: string, params: Record<string, unknown>): [boolean, string] {
|
|
345
|
+
const r = this.validateAndCoerce(toolName, params);
|
|
346
|
+
return [r.ok, r.error || ""];
|
|
294
347
|
}
|
|
295
348
|
|
|
296
349
|
/**
|
|
@@ -316,6 +369,18 @@ export class ToolRegistry extends EventEmitter {
|
|
|
316
369
|
};
|
|
317
370
|
}
|
|
318
371
|
|
|
372
|
+
// Validate + coerce inputs against the schema BEFORE cache/execute, so the
|
|
373
|
+
// handler and the cache key both use clean, typed values.
|
|
374
|
+
const validated = this.validateAndCoerce(toolName, params);
|
|
375
|
+
if (!validated.ok) {
|
|
376
|
+
return {
|
|
377
|
+
success: false,
|
|
378
|
+
result: "",
|
|
379
|
+
error: validated.error,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
params = validated.params!;
|
|
383
|
+
|
|
319
384
|
// Check cache
|
|
320
385
|
if (tool.cacheable) {
|
|
321
386
|
const cacheKey = stableStringify(params);
|
|
@@ -330,16 +395,6 @@ export class ToolRegistry extends EventEmitter {
|
|
|
330
395
|
}
|
|
331
396
|
}
|
|
332
397
|
|
|
333
|
-
// Validate parameters
|
|
334
|
-
const [valid, error] = this.validateParameters(toolName, params);
|
|
335
|
-
if (!valid) {
|
|
336
|
-
return {
|
|
337
|
-
success: false,
|
|
338
|
-
result: "",
|
|
339
|
-
error,
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
|
|
343
398
|
// Execute with retries
|
|
344
399
|
const maxRetries = tool.maxRetries ?? DEFAULT_RETRIES;
|
|
345
400
|
const retryDelay = (tool.retryDelay ?? DEFAULT_RETRY_DELAY) * 1000;
|
package/src/plugins/loader.ts
CHANGED
|
@@ -1,14 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Plugin loader —
|
|
2
|
+
* Plugin loader — discovers external plugins and runs them through an ordered
|
|
3
|
+
* hook lifecycle.
|
|
4
|
+
*
|
|
5
|
+
* A plugin is a directory with an `index.js` exporting either:
|
|
6
|
+
* - activate(ctx): the lifecycle form. `ctx` scopes every registration to the
|
|
7
|
+
* plugin (registerTool / on(hook, fn)), so unload(name) cleanly removes
|
|
8
|
+
* exactly what the plugin added.
|
|
9
|
+
* - register(registry): the legacy form. Still supported — tools it adds are
|
|
10
|
+
* diffed against the registry so they're tracked for unload too.
|
|
11
|
+
*
|
|
12
|
+
* Hooks fire in registration order. Core hooks: `init` (after all plugins
|
|
13
|
+
* load), `tool.register` (a tool was added), `provider.update` (model/provider
|
|
14
|
+
* config changed). Plugins may define and emit their own hook names.
|
|
3
15
|
*/
|
|
4
16
|
|
|
5
17
|
import * as fs from 'fs';
|
|
6
18
|
import * as path from 'path';
|
|
7
|
-
import { ToolRegistry } from '../core/tool';
|
|
19
|
+
import { ToolRegistry, type ToolDefinition } from '../core/tool';
|
|
8
20
|
import { getLogger } from '../core/logger';
|
|
9
21
|
|
|
10
22
|
const log = getLogger('plugin-loader');
|
|
11
23
|
|
|
24
|
+
export type PluginHook = 'init' | 'tool.register' | 'provider.update' | string;
|
|
25
|
+
export type HookHandler = (payload?: any) => void | Promise<void>;
|
|
26
|
+
|
|
27
|
+
/** Scoped API handed to a plugin's activate(); every call is tracked for unload. */
|
|
28
|
+
export interface PluginContext {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly config: any;
|
|
31
|
+
readonly log: ReturnType<typeof getLogger>;
|
|
32
|
+
registerTool(def: ToolDefinition): void;
|
|
33
|
+
on(hook: PluginHook, handler: HookHandler): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Plugin {
|
|
37
|
+
name?: string;
|
|
38
|
+
activate?(ctx: PluginContext): void | Promise<void>;
|
|
39
|
+
register?(registry: ToolRegistry): void; // legacy
|
|
40
|
+
deactivate?(): void | Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface LoadedPlugin {
|
|
44
|
+
name: string;
|
|
45
|
+
module: Plugin;
|
|
46
|
+
tools: string[];
|
|
47
|
+
hooks: Array<{ hook: string; fn: HookHandler }>;
|
|
48
|
+
}
|
|
49
|
+
|
|
12
50
|
/**
|
|
13
51
|
* A plugin path is safe to `require` only if neither it nor (on POSIX) its
|
|
14
52
|
* permissions allow group/world write — otherwise a less-privileged user could
|
|
@@ -28,14 +66,17 @@ export function isSafePluginPath(target: string): boolean {
|
|
|
28
66
|
|
|
29
67
|
export class PluginLoader {
|
|
30
68
|
private toolRegistry: ToolRegistry;
|
|
69
|
+
private config: any;
|
|
70
|
+
private plugins = new Map<string, LoadedPlugin>();
|
|
71
|
+
/** hook name -> handlers in registration order, each tagged with its plugin. */
|
|
72
|
+
private hookHandlers = new Map<string, Array<{ plugin: string; fn: HookHandler }>>();
|
|
31
73
|
|
|
32
|
-
constructor(toolRegistry: ToolRegistry) {
|
|
74
|
+
constructor(toolRegistry: ToolRegistry, config?: any) {
|
|
33
75
|
this.toolRegistry = toolRegistry;
|
|
76
|
+
this.config = config ?? {};
|
|
34
77
|
}
|
|
35
78
|
|
|
36
|
-
/**
|
|
37
|
-
* Load plugins from specified directories.
|
|
38
|
-
*/
|
|
79
|
+
/** Load plugins from specified directories. Returns the number activated. */
|
|
39
80
|
loadFromDirectories(directories: string[]): number {
|
|
40
81
|
let total = 0;
|
|
41
82
|
for (const dir of directories) {
|
|
@@ -44,9 +85,6 @@ export class PluginLoader {
|
|
|
44
85
|
return total;
|
|
45
86
|
}
|
|
46
87
|
|
|
47
|
-
/**
|
|
48
|
-
* Load a single plugin directory.
|
|
49
|
-
*/
|
|
50
88
|
private loadDirectory(dir: string): number {
|
|
51
89
|
if (!fs.existsSync(dir)) {
|
|
52
90
|
log.debug('plugin_dir_not_found', { dir });
|
|
@@ -55,10 +93,9 @@ export class PluginLoader {
|
|
|
55
93
|
|
|
56
94
|
let count = 0;
|
|
57
95
|
try {
|
|
58
|
-
const
|
|
59
|
-
for (const entry of entries) {
|
|
96
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
60
97
|
const pluginPath = path.join(dir, entry);
|
|
61
|
-
if (!fs.statSync(pluginPath).isDirectory()) continue;
|
|
98
|
+
try { if (!fs.statSync(pluginPath).isDirectory()) continue; } catch { continue; }
|
|
62
99
|
|
|
63
100
|
const pluginFile = path.join(pluginPath, 'index.js');
|
|
64
101
|
if (!fs.existsSync(pluginFile)) continue;
|
|
@@ -72,12 +109,8 @@ export class PluginLoader {
|
|
|
72
109
|
}
|
|
73
110
|
|
|
74
111
|
try {
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
plugin.register(this.toolRegistry);
|
|
78
|
-
count++;
|
|
79
|
-
log.info('plugin_loaded', { name: entry });
|
|
80
|
-
}
|
|
112
|
+
const mod = require(pluginFile) as Plugin;
|
|
113
|
+
if (this.activatePlugin(entry, mod)) count++;
|
|
81
114
|
} catch (e) {
|
|
82
115
|
log.warn('plugin_load_failed', { name: entry, error: String(e) });
|
|
83
116
|
}
|
|
@@ -88,4 +121,98 @@ export class PluginLoader {
|
|
|
88
121
|
|
|
89
122
|
return count;
|
|
90
123
|
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Activate a plugin module under a name. Reactivating an already-loaded name
|
|
127
|
+
* unloads the previous instance first. Returns true if anything registered.
|
|
128
|
+
*/
|
|
129
|
+
activatePlugin(name: string, mod: Plugin): boolean {
|
|
130
|
+
const pluginName = mod.name || name;
|
|
131
|
+
if (this.plugins.has(pluginName)) this.unload(pluginName);
|
|
132
|
+
|
|
133
|
+
const record: LoadedPlugin = { name: pluginName, module: mod, tools: [], hooks: [] };
|
|
134
|
+
const self = this;
|
|
135
|
+
|
|
136
|
+
if (typeof mod.activate === 'function') {
|
|
137
|
+
const ctx: PluginContext = {
|
|
138
|
+
name: pluginName,
|
|
139
|
+
config: this.config,
|
|
140
|
+
log: getLogger(`plugin:${pluginName}`),
|
|
141
|
+
registerTool(def: ToolDefinition) {
|
|
142
|
+
self.toolRegistry.register(def);
|
|
143
|
+
record.tools.push(def.name);
|
|
144
|
+
void self.emit('tool.register', { plugin: pluginName, tool: def.name });
|
|
145
|
+
},
|
|
146
|
+
on(hook: PluginHook, handler: HookHandler) {
|
|
147
|
+
record.hooks.push({ hook, fn: handler });
|
|
148
|
+
const arr = self.hookHandlers.get(hook) || [];
|
|
149
|
+
arr.push({ plugin: pluginName, fn: handler });
|
|
150
|
+
self.hookHandlers.set(hook, arr);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
try {
|
|
154
|
+
void mod.activate(ctx);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
log.warn('plugin_activate_failed', { name: pluginName, error: String(e) });
|
|
157
|
+
this.unload(pluginName);
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
} else if (typeof mod.register === 'function') {
|
|
161
|
+
// Legacy: diff the registry to learn which tools the plugin added.
|
|
162
|
+
const before = new Set(this.toolRegistry.listNames());
|
|
163
|
+
try {
|
|
164
|
+
mod.register(this.toolRegistry);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
log.warn('plugin_register_failed', { name: pluginName, error: String(e) });
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
for (const n of this.toolRegistry.listNames()) {
|
|
170
|
+
if (!before.has(n)) record.tools.push(n);
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
log.warn('plugin_no_entrypoint', { name: pluginName });
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
this.plugins.set(pluginName, record);
|
|
178
|
+
log.info('plugin_loaded', { name: pluginName, tools: record.tools.length, hooks: record.hooks.length });
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Fire a hook; handlers run in registration order. Errors are isolated. */
|
|
183
|
+
async emit(hook: PluginHook, payload?: any): Promise<void> {
|
|
184
|
+
const handlers = this.hookHandlers.get(hook);
|
|
185
|
+
if (!handlers || handlers.length === 0) return;
|
|
186
|
+
for (const { plugin, fn } of [...handlers]) {
|
|
187
|
+
try {
|
|
188
|
+
await fn(payload);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
log.warn('plugin_hook_failed', { hook, plugin, error: String(e) });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Unload a plugin: remove its tools and hook handlers, call deactivate. */
|
|
196
|
+
unload(name: string): boolean {
|
|
197
|
+
const record = this.plugins.get(name);
|
|
198
|
+
if (!record) return false;
|
|
199
|
+
|
|
200
|
+
for (const tool of record.tools) this.toolRegistry.unregister(tool);
|
|
201
|
+
for (const [hook, arr] of this.hookHandlers) {
|
|
202
|
+
const filtered = arr.filter((h) => h.plugin !== name);
|
|
203
|
+
if (filtered.length) this.hookHandlers.set(hook, filtered);
|
|
204
|
+
else this.hookHandlers.delete(hook);
|
|
205
|
+
}
|
|
206
|
+
try { record.module.deactivate?.(); } catch (e) { log.warn('plugin_deactivate_failed', { name, error: String(e) }); }
|
|
207
|
+
|
|
208
|
+
this.plugins.delete(name);
|
|
209
|
+
log.info('plugin_unloaded', { name });
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Names of currently loaded plugins. */
|
|
214
|
+
list(): string[] { return [...this.plugins.keys()]; }
|
|
215
|
+
|
|
216
|
+
/** Number of handlers registered for a hook (for diagnostics/tests). */
|
|
217
|
+
hookCount(hook: PluginHook): number { return this.hookHandlers.get(hook)?.length ?? 0; }
|
|
91
218
|
}
|
package/src/tools/builtin.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { registerComputerTools } from './computer';
|
|
|
10
10
|
import { registerExtraTools } from './extra';
|
|
11
11
|
import { isPrivateIp, assertFetchAllowed, fenceRoot, fenceCheck } from './guards';
|
|
12
12
|
import { webSearch, formatSearchResults, readPage } from './websearch';
|
|
13
|
+
import { countOccurrences, unifiedDiff } from '../core/diff';
|
|
14
|
+
import { getDiagnostics, formatDiagnostics } from '../core/diagnostics';
|
|
13
15
|
|
|
14
16
|
// Re-exported so existing importers/tests keep resolving these from builtin.
|
|
15
17
|
export { isPrivateIp, assertFetchAllowed, fenceRoot, fenceCheck };
|
|
@@ -77,32 +79,69 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
|
|
|
77
79
|
|
|
78
80
|
registry.register({
|
|
79
81
|
name: 'edit_file',
|
|
80
|
-
description: 'Edit a file by replacing old_text with new_text.
|
|
82
|
+
description: 'Edit a file by replacing an exact occurrence of old_text with new_text. old_text must match the file exactly (including whitespace and indentation) and must be UNIQUE — include enough surrounding context to disambiguate, or set replace_all to change every occurrence. Returns a unified diff of the change.',
|
|
81
83
|
parameters: [
|
|
82
84
|
{ name: 'path', type: 'string', description: 'Path to the file to edit', required: true },
|
|
83
|
-
{ name: 'old_text', type: 'string', description: '
|
|
84
|
-
{ name: 'new_text', type: 'string', description: '
|
|
85
|
+
{ name: 'old_text', type: 'string', description: 'Exact text to replace (must match the file verbatim, and be unique unless replace_all is set)', required: true },
|
|
86
|
+
{ name: 'new_text', type: 'string', description: 'Replacement text (must differ from old_text)', required: true },
|
|
87
|
+
{ name: 'replace_all', type: 'boolean', description: 'Replace every occurrence instead of requiring a unique match (default false)', required: false },
|
|
85
88
|
],
|
|
86
89
|
handler: async (params) => {
|
|
87
90
|
const filePath = path.resolve(params.path as string);
|
|
88
91
|
const fenced = fenceCheck(filePath); if (fenced) return fenced;
|
|
89
92
|
if (!fs.existsSync(filePath)) return `Error: File not found: ${filePath}`;
|
|
93
|
+
const oldText = params.old_text as string;
|
|
94
|
+
const newText = params.new_text as string;
|
|
95
|
+
const replaceAll = params.replace_all === true || params.replace_all === 'true';
|
|
96
|
+
if (oldText === newText) return 'Error: old_text and new_text are identical — nothing to change.';
|
|
90
97
|
try {
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return `Error: old_text not found in file. Searched for: ${oldText.slice(0, 50)}...`;
|
|
98
|
+
const orig = fs.readFileSync(filePath, 'utf-8');
|
|
99
|
+
const n = countOccurrences(orig, oldText);
|
|
100
|
+
if (n === 0) {
|
|
101
|
+
return `Error: old_text not found in ${filePath}. It must match exactly (including whitespace). Searched for: ${JSON.stringify(oldText.slice(0, 80))}`;
|
|
96
102
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
if (n > 1 && !replaceAll) {
|
|
104
|
+
return `Error: old_text appears ${n} times in ${filePath} — the edit is ambiguous. Add more surrounding context to make it unique, or set replace_all=true to change all ${n} occurrences.`;
|
|
105
|
+
}
|
|
106
|
+
// Literal replacement: split/join (replace_all) and a function replacer
|
|
107
|
+
// (single) both avoid String.replace interpreting `$&`/`$1` in new_text.
|
|
108
|
+
const updated = replaceAll
|
|
109
|
+
? orig.split(oldText).join(newText)
|
|
110
|
+
: orig.replace(oldText, () => newText);
|
|
111
|
+
fs.writeFileSync(filePath, updated, 'utf-8');
|
|
112
|
+
const rel = path.relative(process.cwd(), filePath) || filePath;
|
|
113
|
+
const diff = unifiedDiff(orig, updated, { path: rel, context: 3 });
|
|
114
|
+
const occ = replaceAll ? ` (${n} occurrence${n > 1 ? 's' : ''})` : '';
|
|
115
|
+
const body = diff.text ? `\n${diff.text}` : '';
|
|
116
|
+
return `Successfully edited ${filePath}${occ} · +${diff.stat.added} -${diff.stat.removed}${body}`;
|
|
100
117
|
} catch (e) {
|
|
101
118
|
return `Error editing file: ${e}`;
|
|
102
119
|
}
|
|
103
120
|
},
|
|
104
121
|
});
|
|
105
122
|
|
|
123
|
+
registry.register({
|
|
124
|
+
name: 'get_diagnostics',
|
|
125
|
+
idempotent: true,
|
|
126
|
+
description: 'Get LSP-style diagnostics (type errors, lint issues with line:col) for a source file. TS/JS work out of the box via the workspace TypeScript; other languages use a configured checker (config.yaml diagnostics map). Call this after editing code to confirm it is error-free, or to locate the root cause of a type error.',
|
|
127
|
+
parameters: [
|
|
128
|
+
{ name: 'path', type: 'string', description: 'Path to the source file to check', required: true },
|
|
129
|
+
],
|
|
130
|
+
handler: async (params) => {
|
|
131
|
+
const filePath = path.resolve(params.path as string);
|
|
132
|
+
const fenced = fenceCheck(filePath); if (fenced) return fenced;
|
|
133
|
+
let config: any = {};
|
|
134
|
+
try { const { loadConfig } = require('../core/config'); config = loadConfig(); } catch { /* defaults */ }
|
|
135
|
+
try {
|
|
136
|
+
const result = getDiagnostics(filePath, config);
|
|
137
|
+
if (!Array.isArray(result)) return `[diagnostics unavailable] ${result.unavailable}`;
|
|
138
|
+
return formatDiagnostics(path.relative(process.cwd(), filePath) || filePath, result);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return `Error getting diagnostics: ${e}`;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
106
145
|
registry.register({
|
|
107
146
|
name: 'delete_file',
|
|
108
147
|
description: 'Delete a file at the given path.',
|
|
@@ -172,13 +211,23 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
|
|
|
172
211
|
|
|
173
212
|
registry.register({
|
|
174
213
|
name: 'run_bash',
|
|
175
|
-
description: 'Execute a shell command and return its output.',
|
|
214
|
+
description: 'Execute a shell command and return its output. Set background=true for long-running processes (dev servers, watchers, builds): it returns a job id immediately — read its output later with bash_output, stop it with kill_bash.',
|
|
176
215
|
parameters: [
|
|
177
216
|
{ name: 'command', type: 'string', description: 'Command to execute', required: true },
|
|
178
|
-
{ name: 'timeout', type: 'number', description: 'Timeout in milliseconds (default: 30000)', required: false },
|
|
217
|
+
{ name: 'timeout', type: 'number', description: 'Timeout in milliseconds (default: 30000). Ignored when background=true.', required: false },
|
|
218
|
+
{ name: 'background', type: 'boolean', description: 'Run detached in the background and return a job id instead of blocking (default false)', required: false },
|
|
179
219
|
],
|
|
180
220
|
handler: async (params) => {
|
|
181
221
|
const cmd = params.command as string;
|
|
222
|
+
const background = params.background === true || params.background === 'true';
|
|
223
|
+
if (background) {
|
|
224
|
+
try {
|
|
225
|
+
const { getBackgroundManager } = require('../core/bgproc');
|
|
226
|
+
const { id, error } = getBackgroundManager().start(cmd);
|
|
227
|
+
if (error) return error;
|
|
228
|
+
return `[background job ${id} started] pid running. Use bash_output("${id}") to read output, kill_bash("${id}") to stop, list_bash to see all jobs.`;
|
|
229
|
+
} catch (e: any) { return `Error: ${e.message || e}`; }
|
|
230
|
+
}
|
|
182
231
|
const timeout = (params.timeout as number) || 30000;
|
|
183
232
|
try {
|
|
184
233
|
const { runInSandbox, formatSandboxResult } = require('../core/sandbox');
|
|
@@ -189,6 +238,51 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
|
|
|
189
238
|
dangerous: true,
|
|
190
239
|
});
|
|
191
240
|
|
|
241
|
+
registry.register({
|
|
242
|
+
name: 'bash_output',
|
|
243
|
+
description: 'Read new output from a background shell job (started by run_bash with background=true) since the last read, plus its current status.',
|
|
244
|
+
parameters: [
|
|
245
|
+
{ name: 'id', type: 'string', description: 'The background job id', required: true },
|
|
246
|
+
],
|
|
247
|
+
handler: async (params) => {
|
|
248
|
+
const { getBackgroundManager } = require('../core/bgproc');
|
|
249
|
+
const r = getBackgroundManager().read(String(params.id || ''));
|
|
250
|
+
if (!r.ok) return r.error || 'Error reading background job.';
|
|
251
|
+
const statusLine = `[job ${params.id} · ${r.status}${r.exitCode != null ? ` · exit ${r.exitCode}` : ''}]`;
|
|
252
|
+
const out = r.text && r.text.length ? r.text : '(no new output)';
|
|
253
|
+
return `${statusLine}\n${out}`;
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
registry.register({
|
|
258
|
+
name: 'list_bash',
|
|
259
|
+
description: 'List background shell jobs with their status, pid, and runtime.',
|
|
260
|
+
parameters: [],
|
|
261
|
+
handler: async () => {
|
|
262
|
+
const { getBackgroundManager } = require('../core/bgproc');
|
|
263
|
+
const jobs = getBackgroundManager().list();
|
|
264
|
+
if (!jobs.length) return 'No background jobs.';
|
|
265
|
+
return jobs.map((j: any) => {
|
|
266
|
+
const dur = ((j.endedAt || Date.now()) - j.startedAt) / 1000;
|
|
267
|
+
const ex = j.exitCode != null ? ` exit ${j.exitCode}` : '';
|
|
268
|
+
return `${j.id} · ${j.status}${ex} · pid ${j.pid ?? '?'} · ${dur.toFixed(1)}s · ${j.command.slice(0, 60)}`;
|
|
269
|
+
}).join('\n');
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
registry.register({
|
|
274
|
+
name: 'kill_bash',
|
|
275
|
+
description: 'Terminate a running background shell job.',
|
|
276
|
+
parameters: [
|
|
277
|
+
{ name: 'id', type: 'string', description: 'The background job id to kill', required: true },
|
|
278
|
+
],
|
|
279
|
+
handler: async (params) => {
|
|
280
|
+
const { getBackgroundManager } = require('../core/bgproc');
|
|
281
|
+
const r = getBackgroundManager().kill(String(params.id || ''));
|
|
282
|
+
return r.ok ? `[job ${params.id} killed]` : (r.error || 'Error killing background job.');
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
|
|
192
286
|
// ── HTTP Tools ──
|
|
193
287
|
|
|
194
288
|
registry.register({
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spawn_agent tool — launch a general-purpose, isolated-context subagent to
|
|
3
|
+
* handle one focused, self-contained task and return only its final report.
|
|
4
|
+
*
|
|
5
|
+
* This is sky's analogue of Claude Code's Task tool. Subagent types are
|
|
6
|
+
* discovered from built-ins plus `.claude/agents` / `.sky/agents` definition
|
|
7
|
+
* files (see core/subagent). Subagents never receive spawn_agent themselves,
|
|
8
|
+
* so there is no recursive fan-out.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ToolDefinition } from '../core/tool';
|
|
12
|
+
import type { ToolRegistry } from '../core/tool';
|
|
13
|
+
import type { SkillRegistry } from '../core/skill';
|
|
14
|
+
import type { LLMClient } from '../core/llm';
|
|
15
|
+
import type { MessageBus } from '../core/bus';
|
|
16
|
+
import { loadSubagentDefinitions, runSubagent } from '../core/subagent';
|
|
17
|
+
|
|
18
|
+
export function createSpawnAgentTool(opts: {
|
|
19
|
+
config: any;
|
|
20
|
+
llm: LLMClient;
|
|
21
|
+
bus: MessageBus;
|
|
22
|
+
baseToolRegistry: ToolRegistry;
|
|
23
|
+
baseSkillRegistry: SkillRegistry;
|
|
24
|
+
cwd?: string;
|
|
25
|
+
}): ToolDefinition {
|
|
26
|
+
const cwd = opts.cwd || process.cwd();
|
|
27
|
+
|
|
28
|
+
const buildDescription = (): string => {
|
|
29
|
+
const defs = loadSubagentDefinitions(cwd);
|
|
30
|
+
const lines = [...defs.values()].map((d) => ` - ${d.name}: ${d.description}`);
|
|
31
|
+
return (
|
|
32
|
+
'派生一个隔离上下文的子智能体来独立完成一个聚焦、自洽的任务,只返回它的最终报告。' +
|
|
33
|
+
'当任务需要大量搜索/调研、或你想把一段独立工作从主上下文里隔离出去时使用。' +
|
|
34
|
+
'子智能体看不到你的对话历史,所以 task 必须自带全部所需上下文(目标、相关文件、约束)。' +
|
|
35
|
+
'它无法反问你,会一次性完成并汇报。可并行派生多个互不依赖的子智能体。\n\n可用子智能体类型:\n' +
|
|
36
|
+
lines.join('\n')
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
name: 'spawn_agent',
|
|
42
|
+
description: buildDescription(),
|
|
43
|
+
parameters: [
|
|
44
|
+
{
|
|
45
|
+
name: 'agent_type',
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: '子智能体类型(见工具描述中的可用类型,如 general-purpose / explore 或自定义)',
|
|
48
|
+
required: true,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'task',
|
|
52
|
+
type: 'string',
|
|
53
|
+
description: '完整、自洽的任务描述。子智能体看不到对话历史,必须在此写清目标、相关文件路径与约束。',
|
|
54
|
+
required: true,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'description',
|
|
58
|
+
type: 'string',
|
|
59
|
+
description: '可选:对该任务的简短(3-5 词)标签,用于展示。',
|
|
60
|
+
required: false,
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
// Long-running by nature (full nested agent loop); give it generous headroom.
|
|
64
|
+
timeout: 600000,
|
|
65
|
+
handler: async (params) => {
|
|
66
|
+
const agentType = String(params.agent_type || '').trim();
|
|
67
|
+
const task = String(params.task || '').trim();
|
|
68
|
+
if (!agentType) return '[spawn_agent error] agent_type is required.';
|
|
69
|
+
if (!task) return '[spawn_agent error] task is required.';
|
|
70
|
+
|
|
71
|
+
const defs = loadSubagentDefinitions(cwd);
|
|
72
|
+
const def = defs.get(agentType);
|
|
73
|
+
if (!def) {
|
|
74
|
+
const available = [...defs.keys()].join(', ');
|
|
75
|
+
return `[spawn_agent error] unknown agent_type '${agentType}'. Available: ${available}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const report = await runSubagent({
|
|
79
|
+
def,
|
|
80
|
+
task,
|
|
81
|
+
config: opts.config,
|
|
82
|
+
llm: opts.llm,
|
|
83
|
+
bus: opts.bus,
|
|
84
|
+
baseToolRegistry: opts.baseToolRegistry,
|
|
85
|
+
baseSkillRegistry: opts.baseSkillRegistry,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const header = `[subagent ${def.name} 完成]`;
|
|
89
|
+
return `${header}\n${report}`;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|