@possumtech/rummy 0.3.1 → 0.5.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 -0
- package/FIDELITY_CONTRACT.md +172 -0
- package/README.md +5 -1
- package/SPEC.md +31 -17
- package/migrations/001_initial_schema.sql +3 -4
- package/package.json +1 -1
- package/src/agent/AgentLoop.js +51 -153
- package/src/agent/ContextAssembler.js +2 -0
- package/src/agent/KnownStore.js +16 -9
- package/src/agent/ResponseHealer.js +54 -1
- package/src/agent/TurnExecutor.js +125 -323
- package/src/agent/XmlParser.js +172 -42
- package/src/agent/known_queries.sql +1 -1
- package/src/agent/known_store.sql +29 -72
- package/src/agent/runs.sql +2 -2
- package/src/hooks/Hooks.js +1 -0
- package/src/hooks/PluginContext.js +8 -2
- package/src/hooks/RummyContext.js +6 -3
- package/src/hooks/ToolRegistry.js +29 -32
- package/src/plugins/ask_user/ask_user.js +2 -2
- package/src/plugins/ask_user/ask_userDoc.js +7 -10
- package/src/plugins/budget/README.md +28 -18
- package/src/plugins/budget/budget.js +80 -3
- package/src/plugins/budget/recovery.js +47 -0
- package/src/plugins/cp/cp.js +5 -5
- package/src/plugins/cp/cpDoc.js +1 -14
- package/src/plugins/engine/engine.sql +1 -1
- package/src/plugins/env/env.js +4 -4
- package/src/plugins/env/envDoc.js +4 -9
- package/src/plugins/file/file.js +2 -7
- package/src/plugins/get/get.js +32 -13
- package/src/plugins/get/getDoc.js +26 -44
- package/src/plugins/helpers.js +4 -4
- package/src/plugins/instructions/instructions.js +9 -7
- package/src/plugins/instructions/preamble.md +45 -26
- package/src/plugins/known/known.js +71 -15
- package/src/plugins/known/knownDoc.js +4 -20
- package/src/plugins/mv/mv.js +6 -6
- package/src/plugins/mv/mvDoc.js +4 -30
- package/src/plugins/policy/policy.js +47 -0
- package/src/plugins/previous/previous.js +10 -14
- package/src/plugins/progress/progress.js +29 -48
- package/src/plugins/prompt/prompt.js +18 -6
- package/src/plugins/rm/rm.js +4 -4
- package/src/plugins/rm/rmDoc.js +5 -14
- package/src/plugins/rpc/rpc.js +4 -2
- package/src/plugins/set/set.js +86 -91
- package/src/plugins/set/setDoc.js +28 -41
- package/src/plugins/sh/sh.js +4 -4
- package/src/plugins/sh/shDoc.js +4 -9
- package/src/plugins/skill/skill.js +2 -1
- package/src/plugins/summarize/summarize.js +9 -2
- package/src/plugins/summarize/summarizeDoc.js +10 -16
- package/src/plugins/telemetry/telemetry.js +36 -11
- package/src/plugins/think/think.js +13 -0
- package/src/plugins/think/thinkDoc.js +16 -0
- package/src/plugins/unknown/unknown.js +37 -9
- package/src/plugins/unknown/unknownDoc.js +7 -16
- package/src/plugins/update/update.js +9 -2
- package/src/plugins/update/updateDoc.js +12 -14
- package/src/server/ClientConnection.js +11 -1
- package/src/sql/functions/slugify.js +13 -1
- package/src/sql/v_model_context.sql +6 -6
package/src/agent/XmlParser.js
CHANGED
|
@@ -14,8 +14,6 @@ export const ALL_TOOLS = new Set([
|
|
|
14
14
|
"update",
|
|
15
15
|
"unknown",
|
|
16
16
|
"think",
|
|
17
|
-
"thought",
|
|
18
|
-
"mcp",
|
|
19
17
|
]);
|
|
20
18
|
|
|
21
19
|
/**
|
|
@@ -145,6 +143,8 @@ export default class XmlParser {
|
|
|
145
143
|
* @param {string} content - Raw model response text
|
|
146
144
|
* @returns {{ commands: Array, warnings: string[], unparsed: string }}
|
|
147
145
|
*/
|
|
146
|
+
static MAX_COMMANDS = Number(process.env.RUMMY_MAX_COMMANDS) || 99;
|
|
147
|
+
|
|
148
148
|
static parse(content) {
|
|
149
149
|
if (!content) return { commands: [], warnings: [], unparsed: "" };
|
|
150
150
|
|
|
@@ -154,33 +154,67 @@ export default class XmlParser {
|
|
|
154
154
|
const commands = [];
|
|
155
155
|
const warnings = [];
|
|
156
156
|
const textChunks = [];
|
|
157
|
+
|
|
158
|
+
// Pre-flight: balance unclosed attribute quotes that would otherwise
|
|
159
|
+
// cause htmlparser2 to consume the rest of input as a single attribute
|
|
160
|
+
// value, silently dropping every subsequent tool call.
|
|
161
|
+
const balanced = XmlParser.#balanceAttrQuotes(normalized, warnings);
|
|
157
162
|
let current = null;
|
|
158
163
|
let ended = false;
|
|
164
|
+
let capped = false;
|
|
159
165
|
|
|
160
166
|
const parser = new Parser(
|
|
161
167
|
{
|
|
162
168
|
onopentag(name, attrs) {
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
|
|
169
|
+
if (capped) return;
|
|
170
|
+
|
|
171
|
+
if (current) {
|
|
172
|
+
// Empty-body case: current tool opened but got no text
|
|
173
|
+
// content before a new tag. The model likely meant current
|
|
174
|
+
// to self-close but typed it in paired form, or emitted a
|
|
175
|
+
// mismatched close tag that htmlparser2 silently dropped.
|
|
176
|
+
// Close current, open new.
|
|
177
|
+
const hasBody = current.rawBody.trim() !== "";
|
|
178
|
+
const hasNestedOpens = (current.nested || []).length > 0;
|
|
179
|
+
if (!hasBody && !hasNestedOpens && ALL_TOOLS.has(name)) {
|
|
180
|
+
warnings.push(
|
|
181
|
+
`Unclosed <${current.name}> before <${name}> — recovered`,
|
|
182
|
+
);
|
|
183
|
+
commands.push(
|
|
184
|
+
resolveCommand(current.name, current.attrs, current.rawBody),
|
|
185
|
+
);
|
|
186
|
+
current = null;
|
|
187
|
+
} else {
|
|
188
|
+
// Nested tag inside a body with content — treat as body
|
|
189
|
+
// text. Tool bodies are opaque: the model writing a plan
|
|
190
|
+
// with <get/> in it, SEARCH/REPLACE in <set>, or XML
|
|
191
|
+
// examples in <known> all need to survive intact. Track
|
|
192
|
+
// nested opens on a stack so matching closes pop off and
|
|
193
|
+
// orphan closes (typos) still trigger recovery.
|
|
194
|
+
const attrStr = Object.entries(attrs)
|
|
195
|
+
.map(([k, v]) => (v === "" ? k : `${k}="${v}"`))
|
|
196
|
+
.join(" ");
|
|
197
|
+
current.rawBody += attrStr
|
|
198
|
+
? `<${name} ${attrStr}>`
|
|
199
|
+
: `<${name}>`;
|
|
200
|
+
current.nested ||= [];
|
|
201
|
+
current.nested.push(name);
|
|
202
|
+
return;
|
|
166
203
|
}
|
|
167
|
-
return;
|
|
168
204
|
}
|
|
169
205
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
commands.push(
|
|
176
|
-
resolveCommand(current.name, current.attrs, current.rawBody),
|
|
177
|
-
);
|
|
206
|
+
if (!ALL_TOOLS.has(name)) return;
|
|
207
|
+
|
|
208
|
+
if (commands.length >= XmlParser.MAX_COMMANDS) {
|
|
209
|
+
capped = true;
|
|
210
|
+
return;
|
|
178
211
|
}
|
|
179
212
|
|
|
180
|
-
current = { name, attrs, rawBody: "" };
|
|
213
|
+
current = { name, attrs, rawBody: "", nested: [] };
|
|
181
214
|
},
|
|
182
215
|
|
|
183
216
|
ontext(text) {
|
|
217
|
+
if (capped) return;
|
|
184
218
|
if (current) {
|
|
185
219
|
current.rawBody += text;
|
|
186
220
|
} else {
|
|
@@ -189,28 +223,53 @@ export default class XmlParser {
|
|
|
189
223
|
},
|
|
190
224
|
|
|
191
225
|
onclosetag(name, isImplied) {
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
226
|
+
if (capped) return;
|
|
227
|
+
|
|
228
|
+
if (current) {
|
|
229
|
+
// Matching nested close — pop stack, keep as text.
|
|
230
|
+
const nested = current.nested;
|
|
231
|
+
if (
|
|
232
|
+
nested.length > 0 &&
|
|
233
|
+
nested[nested.length - 1] === name
|
|
234
|
+
) {
|
|
235
|
+
nested.pop();
|
|
236
|
+
current.rawBody += `</${name}>`;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Matching close for outer tool — finalize.
|
|
241
|
+
if (name === current.name && nested.length === 0) {
|
|
242
|
+
if (ended) {
|
|
243
|
+
warnings.push(
|
|
244
|
+
`Unclosed <${name}> tag — content captured anyway`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
commands.push(
|
|
248
|
+
resolveCommand(current.name, current.attrs, current.rawBody),
|
|
249
|
+
);
|
|
250
|
+
current = null;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Orphan close for a known tool (likely typo) — recover.
|
|
255
|
+
if (ALL_TOOLS.has(name)) {
|
|
256
|
+
warnings.push(
|
|
257
|
+
`Mismatched </${name}> closing <${current.name}> — recovered`,
|
|
258
|
+
);
|
|
259
|
+
commands.push(
|
|
260
|
+
resolveCommand(current.name, current.attrs, current.rawBody),
|
|
261
|
+
);
|
|
262
|
+
current = null;
|
|
263
|
+
return;
|
|
195
264
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
);
|
|
199
|
-
current = null;
|
|
200
|
-
} else if (current && ALL_TOOLS.has(name)) {
|
|
201
|
-
// Mismatched close tag for a known tool — close current tag,
|
|
202
|
-
// don't swallow subsequent commands as body text.
|
|
203
|
-
warnings.push(
|
|
204
|
-
`Mismatched </${name}> closing <${current.name}> — recovered`,
|
|
205
|
-
);
|
|
206
|
-
commands.push(
|
|
207
|
-
resolveCommand(current.name, current.attrs, current.rawBody),
|
|
208
|
-
);
|
|
209
|
-
current = null;
|
|
210
|
-
} else if (current) {
|
|
265
|
+
|
|
266
|
+
// Unknown orphan close — text.
|
|
211
267
|
current.rawBody += `</${name}>`;
|
|
212
|
-
|
|
213
|
-
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (isImplied && ALL_TOOLS.has(name)) {
|
|
272
|
+
// Self-closing tag that htmlparser2 auto-closed at top level
|
|
214
273
|
}
|
|
215
274
|
},
|
|
216
275
|
|
|
@@ -225,12 +284,12 @@ export default class XmlParser {
|
|
|
225
284
|
},
|
|
226
285
|
);
|
|
227
286
|
|
|
228
|
-
parser.write(
|
|
287
|
+
parser.write(balanced);
|
|
229
288
|
ended = true;
|
|
230
289
|
parser.end();
|
|
231
290
|
|
|
232
291
|
// Flush any unclosed tool tag
|
|
233
|
-
if (current) {
|
|
292
|
+
if (current && !capped) {
|
|
234
293
|
warnings.push(`Unclosed <${current.name}> tag — content captured anyway`);
|
|
235
294
|
commands.push(
|
|
236
295
|
resolveCommand(current.name, current.attrs, current.rawBody),
|
|
@@ -238,10 +297,46 @@ export default class XmlParser {
|
|
|
238
297
|
current = null;
|
|
239
298
|
}
|
|
240
299
|
|
|
300
|
+
if (capped) {
|
|
301
|
+
warnings.push(
|
|
302
|
+
`Tool call limit (${XmlParser.MAX_COMMANDS}) reached — remaining commands dropped`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
241
306
|
const unparsed = textChunks.join("").trim();
|
|
242
307
|
return { commands, warnings, unparsed };
|
|
243
308
|
}
|
|
244
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Repair a specific malformed-tag pattern: an attribute value opened with
|
|
312
|
+
* `="` that never closes before the next tag. Without repair, htmlparser2
|
|
313
|
+
* consumes the rest of input as one giant attribute value and silently
|
|
314
|
+
* drops every subsequent tool call.
|
|
315
|
+
*
|
|
316
|
+
* Pattern matched: <TAG ... ATTR="text-with-no-quote</NEXT>
|
|
317
|
+
* Repair: <TAG ... ATTR="text-with-no-quote"></NEXT>
|
|
318
|
+
*
|
|
319
|
+
* Conservative — only triggers when the value contains no quote, no `>`,
|
|
320
|
+
* and is followed by another tag opening or close. Well-formed input is
|
|
321
|
+
* untouched.
|
|
322
|
+
*/
|
|
323
|
+
static #balanceAttrQuotes(content, warnings) {
|
|
324
|
+
let fixes = 0;
|
|
325
|
+
const repaired = content.replace(
|
|
326
|
+
/(<\w+\s[^<>]*?\w+=")([^"<>]*?)(<\/?\w+)/g,
|
|
327
|
+
(_, opening, value, nextTag) => {
|
|
328
|
+
fixes++;
|
|
329
|
+
return `${opening}${value}">${nextTag}`;
|
|
330
|
+
},
|
|
331
|
+
);
|
|
332
|
+
if (fixes > 0) {
|
|
333
|
+
warnings.push(
|
|
334
|
+
`Repaired ${fixes} malformed attribute(s) — close all attribute values with a quote.`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
return repaired;
|
|
338
|
+
}
|
|
339
|
+
|
|
245
340
|
/**
|
|
246
341
|
* Normalize native tool call formats to rummy XML.
|
|
247
342
|
* Models sometimes emit their training-format tool calls instead of
|
|
@@ -255,12 +350,30 @@ export default class XmlParser {
|
|
|
255
350
|
);
|
|
256
351
|
|
|
257
352
|
// Qwen/gemma: <|tool_call>call:NAME{key:"value"}<tool_call|>
|
|
353
|
+
// NAME may be namespaced with any of /, :, or . separators
|
|
354
|
+
// (e.g. `rummy.nvim/get`, `rummy:get`) — extract the trailing word
|
|
355
|
+
// sequence as the tool name. Value forms observed in the wild:
|
|
356
|
+
// key="v" / key:"v" / key:v (unquoted) / key:<|"|>v<|"|> (gemma chat-quotes)
|
|
258
357
|
result = result.replace(
|
|
259
|
-
/<\|tool_call>call:(\w+)\{([^}]*)\}<(?:tool_call\||\|tool_call)>/g,
|
|
260
|
-
(
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
358
|
+
/<\|tool_call>call:([\w.:/-]+)\{([^}]*)\}<(?:tool_call\||\|tool_call)>/g,
|
|
359
|
+
(match, qualifiedName, params) => {
|
|
360
|
+
const name = qualifiedName.match(/\w+$/)?.[0] ?? qualifiedName;
|
|
361
|
+
if (!ALL_TOOLS.has(name)) {
|
|
362
|
+
return `<error>Unknown tool '${qualifiedName}' in <|tool_call> format. Use XML tool commands listed above.</error>`;
|
|
363
|
+
}
|
|
364
|
+
const valueMatch = params.match(
|
|
365
|
+
/[=:]\s*(?:<\|"\|>([^<]*?)<\|"\|>|"([^"]*)"|'([^']*)'|([^,}]+))/,
|
|
366
|
+
);
|
|
367
|
+
const body = (
|
|
368
|
+
valueMatch?.[1] ??
|
|
369
|
+
valueMatch?.[2] ??
|
|
370
|
+
valueMatch?.[3] ??
|
|
371
|
+
valueMatch?.[4] ??
|
|
372
|
+
""
|
|
373
|
+
).trim();
|
|
374
|
+
if (!body) {
|
|
375
|
+
return `<error>Could not extract argument from <|tool_call> ${match}. Use XML format like <${name}>value</${name}>.</error>`;
|
|
376
|
+
}
|
|
264
377
|
return `<${name}>${body}</${name}>`;
|
|
265
378
|
},
|
|
266
379
|
);
|
|
@@ -298,6 +411,23 @@ export default class XmlParser {
|
|
|
298
411
|
},
|
|
299
412
|
);
|
|
300
413
|
|
|
414
|
+
// Catch-all: any remaining <|tool_call> tokens are malformed native
|
|
415
|
+
// attempts (no {} block, missing close, wrong shape entirely). Replace
|
|
416
|
+
// each with an <error> so the model gets feedback on its next turn and
|
|
417
|
+
// learns to switch to XML. Lazy-match up to the next native close, the
|
|
418
|
+
// next XML close tag, or end of input — preserves any trailing valid XML.
|
|
419
|
+
// Error body must NOT contain literal <get>/<set>/etc. — those would
|
|
420
|
+
// re-enter the parser as phantom tool calls. Describe the format in
|
|
421
|
+
// prose instead and point at the tool docs above.
|
|
422
|
+
result = result.replace(
|
|
423
|
+
/<\|tool_call>[\s\S]*?(?:<\|?tool_call\|?>|<\/\w+>|$)/g,
|
|
424
|
+
() =>
|
|
425
|
+
"<error>Native tool call format not supported. Use the XML tool commands listed above (e.g. a get tag with a path attribute, or a set tag with path and body).</error>",
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
// Strip any orphan chat-format quote tokens left after replacement.
|
|
429
|
+
result = result.replace(/<\|"\|>/g, '"');
|
|
430
|
+
|
|
301
431
|
return result;
|
|
302
432
|
}
|
|
303
433
|
}
|
|
@@ -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,54 +53,30 @@ 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
|
|
|
73
59
|
-- PREP: promote_path
|
|
74
60
|
UPDATE known_entries
|
|
75
61
|
SET
|
|
76
|
-
fidelity = '
|
|
62
|
+
fidelity = 'promoted'
|
|
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
|
|
|
82
68
|
-- PREP: demote_path
|
|
83
69
|
UPDATE known_entries
|
|
84
70
|
SET
|
|
85
|
-
fidelity = '
|
|
86
|
-
, tokens = 0
|
|
71
|
+
fidelity = 'archived'
|
|
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
|
|
|
@@ -137,9 +111,9 @@ WHERE run_id = :run_id AND path = :path;
|
|
|
137
111
|
-- PREP: promote_by_pattern
|
|
138
112
|
UPDATE known_entries
|
|
139
113
|
SET
|
|
140
|
-
fidelity = '
|
|
114
|
+
fidelity = 'promoted'
|
|
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
|
|
@@ -149,8 +123,7 @@ WHERE
|
|
|
149
123
|
-- PREP: demote_by_pattern
|
|
150
124
|
UPDATE known_entries
|
|
151
125
|
SET
|
|
152
|
-
fidelity = '
|
|
153
|
-
, tokens = 0
|
|
126
|
+
fidelity = 'archived'
|
|
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,7 +155,6 @@ 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
|
|
@@ -191,49 +163,34 @@ WHERE
|
|
|
191
163
|
AND (:body IS NULL OR hedsearch(:body, body));
|
|
192
164
|
|
|
193
165
|
-- PREP: restore_summarized_prompts
|
|
194
|
-
-- Restore prompt entries demoted
|
|
166
|
+
-- Restore prompt entries demoted by a recovery phase that was
|
|
195
167
|
-- interrupted (e.g. server crash). Safe to call unconditionally at loop
|
|
196
168
|
-- start: if the full prompt would overflow, Prompt Demotion handles it.
|
|
197
169
|
UPDATE known_entries
|
|
198
170
|
SET
|
|
199
|
-
fidelity = '
|
|
200
|
-
, tokens = tokens_full
|
|
201
|
-
, updated_at = CURRENT_TIMESTAMP
|
|
202
|
-
WHERE run_id = :run_id AND scheme = 'prompt' AND fidelity = 'summary';
|
|
203
|
-
|
|
204
|
-
-- PREP: demote_previous_loop_logging
|
|
205
|
-
-- Demote full logging entries from all other loops to summary.
|
|
206
|
-
-- Fires at loop start so <previous> entries are already compact.
|
|
207
|
-
UPDATE known_entries
|
|
208
|
-
SET
|
|
209
|
-
fidelity = 'summary'
|
|
210
|
-
, tokens = COALESCE(
|
|
211
|
-
countTokens(json_extract(attributes, '$.summary'))
|
|
212
|
-
, countTokens(substr(body, 1, 80))
|
|
213
|
-
)
|
|
171
|
+
fidelity = 'promoted'
|
|
214
172
|
, updated_at = CURRENT_TIMESTAMP
|
|
215
|
-
WHERE
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
--
|
|
222
|
-
--
|
|
223
|
-
--
|
|
173
|
+
WHERE run_id = :run_id AND scheme = 'prompt' AND fidelity = 'demoted';
|
|
174
|
+
|
|
175
|
+
-- PREP: demote_turn_entries
|
|
176
|
+
-- Demote all promoted entries from a turn for budget claw-back.
|
|
177
|
+
-- Action schemes (set/rm/mv/cp) at status 200 keep their status — those
|
|
178
|
+
-- represent committed side effects (files written/removed) that can't be
|
|
179
|
+
-- clawed back; only the body in context is demoted, not the truth of what
|
|
180
|
+
-- happened. Everything else flips to 413 since promotion was reversed.
|
|
181
|
+
-- Tokens unchanged — always reports full cost regardless of fidelity.
|
|
224
182
|
UPDATE known_entries
|
|
225
183
|
SET
|
|
226
|
-
fidelity = '
|
|
227
|
-
, status =
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
)
|
|
184
|
+
fidelity = 'demoted'
|
|
185
|
+
, status = CASE
|
|
186
|
+
WHEN scheme IN ('set', 'rm', 'mv', 'cp') AND status = 200 THEN 200
|
|
187
|
+
ELSE 413
|
|
188
|
+
END
|
|
232
189
|
, updated_at = CURRENT_TIMESTAMP
|
|
233
190
|
WHERE
|
|
234
191
|
run_id = :run_id
|
|
235
192
|
AND turn = :turn
|
|
236
|
-
AND fidelity = '
|
|
193
|
+
AND fidelity = 'promoted'
|
|
237
194
|
AND status < 400
|
|
238
|
-
|
|
239
|
-
|
|
195
|
+
RETURNING path, tokens;
|
|
196
|
+
|
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/hooks/Hooks.js
CHANGED
|
@@ -56,6 +56,7 @@ export default function createHooks(debug = false) {
|
|
|
56
56
|
turn: {
|
|
57
57
|
started: createEvent("turn.started"),
|
|
58
58
|
response: createEvent("turn.response"),
|
|
59
|
+
proposal: createEvent("turn.proposal"),
|
|
59
60
|
proposing: createEvent("turn.proposing"),
|
|
60
61
|
completed: createEvent("turn.completed"),
|
|
61
62
|
},
|
|
@@ -69,10 +69,16 @@ export default class PluginContext {
|
|
|
69
69
|
this.#hooks.tools.ensureTool(this.#name);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// Mark this plugin's tool as hidden from model-facing tool lists.
|
|
73
|
+
// Handler still dispatches if the model emits the tag.
|
|
74
|
+
markHidden() {
|
|
75
|
+
this.#hooks.tools.markHidden(this.#name);
|
|
76
|
+
}
|
|
77
|
+
|
|
72
78
|
/**
|
|
73
79
|
* Register a named callback for this plugin.
|
|
74
80
|
* "handler" registers the tool handler.
|
|
75
|
-
* "
|
|
81
|
+
* "promoted"/"demoted" register fidelity projections.
|
|
76
82
|
* "docs" sets tool documentation.
|
|
77
83
|
* Everything else resolves to a hook event.
|
|
78
84
|
*/
|
|
@@ -82,7 +88,7 @@ export default class PluginContext {
|
|
|
82
88
|
this.#hooks.tools.onHandle(this.#name, callback, priority);
|
|
83
89
|
return;
|
|
84
90
|
}
|
|
85
|
-
if (event === "
|
|
91
|
+
if (event === "promoted" || event === "demoted") {
|
|
86
92
|
this.#hooks.tools.onView(this.#name, callback, event);
|
|
87
93
|
return;
|
|
88
94
|
}
|
|
@@ -103,9 +103,12 @@ export default class RummyContext {
|
|
|
103
103
|
|
|
104
104
|
async set({ path, body, status = 200, fidelity, attributes } = {}) {
|
|
105
105
|
if (!path) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
path = await this.entries.slugPath(
|
|
107
|
+
this.runId,
|
|
108
|
+
"known",
|
|
109
|
+
body || "",
|
|
110
|
+
attributes?.summary,
|
|
111
|
+
);
|
|
109
112
|
}
|
|
110
113
|
await this.entries.upsert(
|
|
111
114
|
this.runId,
|
|
@@ -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) {
|
|
@@ -31,12 +32,21 @@ export default class ToolRegistry {
|
|
|
31
32
|
#tools = new Map();
|
|
32
33
|
#handlers = new Map();
|
|
33
34
|
#views = new Map();
|
|
35
|
+
#hidden = new Set();
|
|
34
36
|
|
|
35
37
|
ensureTool(scheme) {
|
|
36
38
|
if (this.#tools.has(scheme)) return;
|
|
37
39
|
this.#tools.set(scheme, Object.freeze({}));
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
// Mark a tool as hidden — handler still dispatches if the model emits the
|
|
43
|
+
// tag, but the tool is excluded from all model-facing tool lists. Used for
|
|
44
|
+
// legacy/internal schemes (e.g. <known>, <unknown>) we want to retire
|
|
45
|
+
// without deleting.
|
|
46
|
+
markHidden(scheme) {
|
|
47
|
+
this.#hidden.add(scheme);
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
get(name) {
|
|
41
51
|
return this.#tools.get(name);
|
|
42
52
|
}
|
|
@@ -52,7 +62,7 @@ export default class ToolRegistry {
|
|
|
52
62
|
list.sort((a, b) => a.priority - b.priority);
|
|
53
63
|
}
|
|
54
64
|
|
|
55
|
-
onView(scheme, fn, fidelity = "
|
|
65
|
+
onView(scheme, fn, fidelity = "promoted") {
|
|
56
66
|
if (!this.#views.has(scheme)) this.#views.set(scheme, new Map());
|
|
57
67
|
this.#views.get(scheme).set(fidelity, fn);
|
|
58
68
|
}
|
|
@@ -66,32 +76,11 @@ export default class ToolRegistry {
|
|
|
66
76
|
);
|
|
67
77
|
}
|
|
68
78
|
|
|
69
|
-
const
|
|
70
|
-
typeof entry.attributes === "string"
|
|
71
|
-
? JSON.parse(entry.attributes)
|
|
72
|
-
: entry.attributes;
|
|
73
|
-
const summary = typeof attrs?.summary === "string" ? attrs.summary : null;
|
|
74
|
-
|
|
75
|
-
const fidelity = entry.fidelity || "full";
|
|
79
|
+
const fidelity = entry.fidelity || "promoted";
|
|
76
80
|
const fn = fidelityMap.get(fidelity);
|
|
77
|
-
if (!fn)
|
|
78
|
-
// No view for this fidelity — fall back on model-authored summary
|
|
79
|
-
return summary || "";
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const body = await fn(entry);
|
|
83
|
-
|
|
84
|
-
// Prepend summary keywords above plugin output at summary fidelity
|
|
85
|
-
if (fidelity === "summary" && summary && body) {
|
|
86
|
-
return `${summary}\n${body}`;
|
|
87
|
-
}
|
|
81
|
+
if (!fn) return "";
|
|
88
82
|
|
|
89
|
-
|
|
90
|
-
if (fidelity === "summary" && summary && !body) {
|
|
91
|
-
return summary;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return body;
|
|
83
|
+
return fn(entry);
|
|
95
84
|
}
|
|
96
85
|
|
|
97
86
|
hasView(scheme) {
|
|
@@ -112,15 +101,23 @@ export default class ToolRegistry {
|
|
|
112
101
|
return sortByPriority([...this.#tools.keys()]);
|
|
113
102
|
}
|
|
114
103
|
|
|
104
|
+
// Names advertised to the model — registered tools minus hidden ones.
|
|
105
|
+
// Use this anywhere a tool list is shown to the model.
|
|
106
|
+
get advertisedNames() {
|
|
107
|
+
return sortByPriority(
|
|
108
|
+
[...this.#tools.keys()].filter((n) => !this.#hidden.has(n)),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
115
112
|
/**
|
|
116
113
|
* Compute the active tool set for a loop.
|
|
117
|
-
* All exclusions — mode, flags — handled here. One mechanism.
|
|
114
|
+
* All exclusions — mode, flags, hidden — handled here. One mechanism.
|
|
118
115
|
*/
|
|
119
116
|
resolveForLoop(
|
|
120
117
|
mode,
|
|
121
118
|
{ noInteraction = false, noWeb = false, noProposals = false } = {},
|
|
122
119
|
) {
|
|
123
|
-
const excluded = new Set();
|
|
120
|
+
const excluded = new Set(this.#hidden);
|
|
124
121
|
if (mode === "ask") excluded.add("sh");
|
|
125
122
|
if (noInteraction) excluded.add("ask_user");
|
|
126
123
|
if (noWeb) excluded.add("search");
|