@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
|
@@ -6,6 +6,10 @@ import ResponseHealer from "./ResponseHealer.js";
|
|
|
6
6
|
import { countTokens } from "./tokens.js";
|
|
7
7
|
import XmlParser from "./XmlParser.js";
|
|
8
8
|
|
|
9
|
+
const ACTION_SCHEMES = new Set(["get", "set", "rm", "mv", "cp", "sh", "env", "search"]);
|
|
10
|
+
const MUTATION_SCHEMES = new Set(["set", "rm", "sh", "mv", "cp"]);
|
|
11
|
+
const READ_SCHEMES = new Set(["get", "env", "search"]);
|
|
12
|
+
|
|
9
13
|
export default class TurnExecutor {
|
|
10
14
|
#db;
|
|
11
15
|
#llmProvider;
|
|
@@ -19,6 +23,68 @@ export default class TurnExecutor {
|
|
|
19
23
|
this.#knownStore = knownStore;
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Rebuild turn_context from v_model_context, then assemble messages.
|
|
28
|
+
* Called at turn start and again after any fidelity demotion within the turn.
|
|
29
|
+
*/
|
|
30
|
+
async #materializeTurnContext({
|
|
31
|
+
runId,
|
|
32
|
+
loopId,
|
|
33
|
+
turn,
|
|
34
|
+
systemPrompt,
|
|
35
|
+
mode,
|
|
36
|
+
toolSet,
|
|
37
|
+
contextSize,
|
|
38
|
+
demoted,
|
|
39
|
+
}) {
|
|
40
|
+
await this.#db.clear_turn_context.run({ run_id: runId, turn });
|
|
41
|
+
const viewRows = await this.#db.get_model_context.all({ run_id: runId });
|
|
42
|
+
for (const row of viewRows) {
|
|
43
|
+
const scheme = row.scheme || "file";
|
|
44
|
+
const projectedBody = await this.#hooks.tools.view(scheme, {
|
|
45
|
+
path: row.path,
|
|
46
|
+
scheme,
|
|
47
|
+
body: row.body,
|
|
48
|
+
attributes: row.attributes ? JSON.parse(row.attributes) : null,
|
|
49
|
+
fidelity: row.fidelity,
|
|
50
|
+
category: row.category,
|
|
51
|
+
});
|
|
52
|
+
await this.#db.insert_turn_context.run({
|
|
53
|
+
run_id: runId,
|
|
54
|
+
loop_id: loopId,
|
|
55
|
+
turn,
|
|
56
|
+
ordinal: row.ordinal,
|
|
57
|
+
path: row.path,
|
|
58
|
+
fidelity: row.fidelity,
|
|
59
|
+
status: row.status,
|
|
60
|
+
body: projectedBody ?? "",
|
|
61
|
+
tokens: countTokens(projectedBody ?? ""),
|
|
62
|
+
attributes: row.attributes,
|
|
63
|
+
category: row.category,
|
|
64
|
+
source_turn: row.turn,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const rows = await this.#db.get_turn_context.all({ run_id: runId, turn });
|
|
68
|
+
const lastCtx = await this.#db.get_last_context_tokens.get({
|
|
69
|
+
run_id: runId,
|
|
70
|
+
});
|
|
71
|
+
const lastContextTokens = lastCtx?.context_tokens ?? 0;
|
|
72
|
+
const messages = await ContextAssembler.assembleFromTurnContext(
|
|
73
|
+
rows,
|
|
74
|
+
{
|
|
75
|
+
type: mode,
|
|
76
|
+
systemPrompt,
|
|
77
|
+
contextSize,
|
|
78
|
+
demoted,
|
|
79
|
+
toolSet,
|
|
80
|
+
lastContextTokens,
|
|
81
|
+
turn,
|
|
82
|
+
},
|
|
83
|
+
this.#hooks,
|
|
84
|
+
);
|
|
85
|
+
return { rows, messages, lastContextTokens };
|
|
86
|
+
}
|
|
87
|
+
|
|
22
88
|
async execute({
|
|
23
89
|
mode,
|
|
24
90
|
project,
|
|
@@ -28,12 +94,25 @@ export default class TurnExecutor {
|
|
|
28
94
|
currentLoopId,
|
|
29
95
|
requestedModel,
|
|
30
96
|
loopPrompt,
|
|
97
|
+
loopIteration,
|
|
31
98
|
noRepo,
|
|
32
99
|
toolSet,
|
|
100
|
+
inRecovery = false,
|
|
33
101
|
contextSize,
|
|
34
102
|
options,
|
|
35
103
|
signal,
|
|
36
104
|
}) {
|
|
105
|
+
const RECOVERY_EXCLUDED = new Set([
|
|
106
|
+
"sh",
|
|
107
|
+
"env",
|
|
108
|
+
"search",
|
|
109
|
+
"ask_user",
|
|
110
|
+
"set",
|
|
111
|
+
]);
|
|
112
|
+
const effectiveToolSet = inRecovery
|
|
113
|
+
? new Set([...toolSet].filter((t) => !RECOVERY_EXCLUDED.has(t)))
|
|
114
|
+
: toolSet;
|
|
115
|
+
|
|
37
116
|
const turn = await this.#knownStore.nextTurn(currentRunId);
|
|
38
117
|
|
|
39
118
|
const turnRow = await this.#db.create_turn.get({
|
|
@@ -42,13 +121,6 @@ export default class TurnExecutor {
|
|
|
42
121
|
sequence: turn,
|
|
43
122
|
});
|
|
44
123
|
|
|
45
|
-
const unresolved = await this.#knownStore.getUnresolved(currentRunId);
|
|
46
|
-
if (unresolved.length > 0) {
|
|
47
|
-
throw new Error(
|
|
48
|
-
msg("error.unresolved_proposed", { count: unresolved.length }),
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
124
|
// Build RummyContext before turn.started so plugins can write entries
|
|
53
125
|
const rummy = new RummyContext(
|
|
54
126
|
{
|
|
@@ -73,7 +145,7 @@ export default class TurnExecutor {
|
|
|
73
145
|
loopId: currentLoopId,
|
|
74
146
|
turnId: turnRow.id,
|
|
75
147
|
noRepo,
|
|
76
|
-
toolSet,
|
|
148
|
+
toolSet: effectiveToolSet,
|
|
77
149
|
contextSize,
|
|
78
150
|
systemPrompt: null,
|
|
79
151
|
loopPrompt,
|
|
@@ -85,6 +157,7 @@ export default class TurnExecutor {
|
|
|
85
157
|
mode,
|
|
86
158
|
prompt: loopPrompt,
|
|
87
159
|
isContinuation: options?.isContinuation,
|
|
160
|
+
loopIteration,
|
|
88
161
|
});
|
|
89
162
|
|
|
90
163
|
await this.#hooks.processTurn(rummy);
|
|
@@ -111,43 +184,23 @@ export default class TurnExecutor {
|
|
|
111
184
|
});
|
|
112
185
|
|
|
113
186
|
// Materialize turn_context: VIEW rows projected through tools
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const scheme = row.scheme || "file";
|
|
120
|
-
const projectedBody = await this.#hooks.tools.view(scheme, {
|
|
121
|
-
path: row.path,
|
|
122
|
-
scheme,
|
|
123
|
-
body: row.body,
|
|
124
|
-
attributes: row.attributes ? JSON.parse(row.attributes) : null,
|
|
125
|
-
fidelity: row.fidelity,
|
|
126
|
-
category: row.category,
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
await this.#db.insert_turn_context.run({
|
|
130
|
-
run_id: currentRunId,
|
|
131
|
-
loop_id: currentLoopId,
|
|
187
|
+
const demoted = [];
|
|
188
|
+
let { rows, messages, lastContextTokens } =
|
|
189
|
+
await this.#materializeTurnContext({
|
|
190
|
+
runId: currentRunId,
|
|
191
|
+
loopId: currentLoopId,
|
|
132
192
|
turn,
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
tokens: countTokens(projectedBody ?? ""),
|
|
139
|
-
attributes: row.attributes,
|
|
140
|
-
category: row.category,
|
|
141
|
-
source_turn: row.turn,
|
|
193
|
+
systemPrompt,
|
|
194
|
+
mode,
|
|
195
|
+
toolSet: effectiveToolSet,
|
|
196
|
+
contextSize,
|
|
197
|
+
demoted,
|
|
142
198
|
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const demoted = [];
|
|
146
199
|
|
|
147
200
|
await this.#hooks.context.materialized.emit({
|
|
148
201
|
runId: currentRunId,
|
|
149
202
|
turn,
|
|
150
|
-
rowCount:
|
|
203
|
+
rowCount: rows.length,
|
|
151
204
|
});
|
|
152
205
|
|
|
153
206
|
await this.#hooks.run.progress.emit({
|
|
@@ -157,50 +210,75 @@ export default class TurnExecutor {
|
|
|
157
210
|
status: "thinking",
|
|
158
211
|
});
|
|
159
212
|
|
|
160
|
-
let rows = await this.#db.get_turn_context.all({
|
|
161
|
-
run_id: currentRunId,
|
|
162
|
-
turn,
|
|
163
|
-
});
|
|
164
|
-
const lastCtx = await this.#db.get_last_context_tokens.get({
|
|
165
|
-
run_id: currentRunId,
|
|
166
|
-
});
|
|
167
|
-
const lastContextTokens = lastCtx?.context_tokens ?? 0;
|
|
168
|
-
|
|
169
|
-
let messages = await ContextAssembler.assembleFromTurnContext(
|
|
170
|
-
rows,
|
|
171
|
-
{
|
|
172
|
-
type: mode,
|
|
173
|
-
systemPrompt,
|
|
174
|
-
contextSize,
|
|
175
|
-
demoted,
|
|
176
|
-
toolSet,
|
|
177
|
-
lastContextTokens,
|
|
178
|
-
},
|
|
179
|
-
this.#hooks,
|
|
180
|
-
);
|
|
181
|
-
|
|
182
213
|
const budgetResult = await this.#hooks.budget.enforce({
|
|
183
214
|
contextSize,
|
|
184
215
|
messages,
|
|
185
216
|
rows,
|
|
217
|
+
lastPromptTokens: lastContextTokens,
|
|
186
218
|
});
|
|
187
219
|
messages = budgetResult.messages;
|
|
188
220
|
rows = budgetResult.rows;
|
|
189
|
-
|
|
221
|
+
let assembledTokens =
|
|
190
222
|
budgetResult.assembledTokens ??
|
|
191
223
|
messages.reduce((sum, m) => sum + countTokens(m.content), 0);
|
|
192
224
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
225
|
+
if (budgetResult.status === 413) {
|
|
226
|
+
if (loopIteration === 1) {
|
|
227
|
+
// Prompt Demotion: first-turn overflow — demote incoming prompt to summary
|
|
228
|
+
const promptRow = rows.findLast(
|
|
229
|
+
(r) => r.category === "prompt" && r.scheme === "prompt",
|
|
230
|
+
);
|
|
231
|
+
if (promptRow) {
|
|
232
|
+
await this.#knownStore.setFidelity(
|
|
233
|
+
currentRunId,
|
|
234
|
+
promptRow.path,
|
|
235
|
+
"summary",
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
const reMat = await this.#materializeTurnContext({
|
|
239
|
+
runId: currentRunId,
|
|
240
|
+
loopId: currentLoopId,
|
|
241
|
+
turn,
|
|
242
|
+
systemPrompt,
|
|
243
|
+
mode,
|
|
244
|
+
toolSet: effectiveToolSet,
|
|
245
|
+
contextSize,
|
|
246
|
+
demoted,
|
|
247
|
+
});
|
|
248
|
+
rows = reMat.rows;
|
|
249
|
+
messages = reMat.messages;
|
|
250
|
+
const recheck = await this.#hooks.budget.enforce({
|
|
251
|
+
contextSize,
|
|
252
|
+
messages,
|
|
253
|
+
rows,
|
|
254
|
+
lastPromptTokens: reMat.lastContextTokens,
|
|
255
|
+
});
|
|
256
|
+
messages = recheck.messages;
|
|
257
|
+
rows = recheck.rows;
|
|
258
|
+
assembledTokens =
|
|
259
|
+
recheck.assembledTokens ??
|
|
260
|
+
messages.reduce((sum, m) => sum + countTokens(m.content), 0);
|
|
261
|
+
if (recheck.status === 413) {
|
|
262
|
+
return {
|
|
263
|
+
turn,
|
|
264
|
+
turnId: turnRow.id,
|
|
265
|
+
status: 413,
|
|
266
|
+
assembledTokens,
|
|
267
|
+
contextSize,
|
|
268
|
+
overflow: recheck.overflow,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
// Base context too large even without new prompt — genuine failure
|
|
273
|
+
return {
|
|
274
|
+
turn,
|
|
275
|
+
turnId: turnRow.id,
|
|
276
|
+
status: 413,
|
|
277
|
+
assembledTokens,
|
|
278
|
+
contextSize,
|
|
279
|
+
overflow: budgetResult.overflow,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
204
282
|
}
|
|
205
283
|
|
|
206
284
|
const filteredMessages = await this.#hooks.llm.messages.filter(messages, {
|
|
@@ -216,6 +294,10 @@ export default class TurnExecutor {
|
|
|
216
294
|
/\b(503|429|timeout|ECONNREFUSED|ECONNRESET|unavailable)\b/i.test(
|
|
217
295
|
e.message,
|
|
218
296
|
);
|
|
297
|
+
const isContextExceeded = (e) =>
|
|
298
|
+
/\b(context.*(size|length|limit)|token.*(limit|exceed)|too.*(long|large))\b/i.test(
|
|
299
|
+
e.message,
|
|
300
|
+
);
|
|
219
301
|
|
|
220
302
|
for (let llmAttempt = 0; ; llmAttempt++) {
|
|
221
303
|
try {
|
|
@@ -234,6 +316,18 @@ export default class TurnExecutor {
|
|
|
234
316
|
await new Promise((r) => setTimeout(r, delay));
|
|
235
317
|
continue;
|
|
236
318
|
}
|
|
319
|
+
if (isContextExceeded(err)) {
|
|
320
|
+
console.warn(
|
|
321
|
+
`[RUMMY] LLM context exceeded: ${err.message.slice(0, 120)}. Returning 413.`,
|
|
322
|
+
);
|
|
323
|
+
return {
|
|
324
|
+
turn,
|
|
325
|
+
turnId: turnRow.id,
|
|
326
|
+
status: 413,
|
|
327
|
+
assembledTokens,
|
|
328
|
+
contextSize,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
237
331
|
throw err;
|
|
238
332
|
}
|
|
239
333
|
}
|
|
@@ -290,15 +384,7 @@ export default class TurnExecutor {
|
|
|
290
384
|
});
|
|
291
385
|
|
|
292
386
|
// --- PHASE 1: RECORD ---
|
|
293
|
-
// Split lifecycle signals from action commands.
|
|
294
|
-
// Lifecycle signals (summarize, update, unknown, known) are state
|
|
295
|
-
// declarations — always recorded, never 409'd by sequential dispatch.
|
|
296
|
-
const LIFECYCLE = new Set(["summarize", "update", "unknown", "known"]);
|
|
297
|
-
|
|
298
387
|
const recorded = [];
|
|
299
|
-
const lifecycle = [];
|
|
300
|
-
const actions = [];
|
|
301
|
-
|
|
302
388
|
for (const cmd of commands) {
|
|
303
389
|
const entry = await this.#record(
|
|
304
390
|
currentRunId,
|
|
@@ -307,125 +393,114 @@ export default class TurnExecutor {
|
|
|
307
393
|
mode,
|
|
308
394
|
cmd,
|
|
309
395
|
);
|
|
310
|
-
if (
|
|
311
|
-
recorded.push(entry);
|
|
312
|
-
|
|
313
|
-
if (LIFECYCLE.has(entry.scheme)) {
|
|
314
|
-
lifecycle.push(entry);
|
|
315
|
-
} else {
|
|
316
|
-
actions.push(entry);
|
|
317
|
-
}
|
|
396
|
+
if (entry) recorded.push(entry);
|
|
318
397
|
}
|
|
319
398
|
|
|
320
399
|
// --- PHASE 2: DISPATCH ---
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
contextSize,
|
|
325
|
-
assembledTokens,
|
|
326
|
-
);
|
|
327
|
-
const { BudgetExceeded } = this.#hooks.budget;
|
|
328
|
-
|
|
400
|
+
// Sequential queue. Each tool completes before the next starts.
|
|
401
|
+
// On failure: abort remaining. On proposal: notify client, await
|
|
402
|
+
// resolution, continue.
|
|
329
403
|
let hasErrors = false;
|
|
330
|
-
let hasProposed = false;
|
|
331
404
|
let abortAfter = null;
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
405
|
+
|
|
406
|
+
for (const entry of recorded) {
|
|
407
|
+
if (abortAfter) {
|
|
408
|
+
const errorMsg = `Aborted — preceding <${abortAfter}> failed.`;
|
|
409
|
+
await this.#knownStore.upsert(
|
|
410
|
+
currentRunId,
|
|
411
|
+
turn,
|
|
412
|
+
entry.resultPath || entry.path,
|
|
413
|
+
errorMsg,
|
|
414
|
+
409,
|
|
415
|
+
{ attributes: { error: errorMsg }, loopId: currentLoopId },
|
|
416
|
+
);
|
|
417
|
+
hasErrors = true;
|
|
418
|
+
continue;
|
|
341
419
|
}
|
|
342
420
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
421
|
+
await this.#hooks.tool.before.emit({ entry, rummy });
|
|
422
|
+
await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
|
|
423
|
+
await this.#hooks.tool.after.emit({ entry, rummy });
|
|
424
|
+
await this.#hooks.entry.created.emit(entry);
|
|
425
|
+
|
|
426
|
+
// Materialize proposals for this entry (set revisions → 202)
|
|
427
|
+
await this.#hooks.turn.proposing.emit({ rummy, recorded: [entry] });
|
|
428
|
+
|
|
429
|
+
// Check for any proposals created by this entry's dispatch
|
|
430
|
+
const proposed = await this.#knownStore.getUnresolved(currentRunId);
|
|
431
|
+
for (const p of proposed) {
|
|
432
|
+
await this.#hooks.turn.proposal.emit({
|
|
433
|
+
projectId,
|
|
434
|
+
run: currentAlias,
|
|
435
|
+
proposed: [p],
|
|
436
|
+
});
|
|
437
|
+
await this.#knownStore.waitForResolution(currentRunId, p.path);
|
|
438
|
+
const resolved = await this.#db.get_entry_state.get({
|
|
439
|
+
run_id: currentRunId,
|
|
440
|
+
path: p.path,
|
|
441
|
+
});
|
|
442
|
+
if (resolved?.status >= 400) {
|
|
360
443
|
hasErrors = true;
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
try {
|
|
365
|
-
await this.#hooks.tool.before.emit({ entry, rummy });
|
|
366
|
-
await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
|
|
367
|
-
await this.#hooks.tool.after.emit({ entry, rummy });
|
|
368
|
-
await this.#hooks.entry.created.emit(entry);
|
|
369
|
-
dispatched.push(entry);
|
|
370
|
-
} catch (err) {
|
|
371
|
-
if (err instanceof BudgetExceeded) {
|
|
372
|
-
guard.trip(entry.scheme);
|
|
373
|
-
await this.#knownStore.upsert(
|
|
374
|
-
currentRunId,
|
|
375
|
-
turn,
|
|
376
|
-
entry.resultPath || entry.path,
|
|
377
|
-
`Budget exceeded: ${err.requested} tokens requested, ${err.remaining} remaining.`,
|
|
378
|
-
413,
|
|
379
|
-
{ attributes: { error: err.message }, loopId: currentLoopId },
|
|
380
|
-
);
|
|
381
|
-
hasErrors = true;
|
|
382
|
-
abortAfter = entry.scheme;
|
|
383
|
-
continue;
|
|
384
|
-
}
|
|
385
|
-
throw err;
|
|
444
|
+
abortAfter = entry.scheme;
|
|
386
445
|
}
|
|
446
|
+
}
|
|
387
447
|
|
|
448
|
+
// Also check the entry itself for direct failures
|
|
449
|
+
if (!hasErrors) {
|
|
450
|
+
const entryPath = entry.resultPath || entry.path;
|
|
388
451
|
const row = await this.#db.get_entry_state.get({
|
|
389
452
|
run_id: currentRunId,
|
|
390
|
-
path:
|
|
453
|
+
path: entryPath,
|
|
391
454
|
});
|
|
392
|
-
if (row?.status
|
|
393
|
-
hasProposed = true;
|
|
394
|
-
abortAfter = entry.scheme;
|
|
395
|
-
} else if (row?.status >= 400) {
|
|
455
|
+
if (row?.status >= 400) {
|
|
396
456
|
hasErrors = true;
|
|
397
457
|
abortAfter = entry.scheme;
|
|
398
458
|
}
|
|
399
459
|
}
|
|
460
|
+
}
|
|
400
461
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
this.#hooks.budget.
|
|
462
|
+
// Turn Demotion: if end-of-turn context exceeds ceiling, demote this
|
|
463
|
+
// turn's data entries and the incoming prompt to summary, then force a
|
|
464
|
+
// budget recovery phase before continuing.
|
|
465
|
+
let budgetRecovery = null;
|
|
466
|
+
// Use actual prompt_tokens from this turn's LLM response as the ground-truth
|
|
467
|
+
// Post-dispatch budget check — demotion handled by budget plugin
|
|
468
|
+
if (contextSize) {
|
|
469
|
+
const postMat = await this.#materializeTurnContext({
|
|
470
|
+
runId: currentRunId,
|
|
471
|
+
loopId: currentLoopId,
|
|
472
|
+
turn,
|
|
473
|
+
systemPrompt,
|
|
474
|
+
mode,
|
|
475
|
+
toolSet: effectiveToolSet,
|
|
476
|
+
contextSize,
|
|
477
|
+
demoted,
|
|
478
|
+
});
|
|
479
|
+
budgetRecovery = await this.#hooks.budget.postDispatch({
|
|
480
|
+
contextSize,
|
|
481
|
+
messages: postMat.messages,
|
|
482
|
+
rows: postMat.rows,
|
|
483
|
+
runId: currentRunId,
|
|
484
|
+
loopId: currentLoopId,
|
|
485
|
+
turn,
|
|
486
|
+
db: this.#db,
|
|
487
|
+
store: this.#knownStore,
|
|
488
|
+
});
|
|
419
489
|
}
|
|
420
490
|
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
const updateEntry = lifecycle.find((e) => e.scheme === "update");
|
|
491
|
+
const summaryEntry = recorded.findLast((e) => e.scheme === "summarize");
|
|
492
|
+
const updateEntry = recorded.findLast((e) => e.scheme === "update");
|
|
424
493
|
let summaryText = summaryEntry?.body || null;
|
|
425
494
|
let updateText = updateEntry?.body || null;
|
|
426
495
|
|
|
427
|
-
// If model sent both,
|
|
428
|
-
if (summaryText && updateText)
|
|
496
|
+
// If model sent both, last signal wins — respects the model's final intent
|
|
497
|
+
if (summaryText && updateText) {
|
|
498
|
+
const lastLifecycle = recorded.findLast(
|
|
499
|
+
(e) => e.scheme === "summarize" || e.scheme === "update",
|
|
500
|
+
);
|
|
501
|
+
if (lastLifecycle.scheme === "summarize") updateText = null;
|
|
502
|
+
else summaryText = null;
|
|
503
|
+
}
|
|
429
504
|
|
|
430
505
|
// If model says "done" but actions failed, override — the model's
|
|
431
506
|
// assertion that it's done is false if it failed to do what it tried.
|
|
@@ -433,6 +508,15 @@ export default class TurnExecutor {
|
|
|
433
508
|
console.warn(
|
|
434
509
|
"[RUMMY] Overriding <summarize> — actions in this turn failed. Continuing.",
|
|
435
510
|
);
|
|
511
|
+
// Mark the recorded summarize entry as 409 so the model sees it was rejected
|
|
512
|
+
if (summaryEntry?.path) {
|
|
513
|
+
await this.#knownStore.resolve(
|
|
514
|
+
currentRunId,
|
|
515
|
+
summaryEntry.path,
|
|
516
|
+
409,
|
|
517
|
+
"Overridden — actions in this turn failed. Use <update/> until resolved.",
|
|
518
|
+
);
|
|
519
|
+
}
|
|
436
520
|
updateText = summaryText;
|
|
437
521
|
summaryText = null;
|
|
438
522
|
}
|
|
@@ -448,11 +532,7 @@ export default class TurnExecutor {
|
|
|
448
532
|
|
|
449
533
|
// --- Classify for return value ---
|
|
450
534
|
|
|
451
|
-
const actionCalls = recorded.filter((e) =>
|
|
452
|
-
["get", "set", "rm", "mv", "cp", "sh", "env", "search"].includes(
|
|
453
|
-
e.scheme,
|
|
454
|
-
),
|
|
455
|
-
);
|
|
535
|
+
const actionCalls = recorded.filter((e) => ACTION_SCHEMES.has(e.scheme));
|
|
456
536
|
const writeCalls = recorded.filter(
|
|
457
537
|
(e) =>
|
|
458
538
|
e.scheme === "known" ||
|
|
@@ -460,12 +540,8 @@ export default class TurnExecutor {
|
|
|
460
540
|
);
|
|
461
541
|
const unknownCalls = recorded.filter((e) => e.scheme === "unknown");
|
|
462
542
|
|
|
463
|
-
const hasAct = actionCalls.some((c) =>
|
|
464
|
-
|
|
465
|
-
);
|
|
466
|
-
const hasReads = actionCalls.some((c) =>
|
|
467
|
-
["get", "env", "search"].includes(c.scheme),
|
|
468
|
-
);
|
|
543
|
+
const hasAct = actionCalls.some((c) => MUTATION_SCHEMES.has(c.scheme));
|
|
544
|
+
const hasReads = actionCalls.some((c) => READ_SCHEMES.has(c.scheme));
|
|
469
545
|
const hasWrites = writeCalls.length > 0 || unknownCalls.length > 0;
|
|
470
546
|
const flags = { hasAct, hasReads, hasWrites };
|
|
471
547
|
|
|
@@ -484,12 +560,11 @@ export default class TurnExecutor {
|
|
|
484
560
|
flags,
|
|
485
561
|
model: result.model || requestedModel,
|
|
486
562
|
modelAlias: requestedModel,
|
|
487
|
-
temperature:
|
|
488
|
-
options?.temperature ??
|
|
489
|
-
Number.parseFloat(process.env.RUMMY_TEMPERATURE || "0.7"),
|
|
563
|
+
temperature: options?.temperature,
|
|
490
564
|
contextSize,
|
|
491
565
|
assembledTokens,
|
|
492
566
|
usage: result.usage,
|
|
567
|
+
budgetRecovery,
|
|
493
568
|
};
|
|
494
569
|
|
|
495
570
|
await this.#hooks.turn.completed.emit(turnResult);
|
|
@@ -502,85 +577,33 @@ export default class TurnExecutor {
|
|
|
502
577
|
* Returns the recorded entry descriptor, or null if rejected/skipped.
|
|
503
578
|
*/
|
|
504
579
|
async #record(runId, loopId, turn, mode, cmd) {
|
|
505
|
-
// Mode enforcement — reject prohibited commands in ask/panic mode
|
|
506
|
-
if (mode === "ask" || mode === "panic") {
|
|
507
|
-
if (cmd.name === "sh") {
|
|
508
|
-
console.warn("[RUMMY] Rejected <sh> in ask mode");
|
|
509
|
-
return null;
|
|
510
|
-
}
|
|
511
|
-
if (cmd.name === "set" && cmd.path && cmd.body) {
|
|
512
|
-
const scheme = KnownStore.scheme(cmd.path);
|
|
513
|
-
if (scheme === null) {
|
|
514
|
-
console.warn(
|
|
515
|
-
`[RUMMY] Rejected file edit to ${cmd.path} in ${mode} mode`,
|
|
516
|
-
);
|
|
517
|
-
return null;
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
if (cmd.name === "rm" && cmd.path) {
|
|
521
|
-
const scheme = KnownStore.scheme(cmd.path);
|
|
522
|
-
if (scheme === null) {
|
|
523
|
-
console.warn(`[RUMMY] Rejected file rm of ${cmd.path} in ask mode`);
|
|
524
|
-
return null;
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
if ((cmd.name === "mv" || cmd.name === "cp") && cmd.to) {
|
|
528
|
-
const destScheme = KnownStore.scheme(cmd.to);
|
|
529
|
-
if (destScheme === null) {
|
|
530
|
-
console.warn(
|
|
531
|
-
`[RUMMY] Rejected ${cmd.name} to file ${cmd.to} in ask mode`,
|
|
532
|
-
);
|
|
533
|
-
return null;
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
580
|
const scheme = cmd.name;
|
|
539
|
-
|
|
540
|
-
//
|
|
541
|
-
if (
|
|
542
|
-
const
|
|
581
|
+
const rawTarget = cmd.path || cmd.command || cmd.question || "";
|
|
582
|
+
// Reject paths that are likely reasoning bleed — too long or contain non-printing chars
|
|
583
|
+
if (rawTarget.length > 512 || /\p{Cc}/u.test(rawTarget)) {
|
|
584
|
+
const rejectPath = await this.#knownStore.dedup(
|
|
543
585
|
runId,
|
|
544
586
|
scheme,
|
|
545
|
-
|
|
587
|
+
`${scheme}://invalid`,
|
|
588
|
+
turn,
|
|
546
589
|
);
|
|
547
|
-
await this.#knownStore.upsert(
|
|
548
|
-
loopId,
|
|
549
|
-
});
|
|
550
|
-
return {
|
|
551
|
-
scheme,
|
|
552
|
-
body: cmd.body,
|
|
553
|
-
path: statusPath,
|
|
554
|
-
resultPath: statusPath,
|
|
555
|
-
attributes: null,
|
|
556
|
-
};
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// Unknown — deduplicated, sticky
|
|
560
|
-
if (scheme === "unknown") {
|
|
561
|
-
const existingValues = await this.#knownStore.getUnknownValues(runId);
|
|
562
|
-
if (existingValues.has(cmd.body)) {
|
|
563
|
-
console.warn(`[RUMMY] Unknown deduped: "${cmd.body.slice(0, 60)}"`);
|
|
564
|
-
return null;
|
|
565
|
-
}
|
|
566
|
-
const unknownPath = await this.#knownStore.slugPath(
|
|
590
|
+
await this.#knownStore.upsert(
|
|
567
591
|
runId,
|
|
568
|
-
|
|
569
|
-
|
|
592
|
+
turn,
|
|
593
|
+
rejectPath,
|
|
594
|
+
`Invalid path: too long or contains non-printing characters`,
|
|
595
|
+
400,
|
|
596
|
+
{ loopId },
|
|
570
597
|
);
|
|
571
|
-
await this.#knownStore.upsert(runId, turn, unknownPath, cmd.body, 200, {
|
|
572
|
-
loopId,
|
|
573
|
-
});
|
|
574
598
|
return {
|
|
575
599
|
scheme,
|
|
576
|
-
path:
|
|
577
|
-
body:
|
|
578
|
-
|
|
579
|
-
|
|
600
|
+
path: rejectPath,
|
|
601
|
+
body: "",
|
|
602
|
+
attributes: {},
|
|
603
|
+
status: 400,
|
|
604
|
+
resultPath: rejectPath,
|
|
580
605
|
};
|
|
581
606
|
}
|
|
582
|
-
|
|
583
|
-
const rawTarget = cmd.path || cmd.command || cmd.question || "";
|
|
584
607
|
const target = rawTarget;
|
|
585
608
|
const resultPath = await this.#knownStore.dedup(
|
|
586
609
|
runId,
|
|
@@ -593,106 +616,15 @@ export default class TurnExecutor {
|
|
|
593
616
|
const { name: _, ...attributes } = cmd;
|
|
594
617
|
if (cmd.path) attributes.path = target;
|
|
595
618
|
|
|
596
|
-
// known tool or naked write → known:// slug from body
|
|
597
|
-
if (scheme === "known" || (scheme === "set" && !cmd.path)) {
|
|
598
|
-
if (!cmd.body) return null;
|
|
599
|
-
|
|
600
|
-
// Size gate: reject entries > 512 tokens — force atomic entries
|
|
601
|
-
const entryTokens = countTokens(cmd.body);
|
|
602
|
-
const MAX_ENTRY_TOKENS = 512;
|
|
603
|
-
if (scheme === "known" && entryTokens > MAX_ENTRY_TOKENS) {
|
|
604
|
-
const rejectPath = await this.#knownStore.slugPath(
|
|
605
|
-
runId,
|
|
606
|
-
scheme,
|
|
607
|
-
cmd.body,
|
|
608
|
-
);
|
|
609
|
-
await this.#knownStore.upsert(
|
|
610
|
-
runId,
|
|
611
|
-
turn,
|
|
612
|
-
rejectPath,
|
|
613
|
-
`Entry too large (${entryTokens} tokens, max ${MAX_ENTRY_TOKENS}). Sort the information, ideas, or plans carefully into multiple entries.`,
|
|
614
|
-
413,
|
|
615
|
-
{ loopId },
|
|
616
|
-
);
|
|
617
|
-
return {
|
|
618
|
-
scheme,
|
|
619
|
-
path: rejectPath,
|
|
620
|
-
body: "",
|
|
621
|
-
resultPath: rejectPath,
|
|
622
|
-
attributes,
|
|
623
|
-
status: 413,
|
|
624
|
-
};
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
let knownPath = cmd.path;
|
|
628
|
-
if (!knownPath) {
|
|
629
|
-
knownPath = await this.#knownStore.slugPath(
|
|
630
|
-
runId,
|
|
631
|
-
"known",
|
|
632
|
-
cmd.body,
|
|
633
|
-
cmd.summary,
|
|
634
|
-
);
|
|
635
|
-
}
|
|
636
|
-
// Dedup: if this exact path already exists, update rather than duplicate
|
|
637
|
-
const existing = await this.#knownStore.getEntriesByPattern(
|
|
638
|
-
runId,
|
|
639
|
-
knownPath,
|
|
640
|
-
null,
|
|
641
|
-
);
|
|
642
|
-
if (existing.length > 0) {
|
|
643
|
-
// Path exists — update body and turn, skip creating a new entry
|
|
644
|
-
await this.#knownStore.upsert(
|
|
645
|
-
runId,
|
|
646
|
-
turn,
|
|
647
|
-
existing[0].path,
|
|
648
|
-
cmd.body || existing[0].body,
|
|
649
|
-
200,
|
|
650
|
-
{
|
|
651
|
-
loopId,
|
|
652
|
-
},
|
|
653
|
-
);
|
|
654
|
-
return {
|
|
655
|
-
scheme: "known",
|
|
656
|
-
path: existing[0].path,
|
|
657
|
-
body: cmd.body || existing[0].body,
|
|
658
|
-
resultPath: existing[0].path,
|
|
659
|
-
attributes,
|
|
660
|
-
};
|
|
661
|
-
}
|
|
662
|
-
await this.#knownStore.upsert(runId, turn, knownPath, cmd.body, 200, {
|
|
663
|
-
loopId,
|
|
664
|
-
});
|
|
665
|
-
return {
|
|
666
|
-
scheme: "known",
|
|
667
|
-
path: knownPath,
|
|
668
|
-
body: cmd.body,
|
|
669
|
-
resultPath: knownPath,
|
|
670
|
-
attributes,
|
|
671
|
-
};
|
|
672
|
-
}
|
|
673
|
-
|
|
674
619
|
const body = cmd.body || cmd.command || cmd.question || "";
|
|
675
620
|
|
|
676
621
|
// Filter: plugins can validate/transform before recording
|
|
677
622
|
const filtered = await this.#hooks.entry.recording.filter(
|
|
678
623
|
{ scheme, path: resultPath, body, attributes, status: 200 },
|
|
679
|
-
{ runId, turn, loopId },
|
|
624
|
+
{ runId, turn, loopId, mode },
|
|
680
625
|
);
|
|
681
626
|
if (filtered.status >= 400) return filtered;
|
|
682
627
|
|
|
683
|
-
// Record the entry — 200 OK, handlers change status during dispatch
|
|
684
|
-
await this.#knownStore.upsert(
|
|
685
|
-
runId,
|
|
686
|
-
turn,
|
|
687
|
-
filtered.path,
|
|
688
|
-
filtered.body,
|
|
689
|
-
200,
|
|
690
|
-
{
|
|
691
|
-
attributes: filtered.attributes,
|
|
692
|
-
loopId,
|
|
693
|
-
},
|
|
694
|
-
);
|
|
695
|
-
|
|
696
628
|
return {
|
|
697
629
|
scheme: filtered.scheme,
|
|
698
630
|
path: filtered.path,
|
|
@@ -702,34 +634,4 @@ export default class TurnExecutor {
|
|
|
702
634
|
resultPath: filtered.path,
|
|
703
635
|
};
|
|
704
636
|
}
|
|
705
|
-
|
|
706
|
-
async #rematerialize(runId, loopId, turn) {
|
|
707
|
-
await this.#db.clear_turn_context.run({ run_id: runId, turn });
|
|
708
|
-
const viewRows = await this.#db.get_model_context.all({ run_id: runId });
|
|
709
|
-
for (const row of viewRows) {
|
|
710
|
-
const scheme = row.scheme || "file";
|
|
711
|
-
const projectedBody = await this.#hooks.tools.view(scheme, {
|
|
712
|
-
path: row.path,
|
|
713
|
-
scheme,
|
|
714
|
-
body: row.body,
|
|
715
|
-
attributes: row.attributes ? JSON.parse(row.attributes) : null,
|
|
716
|
-
fidelity: row.fidelity,
|
|
717
|
-
category: row.category,
|
|
718
|
-
});
|
|
719
|
-
await this.#db.insert_turn_context.run({
|
|
720
|
-
run_id: runId,
|
|
721
|
-
loop_id: loopId,
|
|
722
|
-
turn,
|
|
723
|
-
ordinal: row.ordinal,
|
|
724
|
-
path: row.path,
|
|
725
|
-
fidelity: row.fidelity,
|
|
726
|
-
status: row.status,
|
|
727
|
-
body: projectedBody ?? "",
|
|
728
|
-
tokens: countTokens(projectedBody ?? ""),
|
|
729
|
-
attributes: row.attributes,
|
|
730
|
-
category: row.category,
|
|
731
|
-
source_turn: row.turn,
|
|
732
|
-
});
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
637
|
}
|