@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
package/src/agent/budget.js
CHANGED
|
@@ -1,46 +1,23 @@
|
|
|
1
|
+
import config from "./config.js";
|
|
1
2
|
import { countTokens } from "./tokens.js";
|
|
2
3
|
|
|
3
|
-
const CEILING_RATIO =
|
|
4
|
-
if (!CEILING_RATIO) throw new Error("RUMMY_BUDGET_CEILING must be set");
|
|
4
|
+
const CEILING_RATIO = config.BUDGET_CEILING;
|
|
5
5
|
|
|
6
6
|
export function ceiling(contextSize) {
|
|
7
7
|
return Math.floor(contextSize * CEILING_RATIO);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
* Sum assembled-message token counts.
|
|
12
|
-
* Used by the budget enforce gate, which has the real messages.
|
|
13
|
-
*/
|
|
10
|
+
// Sum assembled-message token counts; used by the budget enforce gate.
|
|
14
11
|
export function measureMessages(messages) {
|
|
15
12
|
return messages.reduce((sum, m) => sum + countTokens(m.content), 0);
|
|
16
13
|
}
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
* Sum projected row body token counts — what's actually in the packet
|
|
20
|
-
* for each entry at its current visibility. Used by prompt.js while
|
|
21
|
-
* generating the <prompt> tag (before assembly completes).
|
|
22
|
-
*/
|
|
15
|
+
// Sum projected row body token counts; used by prompt.js pre-assembly.
|
|
23
16
|
export function measureRows(rows) {
|
|
24
17
|
return rows.reduce((sum, r) => sum + countTokens(r.body), 0);
|
|
25
18
|
}
|
|
26
19
|
|
|
27
|
-
|
|
28
|
-
* Single source of truth for budget numbers. Every caller — prompt.js
|
|
29
|
-
* generating the <prompt> tag, budget.js enforcing the ceiling,
|
|
30
|
-
* AgentLoop emitting telemetry — passes in its own measured totalTokens
|
|
31
|
-
* and reads the same object back. No fallbacks: callers produce the
|
|
32
|
-
* measurement they have.
|
|
33
|
-
*
|
|
34
|
-
* Returns:
|
|
35
|
-
* ceiling — floor(contextSize × CEILING_RATIO), the hard wall
|
|
36
|
-
* totalTokens — echoed back (the full packet size the caller measured)
|
|
37
|
-
* tokenUsage — same as totalTokens. Kept under this name for the
|
|
38
|
-
* `<prompt tokenUsage="N">` attribute on the wire. Must
|
|
39
|
-
* agree with totalTokens so the model's math is honest.
|
|
40
|
-
* tokensFree — ceiling − totalTokens (floor 0)
|
|
41
|
-
* overflow — max(0, totalTokens − ceiling)
|
|
42
|
-
* ok — overflow === 0
|
|
43
|
-
*/
|
|
20
|
+
// Single source of truth for budget numbers; tokenUsage echoes totalTokens for the wire attribute.
|
|
44
21
|
export function computeBudget({ contextSize, totalTokens }) {
|
|
45
22
|
const cap = ceiling(contextSize);
|
|
46
23
|
const tokensFree = Math.max(0, cap - totalTokens);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Validates required RUMMY_* env at module load; defaults in .env.example.
|
|
2
|
+
|
|
3
|
+
const REQUIRED = {
|
|
4
|
+
BUDGET_CEILING: { env: "RUMMY_BUDGET_CEILING", parse: Number },
|
|
5
|
+
LLM_DEADLINE: { env: "RUMMY_LLM_DEADLINE", parse: Number },
|
|
6
|
+
LLM_MAX_BACKOFF: { env: "RUMMY_LLM_MAX_BACKOFF", parse: Number },
|
|
7
|
+
FETCH_TIMEOUT: { env: "RUMMY_FETCH_TIMEOUT", parse: Number },
|
|
8
|
+
MAX_STRIKES: { env: "RUMMY_MAX_STRIKES", parse: Number },
|
|
9
|
+
MIN_CYCLES: { env: "RUMMY_MIN_CYCLES", parse: Number },
|
|
10
|
+
MAX_CYCLE_PERIOD: { env: "RUMMY_MAX_CYCLE_PERIOD", parse: Number },
|
|
11
|
+
RUN_TIMEOUT: { env: "RUMMY_RUN_TIMEOUT", parse: Number },
|
|
12
|
+
PLUGINS_LOAD_TIMEOUT: { env: "RUMMY_PLUGINS_LOAD_TIMEOUT", parse: Number },
|
|
13
|
+
THINK: { env: "RUMMY_THINK", parse: (v) => v },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const config = {};
|
|
17
|
+
const missing = [];
|
|
18
|
+
for (const [key, spec] of Object.entries(REQUIRED)) {
|
|
19
|
+
const raw = process.env[spec.env];
|
|
20
|
+
if (raw === undefined || raw === "") {
|
|
21
|
+
missing.push(spec.env);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const parsed = spec.parse(raw);
|
|
25
|
+
if (typeof parsed === "number" && Number.isNaN(parsed)) {
|
|
26
|
+
missing.push(`${spec.env} (got "${raw}", expected number)`);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
config[key] = parsed;
|
|
30
|
+
}
|
|
31
|
+
if (missing.length > 0) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`RUMMY config missing or invalid: ${missing.join(", ")}. ` +
|
|
34
|
+
"Set in .env, .env.example, or shell env.",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default Object.freeze(config);
|
package/src/agent/errors.js
CHANGED
|
@@ -1,21 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
* Typed errors for the agent/Entries layer. Callers catch by type,
|
|
3
|
-
* not by regex.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Thrown when a writer tier isn't permitted to write to a scheme.
|
|
8
|
-
* See SPEC writer_tiers: schemes declare writable_by = subset of
|
|
9
|
-
* {system, plugin, client, model}. A write from an excluded tier
|
|
10
|
-
* rejects with this error.
|
|
11
|
-
*/
|
|
1
|
+
// Writer tier excluded from scheme.writable_by; see SPEC writer_tiers.
|
|
12
2
|
export class PermissionError extends Error {
|
|
13
3
|
constructor(scheme, writer, allowed) {
|
|
4
|
+
// Paths without `://` have a null scheme. Report null verbatim
|
|
5
|
+
// rather than substituting a plausible-sounding "file" — there is
|
|
6
|
+
// no scheme called "file" and the error must reflect actual state.
|
|
7
|
+
const schemeLabel = scheme === null ? "(none)" : scheme;
|
|
14
8
|
super(
|
|
15
|
-
`403: writer "${writer}" not permitted for scheme "${
|
|
9
|
+
`403: writer "${writer}" not permitted for scheme "${schemeLabel}" (allowed: ${allowed.join(", ")})`,
|
|
16
10
|
);
|
|
17
11
|
this.name = "PermissionError";
|
|
18
|
-
this.scheme = scheme
|
|
12
|
+
this.scheme = scheme;
|
|
19
13
|
this.writer = writer;
|
|
20
14
|
this.allowed = [...allowed];
|
|
21
15
|
}
|
package/src/agent/httpStatus.js
CHANGED
|
@@ -1,22 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Map the entry-layer (state, outcome) tuple to an HTTP status number for
|
|
3
|
-
* model-facing tag rendering.
|
|
4
|
-
*
|
|
5
|
-
* Model-facing tags still carry `status="NNN"` because the model's
|
|
6
|
-
* vocabulary (instructions + tooldocs + training) is HTTP-shaped. The DB
|
|
7
|
-
* stores categorical state + textual outcome (see SPEC entries); this helper
|
|
8
|
-
* is the one-way translation for rendering.
|
|
9
|
-
*
|
|
10
|
-
* Outcome strings prefixed with a 3-digit HTTP code (e.g.
|
|
11
|
-
* `"overflow:413:..."` or `"permission:403:..."`) extract the code
|
|
12
|
-
* verbatim. Otherwise state maps to a canonical HTTP:
|
|
13
|
-
*
|
|
14
|
-
* resolved → 200
|
|
15
|
-
* proposed → 202
|
|
16
|
-
* streaming → 102
|
|
17
|
-
* cancelled → 499
|
|
18
|
-
* failed → 500 (unless outcome carries a code)
|
|
19
|
-
*/
|
|
1
|
+
// (state, outcome) → HTTP status for model-facing tags; outcome's 3-digit prefix wins.
|
|
20
2
|
export function stateToStatus(state, outcome = null) {
|
|
21
3
|
if (outcome) {
|
|
22
4
|
const match = /(\d{3})/.exec(outcome);
|
|
@@ -226,8 +226,10 @@ WHERE run_id = :run_id AND entry_id IN (
|
|
|
226
226
|
-- Default excludes audit schemes (system://, reasoning://, model://, user://,
|
|
227
227
|
-- assistant://, content://, instructions://) so model-facing tools never leak
|
|
228
228
|
-- internal entries. Internal callers that need them pass include_audit_schemes=1.
|
|
229
|
+
-- :since filters to entries created after a given id; when set, results order
|
|
230
|
+
-- by id (insertion order) for streaming consumers; otherwise by path.
|
|
229
231
|
SELECT
|
|
230
|
-
e.path, e.body, e.scheme, rv.state, rv.outcome, rv.visibility
|
|
232
|
+
e.id, e.path, e.body, e.scheme, rv.state, rv.outcome, rv.visibility, rv.turn
|
|
231
233
|
, countTokens(e.body) AS tokens, e.attributes
|
|
232
234
|
FROM run_views AS rv
|
|
233
235
|
JOIN entries AS e ON e.id = rv.entry_id
|
|
@@ -237,7 +239,10 @@ WHERE
|
|
|
237
239
|
AND hedmatch(:path, e.path)
|
|
238
240
|
AND (:body IS NULL OR hedsearch(:body, e.body))
|
|
239
241
|
AND (:include_audit_schemes IS NOT NULL OR s.model_visible = 1)
|
|
240
|
-
|
|
242
|
+
AND (:since IS NULL OR e.id > :since)
|
|
243
|
+
ORDER BY
|
|
244
|
+
CASE WHEN :since IS NOT NULL THEN e.id ELSE 0 END,
|
|
245
|
+
e.path
|
|
241
246
|
LIMIT
|
|
242
247
|
COALESCE(:limit, -1)
|
|
243
248
|
OFFSET COALESCE(:offset, 0);
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import ContextAssembler from "./ContextAssembler.js";
|
|
2
2
|
import { countLines, countTokens } from "./tokens.js";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
* Rebuild turn_context from v_model_context, then assemble messages.
|
|
6
|
-
* Called at turn start and again by the budget plugin when it needs a
|
|
7
|
-
* fresh measurement after mutating visibility.
|
|
8
|
-
*/
|
|
4
|
+
// Rebuild turn_context from v_model_context and assemble messages.
|
|
9
5
|
export default async function materializeContext({
|
|
10
6
|
db,
|
|
11
7
|
hooks,
|
|
@@ -16,22 +12,15 @@ export default async function materializeContext({
|
|
|
16
12
|
mode,
|
|
17
13
|
toolSet,
|
|
18
14
|
contextSize,
|
|
19
|
-
demoted,
|
|
20
15
|
}) {
|
|
21
16
|
await db.clear_turn_context.run({ run_id: runId, turn });
|
|
22
17
|
const viewRows = await db.get_model_context.all({ run_id: runId });
|
|
23
|
-
// Per-entry token accounting
|
|
24
|
-
// here while we still have the raw body, then merged onto rows after
|
|
25
|
-
// the read-back roundtrip through turn_context.
|
|
18
|
+
// Per-entry token accounting; merged back after the turn_context roundtrip.
|
|
26
19
|
const tokenAccounting = new Map();
|
|
27
20
|
for (const row of viewRows) {
|
|
28
|
-
// schemeOf() yields NULL (or "") for bare file paths — translate
|
|
29
|
-
// to "file" so the view lookup finds the file scheme handler.
|
|
30
21
|
const scheme = row.scheme ? row.scheme : "file";
|
|
31
22
|
const attrs = row.attributes ? JSON.parse(row.attributes) : null;
|
|
32
|
-
//
|
|
33
|
-
// to the action plugin's view (set, update, search, etc.) by
|
|
34
|
-
// extracting the action segment from the path.
|
|
23
|
+
// Dispatch log entries to their action plugin's view via path segment.
|
|
35
24
|
let projectionKey = scheme;
|
|
36
25
|
if (scheme === "log") {
|
|
37
26
|
const m = row.path.match(/^log:\/\/turn_\d+\/([^/]+)\//);
|
|
@@ -55,7 +44,13 @@ export default async function materializeContext({
|
|
|
55
44
|
const vTokens = countTokens(visibleProjection);
|
|
56
45
|
const sTokens = countTokens(summarizedProjection);
|
|
57
46
|
const vLines = countLines(visibleProjection);
|
|
58
|
-
tokenAccounting.set(row.path, {
|
|
47
|
+
tokenAccounting.set(row.path, {
|
|
48
|
+
vTokens,
|
|
49
|
+
sTokens,
|
|
50
|
+
vLines,
|
|
51
|
+
vBody: visibleProjection,
|
|
52
|
+
sBody: summarizedProjection,
|
|
53
|
+
});
|
|
59
54
|
const projectedBody =
|
|
60
55
|
row.visibility === "visible" ? visibleProjection : summarizedProjection;
|
|
61
56
|
await db.insert_turn_context.run({
|
|
@@ -81,9 +76,10 @@ export default async function materializeContext({
|
|
|
81
76
|
row.sTokens = t.sTokens;
|
|
82
77
|
row.aTokens = t.vTokens - t.sTokens;
|
|
83
78
|
row.vLines = t.vLines;
|
|
79
|
+
row.vBody = t.vBody;
|
|
80
|
+
row.sBody = t.sBody;
|
|
84
81
|
}
|
|
85
82
|
const lastCtx = await db.get_last_context_tokens.get({ run_id: runId });
|
|
86
|
-
// First turn of a new run has no prior context.
|
|
87
83
|
let lastContextTokens = 0;
|
|
88
84
|
if (lastCtx) lastContextTokens = lastCtx.context_tokens;
|
|
89
85
|
|
|
@@ -93,7 +89,6 @@ export default async function materializeContext({
|
|
|
93
89
|
type: mode,
|
|
94
90
|
systemPrompt,
|
|
95
91
|
contextSize,
|
|
96
|
-
demoted,
|
|
97
92
|
toolSet,
|
|
98
93
|
lastContextTokens,
|
|
99
94
|
turn,
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Single source of truth for path-segment encoding: spaces → _, then URL-encode.
|
|
2
|
+
// Used by slugify (for summary-derived slugs) and Entries (for normalize/dedup/logPath).
|
|
3
|
+
export default function encodeSegment(s) {
|
|
4
|
+
return encodeURIComponent(String(s).replace(/ /g, "_"));
|
|
5
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// RUMMY_HOME default per README §Installation; resolved here because
|
|
5
|
+
// entrypoints run before env files load.
|
|
6
|
+
export default function resolveRummyHome() {
|
|
7
|
+
if (process.env.RUMMY_HOME) return process.env.RUMMY_HOME;
|
|
8
|
+
return join(homedir(), ".rummy");
|
|
9
|
+
}
|
package/src/agent/runs.sql
CHANGED
|
@@ -56,6 +56,24 @@ LIMIT
|
|
|
56
56
|
OFFSET
|
|
57
57
|
COALESCE(:offset, 0);
|
|
58
58
|
|
|
59
|
+
-- PREP: get_run_summary
|
|
60
|
+
-- Per-run aggregation across all turns. LEFT JOIN: a run with zero
|
|
61
|
+
-- recorded turns (e.g. signal abort before first turn) returns 0s,
|
|
62
|
+
-- not NULL.
|
|
63
|
+
SELECT
|
|
64
|
+
r.model AS model
|
|
65
|
+
, COUNT(t.id) AS turns
|
|
66
|
+
, COALESCE(SUM(t.cost), 0) AS cost
|
|
67
|
+
, COALESCE(SUM(t.prompt_tokens), 0) AS prompt_tokens
|
|
68
|
+
, COALESCE(SUM(t.cached_tokens), 0) AS cached_tokens
|
|
69
|
+
, COALESCE(SUM(t.completion_tokens), 0) AS completion_tokens
|
|
70
|
+
, COALESCE(SUM(t.reasoning_tokens), 0) AS reasoning_tokens
|
|
71
|
+
, COALESCE(SUM(t.total_tokens), 0) AS total_tokens
|
|
72
|
+
FROM runs AS r
|
|
73
|
+
LEFT JOIN turns AS t ON t.run_id = r.id
|
|
74
|
+
WHERE r.id = :id
|
|
75
|
+
GROUP BY r.id;
|
|
76
|
+
|
|
59
77
|
-- PREP: rename_run
|
|
60
78
|
UPDATE runs
|
|
61
79
|
SET alias = :new_alias
|
package/src/agent/tokens.js
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Token estimation. Conservative character-based approximation.
|
|
3
|
-
* RUMMY_TOKEN_DIVISOR controls characters per token.
|
|
4
|
-
* No external dependencies. The budget contract is exact.
|
|
5
|
-
* contextSize is the ceiling. countTokens is the measurement.
|
|
6
|
-
*/
|
|
7
|
-
|
|
1
|
+
// Conservative chars/token approximation; RUMMY_TOKEN_DIVISOR controls the divisor.
|
|
8
2
|
const DIVISOR = Number(process.env.RUMMY_TOKEN_DIVISOR);
|
|
9
3
|
if (!DIVISOR) throw new Error("RUMMY_TOKEN_DIVISOR must be a non-zero number");
|
|
10
4
|
|
|
@@ -15,6 +9,6 @@ export function countTokens(text) {
|
|
|
15
9
|
|
|
16
10
|
export function countLines(text) {
|
|
17
11
|
if (!text) return 0;
|
|
18
|
-
const newlines =
|
|
12
|
+
const newlines = text.split("\n").length - 1;
|
|
19
13
|
return text.endsWith("\n") ? newlines : newlines + 1;
|
|
20
14
|
}
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* HookRegistry manages a simple, priority-ordered pipeline of processors.
|
|
3
|
-
* It also supports basic event emitters for side-effects.
|
|
4
|
-
*/
|
|
1
|
+
// Priority-ordered processors + filters + events.
|
|
5
2
|
export default class HookRegistry {
|
|
6
3
|
#processors = [];
|
|
7
4
|
#events = new Map();
|
|
@@ -12,17 +9,11 @@ export default class HookRegistry {
|
|
|
12
9
|
this.#debug = debug;
|
|
13
10
|
}
|
|
14
11
|
|
|
15
|
-
/**
|
|
16
|
-
* Register a processor for the Turn XML Document.
|
|
17
|
-
*/
|
|
18
12
|
onTurn(callback, priority = 10) {
|
|
19
13
|
this.#processors.push({ callback, priority });
|
|
20
14
|
this.#processors.sort((a, b) => a.priority - b.priority);
|
|
21
15
|
}
|
|
22
16
|
|
|
23
|
-
/**
|
|
24
|
-
* Run all registered Turn processors.
|
|
25
|
-
*/
|
|
26
17
|
async processTurn(rummy) {
|
|
27
18
|
for (const p of this.#processors) {
|
|
28
19
|
const start = performance.now();
|
|
@@ -35,9 +26,6 @@ export default class HookRegistry {
|
|
|
35
26
|
}
|
|
36
27
|
}
|
|
37
28
|
|
|
38
|
-
/**
|
|
39
|
-
* Standard WordPress-style Filters for non-DOM data.
|
|
40
|
-
*/
|
|
41
29
|
addFilter(tag, callback, priority = 10) {
|
|
42
30
|
if (!this.#filters.has(tag)) this.#filters.set(tag, []);
|
|
43
31
|
this.#filters.get(tag).push({ callback, priority });
|
|
@@ -54,9 +42,6 @@ export default class HookRegistry {
|
|
|
54
42
|
return result;
|
|
55
43
|
}
|
|
56
44
|
|
|
57
|
-
/**
|
|
58
|
-
* Standard WordPress-style Events for side-effects.
|
|
59
|
-
*/
|
|
60
45
|
addEvent(tag, callback, priority = 10) {
|
|
61
46
|
if (!this.#events.has(tag)) this.#events.set(tag, []);
|
|
62
47
|
this.#events.get(tag).push({ callback, priority });
|
package/src/hooks/Hooks.js
CHANGED
|
@@ -2,10 +2,7 @@ import HookRegistry from "./HookRegistry.js";
|
|
|
2
2
|
import RpcRegistry from "./RpcRegistry.js";
|
|
3
3
|
import ToolRegistry from "./ToolRegistry.js";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
* createHooks returns a structured, strictly-typed API for registering
|
|
7
|
-
* and emitting hooks, removing the dynamic stringly-typed Proxy magic.
|
|
8
|
-
*/
|
|
5
|
+
// Strictly-typed hook surface; replaces the previous Proxy magic.
|
|
9
6
|
export default function createHooks(debug = false) {
|
|
10
7
|
const registry = new HookRegistry(debug);
|
|
11
8
|
const tools = new ToolRegistry();
|
|
@@ -28,6 +25,10 @@ export default function createHooks(debug = false) {
|
|
|
28
25
|
processTurn: registry.processTurn.bind(registry),
|
|
29
26
|
|
|
30
27
|
// Explicit Hook Schema
|
|
28
|
+
boot: {
|
|
29
|
+
// Post-init, pre-accept-connections; one-shot post-init actions subscribe here.
|
|
30
|
+
completed: createEvent("boot.completed"),
|
|
31
|
+
},
|
|
31
32
|
project: {
|
|
32
33
|
init: {
|
|
33
34
|
started: createEvent("project.init.started"),
|
|
@@ -43,8 +44,6 @@ export default function createHooks(debug = false) {
|
|
|
43
44
|
run: {
|
|
44
45
|
created: createEvent("run.created"),
|
|
45
46
|
started: createEvent("run.started"),
|
|
46
|
-
progress: createEvent("run.progress"),
|
|
47
|
-
state: createEvent("run.state"),
|
|
48
47
|
config: createFilter("run.config"),
|
|
49
48
|
step: {
|
|
50
49
|
completed: createEvent("run.step.completed"),
|
|
@@ -59,21 +58,13 @@ export default function createHooks(debug = false) {
|
|
|
59
58
|
response: createEvent("turn.response"),
|
|
60
59
|
completed: createEvent("turn.completed"),
|
|
61
60
|
},
|
|
61
|
+
// SPEC #resolution covers the proposal hook chain.
|
|
62
62
|
proposal: {
|
|
63
63
|
prepare: createEvent("proposal.prepare"),
|
|
64
64
|
pending: createEvent("proposal.pending"),
|
|
65
|
-
// Plugins veto acceptance by returning {allow:false, outcome, body}.
|
|
66
|
-
// Used e.g. by set plugin's readonly constraint check.
|
|
67
65
|
accepting: createFilter("proposal.accepting"),
|
|
68
|
-
// Plugins compose the resolved body based on path/action. Default
|
|
69
|
-
// is output || "". Used e.g. by set plugin to preserve the
|
|
70
|
-
// model's proposed content as the resolved body.
|
|
71
66
|
content: createFilter("proposal.content"),
|
|
72
|
-
// Fires after a proposal resolves with action="accept". Plugins
|
|
73
|
-
// perform their side effects (file materialize, unlink, stream
|
|
74
|
-
// setup, etc.) here — NOT in AgentLoop.resolve.
|
|
75
67
|
accepted: createEvent("proposal.accepted"),
|
|
76
|
-
// Fires after a proposal resolves with action="error" or "reject".
|
|
77
68
|
rejected: createEvent("proposal.rejected"),
|
|
78
69
|
},
|
|
79
70
|
assembly: {
|
|
@@ -98,24 +89,9 @@ export default function createHooks(debug = false) {
|
|
|
98
89
|
},
|
|
99
90
|
messages: createFilter("llm.messages"),
|
|
100
91
|
response: createFilter("llm.response"),
|
|
101
|
-
//
|
|
102
|
-
// reasoning text (e.g. the think plugin's <think>…</think>)
|
|
103
|
-
// to the model's reasoning_content field. Fires between parse
|
|
104
|
-
// and turn.response.
|
|
92
|
+
// Plugins contribute reasoning text into reasoning_content; fires between parse and turn.response.
|
|
105
93
|
reasoning: createFilter("llm.reasoning"),
|
|
106
|
-
//
|
|
107
|
-
// {
|
|
108
|
-
// name: string,
|
|
109
|
-
// matches: (modelAlias) => boolean,
|
|
110
|
-
// completion: (messages, modelAlias, options) => Promise<response>,
|
|
111
|
-
// getContextSize: (modelAlias) => Promise<number>,
|
|
112
|
-
// }
|
|
113
|
-
// Each provider owns a prefix namespace (e.g. "openai/", "ollama/",
|
|
114
|
-
// "openrouter/"). LlmProvider picks the first provider whose
|
|
115
|
-
// matches() returns true. No catchall — if a model alias doesn't
|
|
116
|
-
// match any registered provider, the request fails with a clear
|
|
117
|
-
// "no provider registered" error. External plugins add new
|
|
118
|
-
// prefixes without namespace collision.
|
|
94
|
+
// Provider entries: { name, matches, completion, getContextSize }.
|
|
119
95
|
providers: [],
|
|
120
96
|
},
|
|
121
97
|
file: {},
|
|
@@ -163,7 +139,6 @@ export default function createHooks(debug = false) {
|
|
|
163
139
|
agent: {},
|
|
164
140
|
tools,
|
|
165
141
|
|
|
166
|
-
// Utility to add raw filters/events directly if needed for tests
|
|
167
142
|
addFilter: registry.addFilter.bind(registry),
|
|
168
143
|
applyFilters: registry.applyFilters.bind(registry),
|
|
169
144
|
addEvent: registry.addEvent.bind(registry),
|
|
@@ -1,12 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* PluginContext is the plugin-only interface to the rummy system.
|
|
3
|
-
* Available as `rummy.core` on the per-turn RummyContext, and as the
|
|
4
|
-
* direct object passed to plugin constructors at startup.
|
|
5
|
-
*
|
|
6
|
-
* Carries plugin identity, hook registration, and infrastructure access.
|
|
7
|
-
* The unified API (tool verbs, queries) lives on RummyContext.
|
|
8
|
-
* This is the tier boundary: clients can't reach core.
|
|
9
|
-
*/
|
|
1
|
+
// Plugin-only registration interface; tool verbs live on RummyContext. PLUGINS.md.
|
|
10
2
|
export default class PluginContext {
|
|
11
3
|
#name;
|
|
12
4
|
#hooks;
|
|
@@ -77,19 +69,12 @@ export default class PluginContext {
|
|
|
77
69
|
this.#hooks.tools.ensureTool(this.#name);
|
|
78
70
|
}
|
|
79
71
|
|
|
80
|
-
//
|
|
81
|
-
// Handler still dispatches if the model emits the tag.
|
|
72
|
+
// Hide from tool lists; handler still dispatches if the model emits the tag.
|
|
82
73
|
markHidden() {
|
|
83
74
|
this.#hooks.tools.markHidden(this.#name);
|
|
84
75
|
}
|
|
85
76
|
|
|
86
|
-
|
|
87
|
-
* Register a named callback for this plugin.
|
|
88
|
-
* "handler" registers the tool handler.
|
|
89
|
-
* "visible"/"summarized" register visibility projections.
|
|
90
|
-
* "docs" sets tool documentation.
|
|
91
|
-
* Everything else resolves to a hook event.
|
|
92
|
-
*/
|
|
77
|
+
// "handler" / "visible" / "summarized" are special; everything else is a hook event name.
|
|
93
78
|
on(event, callback, priority = 10) {
|
|
94
79
|
if (event === "handler") {
|
|
95
80
|
this.#hooks.tools.ensureTool(this.#name);
|
|
@@ -104,9 +89,6 @@ export default class PluginContext {
|
|
|
104
89
|
if (hook) hook.on(callback, priority);
|
|
105
90
|
}
|
|
106
91
|
|
|
107
|
-
/**
|
|
108
|
-
* Register a filter callback.
|
|
109
|
-
*/
|
|
110
92
|
filter(name, callback, priority = 10) {
|
|
111
93
|
const hook = this.#resolveFilter(name);
|
|
112
94
|
if (hook) hook.addFilter(callback, priority);
|
package/src/hooks/RpcRegistry.js
CHANGED
|
@@ -26,10 +26,7 @@ export default class RpcRegistry {
|
|
|
26
26
|
|
|
27
27
|
#toolFallback = null;
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
* Set a fallback that auto-dispatches any registered tool via RPC.
|
|
31
|
-
* Checked at request time — tools registered after this call still work.
|
|
32
|
-
*/
|
|
29
|
+
// Late-binding tool dispatcher; resolved per request.
|
|
33
30
|
setToolFallback(hooks, buildRunContext, dispatchTool) {
|
|
34
31
|
this.#toolFallback = { hooks, buildRunContext, dispatchTool };
|
|
35
32
|
}
|
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
* RummyContext provides a unified, semantic API for plugins to interact with
|
|
3
|
-
* the Turn node tree and core resources like the Database and Project metadata.
|
|
4
|
-
*/
|
|
5
|
-
// Entries write verbs that should automatically carry the caller's
|
|
6
|
-
// writer identity. Handler-issued writes on behalf of the model default
|
|
7
|
-
// to writer=model; plugin background writes (set via rummy from a hook
|
|
8
|
-
// with writer: "plugin" or "system" in ctx) get the context's writer.
|
|
1
|
+
// Per-turn plugin API (see PLUGINS.md); write verbs auto-carry writer identity.
|
|
9
2
|
const WRITE_VERBS = new Set(["set", "rm", "cp", "mv", "update"]);
|
|
10
3
|
|
|
11
|
-
// Defaults applied at construction so every plugin-facing getter
|
|
12
|
-
// returns a predictable shape without per-access fallbacks.
|
|
13
4
|
const CONTEXT_DEFAULTS = Object.freeze({
|
|
14
5
|
hooks: null,
|
|
15
6
|
activeFiles: [],
|
|
@@ -126,12 +117,7 @@ export default class RummyContext {
|
|
|
126
117
|
return this.#context.loopPrompt;
|
|
127
118
|
}
|
|
128
119
|
|
|
129
|
-
|
|
130
|
-
* Writer identity for Entries permission checks. Defaults to
|
|
131
|
-
* 'model' — handlers write on behalf of the model's emitted command.
|
|
132
|
-
* Non-handler plugin code (streaming callbacks, background emissions)
|
|
133
|
-
* passes `writer: 'plugin'` or `'system'` explicitly.
|
|
134
|
-
*/
|
|
120
|
+
// Default 'model' (handlers write on the model's behalf); plugins pass writer explicitly.
|
|
135
121
|
get writer() {
|
|
136
122
|
return this.#context.writer;
|
|
137
123
|
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Position in the list implies priority to the model.
|
|
3
|
-
// `update` is pinned last — it's the turn-closer, not an action.
|
|
1
|
+
// gather → reason → act → communicate; update pinned last (turn-closer).
|
|
4
2
|
const TOOL_ORDER = [
|
|
5
3
|
"think",
|
|
6
4
|
"unknown",
|
|
@@ -40,9 +38,7 @@ export default class ToolRegistry {
|
|
|
40
38
|
this.#tools.set(scheme, Object.freeze({}));
|
|
41
39
|
}
|
|
42
40
|
|
|
43
|
-
// Hidden tools dispatch on direct emission but
|
|
44
|
-
// model-facing tool list. Internal schemes (e.g. <known>, <unknown>)
|
|
45
|
-
// the model writes via <set path="scheme://..."> instead.
|
|
41
|
+
// Hidden tools dispatch on direct emission but never appear in tool lists.
|
|
46
42
|
markHidden(scheme) {
|
|
47
43
|
this.#hidden.add(scheme);
|
|
48
44
|
}
|
|
@@ -82,9 +78,7 @@ export default class ToolRegistry {
|
|
|
82
78
|
if (!fn) return "";
|
|
83
79
|
|
|
84
80
|
const body = await fn(entry);
|
|
85
|
-
//
|
|
86
|
-
// body at this visibility" — normalize at this boundary so callers
|
|
87
|
-
// get a predictable string.
|
|
81
|
+
// undefined/null = "no projected body at this visibility"; normalize to "".
|
|
88
82
|
return body == null ? "" : body;
|
|
89
83
|
}
|
|
90
84
|
|
|
@@ -106,18 +100,14 @@ export default class ToolRegistry {
|
|
|
106
100
|
return sortByPriority([...this.#tools.keys()]);
|
|
107
101
|
}
|
|
108
102
|
|
|
109
|
-
//
|
|
110
|
-
// Use this anywhere a tool list is shown to the model.
|
|
103
|
+
// Registered tools minus hidden; use anywhere a list reaches the model.
|
|
111
104
|
get advertisedNames() {
|
|
112
105
|
return sortByPriority(
|
|
113
106
|
[...this.#tools.keys()].filter((n) => !this.#hidden.has(n)),
|
|
114
107
|
);
|
|
115
108
|
}
|
|
116
109
|
|
|
117
|
-
|
|
118
|
-
* Compute the active tool set for a loop.
|
|
119
|
-
* All exclusions — mode, flags, hidden — handled here. One mechanism.
|
|
120
|
-
*/
|
|
110
|
+
// Single source of truth for active-tool exclusions; SPEC #mode_enforcement.
|
|
121
111
|
resolveForLoop(
|
|
122
112
|
mode,
|
|
123
113
|
{ noInteraction = false, noWeb = false, noProposals = false } = {},
|
package/src/llm/LlmProvider.js
CHANGED
|
@@ -1,27 +1,33 @@
|
|
|
1
|
+
import config from "../agent/config.js";
|
|
1
2
|
import msg from "../agent/messages.js";
|
|
2
3
|
import {
|
|
3
4
|
ContextExceededError,
|
|
5
|
+
classifyTransient,
|
|
4
6
|
isContextExceededMessage,
|
|
5
|
-
isTransientMessage,
|
|
6
7
|
} from "./errors.js";
|
|
7
|
-
import {
|
|
8
|
+
import { retryClassified } from "./retry.js";
|
|
8
9
|
|
|
9
|
-
const
|
|
10
|
-
const MAX_BACKOFF_MS = Number(process.env.RUMMY_LLM_MAX_BACKOFF_MS);
|
|
11
|
-
if (!DEADLINE_MS) throw new Error("RUMMY_LLM_DEADLINE_MS must be set");
|
|
12
|
-
if (!MAX_BACKOFF_MS) throw new Error("RUMMY_LLM_MAX_BACKOFF_MS must be set");
|
|
10
|
+
const { LLM_DEADLINE, LLM_MAX_BACKOFF } = config;
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
12
|
+
// Per-category retry policies. Gateway/server are bounded short because
|
|
13
|
+
// upstream-down won't recover by waiting; warmup/rate_limit get the full
|
|
14
|
+
// LLM deadline because they're recoverable wait states with knowable bounds.
|
|
15
|
+
const POLICIES = Object.freeze({
|
|
16
|
+
gateway: { deadlineMs: 30_000, baseDelayMs: 500, maxDelayMs: 5_000 },
|
|
17
|
+
warmup: {
|
|
18
|
+
deadlineMs: LLM_DEADLINE,
|
|
19
|
+
baseDelayMs: 2000,
|
|
20
|
+
maxDelayMs: LLM_MAX_BACKOFF,
|
|
21
|
+
},
|
|
22
|
+
rate_limit: {
|
|
23
|
+
deadlineMs: LLM_DEADLINE,
|
|
24
|
+
baseDelayMs: 1000,
|
|
25
|
+
maxDelayMs: LLM_MAX_BACKOFF,
|
|
26
|
+
},
|
|
27
|
+
server: { deadlineMs: 60_000, baseDelayMs: 1000, maxDelayMs: 10_000 },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Dispatches to hooks.llm.providers; per-category transient retry; ContextExceededError surface.
|
|
25
31
|
export default class LlmProvider {
|
|
26
32
|
#db;
|
|
27
33
|
#hooks;
|
|
@@ -60,16 +66,15 @@ export default class LlmProvider {
|
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
try {
|
|
63
|
-
return await
|
|
69
|
+
return await retryClassified(
|
|
64
70
|
() => provider.completion(messages, resolvedModel, resolvedOptions),
|
|
65
71
|
{
|
|
66
72
|
signal: options.signal,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
onRetry: (err, attempt, delayMs, remainingMs) => {
|
|
73
|
+
classify: classifyTransient,
|
|
74
|
+
policies: POLICIES,
|
|
75
|
+
onRetry: (err, category, attempt, delayMs, remainingMs) => {
|
|
71
76
|
console.error(
|
|
72
|
-
`[LLM]
|
|
77
|
+
`[LLM] ${category} on ${provider.name} attempt ${attempt}: ${err.message}; retrying in ${delayMs}ms (${Math.round(remainingMs / 1000)}s ${category} budget remaining)`,
|
|
73
78
|
);
|
|
74
79
|
},
|
|
75
80
|
},
|