skyloom 1.13.5 → 1.13.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +36 -36
- package/README.md +220 -159
- package/config/providers.yaml +39 -39
- package/config/skills/api_integrator/SKILL.md +15 -15
- package/config/skills/arch_designer/SKILL.md +13 -13
- package/config/skills/ci_cd_manager/SKILL.md +14 -14
- package/config/skills/code_analysis/SKILL.md +13 -13
- package/config/skills/code_generator/SKILL.md +12 -12
- package/config/skills/code_reviewer/SKILL.md +13 -13
- package/config/skills/content_writer/SKILL.md +14 -14
- package/config/skills/data_transformer/SKILL.md +15 -15
- package/config/skills/document_analysis/SKILL.md +13 -13
- package/config/skills/emotional_companion/SKILL.md +15 -15
- package/config/skills/performance_checker/SKILL.md +14 -14
- package/config/skills/security_auditor/SKILL.md +14 -14
- package/config/skills/self_evolve/SKILL.md +13 -13
- package/config/skills/sys_operator/SKILL.md +15 -15
- package/config/skills/task_planner/SKILL.md +14 -14
- package/config/skills/web_research/SKILL.md +14 -14
- package/config/skills/workflow_designer/SKILL.md +13 -13
- package/dist/agents/dew.js +52 -52
- package/dist/agents/fair.js +84 -84
- package/dist/agents/fog.js +30 -30
- package/dist/agents/frost.js +32 -32
- package/dist/agents/rain.js +32 -32
- package/dist/agents/snow.js +68 -68
- package/dist/cli/commands_md.d.ts +41 -0
- package/dist/cli/commands_md.d.ts.map +1 -0
- package/dist/cli/commands_md.js +140 -0
- package/dist/cli/commands_md.js.map +1 -0
- package/dist/cli/input_macros.d.ts +28 -0
- package/dist/cli/input_macros.d.ts.map +1 -0
- package/dist/cli/input_macros.js +120 -0
- package/dist/cli/input_macros.js.map +1 -0
- package/dist/cli/loom.d.ts +220 -0
- package/dist/cli/loom.d.ts.map +1 -0
- package/dist/cli/loom.js +1094 -0
- package/dist/cli/loom.js.map +1 -0
- package/dist/cli/loom_chat.d.ts +20 -0
- package/dist/cli/loom_chat.d.ts.map +1 -0
- package/dist/cli/loom_chat.js +685 -0
- package/dist/cli/loom_chat.js.map +1 -0
- package/dist/cli/main.js +310 -14
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/tui.d.ts.map +1 -1
- package/dist/cli/tui.js +7 -1
- package/dist/cli/tui.js.map +1 -1
- package/dist/core/agent/guard.d.ts +45 -0
- package/dist/core/agent/guard.d.ts.map +1 -0
- package/dist/core/agent/guard.js +113 -0
- package/dist/core/agent/guard.js.map +1 -0
- package/dist/core/agent.d.ts +17 -0
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +182 -93
- package/dist/core/agent.js.map +1 -1
- package/dist/core/factory.d.ts.map +1 -1
- package/dist/core/factory.js +34 -2
- package/dist/core/factory.js.map +1 -1
- package/dist/core/file_checkpoint.d.ts +57 -0
- package/dist/core/file_checkpoint.d.ts.map +1 -0
- package/dist/core/file_checkpoint.js +162 -0
- package/dist/core/file_checkpoint.js.map +1 -0
- package/dist/core/hooks.d.ts +43 -0
- package/dist/core/hooks.d.ts.map +1 -0
- package/dist/core/hooks.js +110 -0
- package/dist/core/hooks.js.map +1 -0
- package/dist/core/llm.d.ts.map +1 -1
- package/dist/core/llm.js +15 -9
- package/dist/core/llm.js.map +1 -1
- package/dist/core/longdoc.js +5 -5
- package/dist/core/mcp.d.ts +16 -0
- package/dist/core/mcp.d.ts.map +1 -1
- package/dist/core/mcp.js +55 -0
- package/dist/core/mcp.js.map +1 -1
- package/dist/core/model_config.d.ts +40 -0
- package/dist/core/model_config.d.ts.map +1 -0
- package/dist/core/model_config.js +191 -0
- package/dist/core/model_config.js.map +1 -0
- package/dist/core/skill.d.ts +7 -0
- package/dist/core/skill.d.ts.map +1 -1
- package/dist/core/skill.js +47 -0
- package/dist/core/skill.js.map +1 -1
- package/dist/core/skymd.d.ts +39 -0
- package/dist/core/skymd.d.ts.map +1 -0
- package/dist/core/skymd.js +177 -0
- package/dist/core/skymd.js.map +1 -0
- package/dist/core/tool.d.ts +12 -0
- package/dist/core/tool.d.ts.map +1 -1
- package/dist/core/tool.js +30 -0
- package/dist/core/tool.js.map +1 -1
- package/dist/core/verify.d.ts +27 -0
- package/dist/core/verify.d.ts.map +1 -0
- package/dist/core/verify.js +62 -0
- package/dist/core/verify.js.map +1 -0
- package/dist/skills/loader.d.ts +22 -2
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +45 -15
- package/dist/skills/loader.js.map +1 -1
- package/dist/tools/builtin.d.ts.map +1 -1
- package/dist/tools/builtin.js +13 -3
- package/dist/tools/builtin.js.map +1 -1
- package/dist/tools/model_tool.d.ts +11 -0
- package/dist/tools/model_tool.d.ts.map +1 -0
- package/dist/tools/model_tool.js +71 -0
- package/dist/tools/model_tool.js.map +1 -0
- package/dist/tools/todo.d.ts +30 -0
- package/dist/tools/todo.d.ts.map +1 -0
- package/dist/tools/todo.js +78 -0
- package/dist/tools/todo.js.map +1 -0
- package/docs/AESTHETIC_DESIGN.md +152 -144
- package/docs/OPTIMIZATION_PLAN.md +178 -178
- package/package.json +1 -1
- package/scripts/install.js +48 -48
- package/scripts/link.js +10 -10
- package/setup.bat +79 -79
- package/skill-test-ty2fOA/test.md +10 -10
- package/src/agents/dew.ts +70 -70
- package/src/agents/fair.ts +102 -102
- package/src/agents/fog.ts +48 -48
- package/src/agents/frost.ts +50 -50
- package/src/agents/rain.ts +50 -50
- package/src/agents/snow.ts +239 -239
- package/src/cli/commands_md.ts +112 -0
- package/src/cli/input_macros.ts +83 -0
- package/src/cli/loom.ts +982 -0
- package/src/cli/loom_chat.ts +598 -0
- package/src/cli/main.ts +255 -9
- package/src/cli/mode.ts +58 -58
- package/src/cli/tui.ts +228 -222
- package/src/core/agent/guard.ts +134 -0
- package/src/core/agent/task.ts +100 -100
- package/src/core/agent.ts +177 -95
- package/src/core/arbitrate.ts +162 -162
- package/src/core/catalog.ts +178 -178
- package/src/core/checkpoint.ts +94 -94
- package/src/core/estimate.ts +104 -104
- package/src/core/evolve.ts +191 -191
- package/src/core/factory.ts +31 -2
- package/src/core/file_checkpoint.ts +136 -0
- package/src/core/filter.ts +103 -103
- package/src/core/graph.ts +156 -156
- package/src/core/hooks.ts +126 -0
- package/src/core/icons.ts +53 -53
- package/src/core/index.ts +37 -37
- package/src/core/learn.ts +146 -146
- package/src/core/llm.ts +15 -9
- package/src/core/longdoc.ts +155 -155
- package/src/core/mcp.ts +48 -0
- package/src/core/mcp_server.ts +176 -176
- package/src/core/model_config.ts +157 -0
- package/src/core/profile.ts +255 -255
- package/src/core/router.ts +124 -124
- package/src/core/sandbox.ts +142 -142
- package/src/core/security.ts +243 -243
- package/src/core/skill.ts +42 -0
- package/src/core/skymd.ts +143 -0
- package/src/core/theme.ts +65 -65
- package/src/core/tool.ts +30 -0
- package/src/core/tool_router.ts +193 -193
- package/src/core/vector.ts +152 -152
- package/src/core/verify.ts +71 -0
- package/src/core/workspace.ts +150 -150
- package/src/plugins/loader.ts +66 -66
- package/src/skills/loader.ts +45 -16
- package/src/sql.js.d.ts +29 -29
- package/src/tools/builtin.ts +13 -3
- package/src/tools/computer.ts +269 -269
- package/src/tools/delegate.ts +49 -49
- package/src/tools/model_tool.ts +74 -0
- package/src/tools/todo.ts +76 -0
- package/src/web/tts.ts +93 -93
- package/tests/agent.test.ts +159 -159
- package/tests/agent_helpers.test.ts +48 -48
- package/tests/bus.test.ts +121 -121
- package/tests/catalog.test.ts +86 -86
- package/tests/checkpoint_commands.test.ts +124 -0
- package/tests/claude_compat.test.ts +110 -0
- package/tests/config.test.ts +41 -41
- package/tests/guard.test.ts +75 -0
- package/tests/icons.test.ts +45 -45
- package/tests/loom.test.ts +248 -0
- package/tests/memory.test.ts +170 -170
- package/tests/model_config.test.ts +109 -0
- package/tests/router.test.ts +86 -86
- package/tests/schemas.test.ts +51 -51
- package/tests/semantic.test.ts +83 -83
- package/tests/setup.ts +10 -10
- package/tests/skill.test.ts +172 -172
- package/tests/skymd.test.ts +146 -0
- package/tests/task.test.ts +60 -60
- package/tests/todo_toolstats.test.ts +94 -0
- package/tests/tool.test.ts +108 -108
- package/tests/tool_router.test.ts +71 -71
- package/tests/tui.test.ts +67 -67
- package/vitest.config.ts +17 -17
package/tests/agent.test.ts
CHANGED
|
@@ -1,159 +1,159 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { FogAgent } from "../src/agents/fog";
|
|
3
|
-
import { MessageBus } from "../src/core/bus";
|
|
4
|
-
import { ToolRegistry } from "../src/core/tool";
|
|
5
|
-
import { SkillRegistry } from "../src/core/skill";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Characterization tests for the agent chat/tool loop, driven by a scripted
|
|
9
|
-
* mock LLM (no network). These lock in the behavior of the ~275-line hot path
|
|
10
|
-
* (chatStreamImpl / llmLoop / tool execution / anti-loop guard) so it can be
|
|
11
|
-
* refactored safely (Phase 3) — and they guard against regressions like the
|
|
12
|
-
* first-message crash.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
interface Turn { content?: string; toolCalls?: { name: string; args?: any }[]; reasoning?: string }
|
|
16
|
-
|
|
17
|
-
class MockLLM {
|
|
18
|
-
calls = 0;
|
|
19
|
-
constructor(private turns: Turn[]) {}
|
|
20
|
-
private turn(): Turn { const t = this.turns[Math.min(this.calls, this.turns.length - 1)]; this.calls++; return t || {}; }
|
|
21
|
-
private toolCallObjs(t: Turn) {
|
|
22
|
-
return (t.toolCalls || []).map((tc, i) => ({
|
|
23
|
-
id: `call_${this.calls}_${i}`, type: "function",
|
|
24
|
-
function: { name: tc.name, arguments: JSON.stringify(tc.args || {}) },
|
|
25
|
-
}));
|
|
26
|
-
}
|
|
27
|
-
async *streamWithTools(): AsyncGenerator<any> {
|
|
28
|
-
const t = this.turn();
|
|
29
|
-
if (t.reasoning) yield { type: "reasoning", text: t.reasoning };
|
|
30
|
-
if (t.content) yield { type: "content", text: t.content };
|
|
31
|
-
for (const tc of this.toolCallObjs(t)) yield { type: "tool_call", toolCall: tc };
|
|
32
|
-
yield { type: "done", usage: { promptTokens: 1, completionTokens: 1 } };
|
|
33
|
-
}
|
|
34
|
-
async complete(): Promise<any> {
|
|
35
|
-
const t = this.turn();
|
|
36
|
-
return { content: t.content || "", toolCalls: this.toolCallObjs(t), model: "mock", usage: { promptTokens: 1, completionTokens: 1 }, cost: 0, truncated: false };
|
|
37
|
-
}
|
|
38
|
-
getTotalCost() { return 0; }
|
|
39
|
-
getModel() { return "mock"; }
|
|
40
|
-
setLogger() { /* noop */ }
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function makeAgent(turns: Turn[], tools: { name: string; handler: (a: any) => Promise<string> }[] = []) {
|
|
44
|
-
const reg = new ToolRegistry();
|
|
45
|
-
for (const t of tools) reg.register({ name: t.name, description: t.name, handler: t.handler });
|
|
46
|
-
const config = { agents: { fog: {} }, llm: { language: "zh" }, memory: { shortTermLimit: 100, dbPath: "/tmp/sky-test" } };
|
|
47
|
-
const agent = new FogAgent(config as any, new MockLLM(turns) as any, new MessageBus(), reg, new SkillRegistry());
|
|
48
|
-
return agent;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async function collect(gen: AsyncGenerator<any>, cap = 500): Promise<any[]> {
|
|
52
|
-
const evs: any[] = [];
|
|
53
|
-
for await (const ev of gen) { evs.push(ev); if (evs.length > cap) break; }
|
|
54
|
-
return evs;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
describe("agent · chat loop (mock LLM)", () => {
|
|
58
|
-
it("streams a simple reply and records both messages", async () => {
|
|
59
|
-
const agent = makeAgent([{ content: "你好,我是雾。" }]);
|
|
60
|
-
const evs = await collect(agent.chatStream("你好"));
|
|
61
|
-
const text = evs.filter((e) => e.type === "content").map((e) => e.text).join("");
|
|
62
|
-
expect(text).toContain("你好,我是雾。");
|
|
63
|
-
|
|
64
|
-
const msgs = agent.memory.getMessages();
|
|
65
|
-
expect(msgs[0]).toMatchObject({ role: "user", content: "你好" }); // regression: user msg present
|
|
66
|
-
expect(msgs.some((m) => m.role === "assistant" && String(m.content).includes("雾"))).toBe(true);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("blocking chat() returns the reply", async () => {
|
|
70
|
-
const agent = makeAgent([{ content: "答案是 42" }]);
|
|
71
|
-
const reply = await agent.chat("问题?");
|
|
72
|
-
expect(reply).toContain("42");
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("streams reasoning before content", async () => {
|
|
76
|
-
const agent = makeAgent([{ reasoning: "先想一下…", content: "结论。" }]);
|
|
77
|
-
const evs = await collect(agent.chatStream("?"));
|
|
78
|
-
expect(evs.some((e) => e.type === "reasoning")).toBe(true);
|
|
79
|
-
expect(evs.filter((e) => e.type === "content").map((e) => e.text).join("")).toContain("结论。");
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("executes a tool call then produces the final answer", async () => {
|
|
83
|
-
let received: any = null;
|
|
84
|
-
const agent = makeAgent(
|
|
85
|
-
[{ toolCalls: [{ name: "echo", args: { text: "hi" } }] }, { content: "工具回显: hi" }],
|
|
86
|
-
[{ name: "echo", handler: async (a) => { received = a; return `echo:${a.text}`; } }],
|
|
87
|
-
);
|
|
88
|
-
const evs = await collect(agent.chatStream("用 echo 工具"));
|
|
89
|
-
expect(received).toEqual({ text: "hi" }); // tool actually ran with parsed args
|
|
90
|
-
expect(evs.some((e) => e.type === "tool_status" && e.tool_name === "echo")).toBe(true);
|
|
91
|
-
expect(evs.some((e) => e.type === "tool_done" && e.tool_name === "echo" && e.success)).toBe(true);
|
|
92
|
-
expect(evs.filter((e) => e.type === "content").map((e) => e.text).join("")).toContain("工具回显");
|
|
93
|
-
// tool result recorded to memory
|
|
94
|
-
expect(agent.memory.getMessages().some((m) => m.role === "tool" && String(m.content).includes("echo:hi"))).toBe(true);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("terminates (does not loop forever) when the model repeats the same tool call", async () => {
|
|
98
|
-
// Script the same tool call far beyond the round cap; the anti-loop guard must stop it.
|
|
99
|
-
const turns: Turn[] = Array.from({ length: 60 }, () => ({ toolCalls: [{ name: "spin", args: { n: 1 } }] }));
|
|
100
|
-
const llm = new MockLLM(turns);
|
|
101
|
-
const reg = new ToolRegistry();
|
|
102
|
-
reg.register({ name: "spin", description: "spin", handler: async () => "still spinning" });
|
|
103
|
-
const config = { agents: { fog: {} }, llm: {}, memory: { shortTermLimit: 200, dbPath: "/tmp/sky-test" } };
|
|
104
|
-
const agent = new FogAgent(config as any, llm as any, new MessageBus(), reg, new SkillRegistry());
|
|
105
|
-
|
|
106
|
-
const evs = await collect(agent.chatStream("loop please"), 2000);
|
|
107
|
-
// It must finish (the generator returns), not hang, and not call the model unboundedly.
|
|
108
|
-
expect(evs.some((e) => e.type === "done")).toBe(true);
|
|
109
|
-
expect(llm.calls).toBeLessThan(50); // bounded by the round cap / guard, not 60+
|
|
110
|
-
}, 15000);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
describe("agent · context window (catalog-aware compaction)", () => {
|
|
114
|
-
it("contextUsage uses the active model's real window from the catalog", () => {
|
|
115
|
-
const agent = makeAgent([{ content: "x" }]);
|
|
116
|
-
(agent as any).config.agents.fog.model = "mixtral-8x7b"; // 32768
|
|
117
|
-
expect(agent.contextUsage().maxTokens).toBe(32768);
|
|
118
|
-
expect(agent.contextUsage().model).toBe("mixtral-8x7b");
|
|
119
|
-
(agent as any).config.agents.fog.model = "gemini-2.5-pro"; // 1048576
|
|
120
|
-
expect(agent.contextUsage().maxTokens).toBe(1048576);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("auto-compaction triggers for a small window but not a large one (same history)", () => {
|
|
124
|
-
const agent = makeAgent([{ content: "x" }]);
|
|
125
|
-
const big = "字".repeat(800); // CJK ~2 tokens/char
|
|
126
|
-
for (let i = 0; i < 20; i++) agent.memory.addMessage("user", big); // ~32k tokens
|
|
127
|
-
|
|
128
|
-
(agent as any).config.agents.fog.model = "mixtral-8x7b"; // 32768 window -> over budget
|
|
129
|
-
expect((agent as any).shouldAutoCompact()).toBe(true);
|
|
130
|
-
|
|
131
|
-
(agent as any).config.agents.fog.model = "gemini-2.5-pro"; // 1M window -> fine
|
|
132
|
-
expect((agent as any).shouldAutoCompact()).toBe(false);
|
|
133
|
-
});
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
describe("agent · interrupt (Ctrl-C)", () => {
|
|
137
|
-
it("stops between rounds on abort and preserves partial output", async () => {
|
|
138
|
-
const controller = new AbortController();
|
|
139
|
-
// Round 1 streams some content + a tool call; the tool aborts the signal.
|
|
140
|
-
// Round 2 must never run.
|
|
141
|
-
const turns: Turn[] = [
|
|
142
|
-
{ content: "部分内容已生成…", toolCalls: [{ name: "spin", args: {} }] },
|
|
143
|
-
{ content: "不应出现的第二轮" },
|
|
144
|
-
];
|
|
145
|
-
const reg = new ToolRegistry();
|
|
146
|
-
reg.register({ name: "spin", description: "spin", handler: async () => { controller.abort(); return "spun"; } });
|
|
147
|
-
const config = { agents: { fog: {} }, llm: {}, memory: { shortTermLimit: 200, dbPath: "/tmp/sky-test" } };
|
|
148
|
-
const agent = new FogAgent(config as any, new MockLLM(turns) as any, new MessageBus(), reg, new SkillRegistry());
|
|
149
|
-
|
|
150
|
-
const evs = await collect(agent.chatStream("go", controller.signal));
|
|
151
|
-
const text = evs.filter((e) => e.type === "content").map((e) => e.text).join("");
|
|
152
|
-
|
|
153
|
-
expect(evs.some((e) => e.type === "interrupted")).toBe(true);
|
|
154
|
-
expect(text).toContain("部分内容已生成"); // partial output kept
|
|
155
|
-
expect(text).not.toContain("第二轮"); // round 2 never streamed
|
|
156
|
-
// partial assistant content is in memory
|
|
157
|
-
expect(agent.memory.getMessages().some((m) => m.role === "assistant" && String(m.content).includes("部分内容"))).toBe(true);
|
|
158
|
-
});
|
|
159
|
-
});
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { FogAgent } from "../src/agents/fog";
|
|
3
|
+
import { MessageBus } from "../src/core/bus";
|
|
4
|
+
import { ToolRegistry } from "../src/core/tool";
|
|
5
|
+
import { SkillRegistry } from "../src/core/skill";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Characterization tests for the agent chat/tool loop, driven by a scripted
|
|
9
|
+
* mock LLM (no network). These lock in the behavior of the ~275-line hot path
|
|
10
|
+
* (chatStreamImpl / llmLoop / tool execution / anti-loop guard) so it can be
|
|
11
|
+
* refactored safely (Phase 3) — and they guard against regressions like the
|
|
12
|
+
* first-message crash.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
interface Turn { content?: string; toolCalls?: { name: string; args?: any }[]; reasoning?: string }
|
|
16
|
+
|
|
17
|
+
class MockLLM {
|
|
18
|
+
calls = 0;
|
|
19
|
+
constructor(private turns: Turn[]) {}
|
|
20
|
+
private turn(): Turn { const t = this.turns[Math.min(this.calls, this.turns.length - 1)]; this.calls++; return t || {}; }
|
|
21
|
+
private toolCallObjs(t: Turn) {
|
|
22
|
+
return (t.toolCalls || []).map((tc, i) => ({
|
|
23
|
+
id: `call_${this.calls}_${i}`, type: "function",
|
|
24
|
+
function: { name: tc.name, arguments: JSON.stringify(tc.args || {}) },
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
async *streamWithTools(): AsyncGenerator<any> {
|
|
28
|
+
const t = this.turn();
|
|
29
|
+
if (t.reasoning) yield { type: "reasoning", text: t.reasoning };
|
|
30
|
+
if (t.content) yield { type: "content", text: t.content };
|
|
31
|
+
for (const tc of this.toolCallObjs(t)) yield { type: "tool_call", toolCall: tc };
|
|
32
|
+
yield { type: "done", usage: { promptTokens: 1, completionTokens: 1 } };
|
|
33
|
+
}
|
|
34
|
+
async complete(): Promise<any> {
|
|
35
|
+
const t = this.turn();
|
|
36
|
+
return { content: t.content || "", toolCalls: this.toolCallObjs(t), model: "mock", usage: { promptTokens: 1, completionTokens: 1 }, cost: 0, truncated: false };
|
|
37
|
+
}
|
|
38
|
+
getTotalCost() { return 0; }
|
|
39
|
+
getModel() { return "mock"; }
|
|
40
|
+
setLogger() { /* noop */ }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeAgent(turns: Turn[], tools: { name: string; handler: (a: any) => Promise<string> }[] = []) {
|
|
44
|
+
const reg = new ToolRegistry();
|
|
45
|
+
for (const t of tools) reg.register({ name: t.name, description: t.name, handler: t.handler });
|
|
46
|
+
const config = { agents: { fog: {} }, llm: { language: "zh" }, memory: { shortTermLimit: 100, dbPath: "/tmp/sky-test" } };
|
|
47
|
+
const agent = new FogAgent(config as any, new MockLLM(turns) as any, new MessageBus(), reg, new SkillRegistry());
|
|
48
|
+
return agent;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function collect(gen: AsyncGenerator<any>, cap = 500): Promise<any[]> {
|
|
52
|
+
const evs: any[] = [];
|
|
53
|
+
for await (const ev of gen) { evs.push(ev); if (evs.length > cap) break; }
|
|
54
|
+
return evs;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("agent · chat loop (mock LLM)", () => {
|
|
58
|
+
it("streams a simple reply and records both messages", async () => {
|
|
59
|
+
const agent = makeAgent([{ content: "你好,我是雾。" }]);
|
|
60
|
+
const evs = await collect(agent.chatStream("你好"));
|
|
61
|
+
const text = evs.filter((e) => e.type === "content").map((e) => e.text).join("");
|
|
62
|
+
expect(text).toContain("你好,我是雾。");
|
|
63
|
+
|
|
64
|
+
const msgs = agent.memory.getMessages();
|
|
65
|
+
expect(msgs[0]).toMatchObject({ role: "user", content: "你好" }); // regression: user msg present
|
|
66
|
+
expect(msgs.some((m) => m.role === "assistant" && String(m.content).includes("雾"))).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("blocking chat() returns the reply", async () => {
|
|
70
|
+
const agent = makeAgent([{ content: "答案是 42" }]);
|
|
71
|
+
const reply = await agent.chat("问题?");
|
|
72
|
+
expect(reply).toContain("42");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("streams reasoning before content", async () => {
|
|
76
|
+
const agent = makeAgent([{ reasoning: "先想一下…", content: "结论。" }]);
|
|
77
|
+
const evs = await collect(agent.chatStream("?"));
|
|
78
|
+
expect(evs.some((e) => e.type === "reasoning")).toBe(true);
|
|
79
|
+
expect(evs.filter((e) => e.type === "content").map((e) => e.text).join("")).toContain("结论。");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("executes a tool call then produces the final answer", async () => {
|
|
83
|
+
let received: any = null;
|
|
84
|
+
const agent = makeAgent(
|
|
85
|
+
[{ toolCalls: [{ name: "echo", args: { text: "hi" } }] }, { content: "工具回显: hi" }],
|
|
86
|
+
[{ name: "echo", handler: async (a) => { received = a; return `echo:${a.text}`; } }],
|
|
87
|
+
);
|
|
88
|
+
const evs = await collect(agent.chatStream("用 echo 工具"));
|
|
89
|
+
expect(received).toEqual({ text: "hi" }); // tool actually ran with parsed args
|
|
90
|
+
expect(evs.some((e) => e.type === "tool_status" && e.tool_name === "echo")).toBe(true);
|
|
91
|
+
expect(evs.some((e) => e.type === "tool_done" && e.tool_name === "echo" && e.success)).toBe(true);
|
|
92
|
+
expect(evs.filter((e) => e.type === "content").map((e) => e.text).join("")).toContain("工具回显");
|
|
93
|
+
// tool result recorded to memory
|
|
94
|
+
expect(agent.memory.getMessages().some((m) => m.role === "tool" && String(m.content).includes("echo:hi"))).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("terminates (does not loop forever) when the model repeats the same tool call", async () => {
|
|
98
|
+
// Script the same tool call far beyond the round cap; the anti-loop guard must stop it.
|
|
99
|
+
const turns: Turn[] = Array.from({ length: 60 }, () => ({ toolCalls: [{ name: "spin", args: { n: 1 } }] }));
|
|
100
|
+
const llm = new MockLLM(turns);
|
|
101
|
+
const reg = new ToolRegistry();
|
|
102
|
+
reg.register({ name: "spin", description: "spin", handler: async () => "still spinning" });
|
|
103
|
+
const config = { agents: { fog: {} }, llm: {}, memory: { shortTermLimit: 200, dbPath: "/tmp/sky-test" } };
|
|
104
|
+
const agent = new FogAgent(config as any, llm as any, new MessageBus(), reg, new SkillRegistry());
|
|
105
|
+
|
|
106
|
+
const evs = await collect(agent.chatStream("loop please"), 2000);
|
|
107
|
+
// It must finish (the generator returns), not hang, and not call the model unboundedly.
|
|
108
|
+
expect(evs.some((e) => e.type === "done")).toBe(true);
|
|
109
|
+
expect(llm.calls).toBeLessThan(50); // bounded by the round cap / guard, not 60+
|
|
110
|
+
}, 15000);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("agent · context window (catalog-aware compaction)", () => {
|
|
114
|
+
it("contextUsage uses the active model's real window from the catalog", () => {
|
|
115
|
+
const agent = makeAgent([{ content: "x" }]);
|
|
116
|
+
(agent as any).config.agents.fog.model = "mixtral-8x7b"; // 32768
|
|
117
|
+
expect(agent.contextUsage().maxTokens).toBe(32768);
|
|
118
|
+
expect(agent.contextUsage().model).toBe("mixtral-8x7b");
|
|
119
|
+
(agent as any).config.agents.fog.model = "gemini-2.5-pro"; // 1048576
|
|
120
|
+
expect(agent.contextUsage().maxTokens).toBe(1048576);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("auto-compaction triggers for a small window but not a large one (same history)", () => {
|
|
124
|
+
const agent = makeAgent([{ content: "x" }]);
|
|
125
|
+
const big = "字".repeat(800); // CJK ~2 tokens/char
|
|
126
|
+
for (let i = 0; i < 20; i++) agent.memory.addMessage("user", big); // ~32k tokens
|
|
127
|
+
|
|
128
|
+
(agent as any).config.agents.fog.model = "mixtral-8x7b"; // 32768 window -> over budget
|
|
129
|
+
expect((agent as any).shouldAutoCompact()).toBe(true);
|
|
130
|
+
|
|
131
|
+
(agent as any).config.agents.fog.model = "gemini-2.5-pro"; // 1M window -> fine
|
|
132
|
+
expect((agent as any).shouldAutoCompact()).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("agent · interrupt (Ctrl-C)", () => {
|
|
137
|
+
it("stops between rounds on abort and preserves partial output", async () => {
|
|
138
|
+
const controller = new AbortController();
|
|
139
|
+
// Round 1 streams some content + a tool call; the tool aborts the signal.
|
|
140
|
+
// Round 2 must never run.
|
|
141
|
+
const turns: Turn[] = [
|
|
142
|
+
{ content: "部分内容已生成…", toolCalls: [{ name: "spin", args: {} }] },
|
|
143
|
+
{ content: "不应出现的第二轮" },
|
|
144
|
+
];
|
|
145
|
+
const reg = new ToolRegistry();
|
|
146
|
+
reg.register({ name: "spin", description: "spin", handler: async () => { controller.abort(); return "spun"; } });
|
|
147
|
+
const config = { agents: { fog: {} }, llm: {}, memory: { shortTermLimit: 200, dbPath: "/tmp/sky-test" } };
|
|
148
|
+
const agent = new FogAgent(config as any, new MockLLM(turns) as any, new MessageBus(), reg, new SkillRegistry());
|
|
149
|
+
|
|
150
|
+
const evs = await collect(agent.chatStream("go", controller.signal));
|
|
151
|
+
const text = evs.filter((e) => e.type === "content").map((e) => e.text).join("");
|
|
152
|
+
|
|
153
|
+
expect(evs.some((e) => e.type === "interrupted")).toBe(true);
|
|
154
|
+
expect(text).toContain("部分内容已生成"); // partial output kept
|
|
155
|
+
expect(text).not.toContain("第二轮"); // round 2 never streamed
|
|
156
|
+
// partial assistant content is in memory
|
|
157
|
+
expect(agent.memory.getMessages().some((m) => m.role === "assistant" && String(m.content).includes("部分内容"))).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -1,48 +1,48 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { parseExtractedFacts, synthesizeDelegationSummary } from "../src/core/agent_helpers";
|
|
3
|
-
|
|
4
|
-
describe("parseExtractedFacts", () => {
|
|
5
|
-
it("parses a raw JSON array", () => {
|
|
6
|
-
const out = parseExtractedFacts('[{"key":"lang","value":"ts"}]');
|
|
7
|
-
expect(out).toEqual([{ key: "lang", value: "ts" }]);
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
it("parses JSON inside a markdown fence", () => {
|
|
11
|
-
const out = parseExtractedFacts('```json\n[{"key":"a","value":1}]\n```');
|
|
12
|
-
expect(out).toEqual([{ key: "a", value: 1 }]);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("extracts a JSON array embedded in prose", () => {
|
|
16
|
-
const out = parseExtractedFacts('Sure! Here are the facts: [{"key":"x","value":true}] done.');
|
|
17
|
-
expect(out).toEqual([{ key: "x", value: true }]);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("returns [] for empty or non-JSON input", () => {
|
|
21
|
-
expect(parseExtractedFacts("")).toEqual([]);
|
|
22
|
-
expect(parseExtractedFacts("no json here")).toEqual([]);
|
|
23
|
-
expect(parseExtractedFacts(" ")).toEqual([]);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("returns [] when JSON is an object, not an array", () => {
|
|
27
|
-
expect(parseExtractedFacts('{"key":"x"}')).toEqual([]);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("filters out non-object array members", () => {
|
|
31
|
-
const out = parseExtractedFacts('[{"key":"a","value":1}, "junk", 42, null]');
|
|
32
|
-
// null is typeof 'object' in JS, so it survives the filter — assert the real members
|
|
33
|
-
expect(out).toContainEqual({ key: "a", value: 1 });
|
|
34
|
-
expect(out).not.toContain("junk");
|
|
35
|
-
expect(out).not.toContain(42);
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
describe("synthesizeDelegationSummary", () => {
|
|
40
|
-
it("summarizes successes and failures", () => {
|
|
41
|
-
expect(synthesizeDelegationSummary([["fog", true], ["rain", false]])).toBe(
|
|
42
|
-
"[Delegated: fog | Failed: rain]"
|
|
43
|
-
);
|
|
44
|
-
});
|
|
45
|
-
it("returns empty string when no delegations", () => {
|
|
46
|
-
expect(synthesizeDelegationSummary([])).toBe("");
|
|
47
|
-
});
|
|
48
|
-
});
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseExtractedFacts, synthesizeDelegationSummary } from "../src/core/agent_helpers";
|
|
3
|
+
|
|
4
|
+
describe("parseExtractedFacts", () => {
|
|
5
|
+
it("parses a raw JSON array", () => {
|
|
6
|
+
const out = parseExtractedFacts('[{"key":"lang","value":"ts"}]');
|
|
7
|
+
expect(out).toEqual([{ key: "lang", value: "ts" }]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("parses JSON inside a markdown fence", () => {
|
|
11
|
+
const out = parseExtractedFacts('```json\n[{"key":"a","value":1}]\n```');
|
|
12
|
+
expect(out).toEqual([{ key: "a", value: 1 }]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("extracts a JSON array embedded in prose", () => {
|
|
16
|
+
const out = parseExtractedFacts('Sure! Here are the facts: [{"key":"x","value":true}] done.');
|
|
17
|
+
expect(out).toEqual([{ key: "x", value: true }]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("returns [] for empty or non-JSON input", () => {
|
|
21
|
+
expect(parseExtractedFacts("")).toEqual([]);
|
|
22
|
+
expect(parseExtractedFacts("no json here")).toEqual([]);
|
|
23
|
+
expect(parseExtractedFacts(" ")).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns [] when JSON is an object, not an array", () => {
|
|
27
|
+
expect(parseExtractedFacts('{"key":"x"}')).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("filters out non-object array members", () => {
|
|
31
|
+
const out = parseExtractedFacts('[{"key":"a","value":1}, "junk", 42, null]');
|
|
32
|
+
// null is typeof 'object' in JS, so it survives the filter — assert the real members
|
|
33
|
+
expect(out).toContainEqual({ key: "a", value: 1 });
|
|
34
|
+
expect(out).not.toContain("junk");
|
|
35
|
+
expect(out).not.toContain(42);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("synthesizeDelegationSummary", () => {
|
|
40
|
+
it("summarizes successes and failures", () => {
|
|
41
|
+
expect(synthesizeDelegationSummary([["fog", true], ["rain", false]])).toBe(
|
|
42
|
+
"[Delegated: fog | Failed: rain]"
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
it("returns empty string when no delegations", () => {
|
|
46
|
+
expect(synthesizeDelegationSummary([])).toBe("");
|
|
47
|
+
});
|
|
48
|
+
});
|