akemon 0.2.24 → 0.2.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/context.js +11 -6
- package/dist/context.test.js +90 -0
- package/dist/engine-peripheral.js +96 -21
- package/dist/engine-queue.js +49 -1
- package/dist/engine-queue.test.js +99 -0
- package/dist/engine-routing.js +52 -0
- package/dist/engine-routing.test.js +122 -0
- package/dist/mcp-server.js +18 -23
- package/dist/memory-module.js +2 -0
- package/dist/metrics.js +30 -0
- package/dist/orphan-scan.js +79 -0
- package/dist/orphan-scan.test.js +81 -0
- package/dist/reflection-module.integration.test.js +180 -0
- package/dist/reflection-module.js +26 -28
- package/dist/reflection-module.test.js +66 -0
- package/dist/relay-client.js +17 -1
- package/dist/role-module.js +2 -2
- package/dist/role-module.test.js +208 -0
- package/dist/script-module.js +1 -0
- package/dist/server.js +28 -4
- package/dist/task-helpers.js +26 -0
- package/dist/task-helpers.test.js +88 -0
- package/dist/task-module.js +52 -31
- package/package.json +3 -2
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { parseRole, parseProduct, resolveRoles, resolveProduct, buildRoleContext, } from "./role-module.js";
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
function makeRole(name, triggers = [], extras = {}) {
|
|
11
|
+
return { name, description: "", triggers, include: [], exclude: [], customRules: "", raw: "", ...extras };
|
|
12
|
+
}
|
|
13
|
+
function makeProduct(name, extras = {}) {
|
|
14
|
+
return { name, playbook: "", productIds: [], raw: "", ...extras };
|
|
15
|
+
}
|
|
16
|
+
function makePlaybook(name, raw = "") {
|
|
17
|
+
return { name, raw };
|
|
18
|
+
}
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Pure parsing — parseRole
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
describe("parseRole — pure parsing", () => {
|
|
23
|
+
it("extracts description, triggers, include, exclude from standard role md", () => {
|
|
24
|
+
const raw = `# Sales Rep
|
|
25
|
+
|
|
26
|
+
Expert in closing deals and building client relationships.
|
|
27
|
+
|
|
28
|
+
## 激活
|
|
29
|
+
- trigger:order
|
|
30
|
+
- trigger:chat:public
|
|
31
|
+
|
|
32
|
+
## 上下文范围
|
|
33
|
+
include: buyer history, product catalog
|
|
34
|
+
exclude: owner notes, private diary
|
|
35
|
+
`;
|
|
36
|
+
const role = parseRole("sales-rep", raw);
|
|
37
|
+
assert.equal(role.name, "sales-rep");
|
|
38
|
+
assert.equal(role.description, "Expert in closing deals and building client relationships.");
|
|
39
|
+
assert.deepEqual(role.triggers, ["trigger:order", "trigger:chat:public"]);
|
|
40
|
+
assert.deepEqual(role.include, ["buyer history", "product catalog"]);
|
|
41
|
+
assert.deepEqual(role.exclude, ["owner notes", "private diary"]);
|
|
42
|
+
});
|
|
43
|
+
it("returns empty arrays for include/exclude/triggers when sections are missing", () => {
|
|
44
|
+
const raw = `# Simple Role
|
|
45
|
+
|
|
46
|
+
A minimal role with no activation or context sections.
|
|
47
|
+
`;
|
|
48
|
+
const role = parseRole("simple", raw);
|
|
49
|
+
assert.deepEqual(role.triggers, []);
|
|
50
|
+
assert.deepEqual(role.include, []);
|
|
51
|
+
assert.deepEqual(role.exclude, []);
|
|
52
|
+
assert.equal(role.description, "A minimal role with no activation or context sections.");
|
|
53
|
+
});
|
|
54
|
+
it("collects content outside 激活 and 上下文范围 into customRules", () => {
|
|
55
|
+
const raw = `# Worker
|
|
56
|
+
|
|
57
|
+
Efficient task executor.
|
|
58
|
+
|
|
59
|
+
## 激活
|
|
60
|
+
- trigger:agent_call
|
|
61
|
+
|
|
62
|
+
## Operating Principles
|
|
63
|
+
Always reply in the language the user writes in.
|
|
64
|
+
Be concise and accurate.
|
|
65
|
+
|
|
66
|
+
## Tone
|
|
67
|
+
Professional and neutral.
|
|
68
|
+
`;
|
|
69
|
+
const role = parseRole("worker", raw);
|
|
70
|
+
assert.ok(role.customRules.includes("Always reply in the language the user writes in."), "should include content from ## Operating Principles");
|
|
71
|
+
assert.ok(role.customRules.includes("Professional and neutral."), "should include content from ## Tone");
|
|
72
|
+
// triggers and standard sections should not bleed in
|
|
73
|
+
assert.deepEqual(role.triggers, ["trigger:agent_call"]);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Pure parsing — parseProduct
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
describe("parseProduct — pure parsing", () => {
|
|
80
|
+
it("extracts playbook and multiple productIds correctly", () => {
|
|
81
|
+
const raw = `# Widget Pro
|
|
82
|
+
|
|
83
|
+
## playbook
|
|
84
|
+
widget-strategy
|
|
85
|
+
|
|
86
|
+
## products
|
|
87
|
+
- p_abc123
|
|
88
|
+
- p_def456
|
|
89
|
+
- p_ghi789
|
|
90
|
+
`;
|
|
91
|
+
const product = parseProduct("widget-pro", raw);
|
|
92
|
+
assert.equal(product.name, "widget-pro");
|
|
93
|
+
assert.equal(product.playbook, "widget-strategy");
|
|
94
|
+
assert.deepEqual(product.productIds, ["p_abc123", "p_def456", "p_ghi789"]);
|
|
95
|
+
});
|
|
96
|
+
it("returns empty productIds when ## products section is absent", () => {
|
|
97
|
+
const raw = `# No Products
|
|
98
|
+
|
|
99
|
+
## playbook
|
|
100
|
+
some-playbook
|
|
101
|
+
`;
|
|
102
|
+
const product = parseProduct("no-products", raw);
|
|
103
|
+
assert.deepEqual(product.productIds, []);
|
|
104
|
+
assert.equal(product.playbook, "some-playbook");
|
|
105
|
+
});
|
|
106
|
+
it("returns empty string for playbook when ## playbook section is absent", () => {
|
|
107
|
+
const raw = `# No Playbook
|
|
108
|
+
|
|
109
|
+
## products
|
|
110
|
+
- p_xyz
|
|
111
|
+
`;
|
|
112
|
+
const product = parseProduct("no-playbook", raw);
|
|
113
|
+
assert.equal(product.playbook, "");
|
|
114
|
+
assert.deepEqual(product.productIds, ["p_xyz"]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Resolution — resolveRoles
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
describe("resolveRoles — resolution", () => {
|
|
121
|
+
it("partial trigger match: first match is primary, remaining are secondary", () => {
|
|
122
|
+
const roles = [
|
|
123
|
+
makeRole("general-chat", ["trigger:chat"]),
|
|
124
|
+
makeRole("owner-companion", ["trigger:chat:owner"]),
|
|
125
|
+
];
|
|
126
|
+
// "trigger:chat:owner" includes "trigger:chat" → both match
|
|
127
|
+
const { primary, secondary } = resolveRoles(roles, "trigger:chat:owner");
|
|
128
|
+
assert.equal(primary?.name, "general-chat");
|
|
129
|
+
assert.equal(secondary.length, 1);
|
|
130
|
+
assert.equal(secondary[0].name, "owner-companion");
|
|
131
|
+
});
|
|
132
|
+
it("returns primary=null and empty secondary when no trigger matches", () => {
|
|
133
|
+
const roles = [
|
|
134
|
+
makeRole("merchant", ["trigger:order"]),
|
|
135
|
+
makeRole("companion", ["trigger:chat:owner"]),
|
|
136
|
+
];
|
|
137
|
+
const { primary, secondary } = resolveRoles(roles, "trigger:nonexistent");
|
|
138
|
+
assert.equal(primary, null);
|
|
139
|
+
assert.deepEqual(secondary, []);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Resolution — resolveProduct
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
describe("resolveProduct — resolution", () => {
|
|
146
|
+
const products = [
|
|
147
|
+
makeProduct("Alpha Product", { productIds: ["p_alpha"], playbook: "alpha-pb" }),
|
|
148
|
+
makeProduct("Beta Service", { productIds: ["p_beta"], playbook: "beta-pb" }),
|
|
149
|
+
];
|
|
150
|
+
const playbooks = [
|
|
151
|
+
makePlaybook("alpha-pb", "# Alpha Playbook\nContent."),
|
|
152
|
+
makePlaybook("beta-pb", "# Beta Playbook\nContent."),
|
|
153
|
+
];
|
|
154
|
+
it("productId takes priority over productName when both are provided", () => {
|
|
155
|
+
// productId matches "Alpha Product", productName matches "Beta Service"
|
|
156
|
+
const result = resolveProduct(products, playbooks, "Beta Service", "p_alpha");
|
|
157
|
+
assert.ok(result !== null);
|
|
158
|
+
assert.equal(result.product.name, "Alpha Product");
|
|
159
|
+
});
|
|
160
|
+
it("fuzzy name match is case- and separator-insensitive", () => {
|
|
161
|
+
// "alpha_product" → normalized "alphaproduct"
|
|
162
|
+
// "Alpha Product" → normalized "alphaproduct" → exact match
|
|
163
|
+
const result = resolveProduct(products, playbooks, "alpha_product");
|
|
164
|
+
assert.ok(result !== null);
|
|
165
|
+
assert.equal(result.product.name, "Alpha Product");
|
|
166
|
+
});
|
|
167
|
+
it("returns null when neither productId nor productName matches", () => {
|
|
168
|
+
const result = resolveProduct(products, playbooks, "Completely Unknown");
|
|
169
|
+
assert.equal(result, null);
|
|
170
|
+
});
|
|
171
|
+
it("playbook lookup is case-insensitive", () => {
|
|
172
|
+
const prods = [makeProduct("my-widget", { playbook: "My-Playbook", productIds: [] })];
|
|
173
|
+
const pbs = [makePlaybook("my-playbook", "# PB Content")];
|
|
174
|
+
const result = resolveProduct(prods, pbs, "my-widget");
|
|
175
|
+
assert.ok(result !== null);
|
|
176
|
+
assert.ok(result.playbook !== null);
|
|
177
|
+
assert.equal(result.playbook?.name, "my-playbook");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// buildRoleContext — real fs with tmp dir
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
describe("buildRoleContext — fs integration", () => {
|
|
184
|
+
let tmpDir;
|
|
185
|
+
const agentName = "test-agent";
|
|
186
|
+
before(async () => {
|
|
187
|
+
tmpDir = await mkdtemp(join(tmpdir(), "akemon-test-"));
|
|
188
|
+
const selfBase = join(tmpDir, ".akemon", "agents", agentName, "self");
|
|
189
|
+
await mkdir(join(selfBase, "roles"), { recursive: true });
|
|
190
|
+
await mkdir(join(selfBase, "playbooks"), { recursive: true });
|
|
191
|
+
await mkdir(join(selfBase, "products"), { recursive: true });
|
|
192
|
+
await writeFile(join(selfBase, "roles", "worker.md"), `# 工人\n执行任务的专业角色。\n\n## 激活\n- trigger:order\n`);
|
|
193
|
+
await writeFile(join(selfBase, "playbooks", "strategy.md"), `# Strategy\n核心策略内容。\n`);
|
|
194
|
+
await writeFile(join(selfBase, "products", "widget.md"), `# Widget\n\n## playbook\nstrategy\n\n## products\n- p_1234\n`);
|
|
195
|
+
});
|
|
196
|
+
after(async () => {
|
|
197
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
198
|
+
});
|
|
199
|
+
it("matching trigger produces output with [Active role: ...]", async () => {
|
|
200
|
+
const result = await buildRoleContext(tmpDir, agentName, "trigger:order:001");
|
|
201
|
+
assert.ok(result.includes("[Active role: worker]"), `expected '[Active role: worker]' in:\n${result}`);
|
|
202
|
+
});
|
|
203
|
+
it("passing productName produces output with [Product: ...] and [Playbook: ...]", async () => {
|
|
204
|
+
const result = await buildRoleContext(tmpDir, agentName, "trigger:other", "widget");
|
|
205
|
+
assert.ok(result.includes("[Product: widget]"), `expected '[Product: widget]' in:\n${result}`);
|
|
206
|
+
assert.ok(result.includes("[Playbook: strategy]"), `expected '[Playbook: strategy]' in:\n${result}`);
|
|
207
|
+
});
|
|
208
|
+
});
|
package/dist/script-module.js
CHANGED
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2
2
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
3
3
|
import { exec } from "child_process";
|
|
4
|
+
import { scanAndKillOrphans } from "./orphan-scan.js";
|
|
4
5
|
import { createServer } from "http";
|
|
5
6
|
import { createInterface } from "readline";
|
|
6
7
|
import { initWorld, initBioState, initGuide, getSelfState, loadRecentCanvasEntries, initAgentConfig, loadAgentConfig, loadDirectives, loadTaskHistory, reviveAgent, } from "./self.js";
|
|
@@ -98,11 +99,11 @@ const LLM_ENGINES = LLM_ENGINES_SET;
|
|
|
98
99
|
// Engine execution — delegates to EnginePeripheral (V2 Step 3)
|
|
99
100
|
// ---------------------------------------------------------------------------
|
|
100
101
|
/** Unified engine runner — delegates to EnginePeripheral */
|
|
101
|
-
function runEngine(engine, model, allowAll, task, workdir, extraAllowedTools, relay, signal) {
|
|
102
|
+
function runEngine(engine, model, allowAll, task, workdir, extraAllowedTools, relay, signal, origin, routing) {
|
|
102
103
|
if (!_engineP) {
|
|
103
104
|
throw new Error("Engine peripheral not initialized");
|
|
104
105
|
}
|
|
105
|
-
const result = _engineP.runEngine(task, allowAll, extraAllowedTools, signal);
|
|
106
|
+
const result = _engineP.runEngine(task, allowAll, extraAllowedTools, signal, origin, routing);
|
|
106
107
|
// Sync trace back to module-level for reporting
|
|
107
108
|
result.then(() => { lastEngineTrace = _engineP.lastTrace; }).catch(() => { lastEngineTrace = _engineP.lastTrace; });
|
|
108
109
|
return result;
|
|
@@ -111,6 +112,8 @@ function runEngine(engine, model, allowAll, task, workdir, extraAllowedTools, re
|
|
|
111
112
|
// pullFromRelay → see relay-peripheral.ts
|
|
112
113
|
export async function serve(options) {
|
|
113
114
|
const workdir = options.workdir || process.cwd();
|
|
115
|
+
// Reclaim stale CLI children from any previous daemon crash before starting.
|
|
116
|
+
await scanAndKillOrphans();
|
|
114
117
|
// V2: Relay peripheral — unified relay API access
|
|
115
118
|
const relay = new RelayPeripheral({
|
|
116
119
|
httpUrl: options.relayHttp || "",
|
|
@@ -353,10 +356,25 @@ export async function serve(options) {
|
|
|
353
356
|
// hard timeout, and release. The slot release and subprocess kill are both
|
|
354
357
|
// driven by the same AbortController so a stuck engine can't hold the lock.
|
|
355
358
|
async function requestCompute(req) {
|
|
359
|
+
// Load latest agent config for routing table (fast file read, changes rarely)
|
|
360
|
+
const agentCfg = await loadAgentConfig(workdir, options.agentName);
|
|
361
|
+
const routing = agentCfg.engine_routing;
|
|
362
|
+
// user_manual tasks also hold a subscription-CLI semaphore to cap claude CLI concurrency
|
|
363
|
+
const isUserManual = req.origin === "user_manual";
|
|
364
|
+
if (isUserManual) {
|
|
365
|
+
try {
|
|
366
|
+
await engineQueue.acquireUserManualSlot(ENGINE_WAIT_DEADLINE_MS);
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
return { success: false, error: err.message || "User manual slot timeout" };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
356
372
|
try {
|
|
357
373
|
await engineQueue.acquire(req.priority, ENGINE_WAIT_DEADLINE_MS);
|
|
358
374
|
}
|
|
359
375
|
catch (err) {
|
|
376
|
+
if (isUserManual)
|
|
377
|
+
engineQueue.releaseUserManualSlot();
|
|
360
378
|
return { success: false, error: err.message || "Engine busy timeout" };
|
|
361
379
|
}
|
|
362
380
|
const prompt = req.context
|
|
@@ -365,7 +383,7 @@ export async function serve(options) {
|
|
|
365
383
|
const abortController = new AbortController();
|
|
366
384
|
const timer = setTimeout(() => abortController.abort(), ENGINE_EXEC_TIMEOUT_MS);
|
|
367
385
|
try {
|
|
368
|
-
const response = await runEngine(options.engine || "claude", options.model, options.allowAll, prompt, workdir, req.tools, req.relay, abortController.signal);
|
|
386
|
+
const response = await runEngine(options.engine || "claude", options.model, options.allowAll, prompt, workdir, req.tools, req.relay, abortController.signal, req.origin, routing);
|
|
369
387
|
emitTokenUsage(prompt.length, response.length);
|
|
370
388
|
return { success: true, response };
|
|
371
389
|
}
|
|
@@ -378,6 +396,8 @@ export async function serve(options) {
|
|
|
378
396
|
finally {
|
|
379
397
|
clearTimeout(timer);
|
|
380
398
|
engineQueue.release();
|
|
399
|
+
if (isUserManual)
|
|
400
|
+
engineQueue.releaseUserManualSlot();
|
|
381
401
|
}
|
|
382
402
|
}
|
|
383
403
|
const moduleCtx = {
|
|
@@ -480,9 +500,13 @@ export async function serve(options) {
|
|
|
480
500
|
engine: options.engine,
|
|
481
501
|
modules: loadedModules,
|
|
482
502
|
}));
|
|
483
|
-
// Graceful shutdown
|
|
503
|
+
// Graceful shutdown — kill engine children first, then stop modules.
|
|
504
|
+
// Engine children are killed via process group signal so any sub-forks
|
|
505
|
+
// (e.g. opencode's internal Node workers) are also terminated.
|
|
484
506
|
const shutdown = async () => {
|
|
485
507
|
console.log("[v2] Shutting down...");
|
|
508
|
+
if (_engineP)
|
|
509
|
+
_engineP.killAllChildren();
|
|
486
510
|
bus.emit(SIG.AGENT_STOP, sig(SIG.AGENT_STOP, {}));
|
|
487
511
|
for (const m of allModules) {
|
|
488
512
|
try {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Eisenhower sort: smaller quadrant number first */
|
|
2
|
+
export function sortByQuadrant(items) {
|
|
3
|
+
return [...items].sort((a, b) => a.quadrant - b.quadrant);
|
|
4
|
+
}
|
|
5
|
+
/** Dedup by type+id composite key, preserving first occurrence */
|
|
6
|
+
export function dedupeWorkItems(items) {
|
|
7
|
+
const seen = new Set();
|
|
8
|
+
return items.filter(it => {
|
|
9
|
+
const key = `${it.type}:${it.id}`;
|
|
10
|
+
if (seen.has(key))
|
|
11
|
+
return false;
|
|
12
|
+
seen.add(key);
|
|
13
|
+
return true;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
/** Retry intervals for order execution failures */
|
|
17
|
+
export const RETRY_INTERVALS = [0, 30_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000];
|
|
18
|
+
/**
|
|
19
|
+
* Returns next retry delay in ms given the current retry count, or null if exhausted.
|
|
20
|
+
* count=0 → first retry (0ms). count=intervals.length → null (give up).
|
|
21
|
+
*/
|
|
22
|
+
export function computeRetryDelay(count, intervals = RETRY_INTERVALS) {
|
|
23
|
+
if (count < 0 || count >= intervals.length)
|
|
24
|
+
return null;
|
|
25
|
+
return intervals[count];
|
|
26
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { sortByQuadrant, dedupeWorkItems, computeRetryDelay, RETRY_INTERVALS } from "./task-helpers.js";
|
|
4
|
+
describe("sortByQuadrant", () => {
|
|
5
|
+
it("empty array returns empty array", () => {
|
|
6
|
+
assert.deepStrictEqual(sortByQuadrant([]), []);
|
|
7
|
+
});
|
|
8
|
+
it("does not modify the input array", () => {
|
|
9
|
+
const items = [
|
|
10
|
+
{ quadrant: 3, id: "a" },
|
|
11
|
+
{ quadrant: 1, id: "b" },
|
|
12
|
+
];
|
|
13
|
+
const original = [...items];
|
|
14
|
+
sortByQuadrant(items);
|
|
15
|
+
assert.deepStrictEqual(items, original, "input array must not be mutated");
|
|
16
|
+
});
|
|
17
|
+
it("sorts ascending by quadrant", () => {
|
|
18
|
+
const items = [
|
|
19
|
+
{ quadrant: 4, id: "d" },
|
|
20
|
+
{ quadrant: 2, id: "b" },
|
|
21
|
+
{ quadrant: 3, id: "c" },
|
|
22
|
+
{ quadrant: 1, id: "a" },
|
|
23
|
+
];
|
|
24
|
+
const result = sortByQuadrant(items);
|
|
25
|
+
assert.deepStrictEqual(result.map(i => i.quadrant), [1, 2, 3, 4]);
|
|
26
|
+
});
|
|
27
|
+
it("is stable: equal-quadrant items preserve original order", () => {
|
|
28
|
+
const items = [
|
|
29
|
+
{ quadrant: 2, id: "first" },
|
|
30
|
+
{ quadrant: 1, id: "solo" },
|
|
31
|
+
{ quadrant: 2, id: "second" },
|
|
32
|
+
];
|
|
33
|
+
const result = sortByQuadrant(items);
|
|
34
|
+
assert.strictEqual(result[0].id, "solo");
|
|
35
|
+
assert.strictEqual(result[1].id, "first", "first Q2 item should come before second Q2 item");
|
|
36
|
+
assert.strictEqual(result[2].id, "second");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe("dedupeWorkItems", () => {
|
|
40
|
+
it("different type, same id are kept (not considered duplicates)", () => {
|
|
41
|
+
const items = [
|
|
42
|
+
{ type: "order", id: "abc" },
|
|
43
|
+
{ type: "user_task", id: "abc" },
|
|
44
|
+
];
|
|
45
|
+
const result = dedupeWorkItems(items);
|
|
46
|
+
assert.strictEqual(result.length, 2, "order:abc and user_task:abc must both survive");
|
|
47
|
+
});
|
|
48
|
+
it("same type+id: only first occurrence is kept", () => {
|
|
49
|
+
const items = [
|
|
50
|
+
{ type: "order", id: "x", extra: 1 },
|
|
51
|
+
{ type: "order", id: "x", extra: 2 },
|
|
52
|
+
];
|
|
53
|
+
const result = dedupeWorkItems(items);
|
|
54
|
+
assert.strictEqual(result.length, 1);
|
|
55
|
+
assert.strictEqual(result[0].extra, 1, "first occurrence must be preserved");
|
|
56
|
+
});
|
|
57
|
+
it("returns empty array for empty input", () => {
|
|
58
|
+
assert.deepStrictEqual(dedupeWorkItems([]), []);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe("computeRetryDelay", () => {
|
|
62
|
+
it("count=0 returns 0 (immediate first retry)", () => {
|
|
63
|
+
assert.strictEqual(computeRetryDelay(0), 0);
|
|
64
|
+
});
|
|
65
|
+
it("count=1 returns 30_000", () => {
|
|
66
|
+
assert.strictEqual(computeRetryDelay(1), 30_000);
|
|
67
|
+
});
|
|
68
|
+
it("count=4 returns 7_200_000 (2h)", () => {
|
|
69
|
+
assert.strictEqual(computeRetryDelay(4), 2 * 3600_000);
|
|
70
|
+
});
|
|
71
|
+
it("count=5 returns null (exhausted)", () => {
|
|
72
|
+
assert.strictEqual(computeRetryDelay(5), null);
|
|
73
|
+
});
|
|
74
|
+
it("count=-1 returns null (negative treated as exhausted)", () => {
|
|
75
|
+
assert.strictEqual(computeRetryDelay(-1), null);
|
|
76
|
+
});
|
|
77
|
+
it("uses RETRY_INTERVALS as default intervals", () => {
|
|
78
|
+
for (let i = 0; i < RETRY_INTERVALS.length; i++) {
|
|
79
|
+
assert.strictEqual(computeRetryDelay(i), RETRY_INTERVALS[i]);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
it("accepts a custom intervals array", () => {
|
|
83
|
+
const custom = [100, 200, 300];
|
|
84
|
+
assert.strictEqual(computeRetryDelay(0, custom), 100);
|
|
85
|
+
assert.strictEqual(computeRetryDelay(2, custom), 300);
|
|
86
|
+
assert.strictEqual(computeRetryDelay(3, custom), null);
|
|
87
|
+
});
|
|
88
|
+
});
|
package/dist/task-module.js
CHANGED
|
@@ -13,12 +13,14 @@ import { SIG, sig } from "./types.js";
|
|
|
13
13
|
import { selfDir, biosPath, localNow, loadBioState, saveBioState, syncEnergyFromTokens, loadAgentConfig, getDueUserTasks, loadTaskRuns, saveTaskRuns, loadDirectives, buildDirectivesPrompt, appendTaskHistory, notifyOwner, updateHungerDecay, updateNaturalDecay, resetTokenCountIfNewDay, computeSociability, appendBioEvent, bioStatePromptModifier, feedHunger, SHOP_ITEMS, logBioStatus, logBioDecision, } from "./self.js";
|
|
14
14
|
import { appendMessage } from "./context.js";
|
|
15
15
|
import { buildRoleContext } from "./role-module.js";
|
|
16
|
+
import { sortByQuadrant, dedupeWorkItems, computeRetryDelay } from "./task-helpers.js";
|
|
17
|
+
import { updateMetrics } from "./metrics.js";
|
|
18
|
+
import { downgradeForRetry } from "./engine-routing.js";
|
|
16
19
|
// ---------------------------------------------------------------------------
|
|
17
20
|
// Config
|
|
18
21
|
// ---------------------------------------------------------------------------
|
|
19
22
|
const INITIAL_DELAY = 60_000; // 1 min after startup
|
|
20
23
|
const POLL_INTERVAL = 30_000; // 30s between polls
|
|
21
|
-
const RETRY_INTERVALS = [0, 30_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000];
|
|
22
24
|
const USER_TASK_MAX_RETRIES = 2;
|
|
23
25
|
const USER_TASK_RETRY_DELAY = 2 * 60_000;
|
|
24
26
|
export class TaskModule {
|
|
@@ -33,6 +35,8 @@ export class TaskModule {
|
|
|
33
35
|
userTaskRetry = new Map();
|
|
34
36
|
gaveUp = new Set();
|
|
35
37
|
executing = new Set(); // orders currently being fulfilled
|
|
38
|
+
// Dedup guard: track which orders already had their User message written
|
|
39
|
+
orderUserWritten = new Set();
|
|
36
40
|
// Push notification support
|
|
37
41
|
urgentOrderIds = new Set();
|
|
38
42
|
triggerWorkFn = null;
|
|
@@ -82,6 +86,7 @@ export class TaskModule {
|
|
|
82
86
|
module: "task",
|
|
83
87
|
pendingRetries: this.orderRetry.size + this.userTaskRetry.size,
|
|
84
88
|
gaveUp: this.gaveUp.size,
|
|
89
|
+
executing: this.executing.size,
|
|
85
90
|
};
|
|
86
91
|
}
|
|
87
92
|
/** Push-notify an urgent order (bypasses poll interval) */
|
|
@@ -140,9 +145,12 @@ export class TaskModule {
|
|
|
140
145
|
if (retry && Date.now() < retry.nextAt)
|
|
141
146
|
continue;
|
|
142
147
|
const urgent = this.urgentOrderIds.has(order.id);
|
|
148
|
+
const isRetry = this.orderRetry.has(order.id);
|
|
149
|
+
const rawOrigin = order.human_origin ? "user_manual" : "platform";
|
|
143
150
|
queue.push({
|
|
144
151
|
type: "order", id: order.id,
|
|
145
152
|
quadrant: urgent ? 1 : 2, // orders are important (paid work)
|
|
153
|
+
origin: isRetry ? downgradeForRetry(rawOrigin) : rawOrigin,
|
|
146
154
|
data: order,
|
|
147
155
|
});
|
|
148
156
|
}
|
|
@@ -162,6 +170,7 @@ export class TaskModule {
|
|
|
162
170
|
queue.push({
|
|
163
171
|
type: "user_task", id: taskKey,
|
|
164
172
|
quadrant: rt ? 1 : 2, // retries are urgent+important
|
|
173
|
+
origin: rt ? downgradeForRetry("user_manual") : "user_manual",
|
|
165
174
|
data: task,
|
|
166
175
|
});
|
|
167
176
|
}
|
|
@@ -176,6 +185,7 @@ export class TaskModule {
|
|
|
176
185
|
queue.push({
|
|
177
186
|
type: "relay_task", id: task.id,
|
|
178
187
|
quadrant: 3, // urgent but less important
|
|
188
|
+
origin: "platform",
|
|
179
189
|
data: task,
|
|
180
190
|
});
|
|
181
191
|
}
|
|
@@ -197,16 +207,9 @@ export class TaskModule {
|
|
|
197
207
|
}
|
|
198
208
|
logBioStatus(bio, "work-active");
|
|
199
209
|
// --- Eisenhower sort: Q1 > Q2 > Q3 > Q4 ---
|
|
200
|
-
|
|
210
|
+
const sorted = sortByQuadrant(queue);
|
|
201
211
|
// Dedup
|
|
202
|
-
const
|
|
203
|
-
const deduped = queue.filter(item => {
|
|
204
|
-
const key = `${item.type}:${item.id}`;
|
|
205
|
-
if (seen.has(key))
|
|
206
|
-
return false;
|
|
207
|
-
seen.add(key);
|
|
208
|
-
return true;
|
|
209
|
-
});
|
|
212
|
+
const deduped = dedupeWorkItems(sorted);
|
|
210
213
|
// Bio filtering (fear & boredom)
|
|
211
214
|
const filtered = await this.applyBioFilter(deduped, bio);
|
|
212
215
|
if (!filtered.length)
|
|
@@ -215,18 +218,20 @@ export class TaskModule {
|
|
|
215
218
|
// Execute sequentially
|
|
216
219
|
for (const item of filtered) {
|
|
217
220
|
try {
|
|
218
|
-
if (item.type === "order")
|
|
221
|
+
if (item.type === "order") {
|
|
219
222
|
this.executing.add(item.id);
|
|
223
|
+
updateMetrics({ task_executing: this.executing.size });
|
|
224
|
+
}
|
|
220
225
|
switch (item.type) {
|
|
221
226
|
case "order":
|
|
222
|
-
await this.executeOrder(item.data);
|
|
227
|
+
await this.executeOrder(item.data, item.origin);
|
|
223
228
|
this.urgentOrderIds.delete(item.id);
|
|
224
229
|
break;
|
|
225
230
|
case "user_task":
|
|
226
|
-
await this.executeUserTask(item.data);
|
|
231
|
+
await this.executeUserTask(item.data, item.origin);
|
|
227
232
|
break;
|
|
228
233
|
case "relay_task":
|
|
229
|
-
await this.executeRelayTask(item.data);
|
|
234
|
+
await this.executeRelayTask(item.data, item.origin);
|
|
230
235
|
break;
|
|
231
236
|
}
|
|
232
237
|
}
|
|
@@ -234,8 +239,13 @@ export class TaskModule {
|
|
|
234
239
|
console.log(`[task] Error processing ${item.type}:${item.id}: ${err.message}`);
|
|
235
240
|
}
|
|
236
241
|
finally {
|
|
237
|
-
if (item.type === "order")
|
|
242
|
+
if (item.type === "order") {
|
|
238
243
|
this.executing.delete(item.id);
|
|
244
|
+
updateMetrics({
|
|
245
|
+
task_executing: this.executing.size,
|
|
246
|
+
task_pending_retries: this.orderRetry.size + this.userTaskRetry.size,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
239
249
|
}
|
|
240
250
|
}
|
|
241
251
|
bus.emit(SIG.CYCLE_END, sig(SIG.CYCLE_END, { ts: Date.now() }));
|
|
@@ -291,7 +301,7 @@ export class TaskModule {
|
|
|
291
301
|
// ---------------------------------------------------------------------------
|
|
292
302
|
// Execute order
|
|
293
303
|
// ---------------------------------------------------------------------------
|
|
294
|
-
async executeOrder(order) {
|
|
304
|
+
async executeOrder(order, origin = "platform") {
|
|
295
305
|
if (!this.ctx)
|
|
296
306
|
return;
|
|
297
307
|
const { workdir, agentName, bus } = this.ctx;
|
|
@@ -332,21 +342,25 @@ Steps:
|
|
|
332
342
|
|
|
333
343
|
RESPOND IN THE SAME LANGUAGE AS THE REQUEST.`;
|
|
334
344
|
// Write user message to conversation immediately (before engine runs)
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const buyerPubId = orderBuyer;
|
|
345
|
+
// Bug 1 fix: buyer_ip = actual publisherId (despite the name); buyer_name = display name only.
|
|
346
|
+
// Must use buyer_ip to match the MCP chat path convId (pub_<publisherId>).
|
|
347
|
+
const buyerPubId = order.buyer_ip || order.buyer_name || "anonymous";
|
|
339
348
|
const productScope = order.product_id ? `:prod_${order.product_id}` : "";
|
|
340
349
|
const orderConvId = `pub_${buyerPubId}${productScope}`;
|
|
341
350
|
const orderUserMsg = order.buyer_task || "(no message)";
|
|
342
|
-
|
|
343
|
-
|
|
351
|
+
// Bug 3 fix: only write User message once per order (retries must not duplicate it)
|
|
352
|
+
if (!this.orderUserWritten.has(order.id)) {
|
|
353
|
+
this.orderUserWritten.add(order.id);
|
|
354
|
+
await appendMessage(workdir, agentName, orderConvId, "User", orderUserMsg, "order");
|
|
355
|
+
}
|
|
356
|
+
console.log(`[task] Fulfilling order ${order.id}... (origin=${origin})`);
|
|
344
357
|
const result = await this.ctx.requestCompute({
|
|
345
358
|
context,
|
|
346
359
|
question,
|
|
347
360
|
priority: "high",
|
|
348
361
|
tools: ["Bash(curl *)"],
|
|
349
362
|
relay: this.relayHttp ? { http: this.relayHttp, agentName } : undefined,
|
|
363
|
+
origin,
|
|
350
364
|
});
|
|
351
365
|
if (!result.success)
|
|
352
366
|
throw new Error(result.error || "compute failed");
|
|
@@ -359,7 +373,8 @@ RESPOND IN THE SAME LANGUAGE AS THE REQUEST.`;
|
|
|
359
373
|
const orderAgentMsg = (finalStatus.result_text || result.response || "").slice(0, 2000);
|
|
360
374
|
console.log(`[task] Order ${order.id} delivered`);
|
|
361
375
|
this.orderRetry.delete(order.id);
|
|
362
|
-
|
|
376
|
+
this.orderUserWritten.delete(order.id);
|
|
377
|
+
await appendMessage(workdir, agentName, orderConvId, "Agent", orderAgentMsg, "order");
|
|
363
378
|
await appendTaskHistory(workdir, agentName, { ts: localNow(), id: order.id, type: "order", status: "success", duration_ms: duration, output_summary: (result.response || "").slice(0, 500) });
|
|
364
379
|
await notifyOwner(nurl, `${agentName}: order done`, `Order ${order.id} delivered`, "default", ["package"]);
|
|
365
380
|
bus.emit(SIG.TASK_COMPLETED, sig(SIG.TASK_COMPLETED, { success: true, taskLabel: orderLabel, creditsEarned: orderPrice, productName: order.product_name }));
|
|
@@ -371,7 +386,8 @@ RESPOND IN THE SAME LANGUAGE AS THE REQUEST.`;
|
|
|
371
386
|
const orderAgentMsg = (result.response || "").slice(0, 2000);
|
|
372
387
|
console.log(`[task] Delivered order ${order.id} (fallback)`);
|
|
373
388
|
this.orderRetry.delete(order.id);
|
|
374
|
-
|
|
389
|
+
this.orderUserWritten.delete(order.id);
|
|
390
|
+
await appendMessage(workdir, agentName, orderConvId, "Agent", orderAgentMsg, "order");
|
|
375
391
|
await appendTaskHistory(workdir, agentName, { ts: localNow(), id: order.id, type: "order", status: "success", duration_ms: duration, output_summary: result.response.slice(0, 500) });
|
|
376
392
|
await notifyOwner(nurl, `${agentName}: order done`, `Order ${order.id}: ${result.response.slice(0, 200)}`, "default", ["package"]);
|
|
377
393
|
bus.emit(SIG.TASK_COMPLETED, sig(SIG.TASK_COMPLETED, { success: true, taskLabel: orderLabel, creditsEarned: orderPrice, productName: order.product_name }));
|
|
@@ -400,10 +416,11 @@ RESPOND IN THE SAME LANGUAGE AS THE REQUEST.`;
|
|
|
400
416
|
// Retry logic
|
|
401
417
|
const current = this.orderRetry.get(order.id) || { count: 0, nextAt: 0 };
|
|
402
418
|
current.count++;
|
|
403
|
-
|
|
404
|
-
|
|
419
|
+
const delay = computeRetryDelay(current.count);
|
|
420
|
+
if (delay !== null) {
|
|
421
|
+
current.nextAt = Date.now() + delay;
|
|
405
422
|
this.orderRetry.set(order.id, current);
|
|
406
|
-
console.log(`[task] Retry ${order.id} in ${
|
|
423
|
+
console.log(`[task] Retry ${order.id} in ${delay / 1000}s`);
|
|
407
424
|
try {
|
|
408
425
|
await relay.extendOrder(order.id);
|
|
409
426
|
}
|
|
@@ -411,6 +428,7 @@ RESPOND IN THE SAME LANGUAGE AS THE REQUEST.`;
|
|
|
411
428
|
}
|
|
412
429
|
else {
|
|
413
430
|
this.orderRetry.delete(order.id);
|
|
431
|
+
this.orderUserWritten.delete(order.id);
|
|
414
432
|
this.gaveUp.add(order.id);
|
|
415
433
|
bus.emit(SIG.TASK_COMPLETED, sig(SIG.TASK_COMPLETED, { success: false, taskLabel: orderLabel }));
|
|
416
434
|
try {
|
|
@@ -423,7 +441,7 @@ RESPOND IN THE SAME LANGUAGE AS THE REQUEST.`;
|
|
|
423
441
|
// ---------------------------------------------------------------------------
|
|
424
442
|
// Execute user task
|
|
425
443
|
// ---------------------------------------------------------------------------
|
|
426
|
-
async executeUserTask(task) {
|
|
444
|
+
async executeUserTask(task, origin = "user_manual") {
|
|
427
445
|
if (!this.ctx)
|
|
428
446
|
return;
|
|
429
447
|
const { workdir, agentName, bus } = this.ctx;
|
|
@@ -458,6 +476,7 @@ Your personal directory: ${sd}/`;
|
|
|
458
476
|
priority: "high",
|
|
459
477
|
tools: ["Bash(curl *)"],
|
|
460
478
|
relay: this.relayHttp ? { http: this.relayHttp, agentName } : undefined,
|
|
479
|
+
origin,
|
|
461
480
|
});
|
|
462
481
|
if (!result.success)
|
|
463
482
|
throw new Error(result.error || "compute failed");
|
|
@@ -483,8 +502,9 @@ Your personal directory: ${sd}/`;
|
|
|
483
502
|
relay?.reportLog("user_task", taskKey, "failed", err.message, []);
|
|
484
503
|
const retry = this.userTaskRetry.get(taskKey) || { count: 0, nextAt: 0 };
|
|
485
504
|
retry.count++;
|
|
486
|
-
|
|
487
|
-
|
|
505
|
+
const retryDelay = computeRetryDelay(retry.count);
|
|
506
|
+
if (retryDelay !== null) {
|
|
507
|
+
retry.nextAt = Date.now() + retryDelay;
|
|
488
508
|
this.userTaskRetry.set(taskKey, retry);
|
|
489
509
|
await appendTaskHistory(workdir, agentName, {
|
|
490
510
|
ts: localNow(), id: taskKey, type: "user_task", status: "retry",
|
|
@@ -508,7 +528,7 @@ Your personal directory: ${sd}/`;
|
|
|
508
528
|
// ---------------------------------------------------------------------------
|
|
509
529
|
// Execute relay platform task
|
|
510
530
|
// ---------------------------------------------------------------------------
|
|
511
|
-
async executeRelayTask(task) {
|
|
531
|
+
async executeRelayTask(task, origin = "platform") {
|
|
512
532
|
if (!this.ctx)
|
|
513
533
|
return;
|
|
514
534
|
const { workdir, agentName, bus } = this.ctx;
|
|
@@ -560,6 +580,7 @@ Complete this task. Use the environment info above and tools (curl, etc.) as nee
|
|
|
560
580
|
priority: "low",
|
|
561
581
|
tools: ["Bash(curl *)"],
|
|
562
582
|
relay: this.relayHttp ? { http: this.relayHttp, agentName } : undefined,
|
|
583
|
+
origin,
|
|
563
584
|
});
|
|
564
585
|
if (!result.success)
|
|
565
586
|
throw new Error(result.error || "compute failed");
|