skyloom 1.16.0 → 1.16.2
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/dist/cli/loom.d.ts +16 -1
- package/dist/cli/loom.d.ts.map +1 -1
- package/dist/cli/loom.js +35 -9
- package/dist/cli/loom.js.map +1 -1
- package/dist/cli/loom_chat.d.ts.map +1 -1
- package/dist/cli/loom_chat.js +32 -3
- package/dist/cli/loom_chat.js.map +1 -1
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +12 -4
- package/dist/core/agent.js.map +1 -1
- package/dist/core/commands.d.ts +5 -0
- package/dist/core/commands.d.ts.map +1 -1
- package/dist/core/commands.js +21 -0
- package/dist/core/commands.js.map +1 -1
- package/dist/core/tool.d.ts +10 -0
- package/dist/core/tool.d.ts.map +1 -1
- package/dist/core/tool.js +25 -6
- package/dist/core/tool.js.map +1 -1
- package/dist/tools/builtin.d.ts.map +1 -1
- package/dist/tools/builtin.js +8 -0
- package/dist/tools/builtin.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/loom.ts +36 -7
- package/src/cli/loom_chat.ts +30 -3
- package/src/core/agent.ts +12 -4
- package/src/core/commands.ts +21 -0
- package/src/core/tool.ts +33 -9
- package/src/tools/builtin.ts +8 -0
- package/tests/agent.test.ts +35 -2
- package/tests/commands.test.ts +23 -0
- package/tests/loom.test.ts +42 -0
- package/tests/tool.test.ts +40 -0
package/src/core/tool.ts
CHANGED
|
@@ -37,9 +37,26 @@ export interface ToolDefinition {
|
|
|
37
37
|
retryDelay?: number;
|
|
38
38
|
dangerous?: boolean;
|
|
39
39
|
cacheable?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Pure read-only tool with no side effects: two identical calls in the same
|
|
42
|
+
* round always return the same thing. Enables within-round dedup (the second
|
|
43
|
+
* identical call is skipped and shares the first result), which avoids
|
|
44
|
+
* duplicate file reads / network searches the model often emits in parallel.
|
|
45
|
+
* Unlike `cacheable`, it does NOT cache across rounds (the world may change).
|
|
46
|
+
*/
|
|
47
|
+
idempotent?: boolean;
|
|
40
48
|
timeout?: number;
|
|
41
49
|
}
|
|
42
50
|
|
|
51
|
+
/** Order-stable JSON key so {a,b} and {b,a} hash to the same cache/dedup key. */
|
|
52
|
+
export function stableStringify(value: unknown): string {
|
|
53
|
+
return JSON.stringify(value, (_k, v) =>
|
|
54
|
+
v && typeof v === "object" && !Array.isArray(v)
|
|
55
|
+
? Object.keys(v).sort().reduce((acc: Record<string, unknown>, key) => { acc[key] = v[key]; return acc; }, {})
|
|
56
|
+
: v,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
/**
|
|
44
61
|
* Tool execution result
|
|
45
62
|
*/
|
|
@@ -301,7 +318,7 @@ export class ToolRegistry extends EventEmitter {
|
|
|
301
318
|
|
|
302
319
|
// Check cache
|
|
303
320
|
if (tool.cacheable) {
|
|
304
|
-
const cacheKey =
|
|
321
|
+
const cacheKey = stableStringify(params);
|
|
305
322
|
const cached = resultStore.get(toolName, cacheKey);
|
|
306
323
|
if (cached) {
|
|
307
324
|
log.debug("Tool cache hit", { tool: toolName });
|
|
@@ -343,19 +360,26 @@ export class ToolRegistry extends EventEmitter {
|
|
|
343
360
|
|
|
344
361
|
const startTime = Date.now();
|
|
345
362
|
|
|
346
|
-
// Execute with timeout
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
363
|
+
// Execute with a timeout that we always clear. The previous
|
|
364
|
+
// Promise.race left the timeout's setTimeout pending whenever the
|
|
365
|
+
// handler won — a dangling 30s timer per tool call that kept the event
|
|
366
|
+
// loop alive (delaying process exit) and accumulated under load.
|
|
367
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
368
|
+
const timeoutPromise = new Promise<string>((_, reject) => {
|
|
369
|
+
timer = setTimeout(() => reject(new Error("Tool execution timeout")), timeout);
|
|
370
|
+
});
|
|
371
|
+
let result: string;
|
|
372
|
+
try {
|
|
373
|
+
result = await Promise.race([tool.handler(params), timeoutPromise]);
|
|
374
|
+
} finally {
|
|
375
|
+
if (timer) clearTimeout(timer);
|
|
376
|
+
}
|
|
353
377
|
|
|
354
378
|
const duration = Date.now() - startTime;
|
|
355
379
|
|
|
356
380
|
// Cache result
|
|
357
381
|
if (tool.cacheable) {
|
|
358
|
-
const cacheKey =
|
|
382
|
+
const cacheKey = stableStringify(params);
|
|
359
383
|
resultStore.set(toolName, cacheKey, result);
|
|
360
384
|
}
|
|
361
385
|
|
package/src/tools/builtin.ts
CHANGED
|
@@ -27,6 +27,7 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
|
|
|
27
27
|
|
|
28
28
|
registry.register({
|
|
29
29
|
name: 'read_file',
|
|
30
|
+
idempotent: true,
|
|
30
31
|
description: 'Read the contents of a file. Large files are paged: pass offset (1-based start line) and limit (line count) to read further sections; use grep to locate the right offset first.',
|
|
31
32
|
parameters: [
|
|
32
33
|
{ name: 'path', type: 'string', description: 'Absolute or relative path to the file', required: true },
|
|
@@ -123,6 +124,7 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
|
|
|
123
124
|
|
|
124
125
|
registry.register({
|
|
125
126
|
name: 'list_directory',
|
|
127
|
+
idempotent: true,
|
|
126
128
|
description: 'List files and directories at the given path.',
|
|
127
129
|
parameters: [
|
|
128
130
|
{ name: 'path', type: 'string', description: 'Path to list', required: true },
|
|
@@ -145,6 +147,7 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
|
|
|
145
147
|
|
|
146
148
|
registry.register({
|
|
147
149
|
name: 'file_search',
|
|
150
|
+
idempotent: true,
|
|
148
151
|
description: 'Search for files matching a glob pattern.',
|
|
149
152
|
parameters: [
|
|
150
153
|
{ name: 'pattern', type: 'string', description: 'Glob pattern to match (e.g. "**/*.ts")', required: true },
|
|
@@ -190,6 +193,7 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
|
|
|
190
193
|
|
|
191
194
|
registry.register({
|
|
192
195
|
name: 'http_get',
|
|
196
|
+
idempotent: true,
|
|
193
197
|
description: 'Make an HTTP GET request to a URL.',
|
|
194
198
|
parameters: [
|
|
195
199
|
{ name: 'url', type: 'string', description: 'URL to fetch', required: true },
|
|
@@ -224,6 +228,7 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
|
|
|
224
228
|
|
|
225
229
|
registry.register({
|
|
226
230
|
name: 'web_search',
|
|
231
|
+
idempotent: true,
|
|
227
232
|
description:
|
|
228
233
|
'Search the live web and return titles, URLs, and snippets (plus a direct answer when available). ' +
|
|
229
234
|
'USE THIS whenever the answer depends on current or real-time information — today\'s news and hot topics, ' +
|
|
@@ -253,6 +258,7 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
|
|
|
253
258
|
|
|
254
259
|
registry.register({
|
|
255
260
|
name: 'read_url',
|
|
261
|
+
idempotent: true,
|
|
256
262
|
description:
|
|
257
263
|
'Fetch a web page as clean, readable text (markdown), with boilerplate (nav/ads) stripped. ' +
|
|
258
264
|
'Use after web_search to read a result in full, or to read any known URL. Prefer this over http_get for articles/pages.',
|
|
@@ -372,6 +378,7 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
|
|
|
372
378
|
|
|
373
379
|
registry.register({
|
|
374
380
|
name: 'grep',
|
|
381
|
+
idempotent: true,
|
|
375
382
|
description: 'Search for a pattern in files using ripgrep or grep.',
|
|
376
383
|
parameters: [
|
|
377
384
|
{ name: 'pattern', type: 'string', description: 'Regex pattern to search for', required: true },
|
|
@@ -406,6 +413,7 @@ export function registerBuiltinTools(registry: ToolRegistry): void {
|
|
|
406
413
|
|
|
407
414
|
registry.register({
|
|
408
415
|
name: 'tree',
|
|
416
|
+
idempotent: true,
|
|
409
417
|
description: 'Display directory tree structure.',
|
|
410
418
|
parameters: [
|
|
411
419
|
{ name: 'directory', type: 'string', description: 'Directory to show tree for', required: false },
|
package/tests/agent.test.ts
CHANGED
|
@@ -40,9 +40,9 @@ class MockLLM {
|
|
|
40
40
|
setLogger() { /* noop */ }
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function makeAgent(turns: Turn[], tools: { name: string; handler: (a: any) => Promise<string
|
|
43
|
+
function makeAgent(turns: Turn[], tools: { name: string; handler: (a: any) => Promise<string>; idempotent?: boolean }[] = []) {
|
|
44
44
|
const reg = new ToolRegistry();
|
|
45
|
-
for (const t of tools) reg.register({ name: t.name, description: t.name, handler: t.handler });
|
|
45
|
+
for (const t of tools) reg.register({ name: t.name, description: t.name, handler: t.handler, idempotent: t.idempotent });
|
|
46
46
|
const config = { agents: { fog: {} }, llm: { language: "zh" }, memory: { shortTermLimit: 100, dbPath: "/tmp/sky-test" } };
|
|
47
47
|
const agent = new FogAgent(config as any, new MockLLM(turns) as any, new MessageBus(), reg, new SkillRegistry());
|
|
48
48
|
return agent;
|
|
@@ -209,3 +209,36 @@ describe("agent · run tracing", () => {
|
|
|
209
209
|
expect(trace!.spans.every((s: any) => s.endMs !== null)).toBe(true);
|
|
210
210
|
});
|
|
211
211
|
});
|
|
212
|
+
|
|
213
|
+
describe("agent · within-round dedup of read-only tools", () => {
|
|
214
|
+
it("runs an idempotent tool once when the model emits it twice with identical args", async () => {
|
|
215
|
+
let runs = 0;
|
|
216
|
+
const agent = makeAgent(
|
|
217
|
+
[{ content: "looking", toolCalls: [{ name: "rd", args: { p: "x" } }, { name: "rd", args: { p: "x" } }] }, { content: "done" }],
|
|
218
|
+
[{ name: "rd", idempotent: true, handler: async () => { runs++; return "content-x"; } }],
|
|
219
|
+
);
|
|
220
|
+
const evs = await collect(agent.chatStream("go"));
|
|
221
|
+
expect(runs).toBe(1); // deduped — handler ran once
|
|
222
|
+
// both tool calls still get a result (the duplicate shares the original's)
|
|
223
|
+
const done = evs.filter((e) => e.type === "tool_done" && e.tool_name === "rd");
|
|
224
|
+
expect(done.length).toBe(2);
|
|
225
|
+
expect(done.every((e) => e.success)).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("does NOT dedup different args, nor non-idempotent tools", async () => {
|
|
229
|
+
let rdRuns = 0, wrRuns = 0;
|
|
230
|
+
const agent = makeAgent(
|
|
231
|
+
[{ content: "x", toolCalls: [
|
|
232
|
+
{ name: "rd", args: { p: "a" } }, { name: "rd", args: { p: "b" } }, // different args → both run
|
|
233
|
+
{ name: "wr", args: { p: "a" } }, { name: "wr", args: { p: "a" } }, // not idempotent → both run
|
|
234
|
+
] }, { content: "done" }],
|
|
235
|
+
[
|
|
236
|
+
{ name: "rd", idempotent: true, handler: async () => { rdRuns++; return "r"; } },
|
|
237
|
+
{ name: "wr", handler: async () => { wrRuns++; return "w"; } },
|
|
238
|
+
],
|
|
239
|
+
);
|
|
240
|
+
await collect(agent.chatStream("go"));
|
|
241
|
+
expect(rdRuns).toBe(2);
|
|
242
|
+
expect(wrRuns).toBe(2);
|
|
243
|
+
});
|
|
244
|
+
});
|
package/tests/commands.test.ts
CHANGED
|
@@ -88,6 +88,29 @@ describe("registry ↔ TUI wiring", () => {
|
|
|
88
88
|
});
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
describe("registry renderHelp", () => {
|
|
92
|
+
const lines = registry.renderHelp("zh");
|
|
93
|
+
const text = lines.join("\n");
|
|
94
|
+
|
|
95
|
+
it("groups commands under category section headers", () => {
|
|
96
|
+
expect(lines.some((l) => l.startsWith("§"))).toBe(true);
|
|
97
|
+
expect(text).toContain("§ Agent 切换");
|
|
98
|
+
expect(text).toContain("§ 系统");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("lists real commands with their zh labels and omits hidden ones", () => {
|
|
102
|
+
expect(text).toContain("/trace");
|
|
103
|
+
expect(text).toContain("/apikey");
|
|
104
|
+
expect(text).not.toContain("/share"); // hidden
|
|
105
|
+
expect(text).not.toContain("/unshare");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("marks argument-required commands", () => {
|
|
109
|
+
expect(lines.some((l) => l.includes("/resume …"))).toBe(true);
|
|
110
|
+
expect(lines.some((l) => l.includes("/task …"))).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
91
114
|
describe("registry search / listing", () => {
|
|
92
115
|
it("search matches on name, label, and alias", () => {
|
|
93
116
|
expect(registry.search("trace").some(c => c.name === "trace")).toBe(true);
|
package/tests/loom.test.ts
CHANGED
|
@@ -321,6 +321,48 @@ describe("argument wizard (cascading ↑↓ selection)", () => {
|
|
|
321
321
|
});
|
|
322
322
|
});
|
|
323
323
|
|
|
324
|
+
describe("approval modal (allow once / always / deny)", () => {
|
|
325
|
+
function key(ui: any, name: string, opts: Record<string, any> = {}) { ui.onKey(opts.str ?? "", { name, ...opts }); }
|
|
326
|
+
|
|
327
|
+
it("confirmApproval resolves 'once' / 'always' / 'deny' by key", async () => {
|
|
328
|
+
for (const [k, expected] of [["y", "once"], ["a", "always"], ["n", "deny"]] as const) {
|
|
329
|
+
const ui = makeUI() as any;
|
|
330
|
+
const p = ui.confirmApproval("run_bash (危险等级 3)");
|
|
331
|
+
ui.onKey(k, { name: k, str: k });
|
|
332
|
+
expect(await p).toBe(expected);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("Enter takes the safe default (deny); Esc cancels (deny)", async () => {
|
|
337
|
+
const ui = makeUI() as any;
|
|
338
|
+
const p1 = ui.confirmApproval("x");
|
|
339
|
+
key(ui, "return");
|
|
340
|
+
expect(await p1).toBe("deny");
|
|
341
|
+
|
|
342
|
+
const p2 = ui.confirmApproval("x");
|
|
343
|
+
key(ui, "escape");
|
|
344
|
+
expect(await p2).toBe("deny");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("confirm() stays a boolean y/N", async () => {
|
|
348
|
+
const uiY = makeUI() as any; const py = uiY.confirm("ok?"); uiY.onKey("y", { name: "y", str: "y" });
|
|
349
|
+
expect(await py).toBe(true);
|
|
350
|
+
const uiN = makeUI() as any; const pn = uiN.confirm("ok?"); key(uiN, "return"); // default n
|
|
351
|
+
expect(await pn).toBe(false);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("renders the choice keys and keeps the frame full width", () => {
|
|
355
|
+
const ui = makeUI() as any;
|
|
356
|
+
ui.confirmApproval("delete_file (危险等级 4)");
|
|
357
|
+
const frame = ui.paint();
|
|
358
|
+
const text = frame.map((r: string) => r.replace(/\x1b\[[0-9;]*m/g, "")).join("\n");
|
|
359
|
+
expect(text).toContain("允许一次");
|
|
360
|
+
expect(text).toContain("本会话总是");
|
|
361
|
+
expect(text).toContain("拒绝");
|
|
362
|
+
for (const row of frame) expect(visualWidth(row)).toBe(80);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
324
366
|
describe("mouse wheel scrolling", () => {
|
|
325
367
|
// Replay an SGR mouse sequence the way Node's keypress parser fragments it:
|
|
326
368
|
// ESC[< as one event, then every remaining char separately.
|
package/tests/tool.test.ts
CHANGED
|
@@ -12,6 +12,7 @@ function makeTool(overrides: Partial<ToolDefinition> & { name: string }): ToolDe
|
|
|
12
12
|
handler: overrides.handler ?? vi.fn().mockResolvedValue('ok'),
|
|
13
13
|
dangerous: overrides.dangerous,
|
|
14
14
|
cacheable: overrides.cacheable,
|
|
15
|
+
idempotent: overrides.idempotent,
|
|
15
16
|
maxRetries: overrides.maxRetries,
|
|
16
17
|
retryDelay: overrides.retryDelay,
|
|
17
18
|
timeout: overrides.timeout,
|
|
@@ -106,3 +107,42 @@ describe('ToolRegistry', () => {
|
|
|
106
107
|
expect(handler).not.toHaveBeenCalled();
|
|
107
108
|
});
|
|
108
109
|
});
|
|
110
|
+
|
|
111
|
+
describe('stableStringify', () => {
|
|
112
|
+
it('produces an order-independent key for objects', async () => {
|
|
113
|
+
const { stableStringify } = await import('../src/core/tool');
|
|
114
|
+
expect(stableStringify({ a: 1, b: 2 })).toBe(stableStringify({ b: 2, a: 1 }));
|
|
115
|
+
expect(stableStringify({ a: { y: 1, x: 2 } })).toBe(stableStringify({ a: { x: 2, y: 1 } }));
|
|
116
|
+
expect(stableStringify([3, 1, 2])).toBe('[3,1,2]'); // arrays keep order
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('execute · timeout timer is always cleared (no leak)', () => {
|
|
121
|
+
it('clears the timeout timer when the handler resolves first', async () => {
|
|
122
|
+
vi.useFakeTimers();
|
|
123
|
+
try {
|
|
124
|
+
const registry = new ToolRegistry();
|
|
125
|
+
registry.register(makeTool({ name: 'fast', handler: async () => 'done' }));
|
|
126
|
+
const before = vi.getTimerCount();
|
|
127
|
+
const res = await registry.execute('fast', {});
|
|
128
|
+
expect(res.success).toBe(true);
|
|
129
|
+
// The only timer execute() arms is the timeout guard; it must be cleared.
|
|
130
|
+
expect(vi.getTimerCount()).toBe(before);
|
|
131
|
+
} finally {
|
|
132
|
+
vi.useRealTimers();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('still enforces the timeout when a handler hangs', async () => {
|
|
137
|
+
const registry = new ToolRegistry();
|
|
138
|
+
registry.register(makeTool({
|
|
139
|
+
name: 'slow',
|
|
140
|
+
timeout: 20,
|
|
141
|
+
maxRetries: 0,
|
|
142
|
+
handler: () => new Promise<string>(() => { /* never resolves */ }),
|
|
143
|
+
}));
|
|
144
|
+
const res = await registry.execute('slow', {});
|
|
145
|
+
expect(res.success).toBe(false);
|
|
146
|
+
expect(res.error).toContain('timeout');
|
|
147
|
+
});
|
|
148
|
+
});
|