oh-pi 0.1.71 → 0.1.73
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/package.json +1 -1
- package/pi-package/extensions/ant-colony/nest.test.ts +130 -0
- package/pi-package/extensions/ant-colony/nest.ts +12 -0
- package/pi-package/extensions/ant-colony/prompts.test.ts +42 -0
- package/pi-package/extensions/ant-colony/prompts.ts +7 -1
- package/pi-package/extensions/ant-colony/queen.test.ts +193 -0
- package/pi-package/extensions/ant-colony/queen.ts +149 -12
- package/pi-package/extensions/ant-colony/spawner.ts +14 -1
package/package.json
CHANGED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import { Nest } from "./nest.js";
|
|
6
|
+
import type { ColonyState, Pheromone } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const mkState = (overrides: Partial<ColonyState> = {}): ColonyState => ({
|
|
9
|
+
id: "test-colony", goal: "test", status: "working",
|
|
10
|
+
tasks: [], ants: [], pheromones: [],
|
|
11
|
+
concurrency: { current: 2, min: 1, max: 4, optimal: 3, history: [] },
|
|
12
|
+
metrics: { tasksTotal: 0, tasksDone: 0, tasksFailed: 0, antsSpawned: 0, totalCost: 0, totalTokens: 0, startTime: Date.now(), throughputHistory: [] },
|
|
13
|
+
maxCost: null, modelOverrides: {}, createdAt: Date.now(), finishedAt: null,
|
|
14
|
+
...overrides,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const mkPheromone = (overrides: Partial<Pheromone> = {}): Pheromone => ({
|
|
18
|
+
id: `p-${Math.random().toString(36).slice(2)}`, type: "warning", antId: "ant-1", antCaste: "worker",
|
|
19
|
+
taskId: "t-1", content: "test", files: ["a.ts"], strength: 1.0, createdAt: Date.now(),
|
|
20
|
+
...overrides,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
let tmpDir: string;
|
|
24
|
+
let nest: Nest;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nest-test-"));
|
|
28
|
+
nest = new Nest(tmpDir, "test-colony");
|
|
29
|
+
nest.init(mkState());
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("getStateLight", () => {
|
|
37
|
+
it("returns state without triggering pheromone read", () => {
|
|
38
|
+
nest.dropPheromone(mkPheromone());
|
|
39
|
+
const light = nest.getStateLight();
|
|
40
|
+
expect(light.id).toBe("test-colony");
|
|
41
|
+
expect(light.tasks).toEqual([]);
|
|
42
|
+
// pheromones should not be populated by getStateLight
|
|
43
|
+
// (it returns stateCache which has empty pheromones from init)
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("includes tasks from cache", () => {
|
|
47
|
+
nest.writeTask({
|
|
48
|
+
id: "t-1", parentId: null, title: "Test", description: "desc",
|
|
49
|
+
caste: "worker", status: "pending", priority: 3, files: [],
|
|
50
|
+
claimedBy: null, result: null, error: null, spawnedTasks: [],
|
|
51
|
+
createdAt: Date.now(), startedAt: null, finishedAt: null,
|
|
52
|
+
});
|
|
53
|
+
const light = nest.getStateLight();
|
|
54
|
+
expect(light.tasks).toHaveLength(1);
|
|
55
|
+
expect(light.tasks[0].id).toBe("t-1");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("countWarnings", () => {
|
|
60
|
+
it("returns 0 when no pheromones", () => {
|
|
61
|
+
expect(nest.countWarnings(["a.ts"])).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("counts warning pheromones for matching files", () => {
|
|
65
|
+
nest.dropPheromone(mkPheromone({ type: "warning", files: ["a.ts"] }));
|
|
66
|
+
nest.dropPheromone(mkPheromone({ type: "warning", files: ["a.ts"] }));
|
|
67
|
+
nest.dropPheromone(mkPheromone({ type: "completion", files: ["a.ts"] }));
|
|
68
|
+
expect(nest.countWarnings(["a.ts"])).toBe(2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("counts repellent pheromones", () => {
|
|
72
|
+
nest.dropPheromone(mkPheromone({ type: "repellent", files: ["b.ts"] }));
|
|
73
|
+
expect(nest.countWarnings(["b.ts"])).toBe(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns 0 for unrelated files", () => {
|
|
77
|
+
nest.dropPheromone(mkPheromone({ type: "warning", files: ["a.ts"] }));
|
|
78
|
+
expect(nest.countWarnings(["c.ts"])).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("pheromone dirty flag", () => {
|
|
83
|
+
it("rebuilds index after dropPheromone", () => {
|
|
84
|
+
nest.dropPheromone(mkPheromone({ type: "discovery", files: ["x.ts"] }));
|
|
85
|
+
const pheromones = nest.getAllPheromones();
|
|
86
|
+
expect(pheromones.length).toBe(1);
|
|
87
|
+
expect(pheromones[0].type).toBe("discovery");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("does not rebuild index when nothing changed", () => {
|
|
91
|
+
nest.dropPheromone(mkPheromone({ files: ["x.ts"] }));
|
|
92
|
+
nest.getAllPheromones(); // builds index, clears dirty
|
|
93
|
+
// Second call should use cached index (no new data, no GC)
|
|
94
|
+
const p2 = nest.getAllPheromones();
|
|
95
|
+
expect(p2.length).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("claimNextTask", () => {
|
|
100
|
+
it("claims highest scored pending task", () => {
|
|
101
|
+
nest.writeTask({
|
|
102
|
+
id: "t-low", parentId: null, title: "Low", description: "",
|
|
103
|
+
caste: "worker", status: "pending", priority: 5, files: [],
|
|
104
|
+
claimedBy: null, result: null, error: null, spawnedTasks: [],
|
|
105
|
+
createdAt: Date.now(), startedAt: null, finishedAt: null,
|
|
106
|
+
});
|
|
107
|
+
nest.writeTask({
|
|
108
|
+
id: "t-high", parentId: null, title: "High", description: "",
|
|
109
|
+
caste: "worker", status: "pending", priority: 1, files: [],
|
|
110
|
+
claimedBy: null, result: null, error: null, spawnedTasks: [],
|
|
111
|
+
createdAt: Date.now(), startedAt: null, finishedAt: null,
|
|
112
|
+
});
|
|
113
|
+
const claimed = nest.claimNextTask("worker", "ant-1");
|
|
114
|
+
expect(claimed).not.toBeNull();
|
|
115
|
+
expect(claimed!.id).toBe("t-high");
|
|
116
|
+
expect(claimed!.status).toBe("claimed");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns null when no pending tasks", () => {
|
|
120
|
+
expect(nest.claimNextTask("worker", "ant-1")).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("withStateLock spin", () => {
|
|
125
|
+
it("updateState works under normal conditions", () => {
|
|
126
|
+
nest.updateState({ status: "reviewing" });
|
|
127
|
+
const state = nest.getStateLight();
|
|
128
|
+
expect(state.status).toBe("reviewing");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -228,6 +228,18 @@ export class Nest {
|
|
|
228
228
|
return this.pheromoneCache;
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/** 统计指定文件相关的 warning/repellent 信息素数量 */
|
|
232
|
+
countWarnings(files: string[]): number {
|
|
233
|
+
this.getAllPheromones();
|
|
234
|
+
let count = 0;
|
|
235
|
+
for (const f of files) {
|
|
236
|
+
for (const p of this.pheromoneByFile.get(f) ?? []) {
|
|
237
|
+
if (p.type === "warning" || p.type === "repellent") count++;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return count;
|
|
241
|
+
}
|
|
242
|
+
|
|
231
243
|
/** 读取与特定文件相关的信息素摘要 */
|
|
232
244
|
getPheromoneContext(files: string[], limit = 20): string {
|
|
233
245
|
const relevant = this.getAllPheromones()
|
|
@@ -54,4 +54,46 @@ describe("buildPrompt", () => {
|
|
|
54
54
|
const r = buildPrompt(mkTask({ description: "修复登录问题" }), "", "System");
|
|
55
55
|
expect(r).toContain("Chinese");
|
|
56
56
|
});
|
|
57
|
+
|
|
58
|
+
// Bio 3: 串联觅食 — tandem context
|
|
59
|
+
it("includes tandem parent result when provided", () => {
|
|
60
|
+
const r = buildPrompt(mkTask(), "", "System", 10, { parentResult: "Parent found auth module at src/auth.ts" });
|
|
61
|
+
expect(r).toContain("Tandem Context");
|
|
62
|
+
expect(r).toContain("Parent found auth module");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("includes tandem prior error when provided", () => {
|
|
66
|
+
const r = buildPrompt(mkTask(), "", "System", 10, { priorError: "TypeError: cannot read property" });
|
|
67
|
+
expect(r).toContain("Prior Attempt Failed");
|
|
68
|
+
expect(r).toContain("TypeError: cannot read property");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("includes both tandem fields when provided", () => {
|
|
72
|
+
const r = buildPrompt(mkTask(), "", "System", 10, {
|
|
73
|
+
parentResult: "Scout found files",
|
|
74
|
+
priorError: "Build failed",
|
|
75
|
+
});
|
|
76
|
+
expect(r).toContain("Tandem Context");
|
|
77
|
+
expect(r).toContain("Prior Attempt Failed");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("omits tandem sections when not provided", () => {
|
|
81
|
+
const r = buildPrompt(mkTask(), "", "System", 10);
|
|
82
|
+
expect(r).not.toContain("Tandem Context");
|
|
83
|
+
expect(r).not.toContain("Prior Attempt Failed");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("omits tandem sections when empty object", () => {
|
|
87
|
+
const r = buildPrompt(mkTask(), "", "System", 10, {});
|
|
88
|
+
expect(r).not.toContain("Tandem Context");
|
|
89
|
+
expect(r).not.toContain("Prior Attempt Failed");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("truncates long parent result", () => {
|
|
93
|
+
const longResult = "x".repeat(5000);
|
|
94
|
+
const r = buildPrompt(mkTask(), "", "System", 10, { parentResult: longResult });
|
|
95
|
+
expect(r).toContain("Tandem Context");
|
|
96
|
+
// Should be truncated to 3000 chars
|
|
97
|
+
expect(r.length).toBeLessThan(longResult.length);
|
|
98
|
+
});
|
|
57
99
|
});
|
|
@@ -76,7 +76,7 @@ Output format (MUST follow exactly):
|
|
|
76
76
|
PASS or FAIL with summary.`,
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
-
export function buildPrompt(task: Task, pheromoneContext: string, castePrompt: string, maxTurns?: number): string {
|
|
79
|
+
export function buildPrompt(task: Task, pheromoneContext: string, castePrompt: string, maxTurns?: number, tandem?: { parentResult?: string; priorError?: string }): string {
|
|
80
80
|
let prompt = castePrompt + "\n\n";
|
|
81
81
|
if (maxTurns) {
|
|
82
82
|
prompt += `## ⚠️ Turn Limit\nYou have a MAXIMUM of ${maxTurns} turns. Plan accordingly — reserve your LAST turn to output the structured result format above. Do NOT waste turns on unnecessary exploration.\n\n`;
|
|
@@ -84,6 +84,12 @@ export function buildPrompt(task: Task, pheromoneContext: string, castePrompt: s
|
|
|
84
84
|
if (pheromoneContext) {
|
|
85
85
|
prompt += `## Colony Pheromone Trail (intelligence from other ants)\n${pheromoneContext}\n\n`;
|
|
86
86
|
}
|
|
87
|
+
if (tandem?.parentResult) {
|
|
88
|
+
prompt += `## Tandem Context (from parent task)\n${tandem.parentResult.slice(0, 3000)}\n\n`;
|
|
89
|
+
}
|
|
90
|
+
if (tandem?.priorError) {
|
|
91
|
+
prompt += `## ⚠️ Prior Attempt Failed\nA previous ant failed on this task. Learn from their mistake:\n${tandem.priorError.slice(0, 1500)}\n\n`;
|
|
92
|
+
}
|
|
87
93
|
prompt += `## Your Assignment\n**Task:** ${task.title}\n**Description:** ${task.description}\n`;
|
|
88
94
|
if (task.files.length > 0) {
|
|
89
95
|
prompt += `**Files scope:** ${task.files.join(", ")}\n`;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
|
|
6
|
+
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
|
7
|
+
AuthStorage: class {},
|
|
8
|
+
createAgentSession: vi.fn(),
|
|
9
|
+
createReadTool: vi.fn(), createBashTool: vi.fn(), createEditTool: vi.fn(),
|
|
10
|
+
createWriteTool: vi.fn(), createGrepTool: vi.fn(), createFindTool: vi.fn(),
|
|
11
|
+
createLsTool: vi.fn(), ModelRegistry: class {}, SessionManager: { inMemory: vi.fn() },
|
|
12
|
+
SettingsManager: { inMemory: vi.fn() }, createExtensionRuntime: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
vi.mock("@mariozechner/pi-ai", () => ({ getModel: vi.fn() }));
|
|
15
|
+
|
|
16
|
+
import { classifyError, quorumMergeTasks } from "./queen.js";
|
|
17
|
+
import { Nest } from "./nest.js";
|
|
18
|
+
import type { ColonyState, Task } from "./types.js";
|
|
19
|
+
|
|
20
|
+
// ═══ classifyError ═══
|
|
21
|
+
|
|
22
|
+
describe("classifyError", () => {
|
|
23
|
+
it("classifies TypeError", () => {
|
|
24
|
+
expect(classifyError("TypeError: cannot read property 'x'")).toBe("type_error");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("classifies TS errors", () => {
|
|
28
|
+
expect(classifyError("TS2345: Argument of type")).toBe("type_error");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("classifies permission errors", () => {
|
|
32
|
+
expect(classifyError("EACCES: permission denied")).toBe("permission");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("classifies 401", () => {
|
|
36
|
+
expect(classifyError("Error: 401 Unauthorized")).toBe("permission");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("classifies timeout", () => {
|
|
40
|
+
expect(classifyError("Error: Timeout after 5000ms")).toBe("timeout");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("classifies ETIMEDOUT", () => {
|
|
44
|
+
expect(classifyError("connect ETIMEDOUT 1.2.3.4")).toBe("timeout");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("classifies ENOENT", () => {
|
|
48
|
+
expect(classifyError("ENOENT: no such file or directory")).toBe("not_found");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("classifies Cannot find module", () => {
|
|
52
|
+
expect(classifyError("Cannot find module './foo'")).toBe("not_found");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("classifies syntax errors", () => {
|
|
56
|
+
expect(classifyError("SyntaxError: Unexpected token")).toBe("syntax");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("classifies rate limit", () => {
|
|
60
|
+
expect(classifyError("Error: 429 Too Many Requests")).toBe("rate_limit");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns unknown for unrecognized errors", () => {
|
|
64
|
+
expect(classifyError("Something completely different")).toBe("unknown");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("handles empty string", () => {
|
|
68
|
+
expect(classifyError("")).toBe("unknown");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ═══ quorumMergeTasks ═══
|
|
73
|
+
|
|
74
|
+
const mkState = (overrides: Partial<ColonyState> = {}): ColonyState => ({
|
|
75
|
+
id: "test-colony", goal: "test", status: "working",
|
|
76
|
+
tasks: [], ants: [], pheromones: [],
|
|
77
|
+
concurrency: { current: 2, min: 1, max: 4, optimal: 3, history: [] },
|
|
78
|
+
metrics: { tasksTotal: 0, tasksDone: 0, tasksFailed: 0, antsSpawned: 0, totalCost: 0, totalTokens: 0, startTime: Date.now(), throughputHistory: [] },
|
|
79
|
+
maxCost: null, modelOverrides: {}, createdAt: Date.now(), finishedAt: null,
|
|
80
|
+
...overrides,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const mkTask = (overrides: Partial<Task> = {}): Task => ({
|
|
84
|
+
id: `t-${Math.random().toString(36).slice(2)}`, parentId: null,
|
|
85
|
+
title: "Test task", description: "Do something",
|
|
86
|
+
caste: "worker", status: "pending", priority: 3, files: [],
|
|
87
|
+
claimedBy: null, result: null, error: null, spawnedTasks: [],
|
|
88
|
+
createdAt: Date.now(), startedAt: null, finishedAt: null,
|
|
89
|
+
...overrides,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
let tmpDir: string;
|
|
93
|
+
let nest: Nest;
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "queen-test-"));
|
|
97
|
+
nest = new Nest(tmpDir, "test-colony");
|
|
98
|
+
nest.init(mkState());
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("quorumMergeTasks", () => {
|
|
106
|
+
it("does nothing with 0-1 tasks", () => {
|
|
107
|
+
const t1 = mkTask({ files: ["a.ts"], priority: 3 });
|
|
108
|
+
nest.writeTask(t1);
|
|
109
|
+
quorumMergeTasks(nest);
|
|
110
|
+
const tasks = nest.getAllTasks().filter(t => t.status === "pending");
|
|
111
|
+
expect(tasks).toHaveLength(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("merges duplicate tasks with same files", () => {
|
|
115
|
+
const t1 = mkTask({ id: "t-1", title: "Fix auth", files: ["a.ts", "b.ts"], priority: 3 });
|
|
116
|
+
const t2 = mkTask({ id: "t-2", title: "Fix auth v2", files: ["a.ts", "b.ts"], priority: 3 });
|
|
117
|
+
nest.writeTask(t1);
|
|
118
|
+
nest.writeTask(t2);
|
|
119
|
+
quorumMergeTasks(nest);
|
|
120
|
+
const pending = nest.getAllTasks().filter(t => t.status === "pending");
|
|
121
|
+
const done = nest.getAllTasks().filter(t => t.status === "done");
|
|
122
|
+
expect(pending).toHaveLength(1);
|
|
123
|
+
expect(done).toHaveLength(1);
|
|
124
|
+
expect(done[0].result).toContain("quorum");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("boosts priority of merged task", () => {
|
|
128
|
+
const t1 = mkTask({ id: "t-1", files: ["x.ts"], priority: 3 });
|
|
129
|
+
const t2 = mkTask({ id: "t-2", files: ["x.ts"], priority: 3 });
|
|
130
|
+
nest.writeTask(t1);
|
|
131
|
+
nest.writeTask(t2);
|
|
132
|
+
quorumMergeTasks(nest);
|
|
133
|
+
const pending = nest.getAllTasks().filter(t => t.status === "pending");
|
|
134
|
+
expect(pending[0].priority).toBe(2); // boosted from 3 to 2
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("does not merge tasks with different files", () => {
|
|
138
|
+
const t1 = mkTask({ id: "t-1", files: ["a.ts"] });
|
|
139
|
+
const t2 = mkTask({ id: "t-2", files: ["b.ts"] });
|
|
140
|
+
nest.writeTask(t1);
|
|
141
|
+
nest.writeTask(t2);
|
|
142
|
+
quorumMergeTasks(nest);
|
|
143
|
+
const pending = nest.getAllTasks().filter(t => t.status === "pending");
|
|
144
|
+
expect(pending).toHaveLength(2);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("merges context from duplicate tasks", () => {
|
|
148
|
+
const t1 = mkTask({ id: "t-1", files: ["a.ts"], context: "context A" });
|
|
149
|
+
const t2 = mkTask({ id: "t-2", files: ["a.ts"], context: "context B" });
|
|
150
|
+
nest.writeTask(t1);
|
|
151
|
+
nest.writeTask(t2);
|
|
152
|
+
quorumMergeTasks(nest);
|
|
153
|
+
const pending = nest.getAllTasks().filter(t => t.status === "pending");
|
|
154
|
+
expect(pending[0].context).toContain("context A");
|
|
155
|
+
expect(pending[0].context).toContain("context B");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("handles three duplicates", () => {
|
|
159
|
+
const t1 = mkTask({ id: "t-1", files: ["a.ts"], priority: 4 });
|
|
160
|
+
const t2 = mkTask({ id: "t-2", files: ["a.ts"], priority: 4 });
|
|
161
|
+
const t3 = mkTask({ id: "t-3", files: ["a.ts"], priority: 4 });
|
|
162
|
+
nest.writeTask(t1);
|
|
163
|
+
nest.writeTask(t2);
|
|
164
|
+
nest.writeTask(t3);
|
|
165
|
+
quorumMergeTasks(nest);
|
|
166
|
+
const pending = nest.getAllTasks().filter(t => t.status === "pending");
|
|
167
|
+
const done = nest.getAllTasks().filter(t => t.status === "done");
|
|
168
|
+
expect(pending).toHaveLength(1);
|
|
169
|
+
expect(done).toHaveLength(2);
|
|
170
|
+
expect(pending[0].priority).toBe(3); // boosted from 4 to 3
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("skips non-pending tasks", () => {
|
|
174
|
+
const t1 = mkTask({ id: "t-1", files: ["a.ts"], status: "done" });
|
|
175
|
+
const t2 = mkTask({ id: "t-2", files: ["a.ts"], status: "pending" });
|
|
176
|
+
nest.writeTask(t1);
|
|
177
|
+
nest.writeTask(t2);
|
|
178
|
+
quorumMergeTasks(nest);
|
|
179
|
+
// Only 1 pending, so no merge
|
|
180
|
+
const pending = nest.getAllTasks().filter(t => t.status === "pending");
|
|
181
|
+
expect(pending).toHaveLength(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("does not boost priority below 1", () => {
|
|
185
|
+
const t1 = mkTask({ id: "t-1", files: ["a.ts"], priority: 1 });
|
|
186
|
+
const t2 = mkTask({ id: "t-2", files: ["a.ts"], priority: 1 });
|
|
187
|
+
nest.writeTask(t1);
|
|
188
|
+
nest.writeTask(t2);
|
|
189
|
+
quorumMergeTasks(nest);
|
|
190
|
+
const pending = nest.getAllTasks().filter(t => t.status === "pending");
|
|
191
|
+
expect(pending[0].priority).toBe(1); // stays at 1
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -98,6 +98,44 @@ function childTaskFromParsed(
|
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Bio 5: 蚁群投票 — 合并多 Scout 产生的重复任务
|
|
103
|
+
* 相同文件集合的任务合并,被多 Scout 提及的任务 priority 提升
|
|
104
|
+
*/
|
|
105
|
+
export function quorumMergeTasks(nest: Nest): void {
|
|
106
|
+
const tasks = nest.getAllTasks().filter(t =>
|
|
107
|
+
(t.caste === "worker" || t.caste === "drone") && t.status === "pending"
|
|
108
|
+
);
|
|
109
|
+
if (tasks.length < 2) return;
|
|
110
|
+
|
|
111
|
+
// 按文件集合分组(排序后 join 作为 key)
|
|
112
|
+
const groups = new Map<string, Task[]>();
|
|
113
|
+
for (const t of tasks) {
|
|
114
|
+
const key = [...t.files].sort().join("|") || t.title;
|
|
115
|
+
const arr = groups.get(key) ?? [];
|
|
116
|
+
arr.push(t);
|
|
117
|
+
groups.set(key, arr);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const [, group] of groups) {
|
|
121
|
+
if (group.length < 2) continue;
|
|
122
|
+
// 保留第一个,删除重复的,合并 description
|
|
123
|
+
const keeper = group[0];
|
|
124
|
+
// Quorum 达成:被多 Scout 提及 → priority 提升
|
|
125
|
+
keeper.priority = Math.max(1, keeper.priority - 1) as 1 | 2 | 3 | 4 | 5;
|
|
126
|
+
// 合并其他任务的 context 到 keeper
|
|
127
|
+
for (let i = 1; i < group.length; i++) {
|
|
128
|
+
const dup = group[i];
|
|
129
|
+
if (dup.context && dup.context !== keeper.context) {
|
|
130
|
+
keeper.context = (keeper.context || "") + "\n\n--- Additional scout context ---\n" + dup.context;
|
|
131
|
+
}
|
|
132
|
+
// 标记重复任务为 done(已合并)
|
|
133
|
+
nest.updateTaskStatus(dup.id, "done", `Merged into ${keeper.id} (quorum)`);
|
|
134
|
+
}
|
|
135
|
+
nest.writeTask(keeper);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
101
139
|
function makeReviewTask(completedTasks: Task[]): Task {
|
|
102
140
|
const files = [...new Set(completedTasks.flatMap(t => t.files))];
|
|
103
141
|
return {
|
|
@@ -157,25 +195,49 @@ interface WaveOptions {
|
|
|
157
195
|
importGraph?: ImportGraph;
|
|
158
196
|
}
|
|
159
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Bio 6: 尸体清理 — 错误模式分类
|
|
200
|
+
*/
|
|
201
|
+
export function classifyError(errStr: string): string {
|
|
202
|
+
if (errStr.includes("TypeError") || errStr.includes("type") || errStr.includes("TS")) return "type_error";
|
|
203
|
+
if (errStr.includes("permission") || errStr.includes("401") || errStr.includes("EACCES")) return "permission";
|
|
204
|
+
if (errStr.includes("timeout") || errStr.includes("Timeout") || errStr.includes("ETIMEDOUT")) return "timeout";
|
|
205
|
+
if (errStr.includes("ENOENT") || errStr.includes("not found") || errStr.includes("Cannot find")) return "not_found";
|
|
206
|
+
if (errStr.includes("syntax") || errStr.includes("SyntaxError") || errStr.includes("Unexpected")) return "syntax";
|
|
207
|
+
if (errStr.includes("429") || errStr.includes("rate limit")) return "rate_limit";
|
|
208
|
+
return "unknown";
|
|
209
|
+
}
|
|
210
|
+
|
|
160
211
|
/**
|
|
161
212
|
* 并发执行一批蚂蚁,自适应调节并发度
|
|
162
213
|
*/
|
|
163
214
|
async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
164
215
|
const { nest, cwd, caste, signal, callbacks, currentModel, emitSignal } = opts;
|
|
165
216
|
const casteModel = opts.modelOverrides?.[caste] || currentModel;
|
|
166
|
-
const
|
|
217
|
+
const baseConfig = { ...DEFAULT_ANT_CONFIGS[caste], model: casteModel };
|
|
167
218
|
|
|
168
219
|
let backoffMs = 0; // 429 退避时间
|
|
169
220
|
let consecutiveRateLimits = 0; // 连续限流计数
|
|
170
221
|
const retryCount = new Map<string, number>(); // taskId → retry count
|
|
171
222
|
const MAX_RETRIES = 2;
|
|
172
223
|
|
|
224
|
+
// Bio 6: 尸体清理 — 错误模式追踪
|
|
225
|
+
const errorPatterns = new Map<string, { count: number; files: Set<string>; errors: string[] }>();
|
|
226
|
+
|
|
173
227
|
const runOne = async (): Promise<"done" | "empty" | "rate_limited" | "budget"> => {
|
|
174
228
|
// Budget 刹车:预算用完就不出发(drone 免费,不检查)
|
|
175
229
|
const state = nest.getStateLight();
|
|
176
230
|
if (state.maxCost != null && caste !== "drone") {
|
|
177
231
|
const spent = state.ants.reduce((s, a) => s + a.usage.cost, 0);
|
|
178
232
|
if (spent >= state.maxCost) return "budget";
|
|
233
|
+
|
|
234
|
+
// Bio 4: 巢穴温度 — 成本渐进调控
|
|
235
|
+
const temperature = spent / state.maxCost;
|
|
236
|
+
if (temperature > 0.9) {
|
|
237
|
+
// 紧急模式:只跑 priority 1 任务
|
|
238
|
+
const pending = state.tasks.filter(t => t.status === "pending" && t.caste === caste);
|
|
239
|
+
if (!pending.some(t => t.priority === 1)) return "budget";
|
|
240
|
+
}
|
|
179
241
|
}
|
|
180
242
|
|
|
181
243
|
const task = nest.claimNextTask(caste, "queen");
|
|
@@ -194,6 +256,14 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
194
256
|
const antAbort = new AbortController();
|
|
195
257
|
signal?.addEventListener("abort", () => antAbort.abort(), { once: true });
|
|
196
258
|
const antSignal = antAbort.signal;
|
|
259
|
+
// Bio 7: 年龄多态 — 前期保守,后期收敛
|
|
260
|
+
const progress = state.metrics.tasksTotal > 0 ? state.metrics.tasksDone / state.metrics.tasksTotal : 0;
|
|
261
|
+
const config = { ...baseConfig };
|
|
262
|
+
if (progress < 0.3) {
|
|
263
|
+
config.maxTurns = Math.max(baseConfig.maxTurns - 3, 5); // 前期保守
|
|
264
|
+
} else if (progress > 0.7) {
|
|
265
|
+
config.maxTurns = Math.max(baseConfig.maxTurns - 5, 5); // 后期收敛,只修复收尾
|
|
266
|
+
}
|
|
197
267
|
const antPromise = caste === "drone"
|
|
198
268
|
? runDrone(cwd, nest, task)
|
|
199
269
|
: spawnAnt(cwd, nest, task, config, antSignal, callbacks.onAntStream, opts.authStorage, opts.modelRegistry);
|
|
@@ -220,8 +290,11 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
220
290
|
}
|
|
221
291
|
|
|
222
292
|
// 蚂蚁产生的子任务加入巢穴(限制繁殖上限,防止任务膨胀)
|
|
293
|
+
// Bio 7: 年龄多态 — 后期限制子任务生成
|
|
294
|
+
const m = curState.metrics;
|
|
295
|
+
const colonyProgress = m.tasksTotal > 0 ? m.tasksDone / m.tasksTotal : 0;
|
|
223
296
|
const MAX_TOTAL_TASKS = 30;
|
|
224
|
-
const MAX_SUB_PER_TASK = 5;
|
|
297
|
+
const MAX_SUB_PER_TASK = colonyProgress > 0.7 ? 2 : 5; // 后期收敛
|
|
225
298
|
const accepted = result.newTasks.slice(0, MAX_SUB_PER_TASK);
|
|
226
299
|
for (const sub of accepted) {
|
|
227
300
|
if (nest.getAllTasks().length >= MAX_TOTAL_TASKS) break;
|
|
@@ -240,13 +313,14 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
240
313
|
nest.addSubTask(task.id, child);
|
|
241
314
|
}
|
|
242
315
|
|
|
243
|
-
// 路径强化:成功完成释放 completion
|
|
316
|
+
// 路径强化:成功完成释放 completion 信息素,强度与任务规模成正比(招募信号)
|
|
244
317
|
if (task.files.length > 0) {
|
|
318
|
+
const recruitStrength = Math.min(1.0, 0.5 + task.files.length * 0.1 + result.newTasks.length * 0.15);
|
|
245
319
|
nest.dropPheromone({
|
|
246
320
|
id: makePheromoneId(), type: "completion", antId: result.ant.id,
|
|
247
321
|
antCaste: caste, taskId: task.id,
|
|
248
322
|
content: `Success: ${task.title}`,
|
|
249
|
-
files: task.files, strength:
|
|
323
|
+
files: task.files, strength: recruitStrength, createdAt: Date.now(),
|
|
250
324
|
});
|
|
251
325
|
}
|
|
252
326
|
|
|
@@ -264,16 +338,49 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
264
338
|
retryCount.set(task.id, count + 1);
|
|
265
339
|
nest.updateTaskStatus(task.id, "pending");
|
|
266
340
|
} else {
|
|
267
|
-
// 负信息素:失败任务释放 warning
|
|
341
|
+
// 负信息素:失败任务释放 warning,强度与任务规模成正比
|
|
268
342
|
if (task.files.length > 0) {
|
|
343
|
+
const warnStrength = Math.min(1.0, 0.5 + task.files.length * 0.1);
|
|
269
344
|
nest.dropPheromone({
|
|
270
345
|
id: makePheromoneId(), type: "warning", antId: "queen",
|
|
271
346
|
antCaste: caste, taskId: task.id,
|
|
272
347
|
content: `Failed: ${task.title} — ${String(e).slice(0, 100)}`,
|
|
273
|
-
files: task.files, strength:
|
|
348
|
+
files: task.files, strength: warnStrength, createdAt: Date.now(),
|
|
274
349
|
});
|
|
275
350
|
}
|
|
276
351
|
nest.updateTaskStatus(task.id, "failed", undefined, String(e));
|
|
352
|
+
|
|
353
|
+
// Bio 6: 尸体清理 — 错误模式追踪 + 诊断任务
|
|
354
|
+
const pattern = classifyError(errStr);
|
|
355
|
+
const entry = errorPatterns.get(pattern) ?? { count: 0, files: new Set<string>(), errors: [] };
|
|
356
|
+
entry.count++;
|
|
357
|
+
for (const f of task.files) entry.files.add(f);
|
|
358
|
+
entry.errors.push(errStr.slice(0, 200));
|
|
359
|
+
errorPatterns.set(pattern, entry);
|
|
360
|
+
|
|
361
|
+
if (entry.count >= 2 && entry.files.size > 0) {
|
|
362
|
+
const affectedFiles = [...entry.files];
|
|
363
|
+
// 释放 repellent 信息素
|
|
364
|
+
nest.dropPheromone({
|
|
365
|
+
id: makePheromoneId(), type: "repellent", antId: "queen",
|
|
366
|
+
antCaste: caste, taskId: task.id,
|
|
367
|
+
content: `Recurring ${pattern} errors (${entry.count}x): ${entry.errors[0]?.slice(0, 80)}`,
|
|
368
|
+
files: affectedFiles, strength: 1.0, createdAt: Date.now(),
|
|
369
|
+
});
|
|
370
|
+
// 生成诊断任务(仅首次触发)
|
|
371
|
+
if (entry.count === 2 && nest.getAllTasks().length < 30) {
|
|
372
|
+
const diagTask: Task = {
|
|
373
|
+
id: makeTaskId(), parentId: null,
|
|
374
|
+
title: `Diagnose recurring ${pattern} errors`,
|
|
375
|
+
description: `Multiple ants failed with ${pattern} errors on these files:\n${affectedFiles.map(f => `- ${f}`).join("\n")}\n\nErrors:\n${entry.errors.map(e => `- ${e}`).join("\n")}\n\nInvestigate root cause and generate fix tasks.`,
|
|
376
|
+
caste: "scout", status: "pending", priority: 1,
|
|
377
|
+
files: affectedFiles, claimedBy: null, result: null, error: null,
|
|
378
|
+
spawnedTasks: [], createdAt: Date.now(), startedAt: null, finishedAt: null,
|
|
379
|
+
};
|
|
380
|
+
nest.writeTask(diagTask);
|
|
381
|
+
emitSignal("working", `Diagnosing recurring ${pattern} errors...`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
277
384
|
}
|
|
278
385
|
return "done";
|
|
279
386
|
}
|
|
@@ -422,11 +529,38 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
422
529
|
};
|
|
423
530
|
|
|
424
531
|
try {
|
|
425
|
-
// ═══ Phase 1:
|
|
426
|
-
|
|
427
|
-
|
|
532
|
+
// ═══ Phase 1: 侦察(Bio 5: 蚁群投票 — 复杂目标派多 Scout) ═══
|
|
533
|
+
const scoutCount = opts.goal.length > 500 ? 3 : opts.goal.length > 200 ? 2 : 1;
|
|
534
|
+
if (scoutCount > 1) {
|
|
535
|
+
// 多 Scout 并行:为每只 Scout 创建独立任务
|
|
536
|
+
for (let i = 1; i < scoutCount; i++) {
|
|
537
|
+
const extraScout: Task = {
|
|
538
|
+
id: makeTaskId(),
|
|
539
|
+
parentId: null,
|
|
540
|
+
title: `Scout ${i + 1}: explore codebase for goal`,
|
|
541
|
+
description: `Explore the codebase from a different angle and identify files, modules, and dependencies relevant to this goal:\n\n${opts.goal}\n\nFocus on areas other scouts might miss. Be thorough.`,
|
|
542
|
+
caste: "scout",
|
|
543
|
+
status: "pending",
|
|
544
|
+
priority: 1,
|
|
545
|
+
files: [],
|
|
546
|
+
claimedBy: null,
|
|
547
|
+
result: null,
|
|
548
|
+
error: null,
|
|
549
|
+
spawnedTasks: [],
|
|
550
|
+
createdAt: Date.now(),
|
|
551
|
+
startedAt: null,
|
|
552
|
+
finishedAt: null,
|
|
553
|
+
};
|
|
554
|
+
nest.writeTask(extraScout);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
callbacks.onPhase?.("scouting", `Dispatching ${scoutCount} scout ant(s) to explore codebase...`);
|
|
558
|
+
emitSignal("scouting", `${scoutCount} scouts exploring...`);
|
|
428
559
|
await runAntWave({ ...waveBase, caste: "scout" });
|
|
429
560
|
|
|
561
|
+
// Bio 5: 合并多 Scout 产生的重复任务
|
|
562
|
+
if (scoutCount > 1) quorumMergeTasks(nest);
|
|
563
|
+
|
|
430
564
|
let workerTasks = nest.getAllTasks().filter(t => (t.caste === "worker" || t.caste === "drone") && t.status === "pending");
|
|
431
565
|
|
|
432
566
|
// 只在完全没有 worker 任务时才重试一次
|
|
@@ -514,11 +648,14 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
514
648
|
}
|
|
515
649
|
|
|
516
650
|
// ═══ 持续探索:Worker 完成后检查是否有新发现,有则再派 Scout ═══
|
|
651
|
+
// Bio 4: 巢穴温度 — 超过 50% 预算禁止新 Scout 探索
|
|
517
652
|
const discoveries = nest.getAllPheromones().filter(p => p.type === "discovery");
|
|
518
653
|
const allDone = nest.getAllTasks().filter(t => t.status === "done");
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
654
|
+
const preExploreSpent = nest.getStateLight().ants.reduce((s, a) => s + a.usage.cost, 0);
|
|
655
|
+
const preExploreBudget = nest.getStateLight().maxCost ?? Infinity;
|
|
656
|
+
const costTemperature = preExploreSpent / preExploreBudget;
|
|
657
|
+
if (discoveries.length > allDone.length && costTemperature < 0.5) {
|
|
658
|
+
if (preExploreSpent < preExploreBudget) {
|
|
522
659
|
callbacks.onPhase?.("scouting", "Re-exploring based on new discoveries...");
|
|
523
660
|
emitSignal("scouting", "Re-exploring...");
|
|
524
661
|
await runAntWave({ ...waveBase, caste: "scout" });
|
|
@@ -181,9 +181,22 @@ export async function spawnAnt(
|
|
|
181
181
|
nest.updateAnt(ant);
|
|
182
182
|
nest.updateTaskStatus(task.id, "active");
|
|
183
183
|
|
|
184
|
+
// Bio 2: 任务难度感知 — 动态 maxTurns
|
|
185
|
+
const warnings = nest.countWarnings(task.files);
|
|
186
|
+
const difficultyTurns = Math.min(25, (antConfig.maxTurns || 15) + task.files.length + warnings * 2);
|
|
187
|
+
const effectiveMaxTurns = antConfig.caste === "drone" ? 1 : difficultyTurns;
|
|
188
|
+
|
|
189
|
+
// Bio 3: 串联觅食 — 继承父任务 result 和失败前任 error
|
|
190
|
+
const tandem: { parentResult?: string; priorError?: string } = {};
|
|
191
|
+
if (task.parentId) {
|
|
192
|
+
const parent = nest.getTask(task.parentId);
|
|
193
|
+
if (parent?.result) tandem.parentResult = parent.result;
|
|
194
|
+
}
|
|
195
|
+
if (task.error) tandem.priorError = task.error;
|
|
196
|
+
|
|
184
197
|
const pheromoneCtx = nest.getPheromoneContext(task.files);
|
|
185
198
|
const castePrompt = CASTE_PROMPTS[antConfig.caste];
|
|
186
|
-
const systemPrompt = buildPrompt(task, pheromoneCtx, castePrompt,
|
|
199
|
+
const systemPrompt = buildPrompt(task, pheromoneCtx, castePrompt, effectiveMaxTurns, tandem);
|
|
187
200
|
|
|
188
201
|
const auth = authStorage ?? new AuthStorage();
|
|
189
202
|
const registry = modelRegistry ?? new ModelRegistry(auth);
|