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/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 = JSON.stringify(params);
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
- 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]);
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 = JSON.stringify(params);
382
+ const cacheKey = stableStringify(params);
359
383
  resultStore.set(toolName, cacheKey, result);
360
384
  }
361
385
 
@@ -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 },
@@ -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
+ });
@@ -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);
@@ -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.
@@ -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
+ });