@possumtech/rummy 0.5.0 → 2.0.1

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.
Files changed (157) hide show
  1. package/.env.example +42 -5
  2. package/PLUGINS.md +389 -194
  3. package/README.md +25 -8
  4. package/SPEC.md +934 -373
  5. package/bin/demo.js +166 -0
  6. package/bin/rummy.js +9 -3
  7. package/biome/no-fallbacks.grit +50 -0
  8. package/lang/en.json +2 -2
  9. package/migrations/001_initial_schema.sql +88 -37
  10. package/package.json +13 -11
  11. package/scriptify/ask_run.js +77 -0
  12. package/service.js +50 -9
  13. package/src/agent/AgentLoop.js +476 -335
  14. package/src/agent/ContextAssembler.js +4 -4
  15. package/src/agent/Entries.js +676 -0
  16. package/src/agent/ProjectAgent.js +30 -18
  17. package/src/agent/TurnExecutor.js +232 -421
  18. package/src/agent/XmlParser.js +99 -33
  19. package/src/agent/budget.js +56 -0
  20. package/src/agent/errors.js +22 -0
  21. package/src/agent/httpStatus.js +39 -0
  22. package/src/agent/known_checks.sql +8 -4
  23. package/src/agent/known_queries.sql +9 -13
  24. package/src/agent/known_store.sql +280 -125
  25. package/src/agent/materializeContext.js +104 -0
  26. package/src/agent/runs.sql +29 -7
  27. package/src/agent/schemes.sql +14 -3
  28. package/src/agent/tokens.js +6 -0
  29. package/src/agent/turns.sql +9 -9
  30. package/src/hooks/HookRegistry.js +6 -5
  31. package/src/hooks/Hooks.js +44 -3
  32. package/src/hooks/PluginContext.js +29 -21
  33. package/src/{server → hooks}/RpcRegistry.js +2 -1
  34. package/src/hooks/RummyContext.js +139 -35
  35. package/src/hooks/ToolRegistry.js +21 -16
  36. package/src/llm/LlmProvider.js +66 -89
  37. package/src/llm/errors.js +21 -0
  38. package/src/llm/retry.js +63 -0
  39. package/src/plugins/ask_user/README.md +1 -1
  40. package/src/plugins/ask_user/ask_user.js +37 -12
  41. package/src/plugins/ask_user/ask_userDoc.js +2 -25
  42. package/src/plugins/ask_user/ask_userDoc.md +10 -0
  43. package/src/plugins/budget/README.md +27 -25
  44. package/src/plugins/budget/budget.js +306 -88
  45. package/src/plugins/cp/README.md +2 -2
  46. package/src/plugins/cp/cp.js +29 -11
  47. package/src/plugins/cp/cpDoc.js +2 -15
  48. package/src/plugins/cp/cpDoc.md +7 -0
  49. package/src/plugins/engine/README.md +2 -2
  50. package/src/plugins/engine/engine.sql +4 -4
  51. package/src/plugins/engine/turn_context.sql +10 -10
  52. package/src/plugins/env/README.md +20 -5
  53. package/src/plugins/env/env.js +45 -6
  54. package/src/plugins/env/envDoc.js +2 -23
  55. package/src/plugins/env/envDoc.md +13 -0
  56. package/src/plugins/error/README.md +16 -0
  57. package/src/plugins/error/error.js +151 -0
  58. package/src/plugins/file/README.md +6 -6
  59. package/src/plugins/file/file.js +15 -2
  60. package/src/plugins/get/README.md +1 -1
  61. package/src/plugins/get/get.js +103 -48
  62. package/src/plugins/get/getDoc.js +2 -32
  63. package/src/plugins/get/getDoc.md +36 -0
  64. package/src/plugins/hedberg/README.md +1 -2
  65. package/src/plugins/hedberg/hedberg.js +8 -4
  66. package/src/plugins/hedberg/matcher.js +16 -17
  67. package/src/plugins/hedberg/normalize.js +0 -48
  68. package/src/plugins/helpers.js +42 -2
  69. package/src/plugins/index.js +146 -123
  70. package/src/plugins/instructions/README.md +35 -9
  71. package/src/plugins/instructions/instructions.js +244 -9
  72. package/src/plugins/instructions/instructions.md +33 -0
  73. package/src/plugins/instructions/instructions_104.md +7 -0
  74. package/src/plugins/instructions/instructions_105.md +38 -0
  75. package/src/plugins/instructions/instructions_106.md +21 -0
  76. package/src/plugins/instructions/instructions_107.md +10 -0
  77. package/src/plugins/instructions/instructions_108.md +0 -0
  78. package/src/plugins/instructions/protocol.js +12 -0
  79. package/src/plugins/known/README.md +2 -2
  80. package/src/plugins/known/known.js +68 -36
  81. package/src/plugins/known/knownDoc.js +2 -17
  82. package/src/plugins/known/knownDoc.md +8 -0
  83. package/src/plugins/log/README.md +48 -0
  84. package/src/plugins/log/log.js +129 -0
  85. package/src/plugins/mv/README.md +2 -2
  86. package/src/plugins/mv/mv.js +55 -22
  87. package/src/plugins/mv/mvDoc.js +2 -18
  88. package/src/plugins/mv/mvDoc.md +10 -0
  89. package/src/plugins/ollama/README.md +15 -0
  90. package/src/{llm/OllamaClient.js → plugins/ollama/ollama.js} +40 -18
  91. package/src/plugins/openai/README.md +17 -0
  92. package/src/plugins/openai/openai.js +120 -0
  93. package/src/plugins/openrouter/README.md +27 -0
  94. package/src/plugins/openrouter/openrouter.js +121 -0
  95. package/src/plugins/persona/README.md +20 -0
  96. package/src/plugins/persona/persona.js +9 -16
  97. package/src/plugins/policy/README.md +21 -0
  98. package/src/plugins/policy/policy.js +29 -14
  99. package/src/plugins/prompt/README.md +1 -1
  100. package/src/plugins/prompt/prompt.js +64 -16
  101. package/src/plugins/rm/README.md +1 -1
  102. package/src/plugins/rm/rm.js +56 -12
  103. package/src/plugins/rm/rmDoc.js +2 -20
  104. package/src/plugins/rm/rmDoc.md +13 -0
  105. package/src/plugins/rpc/README.md +2 -2
  106. package/src/plugins/rpc/rpc.js +525 -296
  107. package/src/plugins/set/README.md +1 -1
  108. package/src/plugins/set/set.js +318 -75
  109. package/src/plugins/set/setDoc.js +2 -35
  110. package/src/plugins/set/setDoc.md +22 -0
  111. package/src/plugins/sh/README.md +28 -5
  112. package/src/plugins/sh/sh.js +50 -6
  113. package/src/plugins/sh/shDoc.js +2 -23
  114. package/src/plugins/sh/shDoc.md +13 -0
  115. package/src/plugins/skill/README.md +23 -0
  116. package/src/plugins/skill/skill.js +14 -18
  117. package/src/plugins/stream/README.md +101 -0
  118. package/src/plugins/stream/stream.js +290 -0
  119. package/src/plugins/telemetry/README.md +1 -1
  120. package/src/plugins/telemetry/telemetry.js +129 -80
  121. package/src/plugins/think/README.md +1 -1
  122. package/src/plugins/think/think.js +12 -0
  123. package/src/plugins/think/thinkDoc.js +2 -15
  124. package/src/plugins/think/thinkDoc.md +7 -0
  125. package/src/plugins/unknown/README.md +3 -3
  126. package/src/plugins/unknown/unknown.js +47 -19
  127. package/src/plugins/unknown/unknownDoc.js +2 -21
  128. package/src/plugins/unknown/unknownDoc.md +11 -0
  129. package/src/plugins/update/README.md +1 -1
  130. package/src/plugins/update/update.js +83 -5
  131. package/src/plugins/update/updateDoc.js +2 -30
  132. package/src/plugins/update/updateDoc.md +8 -0
  133. package/src/plugins/xai/README.md +23 -0
  134. package/src/{llm/XaiClient.js → plugins/xai/xai.js} +58 -37
  135. package/src/plugins/yolo/yolo.js +192 -0
  136. package/src/server/ClientConnection.js +64 -37
  137. package/src/server/SocketServer.js +23 -10
  138. package/src/server/protocol.js +11 -0
  139. package/src/sql/v_model_context.sql +27 -31
  140. package/src/sql/v_run_log.sql +9 -14
  141. package/EXCEPTIONS.md +0 -46
  142. package/FIDELITY_CONTRACT.md +0 -172
  143. package/src/agent/KnownStore.js +0 -337
  144. package/src/agent/ResponseHealer.js +0 -241
  145. package/src/llm/OpenAiClient.js +0 -100
  146. package/src/llm/OpenRouterClient.js +0 -100
  147. package/src/plugins/budget/recovery.js +0 -47
  148. package/src/plugins/instructions/preamble.md +0 -45
  149. package/src/plugins/performed/README.md +0 -15
  150. package/src/plugins/performed/performed.js +0 -45
  151. package/src/plugins/previous/README.md +0 -16
  152. package/src/plugins/previous/previous.js +0 -56
  153. package/src/plugins/progress/README.md +0 -16
  154. package/src/plugins/progress/progress.js +0 -43
  155. package/src/plugins/summarize/README.md +0 -19
  156. package/src/plugins/summarize/summarize.js +0 -32
  157. package/src/plugins/summarize/summarizeDoc.js +0 -27
@@ -1,26 +1,51 @@
1
- import { advanceRecovery } from "../plugins/budget/recovery.js";
1
+ import { computeBudget } from "./budget.js";
2
2
  import msg from "./messages.js";
3
- import ResponseHealer from "./ResponseHealer.js";
3
+
4
+ const HTTP_TO_RUN_STATE = {
5
+ 100: "proposed",
6
+ 102: "streaming",
7
+ 200: "resolved",
8
+ 202: "streaming",
9
+ 499: "cancelled",
10
+ 500: "failed",
11
+ };
4
12
 
5
13
  export default class AgentLoop {
6
14
  #db;
7
15
  #llmProvider;
8
16
  #hooks;
9
17
  #turnExecutor;
10
- #knownStore;
18
+ #entries;
11
19
  #activeRuns = new Map();
12
20
 
13
- constructor(db, llmProvider, hooks, turnExecutor, knownStore) {
21
+ constructor(db, llmProvider, hooks, turnExecutor, entries) {
14
22
  this.#db = db;
15
23
  this.#llmProvider = llmProvider;
16
24
  this.#hooks = hooks;
17
25
  this.#turnExecutor = turnExecutor;
18
- this.#knownStore = knownStore;
26
+ this.#entries = entries;
19
27
  }
20
28
 
21
29
  abort(runId) {
22
- const controller = this.#activeRuns.get(runId);
23
- if (controller) controller.abort();
30
+ const active = this.#activeRuns.get(runId);
31
+ if (active) active.controller.abort();
32
+ }
33
+
34
+ /**
35
+ * Abort every in-flight run and wait for each drain to settle.
36
+ * Called from server close / client teardown so the process can
37
+ * exit cleanly instead of leaving detached kickoff Promises
38
+ * pinning the event loop.
39
+ */
40
+ async abortAll() {
41
+ const promises = [];
42
+ for (const { controller, promise } of this.#activeRuns.values()) {
43
+ controller.abort();
44
+ promises.push(promise);
45
+ }
46
+ // allSettled: drain waits for every run to finish; rejections are
47
+ // already surfaced to whoever awaited the original run() call.
48
+ await Promise.allSettled(promises);
24
49
  }
25
50
 
26
51
  async #generateAlias(modelAlias) {
@@ -31,9 +56,126 @@ export default class AgentLoop {
31
56
  return `Turn ${turn}/${maxTurns}`;
32
57
  }
33
58
 
34
- async #ensureRun(projectId, model, run, options) {
35
- const _noRepo = options?.noRepo === true;
36
- const isFork = options?.fork === true;
59
+ async #setRunStatus(runId, alias, httpStatus) {
60
+ await this.#db.update_run_status.run({ id: runId, status: httpStatus });
61
+ const state = HTTP_TO_RUN_STATE[httpStatus];
62
+ if (!state) return;
63
+ await this.#entries.set({
64
+ runId,
65
+ path: `run://${alias}`,
66
+ state,
67
+ writer: "system",
68
+ });
69
+ }
70
+
71
+ async #emitRunState({
72
+ projectId,
73
+ runId,
74
+ alias,
75
+ turn,
76
+ status,
77
+ contextSize,
78
+ result = null,
79
+ }) {
80
+ if (!contextSize) throw new Error("#emitRunState: contextSize is required");
81
+ const runUsage = await this.#db.get_run_usage.get({ run_id: runId });
82
+ const history = await this.#entries.getLog(runId);
83
+ const unknowns = await this.#entries.getUnknowns(runId);
84
+ const latestSummary = this.#hooks.instructions.findLatestSummary(history);
85
+
86
+ // Always emit complete telemetry. When we don't have a fresh turn
87
+ // result (abort/max-turns/crash), read the last turn's context
88
+ // tokens from the DB instead. Both code paths compute a real
89
+ // budget from real data — never undefined, never invented.
90
+ const rows = await this.#db.get_turn_context.all({
91
+ run_id: runId,
92
+ turn,
93
+ });
94
+ let totalTokens;
95
+ if (result) {
96
+ totalTokens = result.assembledTokens;
97
+ } else {
98
+ // No fresh turn result — this happens on abort/max-turns/crash
99
+ // emits that fire before any turn executed, or after a turn
100
+ // that never produced tokens. Read the last turn's assembled
101
+ // context_tokens from the DB; absent means no turn ran yet
102
+ // (zero is the truth, not a fallback).
103
+ const lastCtx = await this.#db.get_last_context_tokens.get({
104
+ run_id: runId,
105
+ });
106
+ totalTokens = lastCtx ? lastCtx.context_tokens : 0;
107
+ }
108
+ const budget = computeBudget({ rows, contextSize, totalTokens });
109
+
110
+ await this.#hooks.run.state.emit({
111
+ projectId,
112
+ run: alias,
113
+ turn,
114
+ status,
115
+ summary: latestSummary?.body,
116
+ history,
117
+ unknowns: unknowns.map((u) => ({ path: u.path, body: u.body })),
118
+ telemetry: {
119
+ modelAlias: result?.modelAlias,
120
+ model: result?.model,
121
+ temperature: result?.temperature,
122
+ context_size: contextSize,
123
+ context_tokens: totalTokens,
124
+ ceiling: budget.ceiling,
125
+ token_usage: budget.tokenUsage,
126
+ tokens_free: budget.tokensFree,
127
+ prompt_tokens: runUsage.prompt_tokens,
128
+ cached_tokens: runUsage.cached_tokens,
129
+ completion_tokens: runUsage.completion_tokens,
130
+ reasoning_tokens: runUsage.reasoning_tokens,
131
+ total_tokens: runUsage.total_tokens,
132
+ cost: runUsage.cost,
133
+ context_distribution: await this.#db.get_turn_distribution.all({
134
+ run_id: runId,
135
+ turn,
136
+ }),
137
+ },
138
+ });
139
+ }
140
+
141
+ async #writeRunEntry(
142
+ runId,
143
+ alias,
144
+ prompt,
145
+ {
146
+ projectId,
147
+ parentRunId,
148
+ model,
149
+ persona = null,
150
+ temperature = null,
151
+ contextLimit = null,
152
+ },
153
+ ) {
154
+ await this.#entries.set({
155
+ runId,
156
+ turn: 0,
157
+ path: `run://${alias}`,
158
+ body: prompt ? prompt : "",
159
+ state: "proposed",
160
+ attributes: {
161
+ projectId,
162
+ parentRunId,
163
+ model,
164
+ persona,
165
+ temperature,
166
+ contextLimit,
167
+ },
168
+ writer: "system",
169
+ });
170
+ }
171
+
172
+ async ensureRun(projectId, model, run, prompt, options = {}) {
173
+ const {
174
+ fork: isFork = false,
175
+ temperature = null,
176
+ persona = null,
177
+ contextLimit = null,
178
+ } = options;
37
179
  const requestedModel = model;
38
180
 
39
181
  if (run && isFork) {
@@ -46,13 +188,18 @@ export default class AgentLoop {
46
188
  parent_run_id: existingRun.id,
47
189
  model: requestedModel,
48
190
  alias,
49
- temperature: options?.temperature ?? null,
50
- persona: options?.persona ?? null,
51
- context_limit: options?.contextLimit ?? null,
191
+ temperature,
192
+ persona,
193
+ context_limit: contextLimit,
52
194
  });
53
- await this.#db.fork_known_entries.run({
54
- new_run_id: runRow.id,
55
- parent_run_id: existingRun.id,
195
+ await this.#entries.forkEntries(existingRun.id, runRow.id);
196
+ await this.#writeRunEntry(runRow.id, alias, prompt, {
197
+ projectId,
198
+ parentRunId: existingRun.id,
199
+ model: requestedModel,
200
+ persona,
201
+ temperature,
202
+ contextLimit,
56
203
  });
57
204
  await this.#hooks.run.created.emit({
58
205
  runId: runRow.id,
@@ -64,34 +211,43 @@ export default class AgentLoop {
64
211
 
65
212
  if (run) {
66
213
  const existingRun = await this.#db.get_run_by_alias.get({ alias: run });
67
- if (!existingRun)
68
- throw new Error(msg("error.run_not_found", { runId: run }));
69
-
70
- const existing = this.#activeRuns.get(existingRun.id);
71
- if (existing) existing.abort();
72
-
73
- // Clean up stale proposals from interrupted runs
74
- const unresolved = await this.#knownStore.getUnresolved(existingRun.id);
75
- for (const u of unresolved) {
76
- await this.#knownStore.resolve(
77
- existingRun.id,
78
- u.path,
79
- 499,
80
- "Stale proposal from interrupted run",
81
- );
214
+ if (existingRun) {
215
+ const existing = this.#activeRuns.get(existingRun.id);
216
+ if (existing) existing.controller.abort();
217
+
218
+ // Clean up stale proposals from interrupted runs
219
+ const unresolved = await this.#entries.getUnresolved(existingRun.id);
220
+ for (const u of unresolved) {
221
+ await this.#entries.set({
222
+ runId: existingRun.id,
223
+ path: u.path,
224
+ state: "cancelled",
225
+ body: "Stale proposal from interrupted run",
226
+ outcome: "interrupted",
227
+ });
228
+ }
229
+ return { runId: existingRun.id, alias: existingRun.alias };
82
230
  }
83
- return { runId: existingRun.id, alias: existingRun.alias };
231
+ // Client-specified alias for a brand-new run — accept it verbatim.
84
232
  }
85
233
 
86
- const alias = await this.#generateAlias(requestedModel);
234
+ const alias = run ? run : await this.#generateAlias(requestedModel);
87
235
  const runRow = await this.#db.create_run.get({
88
236
  project_id: projectId,
89
237
  parent_run_id: null,
90
238
  model: requestedModel,
91
239
  alias,
92
- temperature: options?.temperature ?? null,
93
- persona: options?.persona ?? null,
94
- context_limit: options?.contextLimit ?? null,
240
+ temperature,
241
+ persona,
242
+ context_limit: contextLimit,
243
+ });
244
+ await this.#writeRunEntry(runRow.id, alias, prompt, {
245
+ projectId,
246
+ parentRunId: null,
247
+ model: requestedModel,
248
+ persona,
249
+ temperature,
250
+ contextLimit,
95
251
  });
96
252
  await this.#hooks.run.created.emit({ runId: runRow.id, alias });
97
253
  return { runId: runRow.id, alias };
@@ -119,13 +275,22 @@ export default class AgentLoop {
119
275
  if (!project)
120
276
  throw new Error(msg("error.project_not_found", { projectId }));
121
277
 
122
- const noRepo = options?.noRepo === true;
123
- const noInteraction = options?.noInteraction === true;
124
- const noWeb = options?.noWeb === true;
125
- const noProposals = options?.noProposals === true;
278
+ const noRepo = options?.noRepo ?? process.env.RUMMY_NO_REPO === "1";
279
+ const noInteraction =
280
+ options?.noInteraction ?? process.env.RUMMY_NO_INTERACTION === "1";
281
+ const noWeb = options?.noWeb ?? process.env.RUMMY_NO_WEB === "1";
282
+ const noProposals =
283
+ options?.noProposals ?? process.env.RUMMY_NO_PROPOSALS === "1";
284
+ const yolo = options?.yolo ?? process.env.RUMMY_YOLO === "1";
126
285
  const requestedModel = model;
127
286
 
128
- const runInfo = await this.#ensureRun(projectId, model, run, options);
287
+ const runInfo = await this.ensureRun(
288
+ projectId,
289
+ model,
290
+ run,
291
+ prompt,
292
+ options,
293
+ );
129
294
  const { runId: currentRunId, alias: currentAlias } = runInfo;
130
295
 
131
296
  const loopSeq = await this.#db.next_loop.get({ run_id: currentRunId });
@@ -134,12 +299,13 @@ export default class AgentLoop {
134
299
  sequence: loopSeq.sequence,
135
300
  mode,
136
301
  model: requestedModel,
137
- prompt: prompt || "",
302
+ prompt: prompt ? prompt : "",
138
303
  config: JSON.stringify({
139
304
  noRepo,
140
305
  noInteraction,
141
306
  noWeb,
142
307
  noProposals,
308
+ yolo,
143
309
  temperature: options?.temperature,
144
310
  }),
145
311
  });
@@ -148,28 +314,53 @@ export default class AgentLoop {
148
314
  return { run: currentAlias, status: 100 };
149
315
  }
150
316
 
151
- return this.#drainQueue(
317
+ // Allocate the controller + Promise pair here so `abortAll` can
318
+ // reach both — abort the controller, await the Promise's drain.
319
+ const controller = new AbortController();
320
+ const promise = this.#drainQueue(
152
321
  currentRunId,
153
322
  currentAlias,
154
323
  projectId,
155
324
  project,
156
325
  options,
326
+ controller,
157
327
  );
328
+ this.#activeRuns.set(currentRunId, { controller, promise });
329
+ return promise;
158
330
  }
159
331
 
160
- async #drainQueue(currentRunId, currentAlias, projectId, project, options) {
161
- const controller = new AbortController();
162
- this.#activeRuns.set(currentRunId, controller);
332
+ async #drainQueue(
333
+ currentRunId,
334
+ currentAlias,
335
+ projectId,
336
+ project,
337
+ options,
338
+ controller,
339
+ ) {
340
+ console.error(`[DRAIN] ${currentAlias} enter (runId=${currentRunId})`);
163
341
 
164
342
  try {
165
343
  while (true) {
166
344
  const loop = await this.#db.claim_next_loop.get({
167
345
  run_id: currentRunId,
168
346
  });
169
- if (!loop) break;
347
+ if (!loop) {
348
+ console.error(`[DRAIN] ${currentAlias} queue empty — exiting`);
349
+ break;
350
+ }
351
+ console.error(
352
+ `[DRAIN] ${currentAlias} claimed loop id=${loop.id} mode=${loop.mode} seq=${loop.sequence}`,
353
+ );
170
354
 
171
- const loopConfig = loop.config ? JSON.parse(loop.config) : {};
355
+ const loopConfig = JSON.parse(loop.config);
172
356
  const hook = loop.mode === "ask" ? this.#hooks.ask : this.#hooks.act;
357
+ const {
358
+ noRepo = false,
359
+ noInteraction = false,
360
+ noWeb = false,
361
+ noProposals = false,
362
+ yolo = false,
363
+ } = loopConfig;
173
364
 
174
365
  let result;
175
366
  try {
@@ -182,15 +373,19 @@ export default class AgentLoop {
182
373
  currentLoopId: loop.id,
183
374
  requestedModel: loop.model,
184
375
  prompt: loop.prompt,
185
- noRepo: loopConfig.noRepo || false,
186
- noInteraction: loopConfig.noInteraction || false,
187
- noWeb: loopConfig.noWeb || false,
188
- noProposals: loopConfig.noProposals || false,
376
+ noRepo,
377
+ noInteraction,
378
+ noWeb,
379
+ noProposals,
380
+ yolo,
189
381
  options: { ...options, temperature: loopConfig.temperature },
190
382
  hook,
191
383
  signal: controller.signal,
192
384
  });
193
385
  } catch (err) {
386
+ console.error(
387
+ `[DRAIN] ${currentAlias} loop id=${loop.id} threw: ${err.message}`,
388
+ );
194
389
  await this.#db.complete_loop.run({
195
390
  id: loop.id,
196
391
  status: 500,
@@ -200,6 +395,9 @@ export default class AgentLoop {
200
395
  }
201
396
 
202
397
  if (result.status === 413) {
398
+ console.error(
399
+ `[DRAIN] ${currentAlias} loop id=${loop.id} overflow=413`,
400
+ );
203
401
  await this.#db.complete_loop.run({
204
402
  id: loop.id,
205
403
  status: 413,
@@ -212,6 +410,9 @@ export default class AgentLoop {
212
410
  };
213
411
  }
214
412
 
413
+ console.error(
414
+ `[DRAIN] ${currentAlias} loop id=${loop.id} completed status=${result.status}`,
415
+ );
215
416
  await this.#db.complete_loop.run({
216
417
  id: loop.id,
217
418
  status: result.status,
@@ -222,7 +423,7 @@ export default class AgentLoop {
222
423
  const runRow = await this.#db.get_run_by_alias.get({
223
424
  alias: currentAlias,
224
425
  });
225
- return { run: currentAlias, status: runRow?.status ?? 200 };
426
+ return { run: currentAlias, status: runRow.status };
226
427
  } finally {
227
428
  this.#activeRuns.delete(currentRunId);
228
429
  }
@@ -241,16 +442,14 @@ export default class AgentLoop {
241
442
  noInteraction,
242
443
  noWeb,
243
444
  noProposals,
445
+ yolo,
244
446
  options,
245
447
  hook,
246
448
  signal,
247
449
  }) {
248
450
  const runRow = await this.#db.get_run_by_id.get({ id: currentRunId });
249
451
  if (runRow.status !== 102) {
250
- await this.#db.update_run_status.run({
251
- id: currentRunId,
252
- status: 102,
253
- });
452
+ await this.#setRunStatus(currentRunId, currentAlias, 102);
254
453
  }
255
454
 
256
455
  const modelContextSize =
@@ -266,20 +465,7 @@ export default class AgentLoop {
266
465
  });
267
466
 
268
467
  let loopIteration = 0;
269
- const MAX_LOOP_ITERATIONS = Number(process.env.RUMMY_MAX_TURNS) || 15;
270
- const healer = new ResponseHealer();
271
-
272
- let _lastAssembledTokens = 0;
273
- let recovery = null; // { target, promptPath, strikes, lastTokens }
274
-
275
- // Previous loop entries stay at full fidelity — the model is
276
- // instructed to summarize and demote them. Budget enforcement
277
- // catches overflow if the model fails to manage context.
278
-
279
- // Restore any prompt entries left at summary fidelity by a recovery
280
- // phase that was interrupted (server crash, restart). If the full
281
- // prompt would overflow, Prompt Demotion on turn 1 handles it.
282
- await this.#knownStore.restoreSummarizedPrompts(currentRunId);
468
+ const MAX_LOOP_ITERATIONS = Number(process.env.RUMMY_MAX_TURNS);
283
469
 
284
470
  await this.#hooks.loop.started.emit({
285
471
  runId: currentRunId,
@@ -291,9 +477,17 @@ export default class AgentLoop {
291
477
  try {
292
478
  while (loopIteration < MAX_LOOP_ITERATIONS) {
293
479
  if (signal.aborted) {
294
- await this.#db.update_run_status.run({
295
- id: currentRunId,
480
+ console.error(
481
+ `[LOOP] ${currentAlias} iter=${loopIteration} ABORT via signal`,
482
+ );
483
+ await this.#setRunStatus(currentRunId, currentAlias, 499);
484
+ await this.#emitRunState({
485
+ projectId,
486
+ runId: currentRunId,
487
+ alias: currentAlias,
488
+ turn: loopIteration,
296
489
  status: 499,
490
+ contextSize,
297
491
  });
298
492
  const out = {
299
493
  run: currentAlias,
@@ -304,6 +498,9 @@ export default class AgentLoop {
304
498
  return out;
305
499
  }
306
500
  loopIteration++;
501
+ console.error(
502
+ `[LOOP] ${currentAlias} iter=${loopIteration} ENTER (max=${MAX_LOOP_ITERATIONS})`,
503
+ );
307
504
 
308
505
  let turnPrompt;
309
506
  if (loopIteration === 1) {
@@ -315,6 +512,9 @@ export default class AgentLoop {
315
512
  );
316
513
  }
317
514
 
515
+ console.error(
516
+ `[LOOP] ${currentAlias} iter=${loopIteration} executing turn`,
517
+ );
318
518
  const result = await this.#turnExecutor.execute({
319
519
  mode,
320
520
  project,
@@ -326,198 +526,123 @@ export default class AgentLoop {
326
526
  loopPrompt: turnPrompt,
327
527
  loopIteration,
328
528
  noRepo,
529
+ noWeb,
530
+ noInteraction,
531
+ noProposals,
532
+ yolo,
329
533
  toolSet,
330
- inRecovery: recovery !== null,
331
534
  contextSize,
332
535
  options: { ...options, isContinuation: loopIteration > 1 },
333
536
  signal,
334
537
  });
538
+ console.error(
539
+ `[LOOP] ${currentAlias} iter=${loopIteration} turn done: status=${result.status} turn=${result.turn}`,
540
+ );
335
541
 
336
- if (result.status === 413) {
337
- await this.#db.complete_loop.run({
338
- id: currentLoopId,
339
- status: 413,
340
- result: null,
341
- });
342
- await this.#db.update_run_status.run({
343
- id: currentRunId,
344
- status: 200,
345
- });
346
- const out = {
347
- run: currentAlias,
348
- status: 413,
349
- overflow: result.overflow,
350
- assembledTokens: result.assembledTokens,
351
- contextSize: result.contextSize,
352
- turn: result.turn,
353
- };
354
- await hook.completed.emit({ projectId, ...out });
355
- return out;
356
- }
357
-
358
- _lastAssembledTokens = result.assembledTokens;
359
-
360
- // Budget recovery: enforce progress toward context target.
361
- const ra = advanceRecovery(recovery, result);
362
- recovery = ra.next;
363
- if (ra.action === "restore" && ra.promptPath) {
364
- await this.#knownStore.setFidelity(
365
- currentRunId,
366
- ra.promptPath,
367
- "promoted",
368
- );
369
- }
370
- if (ra.action === "hard413") {
371
- await this.#db.update_run_status.run({
372
- id: currentRunId,
373
- status: 413,
374
- });
375
- const out = {
376
- run: currentAlias,
377
- status: 413,
378
- turn: result.turn,
379
- };
380
- await hook.completed.emit({ projectId, ...out });
381
- return out;
382
- }
383
-
384
- const runUsage = await this.#db.get_run_usage.get({
385
- run_id: currentRunId,
386
- });
387
- const history = await this.#knownStore.getLog(currentRunId);
388
- const unknowns = await this.#db.get_unknowns.all({
389
- run_id: currentRunId,
542
+ const verdict = await this.#hooks.error.verdict({
543
+ store: this.#entries,
544
+ runId: currentRunId,
545
+ loopId: currentLoopId,
546
+ turn: result.turn,
547
+ recorded: result.recorded,
548
+ summaryText: result.summaryText,
390
549
  });
391
- const latestSummary = history
392
- .filter((e) => e.status === 200 && e.path?.startsWith("summarize://"))
393
- .at(-1);
550
+ const vStatus = verdict.status === undefined ? "-" : verdict.status;
551
+ const vReason = verdict.reason ? verdict.reason : "-";
552
+ console.error(
553
+ `[LOOP] ${currentAlias} iter=${loopIteration} verdict: continue=${verdict.continue} status=${vStatus} reason=${vReason}`,
554
+ );
394
555
 
395
- await this.#hooks.run.state.emit({
556
+ await this.#emitRunState({
396
557
  projectId,
397
- run: currentAlias,
558
+ runId: currentRunId,
559
+ alias: currentAlias,
398
560
  turn: result.turn,
399
- status: 102,
400
- summary: latestSummary?.body || "",
401
- history,
402
- unknowns: unknowns.map((u) => ({ path: u.path, body: u.body })),
403
- telemetry: {
404
- modelAlias: result.modelAlias,
405
- model: result.model,
406
- temperature: result.temperature,
407
- context_size: result.contextSize,
408
- context_tokens:
409
- (
410
- await this.#db.get_turn_context_tokens.get({
411
- run_id: currentRunId,
412
- sequence: result.turn,
413
- })
414
- )?.context_tokens ?? 0,
415
- prompt_tokens: runUsage.prompt_tokens,
416
- cached_tokens: runUsage.cached_tokens,
417
- completion_tokens: runUsage.completion_tokens,
418
- reasoning_tokens: runUsage.reasoning_tokens,
419
- total_tokens: runUsage.total_tokens,
420
- cost: runUsage.cost,
421
- context_distribution: await this.#db.get_turn_distribution.all({
422
- run_id: currentRunId,
423
- turn: result.turn,
424
- }),
425
- },
561
+ status: verdict.continue ? 102 : verdict.status,
562
+ contextSize,
563
+ result,
426
564
  });
427
565
  await this.#hooks.run.step.completed.emit({
428
566
  projectId,
429
567
  run: currentAlias,
430
568
  turn: result.turn,
431
- flags: result.flags,
432
569
  });
570
+ if (verdict.continue) continue;
433
571
 
434
- // Don't exit while budget recovery is still active.
435
- if (recovery !== null) continue;
436
-
437
- const repetition = healer.assessRepetition(result);
438
- if (!repetition.continue) {
439
- await this.#db.update_run_status.run({
440
- id: currentRunId,
441
- status: 200,
442
- });
443
- const out = {
444
- run: currentAlias,
445
- status: 200,
572
+ console.error(
573
+ `[LOOP] ${currentAlias} iter=${loopIteration} CLOSE status=${verdict.status} reason=${vReason}`,
574
+ );
575
+ await this.#setRunStatus(currentRunId, currentAlias, verdict.status);
576
+ if (verdict.reason) {
577
+ await this.#hooks.error.log.emit({
578
+ store: this.#entries,
579
+ runId: currentRunId,
446
580
  turn: result.turn,
447
- reason: repetition.reason,
448
- };
449
- await hook.completed.emit({ projectId, ...out });
450
- return out;
581
+ loopId: currentLoopId,
582
+ message: verdict.reason,
583
+ });
451
584
  }
452
-
453
- const progress = healer.assessProgress(result);
454
- if (progress.continue) continue;
455
-
456
- await this.#db.update_run_status.run({
457
- id: currentRunId,
458
- status: 200,
459
- });
460
585
  const out = {
461
586
  run: currentAlias,
462
- status: 200,
587
+ status: verdict.status,
463
588
  turn: result.turn,
589
+ reason: verdict.reason,
464
590
  };
465
591
  await hook.completed.emit({ projectId, ...out });
466
592
  return out;
467
593
  }
468
594
 
469
- await this.#db.update_run_status.run({
470
- id: currentRunId,
471
- status: 200,
595
+ // MAX_TURNS exhaustion without a terminal update is abandonment.
596
+ console.error(
597
+ `[LOOP] ${currentAlias} hit MAX_LOOP_ITERATIONS=${MAX_LOOP_ITERATIONS} — abandoning at 499`,
598
+ );
599
+ await this.#setRunStatus(currentRunId, currentAlias, 499);
600
+ await this.#emitRunState({
601
+ projectId,
602
+ runId: currentRunId,
603
+ alias: currentAlias,
604
+ turn: loopIteration,
605
+ status: 499,
606
+ contextSize,
472
607
  });
473
608
  const out = {
474
609
  run: currentAlias,
475
- status: 200,
610
+ status: 499,
476
611
  turn: loopIteration,
477
612
  };
478
613
  await hook.completed.emit({ projectId, ...out });
479
614
  return out;
480
615
  } catch (err) {
481
- if (signal.aborted) {
482
- await this.#db.update_run_status.run({
483
- id: currentRunId,
484
- status: 499,
616
+ const status = signal.aborted ? 499 : 500;
617
+ await this.#setRunStatus(currentRunId, currentAlias, status);
618
+ await this.#emitRunState({
619
+ projectId,
620
+ runId: currentRunId,
621
+ alias: currentAlias,
622
+ turn: loopIteration,
623
+ status,
624
+ contextSize,
625
+ });
626
+ if (status === 500) {
627
+ await this.#hooks.error.log.emit({
628
+ store: this.#entries,
629
+ runId: currentRunId,
630
+ turn: loopIteration,
631
+ loopId: currentLoopId,
632
+ message: `${err.message}\n${err.stack}`,
485
633
  });
486
- return { run: currentAlias, status: 499, turn: loopIteration };
487
634
  }
488
- console.warn(`[RUMMY] Run failed: ${err.message}`);
489
- console.warn(`[RUMMY] Stack: ${err.stack}`);
490
- await this.#db.update_run_status.run({
491
- id: currentRunId,
492
- status: 500,
493
- });
494
- try {
495
- await this.#knownStore.upsert(
496
- currentRunId,
497
- loopIteration,
498
- `error://${loopIteration}`,
499
- `${err.message}\n${err.stack}`,
500
- 500,
501
- { loopId: currentLoopId },
502
- );
503
- } catch {}
504
- const out = {
505
- run: currentAlias,
506
- status: 500,
507
- turn: loopIteration,
508
- error: err.message,
509
- };
635
+ const out = { run: currentAlias, status, turn: loopIteration };
636
+ if (status === 500) out.error = err.message;
510
637
  await hook.completed.emit({ projectId, ...out });
511
638
  return out;
512
639
  } finally {
513
- await this.#hooks.loop.completed
514
- .emit({
515
- runId: currentRunId,
516
- loopId: currentLoopId,
517
- mode,
518
- turns: loopIteration,
519
- })
520
- .catch(() => {});
640
+ await this.#hooks.loop.completed.emit({
641
+ runId: currentRunId,
642
+ loopId: currentLoopId,
643
+ mode,
644
+ turns: loopIteration,
645
+ });
521
646
  }
522
647
  }
523
648
 
@@ -529,119 +654,128 @@ export default class AgentLoop {
529
654
 
530
655
  const { path, action, output } = resolution;
531
656
 
532
- if (action === "accept" || action === "error") {
533
- const attrs = await this.#knownStore.getAttributes(runId, path);
534
- const resolvedBody = await this.#composeResolvedContent(
657
+ if (action !== "accept" && action !== "error" && action !== "reject") {
658
+ throw new Error(msg("error.resolution_invalid", { action }));
659
+ }
660
+
661
+ if (action === "reject") {
662
+ await this.#entries.set({
663
+ runId,
664
+ path,
665
+ state: "failed",
666
+ body: output ? output : "rejected",
667
+ outcome: "permission",
668
+ });
669
+ await this.#hooks.proposal.rejected.emit({
535
670
  runId,
671
+ runRow,
536
672
  path,
537
- attrs,
538
673
  output,
539
- );
540
- const status = action === "error" ? 500 : 200;
541
- await this.#knownStore.resolve(runId, path, status, resolvedBody);
542
-
543
- // Store answer in attributes for ask_user
544
- if (path.startsWith("ask_user://") && output) {
545
- const turn = (await this.#db.get_run_by_id.get({ id: runId }))
546
- .next_turn;
547
- await this.#knownStore.upsert(runId, turn, path, resolvedBody, status, {
548
- attributes: { ...attrs, answer: output },
549
- });
550
- }
674
+ db: this.#db,
675
+ entries: this.#entries,
676
+ });
677
+ // Report the CURRENT run status (typically 102 mid-run) so the
678
+ // client's dispatch handler doesn't mistake a successful
679
+ // resolve's HTTP-style 200 ack for a terminal run status and
680
+ // prematurely close the document. Real terminal state comes
681
+ // from the run/state notification at end-of-turn.
682
+ return { run: runAlias, status: runRow.status };
683
+ }
551
684
 
552
- if (action === "accept") {
553
- const projectId = runRow.project_id;
554
- const project = await this.#db.get_project_by_id.get({
555
- id: projectId,
685
+ const attrs = await this.#entries.getAttributes(runId, path);
686
+ const project = await this.#db.get_project_by_id.get({
687
+ id: runRow.project_id,
688
+ });
689
+ const ctx = {
690
+ runId,
691
+ runRow,
692
+ projectId: runRow.project_id,
693
+ projectRoot: project?.project_root,
694
+ path,
695
+ attrs,
696
+ output,
697
+ db: this.#db,
698
+ entries: this.#entries,
699
+ };
700
+
701
+ // Plugins veto acceptance (e.g. readonly) via proposal.accepting.
702
+ // First veto wins: state=failed with plugin-supplied outcome + body.
703
+ if (action === "accept") {
704
+ const veto = await this.#hooks.proposal.accepting.filter(null, ctx);
705
+ if (veto?.allow === false) {
706
+ await this.#entries.set({
707
+ runId,
708
+ path,
709
+ state: "failed",
710
+ outcome: veto.outcome,
711
+ body: veto.body,
556
712
  });
557
- const projectRoot = project?.project_root;
558
-
559
- if (path.startsWith("set://") && attrs?.file && attrs?.merge) {
560
- const fileBody = await this.#knownStore.getBody(runId, attrs.file);
561
- if (fileBody != null) {
562
- const blocks = attrs.merge.split(/(?=<<<<<<< SEARCH)/);
563
- let patched = fileBody;
564
- for (const block of blocks) {
565
- const m = block.match(
566
- /<<<<<<< SEARCH\n?([\s\S]*?)\n?=======\n?([\s\S]*?)\n?>>>>>>> REPLACE/,
567
- );
568
- if (m) patched = patched.replace(m[1], m[2]);
569
- }
570
- const turn = (await this.#db.get_run_by_id.get({ id: runId }))
571
- .next_turn;
572
- await this.#knownStore.upsert(
573
- runId,
574
- turn,
575
- attrs.file,
576
- patched,
577
- 200,
578
- );
579
- // Write patched content to disk
580
- if (projectRoot) {
581
- const { writeFile } = await import("node:fs/promises");
582
- const { join } = await import("node:path");
583
- await writeFile(join(projectRoot, attrs.file), patched).catch(
584
- () => {},
585
- );
586
- }
587
- }
588
- }
589
-
590
- if (path.startsWith("rm://")) {
591
- if (attrs?.path) {
592
- await this.#knownStore.remove(runId, attrs.path);
593
- if (projectRoot) {
594
- const { unlink } = await import("node:fs/promises");
595
- const { join } = await import("node:path");
596
- await unlink(join(projectRoot, attrs.path)).catch(() => {});
597
- }
598
- }
599
- }
600
-
601
- if (path.startsWith("mv://")) {
602
- if (attrs?.isMove && attrs?.from) {
603
- await this.#knownStore.remove(runId, attrs.from);
604
- }
605
- }
713
+ return { ok: true, state: "failed", outcome: veto.outcome };
606
714
  }
607
- } else if (action === "reject") {
608
- await this.#knownStore.resolve(runId, path, 403, output || "rejected");
609
- } else {
610
- throw new Error(msg("error.resolution_invalid", { action }));
611
715
  }
612
716
 
613
- // The dispatch loop is awaiting resolution. This unblocks it.
614
- // Dispatch continuation is handled by the loop, not here.
615
- return { run: runAlias, status: 200 };
616
- }
717
+ // Compose the resolved body. Default is output || "". Plugins may
718
+ // override via proposal.content (e.g. set prefers the existing
719
+ // proposed body from the log entry).
720
+ const defaultBody = output ? output : "";
721
+ const resolvedBody = await this.#hooks.proposal.content.filter(
722
+ defaultBody,
723
+ ctx,
724
+ );
725
+ const state = action === "error" ? "failed" : "resolved";
726
+ const outcome = action === "error" ? "error" : null;
727
+ const existing = await this.#entries.getState(runId, path);
728
+ const existingTurn = existing?.turn === undefined ? 0 : existing.turn;
729
+ await this.#entries.set({
730
+ runId,
731
+ turn: existingTurn,
732
+ path,
733
+ state,
734
+ body: resolvedBody,
735
+ outcome,
736
+ });
617
737
 
618
- async #composeResolvedContent(runId, path, _attrs, output) {
619
- const scheme = path.split("://")[0];
620
- switch (scheme) {
621
- case "set": {
622
- const existing = await this.#knownStore.getBody(runId, path);
623
- return existing || output || "";
624
- }
625
- default:
626
- return output || "";
627
- }
738
+ const event =
739
+ action === "accept"
740
+ ? this.#hooks.proposal.accepted
741
+ : this.#hooks.proposal.rejected;
742
+ await event.emit({ ...ctx, resolvedBody });
743
+
744
+ // Same rationale as the reject path: return current run status
745
+ // (102 mid-run) rather than a hardcoded 200 so the nvim client
746
+ // doesn't treat the RPC ack as a terminal signal.
747
+ return { run: runAlias, status: runRow.status };
628
748
  }
629
749
 
630
- async inject(runAlias, message) {
750
+ async inject(runAlias, message, mode, options = {}) {
751
+ if (mode !== "ask" && mode !== "act") {
752
+ throw new Error(
753
+ `inject: mode is required and must be "ask" or "act" (got ${JSON.stringify(mode)})`,
754
+ );
755
+ }
631
756
  const runRow = await this.#db.get_run_by_alias.get({ alias: runAlias });
632
757
  if (!runRow)
633
758
  throw new Error(msg("error.run_not_found", { runId: runAlias }));
634
759
 
760
+ const noRepo = options?.noRepo ?? process.env.RUMMY_NO_REPO === "1";
761
+ const noInteraction =
762
+ options?.noInteraction ?? process.env.RUMMY_NO_INTERACTION === "1";
763
+ const noWeb = options?.noWeb ?? process.env.RUMMY_NO_WEB === "1";
764
+ const noProposals =
765
+ options?.noProposals ?? process.env.RUMMY_NO_PROPOSALS === "1";
766
+ const yolo = options?.yolo ?? process.env.RUMMY_YOLO === "1";
767
+
635
768
  const nextTurn = runRow.next_turn;
636
769
 
637
- await this.#knownStore.upsert(
638
- runRow.id,
639
- nextTurn,
640
- `prompt://${nextTurn}`,
641
- message,
642
- 200,
643
- { attributes: { mode: "ask" } },
644
- );
770
+ await this.#entries.set({
771
+ runId: runRow.id,
772
+ turn: nextTurn,
773
+ path: `prompt://${nextTurn}`,
774
+ body: message,
775
+ state: "resolved",
776
+ attributes: { mode },
777
+ writer: "plugin",
778
+ });
645
779
 
646
780
  if (this.#activeRuns.has(runRow.id)) {
647
781
  return { run: runAlias, status: runRow.status, injected: "next_turn" };
@@ -651,31 +785,38 @@ export default class AgentLoop {
651
785
  await this.#db.enqueue_loop.get({
652
786
  run_id: runRow.id,
653
787
  sequence: injectLoopSeq.sequence,
654
- mode: "ask",
788
+ mode,
655
789
  model: runRow.model,
656
790
  prompt: message,
657
- config: "{}",
791
+ config: JSON.stringify({
792
+ noRepo,
793
+ noInteraction,
794
+ noWeb,
795
+ noProposals,
796
+ yolo,
797
+ temperature: options?.temperature,
798
+ }),
658
799
  });
659
800
 
660
801
  const projectId = runRow.project_id;
661
802
  const project = await this.#db.get_project_by_id.get({ id: projectId });
662
- return this.#drainQueue(runRow.id, runAlias, projectId, project, {});
803
+ const controller = new AbortController();
804
+ const promise = this.#drainQueue(
805
+ runRow.id,
806
+ runAlias,
807
+ projectId,
808
+ project,
809
+ {},
810
+ controller,
811
+ );
812
+ this.#activeRuns.set(runRow.id, { controller, promise });
813
+ return promise;
663
814
  }
664
815
 
665
816
  async getRunHistory(runAlias) {
666
817
  const runRow = await this.#db.get_run_by_alias.get({ alias: runAlias });
667
818
  if (!runRow)
668
819
  throw new Error(msg("error.run_not_found", { runId: runAlias }));
669
- return this.#knownStore.getLog(runRow.id);
820
+ return this.#entries.getLog(runRow.id);
670
821
  }
671
822
  }
672
-
673
- /**
674
- * Pure recovery state transition — exported for testing.
675
- *
676
- * @param {object|null} recovery Current recovery state (mutated copy returned).
677
- * @param {{ assembledTokens: number, budgetRecovery?: { target: number, promptPath: string|null } }} result
678
- * @returns {{ next: object|null, action: null|'restore'|'hard413', promptPath: string|null }}
679
- */
680
- // Re-export for backward compatibility with tests
681
- export { advanceRecovery } from "../plugins/budget/recovery.js";