skyloom 1.16.2 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +15 -3
  2. package/dist/cli/loom_chat.d.ts.map +1 -1
  3. package/dist/cli/loom_chat.js +17 -0
  4. package/dist/cli/loom_chat.js.map +1 -1
  5. package/dist/cli/main.js +37 -1
  6. package/dist/cli/main.js.map +1 -1
  7. package/dist/core/agent.d.ts +2 -0
  8. package/dist/core/agent.d.ts.map +1 -1
  9. package/dist/core/agent.js +21 -5
  10. package/dist/core/agent.js.map +1 -1
  11. package/dist/core/bgproc.d.ts +59 -0
  12. package/dist/core/bgproc.d.ts.map +1 -0
  13. package/dist/core/bgproc.js +135 -0
  14. package/dist/core/bgproc.js.map +1 -0
  15. package/dist/core/commands.d.ts.map +1 -1
  16. package/dist/core/commands.js +20 -0
  17. package/dist/core/commands.js.map +1 -1
  18. package/dist/core/diagnostics.d.ts +39 -0
  19. package/dist/core/diagnostics.d.ts.map +1 -0
  20. package/dist/core/diagnostics.js +206 -0
  21. package/dist/core/diagnostics.js.map +1 -0
  22. package/dist/core/diff.d.ts +31 -0
  23. package/dist/core/diff.d.ts.map +1 -0
  24. package/dist/core/diff.js +82 -0
  25. package/dist/core/diff.js.map +1 -0
  26. package/dist/core/envcontext.d.ts +25 -0
  27. package/dist/core/envcontext.d.ts.map +1 -0
  28. package/dist/core/envcontext.js +112 -0
  29. package/dist/core/envcontext.js.map +1 -0
  30. package/dist/core/factory.d.ts +2 -0
  31. package/dist/core/factory.d.ts.map +1 -1
  32. package/dist/core/factory.js +35 -2
  33. package/dist/core/factory.js.map +1 -1
  34. package/dist/core/patch.d.ts +59 -0
  35. package/dist/core/patch.d.ts.map +1 -0
  36. package/dist/core/patch.js +220 -0
  37. package/dist/core/patch.js.map +1 -0
  38. package/dist/core/protocol.d.ts +11 -0
  39. package/dist/core/protocol.d.ts.map +1 -0
  40. package/dist/core/protocol.js +39 -0
  41. package/dist/core/protocol.js.map +1 -0
  42. package/dist/core/sandbox.d.ts +1 -0
  43. package/dist/core/sandbox.d.ts.map +1 -1
  44. package/dist/core/sandbox.js +1 -0
  45. package/dist/core/sandbox.js.map +1 -1
  46. package/dist/core/search.d.ts +41 -0
  47. package/dist/core/search.d.ts.map +1 -0
  48. package/dist/core/search.js +156 -0
  49. package/dist/core/search.js.map +1 -0
  50. package/dist/core/security.d.ts +22 -2
  51. package/dist/core/security.d.ts.map +1 -1
  52. package/dist/core/security.js +55 -24
  53. package/dist/core/security.js.map +1 -1
  54. package/dist/core/skill.d.ts +4 -0
  55. package/dist/core/skill.d.ts.map +1 -1
  56. package/dist/core/skill.js +1 -0
  57. package/dist/core/skill.js.map +1 -1
  58. package/dist/core/subagent.d.ts +75 -0
  59. package/dist/core/subagent.d.ts.map +1 -0
  60. package/dist/core/subagent.js +287 -0
  61. package/dist/core/subagent.js.map +1 -0
  62. package/dist/core/tool.d.ts +23 -1
  63. package/dist/core/tool.d.ts.map +1 -1
  64. package/dist/core/tool.js +95 -30
  65. package/dist/core/tool.js.map +1 -1
  66. package/dist/plugins/loader.d.ts +49 -8
  67. package/dist/plugins/loader.d.ts.map +1 -1
  68. package/dist/plugins/loader.js +129 -16
  69. package/dist/plugins/loader.js.map +1 -1
  70. package/dist/tools/builtin.d.ts.map +1 -1
  71. package/dist/tools/builtin.js +183 -17
  72. package/dist/tools/builtin.js.map +1 -1
  73. package/dist/tools/spawn.d.ts +23 -0
  74. package/dist/tools/spawn.d.ts.map +1 -0
  75. package/dist/tools/spawn.js +77 -0
  76. package/dist/tools/spawn.js.map +1 -0
  77. package/docs/OPTIMIZATION_PLAN.md +21 -4
  78. package/package.json +1 -1
  79. package/src/cli/loom_chat.ts +11 -0
  80. package/src/cli/main.ts +31 -1
  81. package/src/core/agent.ts +20 -5
  82. package/src/core/bgproc.ts +153 -0
  83. package/src/core/commands.ts +20 -0
  84. package/src/core/diagnostics.ts +178 -0
  85. package/src/core/diff.ts +98 -0
  86. package/src/core/envcontext.ts +79 -0
  87. package/src/core/factory.ts +31 -2
  88. package/src/core/patch.ts +176 -0
  89. package/src/core/protocol.ts +36 -0
  90. package/src/core/sandbox.ts +1 -1
  91. package/src/core/search.ts +138 -0
  92. package/src/core/security.ts +63 -21
  93. package/src/core/skill.ts +1 -1
  94. package/src/core/subagent.ts +272 -0
  95. package/src/core/tool.ts +101 -31
  96. package/src/plugins/loader.ts +145 -18
  97. package/src/tools/builtin.ts +167 -17
  98. package/src/tools/spawn.ts +92 -0
  99. package/tests/bgproc.test.ts +65 -0
  100. package/tests/diagnostics.test.ts +86 -0
  101. package/tests/edit_diff.test.ts +102 -0
  102. package/tests/envcontext.test.ts +67 -0
  103. package/tests/patch.test.ts +128 -0
  104. package/tests/plugins.test.ts +84 -0
  105. package/tests/protocol.test.ts +27 -0
  106. package/tests/search.test.ts +87 -0
  107. package/tests/security.test.ts +87 -0
  108. package/tests/subagent.test.ts +211 -0
  109. package/tests/tool.test.ts +120 -0
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { searchCode, formatSearchResult } from "../src/core/search";
6
+
7
+ describe("search · searchCode (pure JS)", () => {
8
+ let root: string;
9
+ beforeEach(() => {
10
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "sky-search-"));
11
+ fs.writeFileSync(path.join(root, "a.ts"), "const Foo = 1;\nexport function useFoo() { return Foo; }\n");
12
+ fs.writeFileSync(path.join(root, "b.js"), "// foo lower\nconst x = 2;\n");
13
+ fs.mkdirSync(path.join(root, "sub"));
14
+ fs.writeFileSync(path.join(root, "sub", "c.ts"), "import { useFoo } from '../a';\n");
15
+ // should be ignored by default
16
+ fs.mkdirSync(path.join(root, "node_modules", "dep"), { recursive: true });
17
+ fs.writeFileSync(path.join(root, "node_modules", "dep", "x.ts"), "const Foo = 999;\n");
18
+ });
19
+ afterEach(() => { try { fs.rmSync(root, { recursive: true, force: true }); } catch {} });
20
+
21
+ it("finds matches with file:line", () => {
22
+ const res = searchCode({ pattern: "useFoo", root });
23
+ const files = res.matches.map((m) => m.file).sort();
24
+ expect(files).toContain("a.ts");
25
+ expect(files).toContain("sub/c.ts");
26
+ const a = res.matches.find((m) => m.file === "a.ts")!;
27
+ expect(a.line).toBe(2);
28
+ expect(a.text).toContain("useFoo");
29
+ });
30
+
31
+ it("skips node_modules by default", () => {
32
+ const res = searchCode({ pattern: "Foo", root });
33
+ expect(res.matches.some((m) => m.file.includes("node_modules"))).toBe(false);
34
+ });
35
+
36
+ it("restricts by glob", () => {
37
+ const res = searchCode({ pattern: "foo", root, glob: "**/*.ts", ignoreCase: true });
38
+ expect(res.matches.some((m) => m.file === "b.js")).toBe(false);
39
+ expect(res.matches.some((m) => m.file === "a.ts")).toBe(true);
40
+ });
41
+
42
+ it("honors ignoreCase", () => {
43
+ // b.js contains lowercase "foo"; capital "Foo" only matches case-insensitively.
44
+ expect(searchCode({ pattern: "Foo", root, glob: "b.js" }).matches.length).toBe(0);
45
+ expect(searchCode({ pattern: "Foo", root, glob: "b.js", ignoreCase: true }).matches.length).toBe(1);
46
+ });
47
+
48
+ it("returns context lines", () => {
49
+ const res = searchCode({ pattern: "useFoo", root, glob: "a.ts", context: 1 });
50
+ const m = res.matches[0];
51
+ expect(m.before).toEqual(["const Foo = 1;"]);
52
+ });
53
+
54
+ it("treats pattern as literal when regex=false", () => {
55
+ fs.writeFileSync(path.join(root, "d.ts"), "a.b.c\n");
56
+ const asRegex = searchCode({ pattern: "a.b", root, glob: "d.ts" }); // '.' = any char
57
+ const literal = searchCode({ pattern: "a.b", root, glob: "d.ts", regex: false });
58
+ expect(asRegex.matches.length).toBe(1);
59
+ expect(literal.matches.length).toBe(1);
60
+ const noLit = searchCode({ pattern: "axb", root, glob: "d.ts", regex: false });
61
+ expect(noLit.matches.length).toBe(0);
62
+ });
63
+
64
+ it("caps results and flags truncation", () => {
65
+ fs.writeFileSync(path.join(root, "many.ts"), Array.from({ length: 50 }, () => "hit").join("\n"));
66
+ const res = searchCode({ pattern: "hit", root, glob: "many.ts", maxResults: 10 });
67
+ expect(res.matches.length).toBe(10);
68
+ expect(res.truncated).toBe(true);
69
+ });
70
+
71
+ it("reports an invalid regex instead of throwing", () => {
72
+ const res = searchCode({ pattern: "(", root });
73
+ expect(res.error).toContain("invalid regex");
74
+ });
75
+ });
76
+
77
+ describe("search · formatSearchResult", () => {
78
+ it("renders file:line and a no-match message", () => {
79
+ expect(formatSearchResult({ matches: [], filesScanned: 3, truncated: false })).toBe("No matches found.");
80
+ const s = formatSearchResult({
81
+ matches: [{ file: "a.ts", line: 2, text: " return Foo;" }],
82
+ filesScanned: 1, truncated: false,
83
+ });
84
+ expect(s).toContain("a.ts:2:");
85
+ expect(s).toContain("return Foo");
86
+ });
87
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ DangerLevel,
4
+ decideApproval,
5
+ isEditTool,
6
+ SecurityContext,
7
+ } from "../src/core/security";
8
+
9
+ describe("security · decideApproval matrix", () => {
10
+ it("SAFE is always allowed in every mode", () => {
11
+ for (const mode of ["auto", "interactive", "strict", "acceptEdits", "bypass"] as const) {
12
+ expect(decideApproval(DangerLevel.SAFE, mode, "read_file")).toBe("allow");
13
+ }
14
+ });
15
+
16
+ it("strict denies every non-SAFE tool", () => {
17
+ expect(decideApproval(DangerLevel.LOW, "strict", "write_file")).toBe("deny");
18
+ expect(decideApproval(DangerLevel.HIGH, "strict", "run_bash")).toBe("deny");
19
+ });
20
+
21
+ it("bypass allows everything (red-line is gated elsewhere)", () => {
22
+ expect(decideApproval(DangerLevel.CRITICAL, "bypass", "run_bash")).toBe("allow");
23
+ expect(decideApproval(DangerLevel.HIGH, "bypass", "deploy")).toBe("allow");
24
+ });
25
+
26
+ it("interactive asks for every non-SAFE tool", () => {
27
+ expect(decideApproval(DangerLevel.LOW, "interactive", "write_file")).toBe("ask");
28
+ expect(decideApproval(DangerLevel.HIGH, "interactive", "run_bash")).toBe("ask");
29
+ });
30
+
31
+ it("auto allows LOW, asks MEDIUM/HIGH, denies CRITICAL (unchanged)", () => {
32
+ expect(decideApproval(DangerLevel.LOW, "auto", "write_file")).toBe("allow");
33
+ expect(decideApproval(DangerLevel.MEDIUM, "auto", "git_push")).toBe("ask");
34
+ expect(decideApproval(DangerLevel.HIGH, "auto", "run_bash")).toBe("ask");
35
+ expect(decideApproval(DangerLevel.CRITICAL, "auto", "run_bash")).toBe("deny");
36
+ });
37
+
38
+ it("acceptEdits waves through edit tools but asks for other risky tools", () => {
39
+ expect(decideApproval(DangerLevel.LOW, "acceptEdits", "write_file")).toBe("allow");
40
+ expect(decideApproval(DangerLevel.MEDIUM, "acceptEdits", "delete_file")).toBe("allow"); // edit tool
41
+ expect(decideApproval(DangerLevel.HIGH, "acceptEdits", "run_bash")).toBe("ask"); // not an edit
42
+ expect(decideApproval(DangerLevel.CRITICAL, "acceptEdits", "delete_file")).toBe("deny");
43
+ });
44
+ });
45
+
46
+ describe("security · isEditTool", () => {
47
+ it("recognizes filesystem-mutating tools", () => {
48
+ expect(isEditTool("write_file")).toBe(true);
49
+ expect(isEditTool("edit_file")).toBe(true);
50
+ expect(isEditTool("delete_file")).toBe(true);
51
+ expect(isEditTool("move_file")).toBe(true);
52
+ expect(isEditTool("read_file")).toBe(false);
53
+ expect(isEditTool("run_bash")).toBe(false);
54
+ });
55
+ });
56
+
57
+ describe("security · checkApproval integration", () => {
58
+ it("blocks red-line shell commands regardless of mode", async () => {
59
+ const sec = new SecurityContext({ mode: "bypass" });
60
+ const [ok, reason] = await sec.checkApproval("run_bash", { command: "rm -rf /" }, "fog");
61
+ expect(ok).toBe(false);
62
+ expect(reason.toLowerCase()).toContain("red-line");
63
+ });
64
+
65
+ it("write_file: auto allows, strict denies, acceptEdits allows", async () => {
66
+ const args = { path: "a.txt", content: "x" };
67
+ expect((await new SecurityContext({ mode: "auto" }).checkApproval("write_file", args, "rain"))[0]).toBe(true);
68
+ expect((await new SecurityContext({ mode: "strict" }).checkApproval("write_file", args, "rain"))[0]).toBe(false);
69
+ expect((await new SecurityContext({ mode: "acceptEdits" }).checkApproval("write_file", args, "rain"))[0]).toBe(true);
70
+ });
71
+
72
+ it("ask defers to the approval callback", async () => {
73
+ const sec = new SecurityContext({ mode: "interactive" });
74
+ let asked = false;
75
+ sec.setApprovalCallback(async () => { asked = true; return false; });
76
+ const [ok] = await sec.checkApproval("write_file", { path: "a", content: "b" }, "rain");
77
+ expect(asked).toBe(true);
78
+ expect(ok).toBe(false);
79
+ });
80
+
81
+ it("setMode switches behavior at runtime", () => {
82
+ const sec = new SecurityContext({ mode: "auto" });
83
+ expect(sec.approvalMode).toBe("auto");
84
+ sec.setMode("bypass");
85
+ expect(sec.approvalMode).toBe("bypass");
86
+ });
87
+ });
@@ -0,0 +1,211 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { MessageBus } from "../src/core/bus";
6
+ import { ToolRegistry } from "../src/core/tool";
7
+ import { SkillRegistry } from "../src/core/skill";
8
+ import {
9
+ loadSubagentDefinitions,
10
+ parseSubagentFile,
11
+ runSubagent,
12
+ READ_ONLY_TOOLS,
13
+ } from "../src/core/subagent";
14
+ import { createSpawnAgentTool } from "../src/tools/spawn";
15
+
16
+ /**
17
+ * Subagent system: definition loading/parsing + isolated-context execution,
18
+ * driven by a scripted mock LLM (no network), mirroring tests/agent.test.ts.
19
+ */
20
+
21
+ interface Turn { content?: string; toolCalls?: { name: string; args?: any }[] }
22
+
23
+ class MockLLM {
24
+ calls = 0;
25
+ constructor(private turns: Turn[]) {}
26
+ private turn(): Turn { const t = this.turns[Math.min(this.calls, this.turns.length - 1)]; this.calls++; return t || {}; }
27
+ private toolCallObjs(t: Turn) {
28
+ return (t.toolCalls || []).map((tc, i) => ({
29
+ id: `call_${this.calls}_${i}`, type: "function",
30
+ function: { name: tc.name, arguments: JSON.stringify(tc.args || {}) },
31
+ }));
32
+ }
33
+ async *streamWithTools(): AsyncGenerator<any> {
34
+ const t = this.turn();
35
+ if (t.content) yield { type: "content", text: t.content };
36
+ for (const tc of this.toolCallObjs(t)) yield { type: "tool_call", toolCall: tc };
37
+ yield { type: "done", usage: { promptTokens: 1, completionTokens: 1 } };
38
+ }
39
+ async complete(): Promise<any> {
40
+ const t = this.turn();
41
+ return { content: t.content || "", toolCalls: this.toolCallObjs(t), model: "mock", usage: { promptTokens: 1, completionTokens: 1 }, cost: 0, truncated: false };
42
+ }
43
+ getTotalCost() { return 0; }
44
+ getModel() { return "mock"; }
45
+ setLogger() { /* noop */ }
46
+ }
47
+
48
+ function baseConfig() {
49
+ return { agents: {}, llm: { language: "zh" }, memory: { shortTermLimit: 100, dbPath: path.join(os.tmpdir(), "sky-sub-test") } };
50
+ }
51
+
52
+ describe("subagent · definitions", () => {
53
+ it("ships built-in general-purpose and explore agents", () => {
54
+ const defs = loadSubagentDefinitions(os.tmpdir());
55
+ expect(defs.has("general-purpose")).toBe(true);
56
+ expect(defs.has("explore")).toBe(true);
57
+ });
58
+
59
+ it("explore is read-only: includes read_file, excludes write_file", () => {
60
+ const defs = loadSubagentDefinitions(os.tmpdir());
61
+ const explore = defs.get("explore")!;
62
+ expect(explore.tools).not.toBeNull();
63
+ expect(explore.tools).toContain("read_file");
64
+ expect(explore.tools).not.toContain("write_file");
65
+ expect(READ_ONLY_TOOLS).toContain("grep");
66
+ });
67
+
68
+ it("general-purpose inherits the full tool set (tools = null)", () => {
69
+ const defs = loadSubagentDefinitions(os.tmpdir());
70
+ expect(defs.get("general-purpose")!.tools).toBeNull();
71
+ });
72
+ });
73
+
74
+ describe("subagent · file parsing", () => {
75
+ let dir: string;
76
+ beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "sky-agentdefs-")); });
77
+ afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} });
78
+
79
+ it("parses frontmatter, body, and normalizes Claude tool names", () => {
80
+ const file = path.join(dir, "reviewer.md");
81
+ fs.writeFileSync(file,
82
+ "---\nname: reviewer\ndescription: 审查代码\ntools: Read, Grep, Bash\nmodel: gpt-4o\n---\n你是一个代码审查子智能体。\n");
83
+ const def = parseSubagentFile(file)!;
84
+ expect(def.name).toBe("reviewer");
85
+ expect(def.description).toBe("审查代码");
86
+ expect(def.model).toBe("gpt-4o");
87
+ expect(def.systemPrompt).toContain("代码审查");
88
+ // Read -> read_file, Bash -> run_bash, Grep -> grep
89
+ expect(def.tools).toEqual(["read_file", "grep", "run_bash"]);
90
+ });
91
+
92
+ it("omitted tools means inherit all (null)", () => {
93
+ const file = path.join(dir, "helper.md");
94
+ fs.writeFileSync(file, "---\ndescription: 万能\n---\nbody\n");
95
+ const def = parseSubagentFile(file)!;
96
+ expect(def.name).toBe("helper"); // falls back to filename
97
+ expect(def.tools).toBeNull();
98
+ });
99
+
100
+ it("project .sky/agents definitions are discovered and override built-ins", () => {
101
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "sky-cwd-"));
102
+ try {
103
+ const agentsDir = path.join(cwd, ".sky", "agents");
104
+ fs.mkdirSync(agentsDir, { recursive: true });
105
+ fs.writeFileSync(path.join(agentsDir, "custom.md"), "---\ndescription: 自定义\n---\nhi\n");
106
+ const defs = loadSubagentDefinitions(cwd);
107
+ expect(defs.has("custom")).toBe(true);
108
+ expect(defs.get("custom")!.description).toBe("自定义");
109
+ } finally {
110
+ try { fs.rmSync(cwd, { recursive: true, force: true }); } catch {}
111
+ }
112
+ });
113
+ });
114
+
115
+ describe("subagent · isolated execution (mock LLM)", () => {
116
+ it("runs to completion and returns the final report", async () => {
117
+ const defs = loadSubagentDefinitions(os.tmpdir());
118
+ const report = await runSubagent({
119
+ def: defs.get("general-purpose")!,
120
+ task: "say hi",
121
+ config: baseConfig(),
122
+ llm: new MockLLM([{ content: "REPORT: 完成了任务。" }]) as any,
123
+ bus: new MessageBus(),
124
+ baseToolRegistry: new ToolRegistry(),
125
+ baseSkillRegistry: new SkillRegistry(),
126
+ });
127
+ expect(report).toContain("REPORT: 完成了任务。");
128
+ });
129
+
130
+ it("executes inherited tools inside the isolated loop", async () => {
131
+ let ran = false;
132
+ const reg = new ToolRegistry();
133
+ reg.register({ name: "echo", description: "echo", handler: async (a: any) => { ran = true; return `echo:${a.text}`; } });
134
+ const defs = loadSubagentDefinitions(os.tmpdir());
135
+ const report = await runSubagent({
136
+ def: defs.get("general-purpose")!,
137
+ task: "use echo",
138
+ config: baseConfig(),
139
+ llm: new MockLLM([
140
+ { toolCalls: [{ name: "echo", args: { text: "hi" } }] },
141
+ { content: "用过 echo 了。" },
142
+ ]) as any,
143
+ bus: new MessageBus(),
144
+ baseToolRegistry: reg,
145
+ baseSkillRegistry: new SkillRegistry(),
146
+ });
147
+ expect(ran).toBe(true);
148
+ expect(report).toContain("echo");
149
+ });
150
+
151
+ it("never carries spawn_agent into the subagent (no recursion)", async () => {
152
+ // A registry that includes spawn_agent — the subagent must not see it.
153
+ const reg = new ToolRegistry();
154
+ reg.register({ name: "spawn_agent", description: "spawn", handler: async () => "should-not-run" });
155
+ reg.register({ name: "noop", description: "noop", handler: async () => "ok" });
156
+ const defs = loadSubagentDefinitions(os.tmpdir());
157
+ // Script the model to TRY spawn_agent; it should be reported as nonexistent.
158
+ const report = await runSubagent({
159
+ def: defs.get("general-purpose")!,
160
+ task: "try to spawn",
161
+ config: baseConfig(),
162
+ llm: new MockLLM([
163
+ { toolCalls: [{ name: "spawn_agent", args: { agent_type: "x", task: "y" } }] },
164
+ { content: "无法再派生。" },
165
+ ]) as any,
166
+ bus: new MessageBus(),
167
+ baseToolRegistry: reg,
168
+ baseSkillRegistry: new SkillRegistry(),
169
+ });
170
+ expect(report).toContain("无法再派生");
171
+ });
172
+ });
173
+
174
+ describe("spawn_agent tool", () => {
175
+ function makeTool(reg = new ToolRegistry(), llm = new MockLLM([{ content: "done" }])) {
176
+ return createSpawnAgentTool({
177
+ config: baseConfig(),
178
+ llm: llm as any,
179
+ bus: new MessageBus(),
180
+ baseToolRegistry: reg,
181
+ baseSkillRegistry: new SkillRegistry(),
182
+ cwd: os.tmpdir(),
183
+ });
184
+ }
185
+
186
+ it("lists available agent types in its description", () => {
187
+ const tool = makeTool();
188
+ expect(tool.description).toContain("general-purpose");
189
+ expect(tool.description).toContain("explore");
190
+ });
191
+
192
+ it("errors on missing args", async () => {
193
+ const tool = makeTool();
194
+ expect(await tool.handler!({ agent_type: "general-purpose" })).toContain("task is required");
195
+ expect(await tool.handler!({ task: "do" })).toContain("agent_type is required");
196
+ });
197
+
198
+ it("errors on unknown agent_type", async () => {
199
+ const tool = makeTool();
200
+ const out = await tool.handler!({ agent_type: "nope", task: "do" });
201
+ expect(out).toContain("unknown agent_type");
202
+ expect(out).toContain("general-purpose");
203
+ });
204
+
205
+ it("runs a subagent and returns its report with a header", async () => {
206
+ const tool = makeTool(new ToolRegistry(), new MockLLM([{ content: "子任务结果。" }]));
207
+ const out = await tool.handler!({ agent_type: "general-purpose", task: "做点事" });
208
+ expect(out).toContain("subagent general-purpose 完成");
209
+ expect(out).toContain("子任务结果。");
210
+ });
211
+ });
@@ -16,6 +16,7 @@ function makeTool(overrides: Partial<ToolDefinition> & { name: string }): ToolDe
16
16
  maxRetries: overrides.maxRetries,
17
17
  retryDelay: overrides.retryDelay,
18
18
  timeout: overrides.timeout,
19
+ validateOutput: overrides.validateOutput,
19
20
  };
20
21
  }
21
22
 
@@ -108,6 +109,125 @@ describe('ToolRegistry', () => {
108
109
  });
109
110
  });
110
111
 
112
+ describe('ToolRegistry · input validation + coercion', () => {
113
+ let registry: ToolRegistry;
114
+ beforeEach(() => { registry = new ToolRegistry(); });
115
+
116
+ function recordTool(name: string, parameters: any[]) {
117
+ let received: any = null;
118
+ registry.register(makeTool({
119
+ name, parameters,
120
+ handler: async (p: any) => { received = p; return 'ok'; },
121
+ }));
122
+ return () => received;
123
+ }
124
+
125
+ it('coerces a numeric string to a number for the handler', async () => {
126
+ const got = recordTool('n', [{ name: 'x', type: 'number', description: 'x', required: true }]);
127
+ await registry.execute('n', { x: '5' });
128
+ expect(got()).toEqual({ x: 5 });
129
+ });
130
+
131
+ it('does not truncate floats (Number, not parseInt)', async () => {
132
+ const got = recordTool('f', [{ name: 'x', type: 'number', description: 'x', required: true }]);
133
+ await registry.execute('f', { x: '3.5' });
134
+ expect(got()).toEqual({ x: 3.5 });
135
+ });
136
+
137
+ it('coerces boolean-like strings', async () => {
138
+ const got = recordTool('b', [{ name: 'flag', type: 'boolean', description: 'f', required: true }]);
139
+ await registry.execute('b', { flag: 'true' });
140
+ expect(got()).toEqual({ flag: true });
141
+ });
142
+
143
+ it('parses a JSON-string object param', async () => {
144
+ const got = recordTool('o', [{ name: 'cfg', type: 'object', description: 'c', required: true }]);
145
+ await registry.execute('o', { cfg: '{"a":1}' });
146
+ expect(got()).toEqual({ cfg: { a: 1 } });
147
+ });
148
+
149
+ it('rejects an uncoercible type and does not run the handler', async () => {
150
+ const handler = vi.fn().mockResolvedValue('ok');
151
+ registry.register(makeTool({
152
+ name: 'num', parameters: [{ name: 'x', type: 'number', description: 'x', required: true }], handler,
153
+ }));
154
+ const res = await registry.execute('num', { x: 'not-a-number' });
155
+ expect(res.success).toBe(false);
156
+ expect(res.error).toContain('expected number');
157
+ expect(handler).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it('enforces enum membership with a helpful message', async () => {
161
+ const handler = vi.fn().mockResolvedValue('ok');
162
+ registry.register(makeTool({
163
+ name: 'pick',
164
+ parameters: [{ name: 'mode', type: 'string', description: 'm', required: true, enum: ['fast', 'slow'] }],
165
+ handler,
166
+ }));
167
+ const bad = await registry.execute('pick', { mode: 'turbo' });
168
+ expect(bad.success).toBe(false);
169
+ expect(bad.error).toContain('fast, slow');
170
+ expect(handler).not.toHaveBeenCalled();
171
+
172
+ const ok = await registry.execute('pick', { mode: 'fast' });
173
+ expect(ok.success).toBe(true);
174
+ });
175
+
176
+ it('treats a present-but-null required param as missing', async () => {
177
+ const handler = vi.fn().mockResolvedValue('ok');
178
+ registry.register(makeTool({
179
+ name: 'req', parameters: [{ name: 'p', type: 'string', description: 'p', required: true }], handler,
180
+ }));
181
+ const res = await registry.execute('req', { p: null });
182
+ expect(res.success).toBe(false);
183
+ expect(res.error).toContain('required');
184
+ expect(handler).not.toHaveBeenCalled();
185
+ });
186
+ });
187
+
188
+ describe('ToolRegistry · output validation', () => {
189
+ let registry: ToolRegistry;
190
+ beforeEach(() => { registry = new ToolRegistry(); });
191
+
192
+ it('fails the call when validateOutput rejects the result', async () => {
193
+ registry.register(makeTool({
194
+ name: 'guarded',
195
+ maxRetries: 0,
196
+ handler: async () => 'garbage',
197
+ validateOutput: (r) => (r === 'garbage' ? 'looks like garbage' : null),
198
+ }));
199
+ const res = await registry.execute('guarded', {});
200
+ expect(res.success).toBe(false);
201
+ expect(res.error).toContain('invalid tool output');
202
+ expect(res.error).toContain('looks like garbage');
203
+ });
204
+
205
+ it('passes when validateOutput accepts the result', async () => {
206
+ registry.register(makeTool({
207
+ name: 'ok',
208
+ handler: async () => 'fine',
209
+ validateOutput: () => null,
210
+ }));
211
+ const res = await registry.execute('ok', {});
212
+ expect(res.success).toBe(true);
213
+ expect(res.result).toBe('fine');
214
+ });
215
+
216
+ it('retries a rejected output through the normal retry path', async () => {
217
+ let n = 0;
218
+ registry.register(makeTool({
219
+ name: 'retryout',
220
+ maxRetries: 1,
221
+ retryDelay: 0,
222
+ handler: async () => `v${++n}`,
223
+ validateOutput: (r) => (r === 'v1' ? 'first is bad' : null),
224
+ }));
225
+ const res = await registry.execute('retryout', {});
226
+ expect(res.success).toBe(true);
227
+ expect(res.result).toBe('v2');
228
+ });
229
+ });
230
+
111
231
  describe('stableStringify', () => {
112
232
  it('produces an order-independent key for objects', async () => {
113
233
  const { stableStringify } = await import('../src/core/tool');