akemon 0.2.24 → 0.2.25
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/engine-peripheral.js +93 -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 +37 -24
- 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 {
|
|
@@ -82,6 +84,7 @@ export class TaskModule {
|
|
|
82
84
|
module: "task",
|
|
83
85
|
pendingRetries: this.orderRetry.size + this.userTaskRetry.size,
|
|
84
86
|
gaveUp: this.gaveUp.size,
|
|
87
|
+
executing: this.executing.size,
|
|
85
88
|
};
|
|
86
89
|
}
|
|
87
90
|
/** Push-notify an urgent order (bypasses poll interval) */
|
|
@@ -140,9 +143,12 @@ export class TaskModule {
|
|
|
140
143
|
if (retry && Date.now() < retry.nextAt)
|
|
141
144
|
continue;
|
|
142
145
|
const urgent = this.urgentOrderIds.has(order.id);
|
|
146
|
+
const isRetry = this.orderRetry.has(order.id);
|
|
147
|
+
const rawOrigin = order.human_origin ? "user_manual" : "platform";
|
|
143
148
|
queue.push({
|
|
144
149
|
type: "order", id: order.id,
|
|
145
150
|
quadrant: urgent ? 1 : 2, // orders are important (paid work)
|
|
151
|
+
origin: isRetry ? downgradeForRetry(rawOrigin) : rawOrigin,
|
|
146
152
|
data: order,
|
|
147
153
|
});
|
|
148
154
|
}
|
|
@@ -162,6 +168,7 @@ export class TaskModule {
|
|
|
162
168
|
queue.push({
|
|
163
169
|
type: "user_task", id: taskKey,
|
|
164
170
|
quadrant: rt ? 1 : 2, // retries are urgent+important
|
|
171
|
+
origin: rt ? downgradeForRetry("user_manual") : "user_manual",
|
|
165
172
|
data: task,
|
|
166
173
|
});
|
|
167
174
|
}
|
|
@@ -176,6 +183,7 @@ export class TaskModule {
|
|
|
176
183
|
queue.push({
|
|
177
184
|
type: "relay_task", id: task.id,
|
|
178
185
|
quadrant: 3, // urgent but less important
|
|
186
|
+
origin: "platform",
|
|
179
187
|
data: task,
|
|
180
188
|
});
|
|
181
189
|
}
|
|
@@ -197,16 +205,9 @@ export class TaskModule {
|
|
|
197
205
|
}
|
|
198
206
|
logBioStatus(bio, "work-active");
|
|
199
207
|
// --- Eisenhower sort: Q1 > Q2 > Q3 > Q4 ---
|
|
200
|
-
|
|
208
|
+
const sorted = sortByQuadrant(queue);
|
|
201
209
|
// 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
|
-
});
|
|
210
|
+
const deduped = dedupeWorkItems(sorted);
|
|
210
211
|
// Bio filtering (fear & boredom)
|
|
211
212
|
const filtered = await this.applyBioFilter(deduped, bio);
|
|
212
213
|
if (!filtered.length)
|
|
@@ -215,18 +216,20 @@ export class TaskModule {
|
|
|
215
216
|
// Execute sequentially
|
|
216
217
|
for (const item of filtered) {
|
|
217
218
|
try {
|
|
218
|
-
if (item.type === "order")
|
|
219
|
+
if (item.type === "order") {
|
|
219
220
|
this.executing.add(item.id);
|
|
221
|
+
updateMetrics({ task_executing: this.executing.size });
|
|
222
|
+
}
|
|
220
223
|
switch (item.type) {
|
|
221
224
|
case "order":
|
|
222
|
-
await this.executeOrder(item.data);
|
|
225
|
+
await this.executeOrder(item.data, item.origin);
|
|
223
226
|
this.urgentOrderIds.delete(item.id);
|
|
224
227
|
break;
|
|
225
228
|
case "user_task":
|
|
226
|
-
await this.executeUserTask(item.data);
|
|
229
|
+
await this.executeUserTask(item.data, item.origin);
|
|
227
230
|
break;
|
|
228
231
|
case "relay_task":
|
|
229
|
-
await this.executeRelayTask(item.data);
|
|
232
|
+
await this.executeRelayTask(item.data, item.origin);
|
|
230
233
|
break;
|
|
231
234
|
}
|
|
232
235
|
}
|
|
@@ -234,8 +237,13 @@ export class TaskModule {
|
|
|
234
237
|
console.log(`[task] Error processing ${item.type}:${item.id}: ${err.message}`);
|
|
235
238
|
}
|
|
236
239
|
finally {
|
|
237
|
-
if (item.type === "order")
|
|
240
|
+
if (item.type === "order") {
|
|
238
241
|
this.executing.delete(item.id);
|
|
242
|
+
updateMetrics({
|
|
243
|
+
task_executing: this.executing.size,
|
|
244
|
+
task_pending_retries: this.orderRetry.size + this.userTaskRetry.size,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
239
247
|
}
|
|
240
248
|
}
|
|
241
249
|
bus.emit(SIG.CYCLE_END, sig(SIG.CYCLE_END, { ts: Date.now() }));
|
|
@@ -291,7 +299,7 @@ export class TaskModule {
|
|
|
291
299
|
// ---------------------------------------------------------------------------
|
|
292
300
|
// Execute order
|
|
293
301
|
// ---------------------------------------------------------------------------
|
|
294
|
-
async executeOrder(order) {
|
|
302
|
+
async executeOrder(order, origin = "platform") {
|
|
295
303
|
if (!this.ctx)
|
|
296
304
|
return;
|
|
297
305
|
const { workdir, agentName, bus } = this.ctx;
|
|
@@ -340,13 +348,14 @@ RESPOND IN THE SAME LANGUAGE AS THE REQUEST.`;
|
|
|
340
348
|
const orderConvId = `pub_${buyerPubId}${productScope}`;
|
|
341
349
|
const orderUserMsg = order.buyer_task || "(no message)";
|
|
342
350
|
await appendMessage(workdir, agentName, orderConvId, "User", orderUserMsg);
|
|
343
|
-
console.log(`[task] Fulfilling order ${order.id}
|
|
351
|
+
console.log(`[task] Fulfilling order ${order.id}... (origin=${origin})`);
|
|
344
352
|
const result = await this.ctx.requestCompute({
|
|
345
353
|
context,
|
|
346
354
|
question,
|
|
347
355
|
priority: "high",
|
|
348
356
|
tools: ["Bash(curl *)"],
|
|
349
357
|
relay: this.relayHttp ? { http: this.relayHttp, agentName } : undefined,
|
|
358
|
+
origin,
|
|
350
359
|
});
|
|
351
360
|
if (!result.success)
|
|
352
361
|
throw new Error(result.error || "compute failed");
|
|
@@ -400,10 +409,11 @@ RESPOND IN THE SAME LANGUAGE AS THE REQUEST.`;
|
|
|
400
409
|
// Retry logic
|
|
401
410
|
const current = this.orderRetry.get(order.id) || { count: 0, nextAt: 0 };
|
|
402
411
|
current.count++;
|
|
403
|
-
|
|
404
|
-
|
|
412
|
+
const delay = computeRetryDelay(current.count);
|
|
413
|
+
if (delay !== null) {
|
|
414
|
+
current.nextAt = Date.now() + delay;
|
|
405
415
|
this.orderRetry.set(order.id, current);
|
|
406
|
-
console.log(`[task] Retry ${order.id} in ${
|
|
416
|
+
console.log(`[task] Retry ${order.id} in ${delay / 1000}s`);
|
|
407
417
|
try {
|
|
408
418
|
await relay.extendOrder(order.id);
|
|
409
419
|
}
|
|
@@ -423,7 +433,7 @@ RESPOND IN THE SAME LANGUAGE AS THE REQUEST.`;
|
|
|
423
433
|
// ---------------------------------------------------------------------------
|
|
424
434
|
// Execute user task
|
|
425
435
|
// ---------------------------------------------------------------------------
|
|
426
|
-
async executeUserTask(task) {
|
|
436
|
+
async executeUserTask(task, origin = "user_manual") {
|
|
427
437
|
if (!this.ctx)
|
|
428
438
|
return;
|
|
429
439
|
const { workdir, agentName, bus } = this.ctx;
|
|
@@ -458,6 +468,7 @@ Your personal directory: ${sd}/`;
|
|
|
458
468
|
priority: "high",
|
|
459
469
|
tools: ["Bash(curl *)"],
|
|
460
470
|
relay: this.relayHttp ? { http: this.relayHttp, agentName } : undefined,
|
|
471
|
+
origin,
|
|
461
472
|
});
|
|
462
473
|
if (!result.success)
|
|
463
474
|
throw new Error(result.error || "compute failed");
|
|
@@ -483,8 +494,9 @@ Your personal directory: ${sd}/`;
|
|
|
483
494
|
relay?.reportLog("user_task", taskKey, "failed", err.message, []);
|
|
484
495
|
const retry = this.userTaskRetry.get(taskKey) || { count: 0, nextAt: 0 };
|
|
485
496
|
retry.count++;
|
|
486
|
-
|
|
487
|
-
|
|
497
|
+
const retryDelay = computeRetryDelay(retry.count);
|
|
498
|
+
if (retryDelay !== null) {
|
|
499
|
+
retry.nextAt = Date.now() + retryDelay;
|
|
488
500
|
this.userTaskRetry.set(taskKey, retry);
|
|
489
501
|
await appendTaskHistory(workdir, agentName, {
|
|
490
502
|
ts: localNow(), id: taskKey, type: "user_task", status: "retry",
|
|
@@ -508,7 +520,7 @@ Your personal directory: ${sd}/`;
|
|
|
508
520
|
// ---------------------------------------------------------------------------
|
|
509
521
|
// Execute relay platform task
|
|
510
522
|
// ---------------------------------------------------------------------------
|
|
511
|
-
async executeRelayTask(task) {
|
|
523
|
+
async executeRelayTask(task, origin = "platform") {
|
|
512
524
|
if (!this.ctx)
|
|
513
525
|
return;
|
|
514
526
|
const { workdir, agentName, bus } = this.ctx;
|
|
@@ -560,6 +572,7 @@ Complete this task. Use the environment info above and tools (curl, etc.) as nee
|
|
|
560
572
|
priority: "low",
|
|
561
573
|
tools: ["Bash(curl *)"],
|
|
562
574
|
relay: this.relayHttp ? { http: this.relayHttp, agentName } : undefined,
|
|
575
|
+
origin,
|
|
563
576
|
});
|
|
564
577
|
if (!result.success)
|
|
565
578
|
throw new Error(result.error || "compute failed");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akemon",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.25",
|
|
4
4
|
"description": "Agent work marketplace — train your agent, let it work for others",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"build": "tsc && cp src/live.html dist/live.html",
|
|
29
29
|
"dev": "tsc --watch",
|
|
30
30
|
"start": "node dist/cli.js",
|
|
31
|
-
"prepublishOnly": "npm run build"
|
|
31
|
+
"prepublishOnly": "npm run build",
|
|
32
|
+
"test": "tsc -p tsconfig.test.json && node --test test-dist/*.test.js"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
35
|
"@modelcontextprotocol/sdk": "^1.0.0",
|