@possumtech/rummy 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +12 -7
- package/BENCH_ENVIRONMENT.md +230 -0
- package/CLIENT_INTERFACE.md +396 -0
- package/PLUGINS.md +93 -1
- package/SPEC.md +305 -28
- package/bin/postinstall.js +2 -2
- package/bin/rummy.js +2 -2
- package/last_run.txt +5617 -0
- package/migrations/001_initial_schema.sql +2 -1
- package/package.json +6 -2
- package/scriptify/cache_probe.js +66 -0
- package/scriptify/cache_probe_grok.js +74 -0
- package/service.js +22 -11
- package/src/agent/AgentLoop.js +33 -139
- package/src/agent/ContextAssembler.js +2 -9
- package/src/agent/Entries.js +36 -101
- package/src/agent/ProjectAgent.js +2 -9
- package/src/agent/TurnExecutor.js +45 -83
- package/src/agent/XmlParser.js +247 -273
- package/src/agent/budget.js +5 -28
- package/src/agent/config.js +38 -0
- package/src/agent/errors.js +7 -13
- package/src/agent/httpStatus.js +1 -19
- package/src/agent/known_store.sql +7 -2
- package/src/agent/materializeContext.js +12 -17
- package/src/agent/pathEncode.js +5 -0
- package/src/agent/rummyHome.js +9 -0
- package/src/agent/runs.sql +18 -0
- package/src/agent/tokens.js +2 -8
- package/src/hooks/HookRegistry.js +1 -16
- package/src/hooks/Hooks.js +8 -33
- package/src/hooks/PluginContext.js +3 -21
- package/src/hooks/RpcRegistry.js +1 -4
- package/src/hooks/RummyContext.js +2 -16
- package/src/hooks/ToolRegistry.js +5 -15
- package/src/llm/LlmProvider.js +28 -23
- package/src/llm/errors.js +41 -4
- package/src/llm/openaiStream.js +125 -0
- package/src/llm/retry.js +61 -15
- package/src/plugins/budget/budget.js +14 -81
- package/src/plugins/cli/README.md +87 -0
- package/src/plugins/cli/bin.js +61 -0
- package/src/plugins/cli/cli.js +120 -0
- package/src/plugins/env/README.md +2 -1
- package/src/plugins/env/env.js +4 -6
- package/src/plugins/env/envDoc.md +2 -2
- package/src/plugins/error/error.js +23 -23
- package/src/plugins/file/file.js +2 -22
- package/src/plugins/get/get.js +12 -34
- package/src/plugins/get/getDoc.md +5 -3
- package/src/plugins/hedberg/edits.js +1 -11
- package/src/plugins/hedberg/hedberg.js +3 -26
- package/src/plugins/hedberg/normalize.js +1 -5
- package/src/plugins/hedberg/patterns.js +4 -15
- package/src/plugins/hedberg/sed.js +1 -7
- package/src/plugins/helpers.js +28 -20
- package/src/plugins/index.js +25 -41
- package/src/plugins/instructions/README.md +18 -0
- package/src/plugins/instructions/instructions.js +13 -76
- package/src/plugins/instructions/instructions.md +19 -18
- package/src/plugins/instructions/instructions_104.md +5 -4
- package/src/plugins/instructions/instructions_105.md +16 -15
- package/src/plugins/instructions/instructions_106.md +15 -14
- package/src/plugins/instructions/instructions_107.md +13 -6
- package/src/plugins/known/README.md +26 -6
- package/src/plugins/known/known.js +36 -34
- package/src/plugins/log/README.md +2 -2
- package/src/plugins/log/log.js +6 -33
- package/src/plugins/ollama/ollama.js +50 -66
- package/src/plugins/openai/openai.js +26 -44
- package/src/plugins/openrouter/openrouter.js +28 -52
- package/src/plugins/policy/README.md +8 -2
- package/src/plugins/policy/policy.js +8 -21
- package/src/plugins/prompt/README.md +22 -0
- package/src/plugins/prompt/prompt.js +8 -16
- package/src/plugins/rm/rm.js +5 -2
- package/src/plugins/rm/rmDoc.md +4 -4
- package/src/plugins/rpc/README.md +2 -1
- package/src/plugins/rpc/rpc.js +51 -47
- package/src/plugins/set/README.md +5 -1
- package/src/plugins/set/set.js +23 -33
- package/src/plugins/set/setDoc.md +1 -1
- package/src/plugins/sh/README.md +2 -1
- package/src/plugins/sh/sh.js +5 -11
- package/src/plugins/sh/shDoc.md +2 -2
- package/src/plugins/stream/README.md +6 -5
- package/src/plugins/stream/stream.js +6 -35
- package/src/plugins/telemetry/telemetry.js +26 -19
- package/src/plugins/think/think.js +4 -7
- package/src/plugins/unknown/unknown.js +8 -13
- package/src/plugins/update/update.js +36 -35
- package/src/plugins/update/updateDoc.md +3 -3
- package/src/plugins/xai/xai.js +30 -20
- package/src/plugins/yolo/yolo.js +8 -41
- package/src/server/ClientConnection.js +17 -47
- package/src/server/SocketServer.js +14 -14
- package/src/server/protocol.js +1 -10
- package/src/sql/functions/slugify.js +5 -7
- package/src/sql/v_model_context.sql +4 -11
- package/turns/cli_1777462658211/turn_001.txt +772 -0
- package/turns/cli_1777462658211/turn_002.txt +606 -0
- package/turns/cli_1777462658211/turn_003.txt +667 -0
- package/turns/cli_1777462658211/turn_004.txt +297 -0
- package/turns/cli_1777462658211/turn_005.txt +301 -0
- package/turns/cli_1777462658211/turn_006.txt +262 -0
- package/turns/cli_1777465095132/turn_001.txt +715 -0
- package/turns/cli_1777465095132/turn_002.txt +236 -0
- package/turns/cli_1777465095132/turn_003.txt +287 -0
- package/turns/cli_1777465095132/turn_004.txt +694 -0
- package/turns/cli_1777465095132/turn_005.txt +422 -0
- package/turns/cli_1777465095132/turn_006.txt +365 -0
- package/turns/cli_1777465095132/turn_007.txt +885 -0
- package/turns/cli_1777465095132/turn_008.txt +1277 -0
- package/turns/cli_1777465095132/turn_009.txt +736 -0
|
@@ -218,7 +218,8 @@ CREATE TABLE IF NOT EXISTS turn_context (
|
|
|
218
218
|
state IN ('proposed', 'streaming', 'resolved', 'failed', 'cancelled')
|
|
219
219
|
)
|
|
220
220
|
, outcome TEXT
|
|
221
|
-
|
|
221
|
+
-- 'archived' permitted; see prompt plugin README for the exception.
|
|
222
|
+
, visibility TEXT NOT NULL CHECK (visibility IN ('visible', 'summarized', 'archived'))
|
|
222
223
|
, body TEXT NOT NULL DEFAULT ''
|
|
223
224
|
, attributes JSON NOT NULL DEFAULT '{}' CHECK (json_valid(attributes))
|
|
224
225
|
, category TEXT NOT NULL DEFAULT 'logging'
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@possumtech/rummy",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Relational Unknowns Memory Management Yoke",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llm"
|
|
7
7
|
],
|
|
8
8
|
"bin": {
|
|
9
|
-
"rummy": "./bin/rummy.js"
|
|
9
|
+
"rummy": "./bin/rummy.js",
|
|
10
|
+
"rummy-cli": "./src/plugins/cli/bin.js"
|
|
10
11
|
},
|
|
11
12
|
"publishConfig": {
|
|
12
13
|
"access": "public"
|
|
@@ -51,6 +52,9 @@
|
|
|
51
52
|
"test:swe:baseline": "bash -c 'cd test/swe && source .venv/bin/activate && python baseline.py \"$@\"' --",
|
|
52
53
|
"test:lme:clean": "rm -rf test/lme/results/*/",
|
|
53
54
|
"test:swe:clean": "rm -rf test/swe/results/*/ test/swe/repos/",
|
|
55
|
+
"test:tbench:setup": "bash -c 'set -a; source .env.tbench; set +a; bash test/tbench/setup.sh'",
|
|
56
|
+
"test:tbench": "bash -c 'mkdir -p /tmp/rummy_test_diag && node --env-file-if-exists=.env.example --env-file-if-exists=.env --env-file-if-exists=.env.tbench test/tbench/runner.js \"$@\" 2>&1 | tee /tmp/rummy_test_diag/tbench_$(date +%Y%m%dT%H%M%S).log' --",
|
|
57
|
+
"test:tbench:clean": "rm -rf test/tbench/results/*/",
|
|
54
58
|
"test:clear": "rm -rf /tmp/rummy_test_diag /tmp/rummy_test_*.db /tmp/rummy_test_*.db-shm /tmp/rummy_test_*.db-wal /tmp/rummy-stories-*",
|
|
55
59
|
"test:demo": "node --env-file-if-exists=.env.example --env-file-if-exists=.env bin/demo.js",
|
|
56
60
|
"test:spec": "node test/spec-coverage.js"
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Probe llama-server cache behavior. Send variations of the same request
|
|
3
|
+
// and inspect cached_tokens in the response usage block to determine
|
|
4
|
+
// whether caching is token-prefix or message-hash level.
|
|
5
|
+
|
|
6
|
+
const URL = "http://127.0.0.1:11435/v1/chat/completions";
|
|
7
|
+
const MODEL = "gemma-4-26B-A4B-it-UD-Q3_K_XL.gguf";
|
|
8
|
+
|
|
9
|
+
async function probe(label, system, user) {
|
|
10
|
+
const body = {
|
|
11
|
+
model: MODEL,
|
|
12
|
+
messages: [
|
|
13
|
+
{ role: "system", content: system },
|
|
14
|
+
{ role: "user", content: user },
|
|
15
|
+
],
|
|
16
|
+
think: true,
|
|
17
|
+
temperature: 0.5,
|
|
18
|
+
};
|
|
19
|
+
const res = await fetch(URL, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: { "Content-Type": "application/json" },
|
|
22
|
+
body: JSON.stringify(body),
|
|
23
|
+
});
|
|
24
|
+
const data = await res.json();
|
|
25
|
+
const u = data.usage || {};
|
|
26
|
+
const cached =
|
|
27
|
+
u.prompt_tokens_details?.cached_tokens ??
|
|
28
|
+
u.cached_tokens ??
|
|
29
|
+
0;
|
|
30
|
+
console.log(
|
|
31
|
+
`[${label}] prompt_tokens=${u.prompt_tokens ?? "?"} cached_tokens=${cached} system_chars=${system.length} user_chars=${user.length}`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const STATIC_SYSTEM_BASE = `You are a helpful assistant.
|
|
36
|
+
|
|
37
|
+
Tools available:
|
|
38
|
+
- foo: does foo
|
|
39
|
+
- bar: does bar
|
|
40
|
+
- baz: does baz
|
|
41
|
+
|
|
42
|
+
Always be concise.`;
|
|
43
|
+
|
|
44
|
+
const ADDITION_A = "\n\n<context>\n<known path=\"k1\">first known fact</known>\n</context>";
|
|
45
|
+
const ADDITION_B = "\n\n<context>\n<known path=\"k1\">first known fact</known>\n<known path=\"k2\">second known fact</known>\n</context>";
|
|
46
|
+
const ADDITION_C = "\n\n<context>\n<known path=\"k2\">second known fact</known>\n<known path=\"k1\">first known fact</known>\n</context>";
|
|
47
|
+
|
|
48
|
+
const USER_A = "Hello.";
|
|
49
|
+
|
|
50
|
+
console.log("=== Run 1: baseline (cold, then immediate repeat) ===");
|
|
51
|
+
await probe("1a baseline cold", STATIC_SYSTEM_BASE, USER_A);
|
|
52
|
+
await probe("1b same-as-1a ", STATIC_SYSTEM_BASE, USER_A);
|
|
53
|
+
|
|
54
|
+
console.log("\n=== Run 2: same base, then base + appended context (prefix unchanged) ===");
|
|
55
|
+
await probe("2a base only ", STATIC_SYSTEM_BASE, USER_A);
|
|
56
|
+
await probe("2b base + 1 entry", STATIC_SYSTEM_BASE + ADDITION_A, USER_A);
|
|
57
|
+
await probe("2c base + 2 entries", STATIC_SYSTEM_BASE + ADDITION_B, USER_A);
|
|
58
|
+
|
|
59
|
+
console.log("\n=== Run 3: prefix change (entries reordered, same body) ===");
|
|
60
|
+
await probe("3a base + 2 entries (k1,k2)", STATIC_SYSTEM_BASE + ADDITION_B, USER_A);
|
|
61
|
+
await probe("3b base + 2 entries (k2,k1) reordered", STATIC_SYSTEM_BASE + ADDITION_C, USER_A);
|
|
62
|
+
|
|
63
|
+
console.log("\n=== Run 4: small mid-prefix change ===");
|
|
64
|
+
const MIDDIFF = STATIC_SYSTEM_BASE.replace("baz", "qux");
|
|
65
|
+
await probe("4a stable base ", STATIC_SYSTEM_BASE, USER_A);
|
|
66
|
+
await probe("4b changed baz→qux", MIDDIFF, USER_A);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Same probe as cache_probe.js but against OpenRouter's grok endpoint.
|
|
3
|
+
// If cached_tokens behaves sanely (incremental matches preserve prefix),
|
|
4
|
+
// then llama-server's behavior was the local anomaly.
|
|
5
|
+
|
|
6
|
+
const URL = `${process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1"}/chat/completions`;
|
|
7
|
+
const MODEL = "x-ai/grok-4.1-fast";
|
|
8
|
+
|
|
9
|
+
if (!process.env.OPENROUTER_API_KEY) {
|
|
10
|
+
console.error("OPENROUTER_API_KEY required");
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function probe(label, system, user) {
|
|
15
|
+
const body = {
|
|
16
|
+
model: MODEL,
|
|
17
|
+
messages: [
|
|
18
|
+
{ role: "system", content: system },
|
|
19
|
+
{ role: "user", content: user },
|
|
20
|
+
],
|
|
21
|
+
include_reasoning: true,
|
|
22
|
+
temperature: 0.5,
|
|
23
|
+
};
|
|
24
|
+
const res = await fetch(URL, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: {
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
|
29
|
+
},
|
|
30
|
+
body: JSON.stringify(body),
|
|
31
|
+
});
|
|
32
|
+
const data = await res.json();
|
|
33
|
+
const u = data.usage || {};
|
|
34
|
+
const cached =
|
|
35
|
+
u.prompt_tokens_details?.cached_tokens ??
|
|
36
|
+
u.cached_tokens ??
|
|
37
|
+
u.cache_read_input_tokens ??
|
|
38
|
+
0;
|
|
39
|
+
console.log(
|
|
40
|
+
`[${label}] prompt_tokens=${u.prompt_tokens ?? "?"} cached_tokens=${cached} system_chars=${system.length}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const STATIC_SYSTEM_BASE = `You are a helpful assistant.
|
|
45
|
+
|
|
46
|
+
Tools available:
|
|
47
|
+
- foo: does foo
|
|
48
|
+
- bar: does bar
|
|
49
|
+
- baz: does baz
|
|
50
|
+
|
|
51
|
+
Always be concise.`;
|
|
52
|
+
|
|
53
|
+
const ADDITION_A = "\n\n<context>\n<known path=\"k1\">first known fact</known>\n</context>";
|
|
54
|
+
const ADDITION_B = "\n\n<context>\n<known path=\"k1\">first known fact</known>\n<known path=\"k2\">second known fact</known>\n</context>";
|
|
55
|
+
const ADDITION_C = "\n\n<context>\n<known path=\"k2\">second known fact</known>\n<known path=\"k1\">first known fact</known>\n</context>";
|
|
56
|
+
|
|
57
|
+
const USER = "Hello.";
|
|
58
|
+
|
|
59
|
+
console.log("=== Run 1: baseline (cold, then immediate repeat) ===");
|
|
60
|
+
await probe("1a baseline cold", STATIC_SYSTEM_BASE, USER);
|
|
61
|
+
await probe("1b same-as-1a ", STATIC_SYSTEM_BASE, USER);
|
|
62
|
+
|
|
63
|
+
console.log("\n=== Run 2: appended context (prefix unchanged) ===");
|
|
64
|
+
await probe("2a base + 1 ", STATIC_SYSTEM_BASE + ADDITION_A, USER);
|
|
65
|
+
await probe("2b base + 2 ", STATIC_SYSTEM_BASE + ADDITION_B, USER);
|
|
66
|
+
|
|
67
|
+
console.log("\n=== Run 3: reordered (entries shuffled) ===");
|
|
68
|
+
await probe("3a (k1,k2) ", STATIC_SYSTEM_BASE + ADDITION_B, USER);
|
|
69
|
+
await probe("3b (k2,k1) ", STATIC_SYSTEM_BASE + ADDITION_C, USER);
|
|
70
|
+
|
|
71
|
+
console.log("\n=== Run 4: mid-prefix character change ===");
|
|
72
|
+
const MIDDIFF = STATIC_SYSTEM_BASE.replace("baz", "qux");
|
|
73
|
+
await probe("4a stable base ", STATIC_SYSTEM_BASE, USER);
|
|
74
|
+
await probe("4b baz→qux ", MIDDIFF, USER);
|
package/service.js
CHANGED
|
@@ -43,11 +43,7 @@ if (!rummyHome) {
|
|
|
43
43
|
}
|
|
44
44
|
for (const path of [homeExample, homeEnv]) {
|
|
45
45
|
if (!existsSync(path)) continue;
|
|
46
|
-
|
|
47
|
-
process.loadEnvFile(path);
|
|
48
|
-
} catch (err) {
|
|
49
|
-
console.warn(`[RUMMY] Failed to load ${path}: ${err.message}`);
|
|
50
|
-
}
|
|
46
|
+
process.loadEnvFile(path);
|
|
51
47
|
}
|
|
52
48
|
}
|
|
53
49
|
}
|
|
@@ -136,11 +132,21 @@ async function main() {
|
|
|
136
132
|
}
|
|
137
133
|
}
|
|
138
134
|
|
|
139
|
-
// 6b. Database Hygiene
|
|
135
|
+
// 6b. Database Hygiene — opt-in via RUMMY_RETENTION_DAYS.
|
|
140
136
|
const { statSync } = await import("node:fs");
|
|
141
|
-
|
|
137
|
+
const retentionRaw = process.env.RUMMY_RETENTION_DAYS;
|
|
138
|
+
if (retentionRaw == null || retentionRaw === "") {
|
|
139
|
+
const dbSizeMB = (statSync(dbPath).size / 1024 / 1024).toFixed(2);
|
|
140
|
+
console.log(`[RUMMY] DB size: ${dbSizeMB}MB`);
|
|
141
|
+
} else {
|
|
142
|
+
const retentionDays = Number.parseInt(retentionRaw, 10);
|
|
143
|
+
if (!Number.isInteger(retentionDays) || retentionDays < 0) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Invalid RUMMY_RETENTION_DAYS=${JSON.stringify(retentionRaw)} ` +
|
|
146
|
+
"(expected non-negative integer)",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
142
149
|
const dbSizeBefore = statSync(dbPath).size;
|
|
143
|
-
const retentionDays = Number.parseInt(process.env.RUMMY_RETENTION_DAYS, 10);
|
|
144
150
|
await db.purge_old_runs.run({ retention_days: retentionDays });
|
|
145
151
|
const dbSizeAfter = statSync(dbPath).size;
|
|
146
152
|
const dbSizeMB = (dbSizeAfter / 1024 / 1024).toFixed(2);
|
|
@@ -153,8 +159,6 @@ async function main() {
|
|
|
153
159
|
if (dbSizeAfter > 100 * 1024 * 1024) {
|
|
154
160
|
console.warn(`[RUMMY] WARNING: Database exceeds 100MB. Consider manual cleanup.`);
|
|
155
161
|
}
|
|
156
|
-
} catch (err) {
|
|
157
|
-
console.warn(`[RUMMY] Hygiene skipped: ${err.message}`);
|
|
158
162
|
}
|
|
159
163
|
|
|
160
164
|
// 6b. Abort stuck runs (can't be running if the server just started)
|
|
@@ -164,8 +168,15 @@ async function main() {
|
|
|
164
168
|
console.log(`[RUMMY] Recovered ${aborted.changes} stuck run(s)`);
|
|
165
169
|
}
|
|
166
170
|
|
|
171
|
+
// 6c. Boot complete — DB open, plugins inited, models loaded,
|
|
172
|
+
// hygiene done. Plugins that need a one-shot post-boot action
|
|
173
|
+
// (e.g. the cli plugin firing a programmatic run) subscribe to
|
|
174
|
+
// this event. Fires BEFORE SocketServer so RPC clients can't
|
|
175
|
+
// race a one-shot run still being set up.
|
|
176
|
+
await hooks.boot.completed.emit({ db, hooks });
|
|
177
|
+
|
|
167
178
|
// 7. Start RPC Server
|
|
168
|
-
const port = Number.parseInt(process.env.
|
|
179
|
+
const port = Number.parseInt(process.env.RUMMY_PORT);
|
|
169
180
|
const server = new SocketServer(db, { port, hooks });
|
|
170
181
|
|
|
171
182
|
server.on("error", (err) => {
|
package/src/agent/AgentLoop.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { computeBudget } from "./budget.js";
|
|
2
1
|
import msg from "./messages.js";
|
|
3
2
|
|
|
4
3
|
const HTTP_TO_RUN_STATE = {
|
|
@@ -31,20 +30,13 @@ export default class AgentLoop {
|
|
|
31
30
|
if (active) active.controller.abort();
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
* Abort every in-flight run and wait for each drain to settle.
|
|
36
|
-
* Called from server close / client teardown so the process can
|
|
37
|
-
* exit cleanly instead of leaving detached kickoff Promises
|
|
38
|
-
* pinning the event loop.
|
|
39
|
-
*/
|
|
33
|
+
// Abort all in-flight runs and drain; rejections were already surfaced to original awaiters.
|
|
40
34
|
async abortAll() {
|
|
41
35
|
const promises = [];
|
|
42
36
|
for (const { controller, promise } of this.#activeRuns.values()) {
|
|
43
37
|
controller.abort();
|
|
44
38
|
promises.push(promise);
|
|
45
39
|
}
|
|
46
|
-
// allSettled: drain waits for every run to finish; rejections are
|
|
47
|
-
// already surfaced to whoever awaited the original run() call.
|
|
48
40
|
await Promise.allSettled(promises);
|
|
49
41
|
}
|
|
50
42
|
|
|
@@ -56,6 +48,24 @@ export default class AgentLoop {
|
|
|
56
48
|
return `Turn ${turn}/${maxTurns}`;
|
|
57
49
|
}
|
|
58
50
|
|
|
51
|
+
async #emitCompleted(hook, projectId, runId, out) {
|
|
52
|
+
const s = await this.#db.get_run_summary.get({ id: runId });
|
|
53
|
+
await hook.completed.emit({
|
|
54
|
+
projectId,
|
|
55
|
+
...out,
|
|
56
|
+
model: s.model,
|
|
57
|
+
turns: s.turns,
|
|
58
|
+
cost: s.cost,
|
|
59
|
+
tokens: {
|
|
60
|
+
prompt: s.prompt_tokens,
|
|
61
|
+
cached: s.cached_tokens,
|
|
62
|
+
completion: s.completion_tokens,
|
|
63
|
+
reasoning: s.reasoning_tokens,
|
|
64
|
+
total: s.total_tokens,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
59
69
|
async #setRunStatus(runId, alias, httpStatus) {
|
|
60
70
|
await this.#db.update_run_status.run({ id: runId, status: httpStatus });
|
|
61
71
|
const state = HTTP_TO_RUN_STATE[httpStatus];
|
|
@@ -68,76 +78,6 @@ export default class AgentLoop {
|
|
|
68
78
|
});
|
|
69
79
|
}
|
|
70
80
|
|
|
71
|
-
async #emitRunState({
|
|
72
|
-
projectId,
|
|
73
|
-
runId,
|
|
74
|
-
alias,
|
|
75
|
-
turn,
|
|
76
|
-
status,
|
|
77
|
-
contextSize,
|
|
78
|
-
result = null,
|
|
79
|
-
}) {
|
|
80
|
-
if (!contextSize) throw new Error("#emitRunState: contextSize is required");
|
|
81
|
-
const runUsage = await this.#db.get_run_usage.get({ run_id: runId });
|
|
82
|
-
const history = await this.#entries.getLog(runId);
|
|
83
|
-
const unknowns = await this.#entries.getUnknowns(runId);
|
|
84
|
-
const latestSummary = this.#hooks.instructions.findLatestSummary(history);
|
|
85
|
-
|
|
86
|
-
// Always emit complete telemetry. When we don't have a fresh turn
|
|
87
|
-
// result (abort/max-turns/crash), read the last turn's context
|
|
88
|
-
// tokens from the DB instead. Both code paths compute a real
|
|
89
|
-
// budget from real data — never undefined, never invented.
|
|
90
|
-
const rows = await this.#db.get_turn_context.all({
|
|
91
|
-
run_id: runId,
|
|
92
|
-
turn,
|
|
93
|
-
});
|
|
94
|
-
let totalTokens;
|
|
95
|
-
if (result) {
|
|
96
|
-
totalTokens = result.assembledTokens;
|
|
97
|
-
} else {
|
|
98
|
-
// No fresh turn result — this happens on abort/max-turns/crash
|
|
99
|
-
// emits that fire before any turn executed, or after a turn
|
|
100
|
-
// that never produced tokens. Read the last turn's assembled
|
|
101
|
-
// context_tokens from the DB; absent means no turn ran yet
|
|
102
|
-
// (zero is the truth, not a fallback).
|
|
103
|
-
const lastCtx = await this.#db.get_last_context_tokens.get({
|
|
104
|
-
run_id: runId,
|
|
105
|
-
});
|
|
106
|
-
totalTokens = lastCtx ? lastCtx.context_tokens : 0;
|
|
107
|
-
}
|
|
108
|
-
const budget = computeBudget({ rows, contextSize, totalTokens });
|
|
109
|
-
|
|
110
|
-
await this.#hooks.run.state.emit({
|
|
111
|
-
projectId,
|
|
112
|
-
run: alias,
|
|
113
|
-
turn,
|
|
114
|
-
status,
|
|
115
|
-
summary: latestSummary?.body,
|
|
116
|
-
history,
|
|
117
|
-
unknowns: unknowns.map((u) => ({ path: u.path, body: u.body })),
|
|
118
|
-
telemetry: {
|
|
119
|
-
modelAlias: result?.modelAlias,
|
|
120
|
-
model: result?.model,
|
|
121
|
-
temperature: result?.temperature,
|
|
122
|
-
context_size: contextSize,
|
|
123
|
-
context_tokens: totalTokens,
|
|
124
|
-
ceiling: budget.ceiling,
|
|
125
|
-
token_usage: budget.tokenUsage,
|
|
126
|
-
tokens_free: budget.tokensFree,
|
|
127
|
-
prompt_tokens: runUsage.prompt_tokens,
|
|
128
|
-
cached_tokens: runUsage.cached_tokens,
|
|
129
|
-
completion_tokens: runUsage.completion_tokens,
|
|
130
|
-
reasoning_tokens: runUsage.reasoning_tokens,
|
|
131
|
-
total_tokens: runUsage.total_tokens,
|
|
132
|
-
cost: runUsage.cost,
|
|
133
|
-
context_distribution: await this.#db.get_turn_distribution.all({
|
|
134
|
-
run_id: runId,
|
|
135
|
-
turn,
|
|
136
|
-
}),
|
|
137
|
-
},
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
|
|
141
81
|
async #writeRunEntry(
|
|
142
82
|
runId,
|
|
143
83
|
alias,
|
|
@@ -215,7 +155,6 @@ export default class AgentLoop {
|
|
|
215
155
|
const existing = this.#activeRuns.get(existingRun.id);
|
|
216
156
|
if (existing) existing.controller.abort();
|
|
217
157
|
|
|
218
|
-
// Clean up stale proposals from interrupted runs
|
|
219
158
|
const unresolved = await this.#entries.getUnresolved(existingRun.id);
|
|
220
159
|
for (const u of unresolved) {
|
|
221
160
|
await this.#entries.set({
|
|
@@ -228,7 +167,6 @@ export default class AgentLoop {
|
|
|
228
167
|
}
|
|
229
168
|
return { runId: existingRun.id, alias: existingRun.alias };
|
|
230
169
|
}
|
|
231
|
-
// Client-specified alias for a brand-new run — accept it verbatim.
|
|
232
170
|
}
|
|
233
171
|
|
|
234
172
|
const alias = run ? run : await this.#generateAlias(requestedModel);
|
|
@@ -314,8 +252,7 @@ export default class AgentLoop {
|
|
|
314
252
|
return { run: currentAlias, status: 100 };
|
|
315
253
|
}
|
|
316
254
|
|
|
317
|
-
//
|
|
318
|
-
// reach both — abort the controller, await the Promise's drain.
|
|
255
|
+
// Pair controller + Promise so abortAll can both signal and await drain.
|
|
319
256
|
const controller = new AbortController();
|
|
320
257
|
const promise = this.#drainQueue(
|
|
321
258
|
currentRunId,
|
|
@@ -465,7 +402,7 @@ export default class AgentLoop {
|
|
|
465
402
|
});
|
|
466
403
|
|
|
467
404
|
let loopIteration = 0;
|
|
468
|
-
const
|
|
405
|
+
const MAX_LOOP_TURNS = Number(process.env.RUMMY_MAX_LOOP_TURNS);
|
|
469
406
|
|
|
470
407
|
await this.#hooks.loop.started.emit({
|
|
471
408
|
runId: currentRunId,
|
|
@@ -475,31 +412,23 @@ export default class AgentLoop {
|
|
|
475
412
|
});
|
|
476
413
|
|
|
477
414
|
try {
|
|
478
|
-
while (loopIteration <
|
|
415
|
+
while (loopIteration < MAX_LOOP_TURNS) {
|
|
479
416
|
if (signal.aborted) {
|
|
480
417
|
console.error(
|
|
481
418
|
`[LOOP] ${currentAlias} iter=${loopIteration} ABORT via signal`,
|
|
482
419
|
);
|
|
483
420
|
await this.#setRunStatus(currentRunId, currentAlias, 499);
|
|
484
|
-
await this.#emitRunState({
|
|
485
|
-
projectId,
|
|
486
|
-
runId: currentRunId,
|
|
487
|
-
alias: currentAlias,
|
|
488
|
-
turn: loopIteration,
|
|
489
|
-
status: 499,
|
|
490
|
-
contextSize,
|
|
491
|
-
});
|
|
492
421
|
const out = {
|
|
493
422
|
run: currentAlias,
|
|
494
423
|
status: 499,
|
|
495
424
|
turn: loopIteration,
|
|
496
425
|
};
|
|
497
|
-
await hook
|
|
426
|
+
await this.#emitCompleted(hook, projectId, currentRunId, out);
|
|
498
427
|
return out;
|
|
499
428
|
}
|
|
500
429
|
loopIteration++;
|
|
501
430
|
console.error(
|
|
502
|
-
`[LOOP] ${currentAlias} iter=${loopIteration} ENTER (max=${
|
|
431
|
+
`[LOOP] ${currentAlias} iter=${loopIteration} ENTER (max=${MAX_LOOP_TURNS})`,
|
|
503
432
|
);
|
|
504
433
|
|
|
505
434
|
let turnPrompt;
|
|
@@ -508,7 +437,7 @@ export default class AgentLoop {
|
|
|
508
437
|
} else {
|
|
509
438
|
turnPrompt = this.#buildContinuationPrompt(
|
|
510
439
|
loopIteration,
|
|
511
|
-
|
|
440
|
+
MAX_LOOP_TURNS,
|
|
512
441
|
);
|
|
513
442
|
}
|
|
514
443
|
|
|
@@ -553,15 +482,6 @@ export default class AgentLoop {
|
|
|
553
482
|
`[LOOP] ${currentAlias} iter=${loopIteration} verdict: continue=${verdict.continue} status=${vStatus} reason=${vReason}`,
|
|
554
483
|
);
|
|
555
484
|
|
|
556
|
-
await this.#emitRunState({
|
|
557
|
-
projectId,
|
|
558
|
-
runId: currentRunId,
|
|
559
|
-
alias: currentAlias,
|
|
560
|
-
turn: result.turn,
|
|
561
|
-
status: verdict.continue ? 102 : verdict.status,
|
|
562
|
-
contextSize,
|
|
563
|
-
result,
|
|
564
|
-
});
|
|
565
485
|
await this.#hooks.run.step.completed.emit({
|
|
566
486
|
projectId,
|
|
567
487
|
run: currentAlias,
|
|
@@ -588,41 +508,24 @@ export default class AgentLoop {
|
|
|
588
508
|
turn: result.turn,
|
|
589
509
|
reason: verdict.reason,
|
|
590
510
|
};
|
|
591
|
-
await hook
|
|
511
|
+
await this.#emitCompleted(hook, projectId, currentRunId, out);
|
|
592
512
|
return out;
|
|
593
513
|
}
|
|
594
514
|
|
|
595
|
-
// MAX_TURNS exhaustion without a terminal update is abandonment.
|
|
596
515
|
console.error(
|
|
597
|
-
`[LOOP] ${currentAlias} hit
|
|
516
|
+
`[LOOP] ${currentAlias} hit MAX_LOOP_TURNS=${MAX_LOOP_TURNS} — abandoning at 499`,
|
|
598
517
|
);
|
|
599
518
|
await this.#setRunStatus(currentRunId, currentAlias, 499);
|
|
600
|
-
await this.#emitRunState({
|
|
601
|
-
projectId,
|
|
602
|
-
runId: currentRunId,
|
|
603
|
-
alias: currentAlias,
|
|
604
|
-
turn: loopIteration,
|
|
605
|
-
status: 499,
|
|
606
|
-
contextSize,
|
|
607
|
-
});
|
|
608
519
|
const out = {
|
|
609
520
|
run: currentAlias,
|
|
610
521
|
status: 499,
|
|
611
522
|
turn: loopIteration,
|
|
612
523
|
};
|
|
613
|
-
await hook
|
|
524
|
+
await this.#emitCompleted(hook, projectId, currentRunId, out);
|
|
614
525
|
return out;
|
|
615
526
|
} catch (err) {
|
|
616
527
|
const status = signal.aborted ? 499 : 500;
|
|
617
528
|
await this.#setRunStatus(currentRunId, currentAlias, status);
|
|
618
|
-
await this.#emitRunState({
|
|
619
|
-
projectId,
|
|
620
|
-
runId: currentRunId,
|
|
621
|
-
alias: currentAlias,
|
|
622
|
-
turn: loopIteration,
|
|
623
|
-
status,
|
|
624
|
-
contextSize,
|
|
625
|
-
});
|
|
626
529
|
if (status === 500) {
|
|
627
530
|
await this.#hooks.error.log.emit({
|
|
628
531
|
store: this.#entries,
|
|
@@ -634,7 +537,7 @@ export default class AgentLoop {
|
|
|
634
537
|
}
|
|
635
538
|
const out = { run: currentAlias, status, turn: loopIteration };
|
|
636
539
|
if (status === 500) out.error = err.message;
|
|
637
|
-
await hook
|
|
540
|
+
await this.#emitCompleted(hook, projectId, currentRunId, out);
|
|
638
541
|
return out;
|
|
639
542
|
} finally {
|
|
640
543
|
await this.#hooks.loop.completed.emit({
|
|
@@ -674,11 +577,7 @@ export default class AgentLoop {
|
|
|
674
577
|
db: this.#db,
|
|
675
578
|
entries: this.#entries,
|
|
676
579
|
});
|
|
677
|
-
//
|
|
678
|
-
// client's dispatch handler doesn't mistake a successful
|
|
679
|
-
// resolve's HTTP-style 200 ack for a terminal run status and
|
|
680
|
-
// prematurely close the document. Real terminal state comes
|
|
681
|
-
// from the run/state notification at end-of-turn.
|
|
580
|
+
// Return current run status (not 200) so client doesn't close on resolve ack.
|
|
682
581
|
return { run: runAlias, status: runRow.status };
|
|
683
582
|
}
|
|
684
583
|
|
|
@@ -698,8 +597,7 @@ export default class AgentLoop {
|
|
|
698
597
|
entries: this.#entries,
|
|
699
598
|
};
|
|
700
599
|
|
|
701
|
-
//
|
|
702
|
-
// First veto wins: state=failed with plugin-supplied outcome + body.
|
|
600
|
+
// First plugin veto wins via proposal.accepting (e.g. readonly).
|
|
703
601
|
if (action === "accept") {
|
|
704
602
|
const veto = await this.#hooks.proposal.accepting.filter(null, ctx);
|
|
705
603
|
if (veto?.allow === false) {
|
|
@@ -714,9 +612,7 @@ export default class AgentLoop {
|
|
|
714
612
|
}
|
|
715
613
|
}
|
|
716
614
|
|
|
717
|
-
//
|
|
718
|
-
// override via proposal.content (e.g. set prefers the existing
|
|
719
|
-
// proposed body from the log entry).
|
|
615
|
+
// proposal.content override lets plugins prefer the proposed body (e.g. set).
|
|
720
616
|
const defaultBody = output ? output : "";
|
|
721
617
|
const resolvedBody = await this.#hooks.proposal.content.filter(
|
|
722
618
|
defaultBody,
|
|
@@ -741,9 +637,7 @@ export default class AgentLoop {
|
|
|
741
637
|
: this.#hooks.proposal.rejected;
|
|
742
638
|
await event.emit({ ...ctx, resolvedBody });
|
|
743
639
|
|
|
744
|
-
//
|
|
745
|
-
// (102 mid-run) rather than a hardcoded 200 so the nvim client
|
|
746
|
-
// doesn't treat the RPC ack as a terminal signal.
|
|
640
|
+
// Return current run status (not 200) so client doesn't close on resolve ack.
|
|
747
641
|
return { run: runAlias, status: runRow.status };
|
|
748
642
|
}
|
|
749
643
|
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Thin orchestrator. Computes loopStartTurn from the rows,
|
|
3
|
-
* then invokes assembly.system and assembly.user filter chains.
|
|
4
|
-
* All rendering logic lives in plugins.
|
|
5
|
-
*/
|
|
1
|
+
// Orchestrates assembly.system / assembly.user filter chains; plugins do all rendering.
|
|
6
2
|
export default class ContextAssembler {
|
|
7
3
|
static async assembleFromTurnContext(
|
|
8
4
|
rows,
|
|
@@ -10,15 +6,13 @@ export default class ContextAssembler {
|
|
|
10
6
|
type = "ask",
|
|
11
7
|
systemPrompt = "",
|
|
12
8
|
contextSize = 0,
|
|
13
|
-
demoted = [],
|
|
14
9
|
toolSet = null,
|
|
15
10
|
lastContextTokens = 0,
|
|
16
11
|
turn = 1,
|
|
17
12
|
} = {},
|
|
18
13
|
hooks,
|
|
19
14
|
) {
|
|
20
|
-
//
|
|
21
|
-
// the prompt plugin's turn.started handler has run.
|
|
15
|
+
// Loop boundary from active prompt; absent on turn 1 before prompt plugin's turn.started.
|
|
22
16
|
const promptEntry = rows.findLast(
|
|
23
17
|
(r) => r.category === "prompt" && r.scheme === "prompt",
|
|
24
18
|
);
|
|
@@ -31,7 +25,6 @@ export default class ContextAssembler {
|
|
|
31
25
|
type,
|
|
32
26
|
contextSize,
|
|
33
27
|
lastContextTokens,
|
|
34
|
-
demoted,
|
|
35
28
|
toolSet,
|
|
36
29
|
turn,
|
|
37
30
|
};
|