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