@possumtech/rummy 0.3.0 → 0.4.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 +13 -1
- package/PLUGINS.md +1 -1
- package/README.md +5 -1
- package/SPEC.md +211 -54
- package/migrations/001_initial_schema.sql +3 -4
- package/package.json +7 -3
- package/service.js +5 -3
- package/src/agent/AgentLoop.js +183 -238
- package/src/agent/ContextAssembler.js +2 -0
- package/src/agent/KnownStore.js +36 -85
- package/src/agent/ResponseHealer.js +65 -31
- package/src/agent/TurnExecutor.js +284 -382
- package/src/agent/XmlParser.js +28 -4
- package/src/agent/known_queries.sql +1 -1
- package/src/agent/known_store.sql +32 -34
- package/src/agent/runs.sql +2 -2
- package/src/agent/tokens.js +1 -0
- package/src/agent/turns.sql +5 -0
- package/src/hooks/HookRegistry.js +7 -0
- package/src/hooks/Hooks.js +2 -4
- package/src/hooks/ToolRegistry.js +8 -13
- package/src/plugins/ask_user/ask_userDoc.js +3 -8
- package/src/plugins/budget/README.md +26 -30
- package/src/plugins/budget/budget.js +69 -36
- package/src/plugins/budget/recovery.js +47 -0
- package/src/plugins/cp/cp.js +1 -1
- package/src/plugins/cp/cpDoc.js +5 -10
- package/src/plugins/env/envDoc.js +3 -8
- package/src/plugins/get/get.js +70 -2
- package/src/plugins/get/getDoc.js +19 -16
- package/src/plugins/hedberg/matcher.js +10 -29
- package/src/plugins/helpers.js +2 -2
- package/src/plugins/instructions/instructions.js +3 -2
- package/src/plugins/instructions/preamble.md +33 -12
- package/src/plugins/known/known.js +66 -17
- package/src/plugins/known/knownDoc.js +7 -10
- package/src/plugins/mv/mv.js +18 -1
- package/src/plugins/mv/mvDoc.js +9 -10
- package/src/plugins/{current → performed}/README.md +4 -3
- package/src/plugins/{current/current.js → performed/performed.js} +15 -20
- package/src/plugins/policy/policy.js +47 -0
- package/src/plugins/previous/README.md +2 -1
- package/src/plugins/previous/previous.js +31 -25
- package/src/plugins/progress/README.md +1 -2
- package/src/plugins/progress/progress.js +10 -60
- package/src/plugins/prompt/prompt.js +10 -8
- package/src/plugins/rm/rm.js +27 -15
- package/src/plugins/rm/rmDoc.js +6 -11
- package/src/plugins/rpc/rpc.js +3 -1
- package/src/plugins/set/set.js +125 -92
- package/src/plugins/set/setDoc.js +28 -37
- package/src/plugins/sh/shDoc.js +2 -7
- package/src/plugins/summarize/summarize.js +7 -0
- package/src/plugins/summarize/summarizeDoc.js +6 -11
- package/src/plugins/telemetry/telemetry.js +14 -9
- package/src/plugins/think/think.js +12 -0
- package/src/plugins/think/thinkDoc.js +18 -0
- package/src/plugins/unknown/README.md +2 -1
- package/src/plugins/unknown/unknown.js +26 -4
- package/src/plugins/unknown/unknownDoc.js +9 -14
- package/src/plugins/update/update.js +7 -0
- package/src/plugins/update/updateDoc.js +6 -11
- package/src/server/ClientConnection.js +69 -45
- package/src/sql/v_model_context.sql +7 -17
- package/src/plugins/budget/BudgetGuard.js +0 -74
package/src/agent/XmlParser.js
CHANGED
|
@@ -4,7 +4,7 @@ import { normalizeAttrs, parseJsonEdit } from "../plugins/hedberg/normalize.js";
|
|
|
4
4
|
import { parseSed } from "../plugins/hedberg/sed.js";
|
|
5
5
|
|
|
6
6
|
const STORE_TOOLS = new Set(["get", "rm", "set", "mv", "cp", "search"]);
|
|
7
|
-
const ALL_TOOLS = new Set([
|
|
7
|
+
export const ALL_TOOLS = new Set([
|
|
8
8
|
...STORE_TOOLS,
|
|
9
9
|
"known",
|
|
10
10
|
"sh",
|
|
@@ -13,6 +13,7 @@ const ALL_TOOLS = new Set([
|
|
|
13
13
|
"summarize",
|
|
14
14
|
"update",
|
|
15
15
|
"unknown",
|
|
16
|
+
"think",
|
|
16
17
|
]);
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -100,7 +101,7 @@ function resolveCommand(name, attrs, rawBody) {
|
|
|
100
101
|
if (name === "known") {
|
|
101
102
|
const body = trimmed || a.body || "";
|
|
102
103
|
const path = a.path || null;
|
|
103
|
-
return { name, path, body };
|
|
104
|
+
return { name, ...a, path, body };
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
if (name === "get" || name === "rm") {
|
|
@@ -142,6 +143,8 @@ export default class XmlParser {
|
|
|
142
143
|
* @param {string} content - Raw model response text
|
|
143
144
|
* @returns {{ commands: Array, warnings: string[], unparsed: string }}
|
|
144
145
|
*/
|
|
146
|
+
static MAX_COMMANDS = Number(process.env.RUMMY_MAX_COMMANDS) || 99;
|
|
147
|
+
|
|
145
148
|
static parse(content) {
|
|
146
149
|
if (!content) return { commands: [], warnings: [], unparsed: "" };
|
|
147
150
|
|
|
@@ -153,13 +156,20 @@ export default class XmlParser {
|
|
|
153
156
|
const textChunks = [];
|
|
154
157
|
let current = null;
|
|
155
158
|
let ended = false;
|
|
159
|
+
let capped = false;
|
|
156
160
|
|
|
157
161
|
const parser = new Parser(
|
|
158
162
|
{
|
|
159
163
|
onopentag(name, attrs) {
|
|
164
|
+
if (capped) return;
|
|
160
165
|
if (!ALL_TOOLS.has(name)) {
|
|
161
166
|
if (current) {
|
|
162
|
-
|
|
167
|
+
const attrStr = Object.entries(attrs)
|
|
168
|
+
.map(([k, v]) => v === "" ? k : `${k}="${v}"`)
|
|
169
|
+
.join(" ");
|
|
170
|
+
current.rawBody += attrStr
|
|
171
|
+
? `<${name} ${attrStr}>`
|
|
172
|
+
: `<${name}>`;
|
|
163
173
|
}
|
|
164
174
|
return;
|
|
165
175
|
}
|
|
@@ -174,10 +184,17 @@ export default class XmlParser {
|
|
|
174
184
|
);
|
|
175
185
|
}
|
|
176
186
|
|
|
187
|
+
if (commands.length >= XmlParser.MAX_COMMANDS) {
|
|
188
|
+
capped = true;
|
|
189
|
+
current = null;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
177
193
|
current = { name, attrs, rawBody: "" };
|
|
178
194
|
},
|
|
179
195
|
|
|
180
196
|
ontext(text) {
|
|
197
|
+
if (capped) return;
|
|
181
198
|
if (current) {
|
|
182
199
|
current.rawBody += text;
|
|
183
200
|
} else {
|
|
@@ -186,6 +203,7 @@ export default class XmlParser {
|
|
|
186
203
|
},
|
|
187
204
|
|
|
188
205
|
onclosetag(name, isImplied) {
|
|
206
|
+
if (capped) return;
|
|
189
207
|
if (current && name === current.name) {
|
|
190
208
|
if (ended) {
|
|
191
209
|
warnings.push(`Unclosed <${name}> tag — content captured anyway`);
|
|
@@ -227,7 +245,7 @@ export default class XmlParser {
|
|
|
227
245
|
parser.end();
|
|
228
246
|
|
|
229
247
|
// Flush any unclosed tool tag
|
|
230
|
-
if (current) {
|
|
248
|
+
if (current && !capped) {
|
|
231
249
|
warnings.push(`Unclosed <${current.name}> tag — content captured anyway`);
|
|
232
250
|
commands.push(
|
|
233
251
|
resolveCommand(current.name, current.attrs, current.rawBody),
|
|
@@ -235,6 +253,12 @@ export default class XmlParser {
|
|
|
235
253
|
current = null;
|
|
236
254
|
}
|
|
237
255
|
|
|
256
|
+
if (capped) {
|
|
257
|
+
warnings.push(
|
|
258
|
+
`Tool call limit (${XmlParser.MAX_COMMANDS}) reached — remaining commands dropped`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
238
262
|
const unparsed = textChunks.join("").trim();
|
|
239
263
|
return { commands, warnings, unparsed };
|
|
240
264
|
}
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
-- PREP: upsert_known_entry
|
|
2
2
|
INSERT INTO known_entries (
|
|
3
3
|
run_id, loop_id, turn, path, body, status, fidelity, hash
|
|
4
|
-
, attributes, tokens,
|
|
4
|
+
, attributes, tokens, updated_at
|
|
5
5
|
)
|
|
6
6
|
VALUES (
|
|
7
7
|
:run_id, :loop_id, :turn, :path, :body, :status, :fidelity, :hash
|
|
8
8
|
, COALESCE(:attributes, '{}')
|
|
9
9
|
, countTokens(:body)
|
|
10
|
-
, countTokens(:body)
|
|
11
10
|
, COALESCE(:updated_at, CURRENT_TIMESTAMP)
|
|
12
11
|
)
|
|
13
12
|
ON CONFLICT (run_id, path) DO UPDATE SET
|
|
@@ -19,13 +18,12 @@ ON CONFLICT (run_id, path) DO UPDATE SET
|
|
|
19
18
|
, loop_id = excluded.loop_id
|
|
20
19
|
, turn = excluded.turn
|
|
21
20
|
, tokens = countTokens(excluded.body)
|
|
22
|
-
, tokens_full = countTokens(excluded.body)
|
|
23
21
|
, write_count = known_entries.write_count + 1
|
|
24
22
|
, updated_at = COALESCE(excluded.updated_at, CURRENT_TIMESTAMP);
|
|
25
23
|
|
|
26
24
|
-- PREP: recount_tokens
|
|
27
25
|
UPDATE known_entries
|
|
28
|
-
SET tokens = :tokens
|
|
26
|
+
SET tokens = :tokens
|
|
29
27
|
WHERE run_id = :run_id AND path = :path;
|
|
30
28
|
|
|
31
29
|
-- PREP: get_stale_tokens
|
|
@@ -55,18 +53,6 @@ WHERE run_id = :run_id AND path = :path;
|
|
|
55
53
|
UPDATE known_entries
|
|
56
54
|
SET
|
|
57
55
|
fidelity = :fidelity
|
|
58
|
-
, tokens = CASE
|
|
59
|
-
WHEN :fidelity = 'archive'
|
|
60
|
-
THEN 0
|
|
61
|
-
WHEN :fidelity = 'index'
|
|
62
|
-
THEN 0
|
|
63
|
-
WHEN :fidelity = 'summary'
|
|
64
|
-
THEN COALESCE(
|
|
65
|
-
countTokens(json_extract(attributes, '$.summary')),
|
|
66
|
-
countTokens(substr(body, 1, 80))
|
|
67
|
-
)
|
|
68
|
-
ELSE tokens_full
|
|
69
|
-
END
|
|
70
56
|
, updated_at = CURRENT_TIMESTAMP
|
|
71
57
|
WHERE run_id = :run_id AND hedmatch(:pattern, path) AND scheme IS NULL;
|
|
72
58
|
|
|
@@ -74,8 +60,8 @@ WHERE run_id = :run_id AND hedmatch(:pattern, path) AND scheme IS NULL;
|
|
|
74
60
|
UPDATE known_entries
|
|
75
61
|
SET
|
|
76
62
|
fidelity = 'full'
|
|
63
|
+
, status = 200
|
|
77
64
|
, turn = :turn
|
|
78
|
-
, tokens = tokens_full
|
|
79
65
|
, updated_at = CURRENT_TIMESTAMP
|
|
80
66
|
WHERE run_id = :run_id AND path = :path;
|
|
81
67
|
|
|
@@ -83,26 +69,14 @@ WHERE run_id = :run_id AND path = :path;
|
|
|
83
69
|
UPDATE known_entries
|
|
84
70
|
SET
|
|
85
71
|
fidelity = 'archive'
|
|
86
|
-
, tokens = 0
|
|
87
72
|
, updated_at = CURRENT_TIMESTAMP
|
|
88
73
|
WHERE run_id = :run_id AND path = :path;
|
|
89
74
|
|
|
90
75
|
-- PREP: set_fidelity
|
|
76
|
+
-- Tokens unchanged — always reflects full body cost.
|
|
91
77
|
UPDATE known_entries
|
|
92
78
|
SET
|
|
93
79
|
fidelity = :fidelity
|
|
94
|
-
, tokens = CASE
|
|
95
|
-
WHEN :fidelity = 'archive'
|
|
96
|
-
THEN 0
|
|
97
|
-
WHEN :fidelity = 'index'
|
|
98
|
-
THEN 0
|
|
99
|
-
WHEN :fidelity = 'summary'
|
|
100
|
-
THEN COALESCE(
|
|
101
|
-
countTokens(json_extract(attributes, '$.summary')),
|
|
102
|
-
countTokens(substr(body, 1, 80))
|
|
103
|
-
)
|
|
104
|
-
ELSE countTokens(body)
|
|
105
|
-
END
|
|
106
80
|
, updated_at = CURRENT_TIMESTAMP
|
|
107
81
|
WHERE run_id = :run_id AND path = :path;
|
|
108
82
|
|
|
@@ -138,8 +112,8 @@ WHERE run_id = :run_id AND path = :path;
|
|
|
138
112
|
UPDATE known_entries
|
|
139
113
|
SET
|
|
140
114
|
fidelity = 'full'
|
|
115
|
+
, status = 200
|
|
141
116
|
, turn = :turn
|
|
142
|
-
, tokens = tokens_full
|
|
143
117
|
, updated_at = CURRENT_TIMESTAMP
|
|
144
118
|
WHERE
|
|
145
119
|
run_id = :run_id
|
|
@@ -150,7 +124,6 @@ WHERE
|
|
|
150
124
|
UPDATE known_entries
|
|
151
125
|
SET
|
|
152
126
|
fidelity = 'archive'
|
|
153
|
-
, tokens = 0
|
|
154
127
|
, updated_at = CURRENT_TIMESTAMP
|
|
155
128
|
WHERE
|
|
156
129
|
run_id = :run_id
|
|
@@ -158,7 +131,7 @@ WHERE
|
|
|
158
131
|
AND (:body IS NULL OR hedsearch(:body, body));
|
|
159
132
|
|
|
160
133
|
-- PREP: get_entries_by_pattern
|
|
161
|
-
SELECT path, body, scheme, status, fidelity,
|
|
134
|
+
SELECT path, body, scheme, status, fidelity, tokens, attributes
|
|
162
135
|
FROM known_entries
|
|
163
136
|
WHERE
|
|
164
137
|
run_id = :run_id
|
|
@@ -182,10 +155,35 @@ UPDATE known_entries
|
|
|
182
155
|
SET
|
|
183
156
|
body = :new_body
|
|
184
157
|
, tokens = countTokens(:new_body)
|
|
185
|
-
, tokens_full = countTokens(:new_body)
|
|
186
158
|
, write_count = write_count + 1
|
|
187
159
|
, updated_at = CURRENT_TIMESTAMP
|
|
188
160
|
WHERE
|
|
189
161
|
run_id = :run_id
|
|
190
162
|
AND hedmatch(:path, path)
|
|
191
163
|
AND (:body IS NULL OR hedsearch(:body, body));
|
|
164
|
+
|
|
165
|
+
-- PREP: restore_summarized_prompts
|
|
166
|
+
-- Restore prompt entries demoted to summary by a recovery phase that was
|
|
167
|
+
-- interrupted (e.g. server crash). Safe to call unconditionally at loop
|
|
168
|
+
-- start: if the full prompt would overflow, Prompt Demotion handles it.
|
|
169
|
+
UPDATE known_entries
|
|
170
|
+
SET
|
|
171
|
+
fidelity = 'full'
|
|
172
|
+
, updated_at = CURRENT_TIMESTAMP
|
|
173
|
+
WHERE run_id = :run_id AND scheme = 'prompt' AND fidelity = 'summary';
|
|
174
|
+
|
|
175
|
+
-- PREP: demote_turn_entries
|
|
176
|
+
-- Demote all full entries from a turn to summary with 413 status.
|
|
177
|
+
-- Tokens unchanged — always reports full cost regardless of fidelity.
|
|
178
|
+
UPDATE known_entries
|
|
179
|
+
SET
|
|
180
|
+
fidelity = 'summary'
|
|
181
|
+
, status = 413
|
|
182
|
+
, updated_at = CURRENT_TIMESTAMP
|
|
183
|
+
WHERE
|
|
184
|
+
run_id = :run_id
|
|
185
|
+
AND turn = :turn
|
|
186
|
+
AND fidelity = 'full'
|
|
187
|
+
AND status < 400
|
|
188
|
+
RETURNING path, tokens;
|
|
189
|
+
|
package/src/agent/runs.sql
CHANGED
|
@@ -81,11 +81,11 @@ RETURNING next_turn - 1 AS turn;
|
|
|
81
81
|
-- PREP: fork_known_entries
|
|
82
82
|
INSERT INTO known_entries (
|
|
83
83
|
run_id, loop_id, turn, path, body, status, fidelity
|
|
84
|
-
, hash, attributes, tokens,
|
|
84
|
+
, hash, attributes, tokens, refs, write_count
|
|
85
85
|
)
|
|
86
86
|
SELECT
|
|
87
87
|
:new_run_id, NULL, turn, path, body, status, fidelity
|
|
88
|
-
, hash, attributes, tokens,
|
|
88
|
+
, hash, attributes, tokens, refs, write_count
|
|
89
89
|
FROM known_entries
|
|
90
90
|
WHERE run_id = :parent_run_id;
|
|
91
91
|
|
package/src/agent/tokens.js
CHANGED
package/src/agent/turns.sql
CHANGED
|
@@ -27,6 +27,11 @@ SELECT
|
|
|
27
27
|
FROM turns
|
|
28
28
|
WHERE run_id = :run_id;
|
|
29
29
|
|
|
30
|
+
-- PREP: get_turn_context_tokens
|
|
31
|
+
SELECT context_tokens
|
|
32
|
+
FROM turns
|
|
33
|
+
WHERE run_id = :run_id AND sequence = :sequence;
|
|
34
|
+
|
|
30
35
|
-- PREP: get_last_context_tokens
|
|
31
36
|
SELECT context_tokens
|
|
32
37
|
FROM turns
|
|
@@ -63,6 +63,13 @@ export default class HookRegistry {
|
|
|
63
63
|
this.#events.get(tag).sort((a, b) => a.priority - b.priority);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
removeEvent(tag, callback) {
|
|
67
|
+
const hooks = this.#events.get(tag);
|
|
68
|
+
if (!hooks) return;
|
|
69
|
+
const idx = hooks.findIndex((h) => h.callback === callback);
|
|
70
|
+
if (idx !== -1) hooks.splice(idx, 1);
|
|
71
|
+
}
|
|
72
|
+
|
|
66
73
|
async emitEvent(tag, ...args) {
|
|
67
74
|
const hooks = this.#events.get(tag) || [];
|
|
68
75
|
for (const h of hooks) {
|
package/src/hooks/Hooks.js
CHANGED
|
@@ -11,6 +11,7 @@ export default function createHooks(debug = false) {
|
|
|
11
11
|
|
|
12
12
|
const createEvent = (tag) => ({
|
|
13
13
|
on: (callback, priority) => registry.addEvent(tag, callback, priority),
|
|
14
|
+
off: (callback) => registry.removeEvent(tag, callback),
|
|
14
15
|
emit: (...args) => registry.emitEvent(tag, ...args),
|
|
15
16
|
});
|
|
16
17
|
|
|
@@ -55,6 +56,7 @@ export default function createHooks(debug = false) {
|
|
|
55
56
|
turn: {
|
|
56
57
|
started: createEvent("turn.started"),
|
|
57
58
|
response: createEvent("turn.response"),
|
|
59
|
+
proposal: createEvent("turn.proposal"),
|
|
58
60
|
proposing: createEvent("turn.proposing"),
|
|
59
61
|
completed: createEvent("turn.completed"),
|
|
60
62
|
},
|
|
@@ -73,10 +75,6 @@ export default function createHooks(debug = false) {
|
|
|
73
75
|
started: createEvent("act.started"),
|
|
74
76
|
completed: createEvent("act.completed"),
|
|
75
77
|
},
|
|
76
|
-
panic: {
|
|
77
|
-
started: createEvent("panic.started"),
|
|
78
|
-
completed: createEvent("panic.completed"),
|
|
79
|
-
},
|
|
80
78
|
llm: {
|
|
81
79
|
request: {
|
|
82
80
|
started: createEvent("llm.request.started"),
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
// Tool display order: gather → reason → act → communicate.
|
|
2
2
|
// Position in the list implies priority to the model.
|
|
3
3
|
const TOOL_ORDER = [
|
|
4
|
+
"think",
|
|
5
|
+
"unknown",
|
|
6
|
+
"known",
|
|
4
7
|
"get",
|
|
5
8
|
"set",
|
|
6
|
-
"known",
|
|
7
|
-
"unknown",
|
|
8
9
|
"env",
|
|
9
10
|
"sh",
|
|
10
11
|
"rm",
|
|
11
12
|
"cp",
|
|
12
13
|
"mv",
|
|
13
|
-
"search",
|
|
14
|
-
"summarize",
|
|
15
|
-
"update",
|
|
16
14
|
"ask_user",
|
|
15
|
+
"update",
|
|
16
|
+
"summarize",
|
|
17
|
+
"search",
|
|
17
18
|
];
|
|
18
19
|
|
|
19
20
|
function sortByPriority(names) {
|
|
@@ -118,19 +119,13 @@ export default class ToolRegistry {
|
|
|
118
119
|
*/
|
|
119
120
|
resolveForLoop(
|
|
120
121
|
mode,
|
|
121
|
-
{ noInteraction = false, noWeb = false,
|
|
122
|
+
{ noInteraction = false, noWeb = false, noProposals = false } = {},
|
|
122
123
|
) {
|
|
123
124
|
const excluded = new Set();
|
|
124
125
|
if (mode === "ask") excluded.add("sh");
|
|
125
|
-
if (mode === "panic") {
|
|
126
|
-
excluded.add("sh");
|
|
127
|
-
excluded.add("env");
|
|
128
|
-
excluded.add("search");
|
|
129
|
-
excluded.add("ask_user");
|
|
130
|
-
}
|
|
131
126
|
if (noInteraction) excluded.add("ask_user");
|
|
132
127
|
if (noWeb) excluded.add("search");
|
|
133
|
-
if (
|
|
128
|
+
if (noProposals) {
|
|
134
129
|
excluded.add("ask_user");
|
|
135
130
|
excluded.add("env");
|
|
136
131
|
excluded.add("sh");
|
|
@@ -2,27 +2,22 @@
|
|
|
2
2
|
// Text goes to the model. Rationale stays in source.
|
|
3
3
|
// Changing ANY line requires reading ALL rationales first.
|
|
4
4
|
const LINES = [
|
|
5
|
-
// --- Syntax: question attr + options in body
|
|
6
5
|
['## <ask_user question="[Question?]">[option1; option2; ...]</ask_user>'],
|
|
7
|
-
|
|
8
|
-
// --- Constraints FIRST: frames correct usage before examples
|
|
9
6
|
[
|
|
10
7
|
"* YOU SHOULD use for decisions, preferences, or approvals the user must make",
|
|
11
|
-
"Positive framing. Shows what ask_user IS for
|
|
8
|
+
"Positive framing. Shows what ask_user IS for.",
|
|
12
9
|
],
|
|
13
10
|
[
|
|
14
11
|
"* YOU SHOULD use <get> to find information before asking the user",
|
|
15
|
-
"Gentle redirect. Encourages self-sufficiency
|
|
12
|
+
"Gentle redirect. Encourages self-sufficiency.",
|
|
16
13
|
],
|
|
17
|
-
|
|
18
|
-
// --- Examples: genuine decision points where user input is valuable
|
|
19
14
|
[
|
|
20
15
|
'Example: <ask_user question="Which test framework?">Mocha; Jest; Node Native</ask_user>',
|
|
21
16
|
"Preference decision. Model truly cannot know this without asking.",
|
|
22
17
|
],
|
|
23
18
|
[
|
|
24
19
|
'Example: <ask_user question="Deploy to staging or production?">staging; production</ask_user>',
|
|
25
|
-
"Consequential action.
|
|
20
|
+
"Consequential action. High-stakes choice.",
|
|
26
21
|
],
|
|
27
22
|
];
|
|
28
23
|
|
|
@@ -1,43 +1,39 @@
|
|
|
1
1
|
# budget
|
|
2
2
|
|
|
3
|
-
Context ceiling enforcement
|
|
3
|
+
Context ceiling enforcement.
|
|
4
4
|
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
- **budget.js** — Plugin. Pre-LLM enforce, BudgetGuard activation,
|
|
8
|
-
panic prompt generation.
|
|
9
|
-
- **BudgetGuard.js** — Write-layer gate. Installed on KnownStore during
|
|
10
|
-
dispatch. Checks token delta on every upsert, promote, and body update.
|
|
11
|
-
|
|
12
|
-
## Registration
|
|
5
|
+
## Design
|
|
13
6
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
- **Hook**: `hooks.budget.panicPrompt({ shortfall, assembledTokens, contextSize })` — generate panic prompt.
|
|
7
|
+
Ceiling = `floor(contextSize × 0.9)`. The 10% headroom is the system's
|
|
8
|
+
operating room for graceful overflow handling. No per-write gating —
|
|
9
|
+
tools run uninterrupted. Enforcement happens at boundaries.
|
|
18
10
|
|
|
19
|
-
##
|
|
11
|
+
## Enforcement Points
|
|
20
12
|
|
|
21
|
-
|
|
22
|
-
|
|
13
|
+
1. **Pre-LLM enforce** (`budget.enforce`): checks assembled context
|
|
14
|
+
before the LLM call. If over ceiling → Prompt Demotion (summarize
|
|
15
|
+
the incoming prompt). Model runs in the headroom.
|
|
23
16
|
|
|
24
|
-
|
|
17
|
+
2. **Post-dispatch Turn Demotion**: after all tools dispatch, check
|
|
18
|
+
context. If over ceiling → demote ALL entries from this turn to
|
|
19
|
+
summary (every scheme except `budget`). Write `budget://` entry
|
|
20
|
+
listing what was demoted. Model sees it next turn and adapts.
|
|
25
21
|
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
3. **LLM rejection** (`isContextExceeded`): turn-1 token estimate
|
|
23
|
+
drift causes LLM to reject. Same demotion pattern.
|
|
28
24
|
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
4. **AgentLoop recovery**: pre-LLM 413 that Prompt Demotion can't
|
|
26
|
+
resolve. Batch-demote all full entries, budget entry, model gets
|
|
27
|
+
recovery turns. 3 strikes without progress → hard 413 to client.
|
|
28
|
+
Only path where 413 reaches the client.
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
writes fail. TurnExecutor catches per-tool, writes 413 result entry.
|
|
30
|
+
## Files
|
|
34
31
|
|
|
35
|
-
|
|
32
|
+
- **budget.js** — Plugin. Pre-LLM enforce hook.
|
|
33
|
+
- **BudgetGuard.js** — `BudgetExceeded` error type, `delta` utility.
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
The model receives the exact shortfall and must free space using core
|
|
39
|
-
tools (get, set, known, unknown, rm, mv, cp, summarize, update).
|
|
40
|
-
Excluded: sh, env, search, ask_user.
|
|
35
|
+
## Registration
|
|
41
36
|
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
- **Hook**: `hooks.budget.enforce` — pre-LLM ceiling check.
|
|
38
|
+
- **Scheme**: `budget://` — logging category, model-visible. `onView`
|
|
39
|
+
renders body at all fidelity levels (summary shows full content).
|
|
@@ -1,56 +1,44 @@
|
|
|
1
1
|
import { countTokens } from "../../agent/tokens.js";
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
const CEILING_RATIO = Number(process.env.RUMMY_BUDGET_CEILING);
|
|
4
|
+
if (!CEILING_RATIO) throw new Error("RUMMY_BUDGET_CEILING must be set");
|
|
3
5
|
|
|
4
6
|
function measureMessages(messages) {
|
|
5
7
|
return messages.reduce((sum, m) => sum + countTokens(m.content), 0);
|
|
6
8
|
}
|
|
7
9
|
|
|
8
|
-
export { BudgetExceeded };
|
|
9
|
-
|
|
10
10
|
export default class Budget {
|
|
11
11
|
#core;
|
|
12
12
|
|
|
13
13
|
constructor(core) {
|
|
14
14
|
this.#core = core;
|
|
15
|
+
core.registerScheme({
|
|
16
|
+
name: "budget",
|
|
17
|
+
modelVisible: 1,
|
|
18
|
+
category: "logging",
|
|
19
|
+
});
|
|
20
|
+
core.hooks.tools.onView("budget", (entry) => entry.body);
|
|
15
21
|
core.hooks.budget = {
|
|
16
22
|
enforce: this.enforce.bind(this),
|
|
17
|
-
|
|
18
|
-
deactivate: this.deactivate.bind(this),
|
|
19
|
-
panicPrompt: Budget.panicPrompt,
|
|
20
|
-
BudgetExceeded,
|
|
23
|
+
postDispatch: this.postDispatch.bind(this),
|
|
21
24
|
};
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
const target = Math.floor(contextSize * 0.75);
|
|
26
|
-
const mustFree = assembledTokens - target;
|
|
27
|
-
return [
|
|
28
|
-
`CONTEXT OVERFLOW: ${assembledTokens} tokens, ceiling ${contextSize}.`,
|
|
29
|
-
`YOU MUST free ${mustFree} tokens to get below ${target} (75%).`,
|
|
30
|
-
"YOU MUST NOT load or create new content. Only reduce.",
|
|
31
|
-
"",
|
|
32
|
-
"<knowns> above shows each entry with its token count.",
|
|
33
|
-
"Target the largest entries first.",
|
|
34
|
-
'<rm path="..."/> to delete entries you no longer need.',
|
|
35
|
-
'<set path="..." fidelity="summary" summary="keywords"/> to compress.',
|
|
36
|
-
'<set path="..." fidelity="archive"/> to archive out of context.',
|
|
37
|
-
"<summarize/> when done. <update/> if still working.",
|
|
38
|
-
].join("\n");
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async enforce({ contextSize, messages, rows }) {
|
|
27
|
+
async enforce({ contextSize, messages, rows, lastPromptTokens = 0 }) {
|
|
42
28
|
if (!contextSize) {
|
|
43
29
|
return { messages, rows, demoted: [], assembledTokens: 0, status: 200 };
|
|
44
30
|
}
|
|
45
31
|
|
|
46
|
-
const assembledTokens =
|
|
32
|
+
const assembledTokens =
|
|
33
|
+
lastPromptTokens > 0 ? lastPromptTokens : measureMessages(messages);
|
|
47
34
|
|
|
48
35
|
console.warn(
|
|
49
|
-
`[RUMMY] Budget enforce: ${assembledTokens} tokens, ceiling ${contextSize}, ${rows.length} rows`,
|
|
36
|
+
`[RUMMY] Budget enforce: ${assembledTokens} tokens (${lastPromptTokens > 0 ? "actual" : "estimated"}), ceiling ${contextSize}, ${rows.length} rows`,
|
|
50
37
|
);
|
|
51
38
|
|
|
52
|
-
|
|
53
|
-
|
|
39
|
+
const ceiling = Math.floor(contextSize * CEILING_RATIO);
|
|
40
|
+
if (assembledTokens > ceiling) {
|
|
41
|
+
const overflow = assembledTokens - ceiling;
|
|
54
42
|
console.warn(
|
|
55
43
|
`[RUMMY] Budget 413: ${assembledTokens} tokens > ${contextSize} ceiling (${overflow} over)`,
|
|
56
44
|
);
|
|
@@ -67,13 +55,58 @@ export default class Budget {
|
|
|
67
55
|
return { messages, rows, demoted: [], assembledTokens, status: 200 };
|
|
68
56
|
}
|
|
69
57
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
58
|
+
async postDispatch({
|
|
59
|
+
contextSize,
|
|
60
|
+
messages,
|
|
61
|
+
rows,
|
|
62
|
+
runId,
|
|
63
|
+
loopId,
|
|
64
|
+
turn,
|
|
65
|
+
db,
|
|
66
|
+
store,
|
|
67
|
+
}) {
|
|
68
|
+
if (!contextSize) return null;
|
|
69
|
+
|
|
70
|
+
const postBudget = await this.enforce({
|
|
71
|
+
contextSize,
|
|
72
|
+
messages,
|
|
73
|
+
rows,
|
|
74
|
+
lastPromptTokens: 0,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (postBudget.status !== 413) return null;
|
|
75
78
|
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
// Demote this turn's entries
|
|
80
|
+
const demotedEntries = await db.demote_turn_entries.all({
|
|
81
|
+
run_id: runId,
|
|
82
|
+
turn,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Also summarize the prompt
|
|
86
|
+
const promptRow = rows.find((r) => r.scheme === "prompt");
|
|
87
|
+
if (promptRow) {
|
|
88
|
+
await store.setFidelity(runId, promptRow.path, "summary");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Write budget entry
|
|
92
|
+
const ceiling = Math.floor(contextSize * CEILING_RATIO);
|
|
93
|
+
const totalDemoted = demotedEntries.reduce((s, r) => s + r.tokens, 0);
|
|
94
|
+
const pathList = demotedEntries
|
|
95
|
+
.map((r) => `${r.path} (${r.tokens} tokens)`)
|
|
96
|
+
.join("\n");
|
|
97
|
+
const body = [
|
|
98
|
+
`Error 413: Context overflowed by ${postBudget.overflow} tokens.`,
|
|
99
|
+
`${demotedEntries.length} entries (${totalDemoted} tokens total) demoted. Budget: ${ceiling} tokens.`,
|
|
100
|
+
pathList,
|
|
101
|
+
].join("\n");
|
|
102
|
+
|
|
103
|
+
await store.upsert(runId, turn, `budget://${loopId}/${turn}`, body, 413, {
|
|
104
|
+
loopId,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
target: ceiling,
|
|
109
|
+
promptPath: promptRow?.path ?? null,
|
|
110
|
+
};
|
|
78
111
|
}
|
|
79
112
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure recovery state transition — exported for testing.
|
|
3
|
+
*
|
|
4
|
+
* @param {object|null} recovery Current recovery state.
|
|
5
|
+
* @param {{ assembledTokens: number, budgetRecovery?: { target: number, promptPath: string|null } }} result
|
|
6
|
+
* @returns {{ next: object|null, action: null|'restore'|'hard413', promptPath: string|null }}
|
|
7
|
+
*/
|
|
8
|
+
export function advanceRecovery(recovery, result) {
|
|
9
|
+
// Initialise or update recovery state from a new Turn Demotion event.
|
|
10
|
+
if (result.budgetRecovery) {
|
|
11
|
+
if (!recovery) {
|
|
12
|
+
recovery = {
|
|
13
|
+
target: result.budgetRecovery.target,
|
|
14
|
+
promptPath: result.budgetRecovery.promptPath,
|
|
15
|
+
strikes: 0,
|
|
16
|
+
lastTokens: result.assembledTokens,
|
|
17
|
+
};
|
|
18
|
+
} else {
|
|
19
|
+
// Re-overflow during recovery: tighten target, don't count as strike.
|
|
20
|
+
recovery = {
|
|
21
|
+
...recovery,
|
|
22
|
+
target: Math.min(recovery.target, result.budgetRecovery.target),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (recovery === null) return { next: null, action: null, promptPath: null };
|
|
28
|
+
|
|
29
|
+
const current = result.assembledTokens;
|
|
30
|
+
|
|
31
|
+
if (current <= recovery.target) {
|
|
32
|
+
return { next: null, action: "restore", promptPath: recovery.promptPath };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const noProgress = current >= recovery.lastTokens && !result.budgetRecovery;
|
|
36
|
+
const strikes = noProgress ? recovery.strikes + 1 : 0;
|
|
37
|
+
|
|
38
|
+
if (strikes >= 3) {
|
|
39
|
+
return { next: null, action: "hard413", promptPath: null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
next: { ...recovery, strikes, lastTokens: current },
|
|
44
|
+
action: null,
|
|
45
|
+
promptPath: null,
|
|
46
|
+
};
|
|
47
|
+
}
|
package/src/plugins/cp/cp.js
CHANGED
|
@@ -19,7 +19,7 @@ export default class Cp {
|
|
|
19
19
|
async handler(entry, rummy) {
|
|
20
20
|
const { entries: store, sequence: turn, runId, loopId } = rummy;
|
|
21
21
|
const { path, to } = entry.attributes;
|
|
22
|
-
const VALID = { stored: 1, summary: 1, index: 1, full: 1 };
|
|
22
|
+
const VALID = { stored: 1, summary: 1, index: 1, full: 1, archive: 1 };
|
|
23
23
|
const fidelity = VALID[entry.attributes.fidelity]
|
|
24
24
|
? entry.attributes.fidelity
|
|
25
25
|
: undefined;
|