@possumtech/rummy 0.4.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 -4
  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 -331
  13. package/src/agent/ContextAssembler.js +4 -2
  14. package/src/agent/Entries.js +655 -0
  15. package/src/agent/ProjectAgent.js +30 -18
  16. package/src/agent/TurnExecutor.js +232 -379
  17. package/src/agent/XmlParser.js +242 -67
  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 -118
  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 +35 -21
  31. package/src/{server → hooks}/RpcRegistry.js +2 -1
  32. package/src/hooks/RummyContext.js +140 -37
  33. package/src/hooks/ToolRegistry.js +36 -35
  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 -23
  39. package/src/plugins/ask_user/ask_userDoc.md +10 -0
  40. package/src/plugins/budget/README.md +27 -23
  41. package/src/plugins/budget/budget.js +261 -69
  42. package/src/plugins/cp/README.md +2 -2
  43. package/src/plugins/cp/cp.js +31 -13
  44. package/src/plugins/cp/cpDoc.js +2 -23
  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 +47 -8
  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 -7
  57. package/src/plugins/get/README.md +1 -1
  58. package/src/plugins/get/get.js +125 -49
  59. package/src/plugins/get/getDoc.js +2 -43
  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 +43 -3
  66. package/src/plugins/index.js +146 -123
  67. package/src/plugins/instructions/README.md +35 -9
  68. package/src/plugins/instructions/instructions.js +126 -12
  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 +77 -45
  78. package/src/plugins/known/knownDoc.js +2 -29
  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 +57 -24
  84. package/src/plugins/mv/mvDoc.js +2 -29
  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 +63 -18
  98. package/src/plugins/rm/README.md +1 -1
  99. package/src/plugins/rm/rm.js +58 -14
  100. package/src/plugins/rm/rmDoc.js +2 -24
  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 -77
  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 +52 -8
  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 -17
  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 +148 -74
  118. package/src/plugins/think/README.md +1 -1
  119. package/src/plugins/think/think.js +14 -1
  120. package/src/plugins/think/thinkDoc.js +2 -17
  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 +56 -21
  124. package/src/plugins/unknown/unknownDoc.js +2 -25
  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 -27
  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/functions/slugify.js +13 -1
  136. package/src/sql/v_model_context.sql +27 -31
  137. package/src/sql/v_run_log.sql +9 -14
  138. package/EXCEPTIONS.md +0 -46
  139. package/src/agent/KnownStore.js +0 -338
  140. package/src/agent/ResponseHealer.js +0 -188
  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 -37
  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 -60
  149. package/src/plugins/progress/README.md +0 -16
  150. package/src/plugins/progress/progress.js +0 -26
  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 -28
@@ -1,27 +1,51 @@
1
- import { advanceRecovery } from "../plugins/budget/recovery.js";
2
- import KnownStore from "./KnownStore.js";
1
+ import { computeBudget } from "./budget.js";
3
2
  import msg from "./messages.js";
4
- 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
+ };
5
12
 
6
13
  export default class AgentLoop {
7
14
  #db;
8
15
  #llmProvider;
9
16
  #hooks;
10
17
  #turnExecutor;
11
- #knownStore;
18
+ #entries;
12
19
  #activeRuns = new Map();
13
20
 
14
- constructor(db, llmProvider, hooks, turnExecutor, knownStore) {
21
+ constructor(db, llmProvider, hooks, turnExecutor, entries) {
15
22
  this.#db = db;
16
23
  this.#llmProvider = llmProvider;
17
24
  this.#hooks = hooks;
18
25
  this.#turnExecutor = turnExecutor;
19
- this.#knownStore = knownStore;
26
+ this.#entries = entries;
20
27
  }
21
28
 
22
29
  abort(runId) {
23
- const controller = this.#activeRuns.get(runId);
24
- 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);
25
49
  }
26
50
 
27
51
  async #generateAlias(modelAlias) {
@@ -32,9 +56,138 @@ export default class AgentLoop {
32
56
  return `Turn ${turn}/${maxTurns}`;
33
57
  }
34
58
 
35
- async #ensureRun(projectId, model, run, options) {
36
- const _noRepo = options?.noRepo === true;
37
- 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;
38
191
  const requestedModel = model;
39
192
 
40
193
  if (run && isFork) {
@@ -47,13 +200,18 @@ export default class AgentLoop {
47
200
  parent_run_id: existingRun.id,
48
201
  model: requestedModel,
49
202
  alias,
50
- temperature: options?.temperature ?? null,
51
- persona: options?.persona ?? null,
52
- context_limit: options?.contextLimit ?? null,
203
+ temperature,
204
+ persona,
205
+ context_limit: contextLimit,
53
206
  });
54
- await this.#db.fork_known_entries.run({
55
- new_run_id: runRow.id,
56
- 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,
57
215
  });
58
216
  await this.#hooks.run.created.emit({
59
217
  runId: runRow.id,
@@ -65,34 +223,43 @@ export default class AgentLoop {
65
223
 
66
224
  if (run) {
67
225
  const existingRun = await this.#db.get_run_by_alias.get({ alias: run });
68
- if (!existingRun)
69
- throw new Error(msg("error.run_not_found", { runId: run }));
70
-
71
- const existing = this.#activeRuns.get(existingRun.id);
72
- if (existing) existing.abort();
73
-
74
- // Clean up stale proposals from interrupted runs
75
- const unresolved = await this.#knownStore.getUnresolved(existingRun.id);
76
- for (const u of unresolved) {
77
- await this.#knownStore.resolve(
78
- existingRun.id,
79
- u.path,
80
- 499,
81
- "Stale proposal from interrupted run",
82
- );
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 };
83
242
  }
84
- return { runId: existingRun.id, alias: existingRun.alias };
243
+ // Client-specified alias for a brand-new run — accept it verbatim.
85
244
  }
86
245
 
87
- const alias = await this.#generateAlias(requestedModel);
246
+ const alias = run ? run : await this.#generateAlias(requestedModel);
88
247
  const runRow = await this.#db.create_run.get({
89
248
  project_id: projectId,
90
249
  parent_run_id: null,
91
250
  model: requestedModel,
92
251
  alias,
93
- temperature: options?.temperature ?? null,
94
- persona: options?.persona ?? null,
95
- 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,
96
263
  });
97
264
  await this.#hooks.run.created.emit({ runId: runRow.id, alias });
98
265
  return { runId: runRow.id, alias };
@@ -126,7 +293,13 @@ export default class AgentLoop {
126
293
  const noProposals = options?.noProposals === true;
127
294
  const requestedModel = model;
128
295
 
129
- 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
+ );
130
303
  const { runId: currentRunId, alias: currentAlias } = runInfo;
131
304
 
132
305
  const loopSeq = await this.#db.next_loop.get({ run_id: currentRunId });
@@ -135,7 +308,7 @@ export default class AgentLoop {
135
308
  sequence: loopSeq.sequence,
136
309
  mode,
137
310
  model: requestedModel,
138
- prompt: prompt || "",
311
+ prompt: prompt ? prompt : "",
139
312
  config: JSON.stringify({
140
313
  noRepo,
141
314
  noInteraction,
@@ -149,28 +322,52 @@ export default class AgentLoop {
149
322
  return { run: currentAlias, status: 100 };
150
323
  }
151
324
 
152
- 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(
153
329
  currentRunId,
154
330
  currentAlias,
155
331
  projectId,
156
332
  project,
157
333
  options,
334
+ controller,
158
335
  );
336
+ this.#activeRuns.set(currentRunId, { controller, promise });
337
+ return promise;
159
338
  }
160
339
 
161
- async #drainQueue(currentRunId, currentAlias, projectId, project, options) {
162
- const controller = new AbortController();
163
- 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})`);
164
349
 
165
350
  try {
166
351
  while (true) {
167
352
  const loop = await this.#db.claim_next_loop.get({
168
353
  run_id: currentRunId,
169
354
  });
170
- 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
+ );
171
362
 
172
- const loopConfig = loop.config ? JSON.parse(loop.config) : {};
363
+ const loopConfig = JSON.parse(loop.config);
173
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;
174
371
 
175
372
  let result;
176
373
  try {
@@ -183,15 +380,18 @@ export default class AgentLoop {
183
380
  currentLoopId: loop.id,
184
381
  requestedModel: loop.model,
185
382
  prompt: loop.prompt,
186
- noRepo: loopConfig.noRepo || false,
187
- noInteraction: loopConfig.noInteraction || false,
188
- noWeb: loopConfig.noWeb || false,
189
- noProposals: loopConfig.noProposals || false,
383
+ noRepo,
384
+ noInteraction,
385
+ noWeb,
386
+ noProposals,
190
387
  options: { ...options, temperature: loopConfig.temperature },
191
388
  hook,
192
389
  signal: controller.signal,
193
390
  });
194
391
  } catch (err) {
392
+ console.error(
393
+ `[DRAIN] ${currentAlias} loop id=${loop.id} threw: ${err.message}`,
394
+ );
195
395
  await this.#db.complete_loop.run({
196
396
  id: loop.id,
197
397
  status: 500,
@@ -201,6 +401,9 @@ export default class AgentLoop {
201
401
  }
202
402
 
203
403
  if (result.status === 413) {
404
+ console.error(
405
+ `[DRAIN] ${currentAlias} loop id=${loop.id} overflow=413`,
406
+ );
204
407
  await this.#db.complete_loop.run({
205
408
  id: loop.id,
206
409
  status: 413,
@@ -213,6 +416,9 @@ export default class AgentLoop {
213
416
  };
214
417
  }
215
418
 
419
+ console.error(
420
+ `[DRAIN] ${currentAlias} loop id=${loop.id} completed status=${result.status}`,
421
+ );
216
422
  await this.#db.complete_loop.run({
217
423
  id: loop.id,
218
424
  status: result.status,
@@ -223,7 +429,7 @@ export default class AgentLoop {
223
429
  const runRow = await this.#db.get_run_by_alias.get({
224
430
  alias: currentAlias,
225
431
  });
226
- return { run: currentAlias, status: runRow?.status ?? 200 };
432
+ return { run: currentAlias, status: runRow.status };
227
433
  } finally {
228
434
  this.#activeRuns.delete(currentRunId);
229
435
  }
@@ -248,10 +454,7 @@ export default class AgentLoop {
248
454
  }) {
249
455
  const runRow = await this.#db.get_run_by_id.get({ id: currentRunId });
250
456
  if (runRow.status !== 102) {
251
- await this.#db.update_run_status.run({
252
- id: currentRunId,
253
- status: 102,
254
- });
457
+ await this.#setRunStatus(currentRunId, currentAlias, 102);
255
458
  }
256
459
 
257
460
  const modelContextSize =
@@ -267,20 +470,7 @@ export default class AgentLoop {
267
470
  });
268
471
 
269
472
  let loopIteration = 0;
270
- const MAX_LOOP_ITERATIONS = Number(process.env.RUMMY_MAX_TURNS) || 15;
271
- const healer = new ResponseHealer();
272
-
273
- let _lastAssembledTokens = 0;
274
- let recovery = null; // { target, promptPath, strikes, lastTokens }
275
-
276
- // Previous loop entries stay at full fidelity — the model is
277
- // instructed to summarize and demote them. Budget enforcement
278
- // catches overflow if the model fails to manage context.
279
-
280
- // Restore any prompt entries left at summary fidelity by a recovery
281
- // phase that was interrupted (server crash, restart). If the full
282
- // prompt would overflow, Prompt Demotion on turn 1 handles it.
283
- await this.#knownStore.restoreSummarizedPrompts(currentRunId);
473
+ const MAX_LOOP_ITERATIONS = Number(process.env.RUMMY_MAX_TURNS);
284
474
 
285
475
  await this.#hooks.loop.started.emit({
286
476
  runId: currentRunId,
@@ -292,9 +482,17 @@ export default class AgentLoop {
292
482
  try {
293
483
  while (loopIteration < MAX_LOOP_ITERATIONS) {
294
484
  if (signal.aborted) {
295
- await this.#db.update_run_status.run({
296
- 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,
297
494
  status: 499,
495
+ contextSize,
298
496
  });
299
497
  const out = {
300
498
  run: currentAlias,
@@ -305,6 +503,9 @@ export default class AgentLoop {
305
503
  return out;
306
504
  }
307
505
  loopIteration++;
506
+ console.error(
507
+ `[LOOP] ${currentAlias} iter=${loopIteration} ENTER (max=${MAX_LOOP_ITERATIONS})`,
508
+ );
308
509
 
309
510
  let turnPrompt;
310
511
  if (loopIteration === 1) {
@@ -316,6 +517,9 @@ export default class AgentLoop {
316
517
  );
317
518
  }
318
519
 
520
+ console.error(
521
+ `[LOOP] ${currentAlias} iter=${loopIteration} executing turn`,
522
+ );
319
523
  const result = await this.#turnExecutor.execute({
320
524
  mode,
321
525
  project,
@@ -327,198 +531,122 @@ export default class AgentLoop {
327
531
  loopPrompt: turnPrompt,
328
532
  loopIteration,
329
533
  noRepo,
534
+ noWeb,
535
+ noInteraction,
536
+ noProposals,
330
537
  toolSet,
331
- inRecovery: recovery !== null,
332
538
  contextSize,
333
539
  options: { ...options, isContinuation: loopIteration > 1 },
334
540
  signal,
335
541
  });
542
+ console.error(
543
+ `[LOOP] ${currentAlias} iter=${loopIteration} turn done: status=${result.status} turn=${result.turn}`,
544
+ );
336
545
 
337
- if (result.status === 413) {
338
- await this.#db.complete_loop.run({
339
- id: currentLoopId,
340
- status: 413,
341
- result: null,
342
- });
343
- await this.#db.update_run_status.run({
344
- id: currentRunId,
345
- status: 200,
346
- });
347
- const out = {
348
- run: currentAlias,
349
- status: 413,
350
- overflow: result.overflow,
351
- assembledTokens: result.assembledTokens,
352
- contextSize: result.contextSize,
353
- turn: result.turn,
354
- };
355
- await hook.completed.emit({ projectId, ...out });
356
- return out;
357
- }
358
-
359
- _lastAssembledTokens = result.assembledTokens;
360
-
361
- // Budget recovery: enforce progress toward context target.
362
- const ra = advanceRecovery(recovery, result);
363
- recovery = ra.next;
364
- if (ra.action === "restore" && ra.promptPath) {
365
- await this.#knownStore.setFidelity(
366
- currentRunId,
367
- ra.promptPath,
368
- "full",
369
- );
370
- }
371
- if (ra.action === "hard413") {
372
- await this.#db.update_run_status.run({
373
- id: currentRunId,
374
- status: 413,
375
- });
376
- const out = {
377
- run: currentAlias,
378
- status: 413,
379
- turn: result.turn,
380
- };
381
- await hook.completed.emit({ projectId, ...out });
382
- return out;
383
- }
384
-
385
- const runUsage = await this.#db.get_run_usage.get({
386
- run_id: currentRunId,
387
- });
388
- const history = await this.#knownStore.getLog(currentRunId);
389
- const unknowns = await this.#db.get_unknowns.all({
390
- 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,
391
553
  });
392
- const latestSummary = history
393
- .filter((e) => e.status === 200 && e.path?.startsWith("summarize://"))
394
- .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
+ );
395
559
 
396
- await this.#hooks.run.state.emit({
560
+ await this.#emitRunState({
397
561
  projectId,
398
- run: currentAlias,
562
+ runId: currentRunId,
563
+ alias: currentAlias,
399
564
  turn: result.turn,
400
- status: 102,
401
- summary: latestSummary?.body || "",
402
- history,
403
- unknowns: unknowns.map((u) => ({ path: u.path, body: u.body })),
404
- telemetry: {
405
- modelAlias: result.modelAlias,
406
- model: result.model,
407
- temperature: result.temperature,
408
- context_size: result.contextSize,
409
- context_tokens:
410
- (
411
- await this.#db.get_turn_context_tokens.get({
412
- run_id: currentRunId,
413
- sequence: result.turn,
414
- })
415
- )?.context_tokens ?? 0,
416
- prompt_tokens: runUsage.prompt_tokens,
417
- cached_tokens: runUsage.cached_tokens,
418
- completion_tokens: runUsage.completion_tokens,
419
- reasoning_tokens: runUsage.reasoning_tokens,
420
- total_tokens: runUsage.total_tokens,
421
- cost: runUsage.cost,
422
- context_distribution: await this.#db.get_turn_distribution.all({
423
- run_id: currentRunId,
424
- turn: result.turn,
425
- }),
426
- },
565
+ status: verdict.continue ? 102 : verdict.status,
566
+ contextSize,
567
+ result,
427
568
  });
428
569
  await this.#hooks.run.step.completed.emit({
429
570
  projectId,
430
571
  run: currentAlias,
431
572
  turn: result.turn,
432
- flags: result.flags,
433
573
  });
574
+ if (verdict.continue) continue;
434
575
 
435
- // Don't exit while budget recovery is still active.
436
- if (recovery !== null) continue;
437
-
438
- const repetition = healer.assessRepetition(result);
439
- if (!repetition.continue) {
440
- await this.#db.update_run_status.run({
441
- id: currentRunId,
442
- status: 200,
443
- });
444
- const out = {
445
- run: currentAlias,
446
- 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,
447
584
  turn: result.turn,
448
- reason: repetition.reason,
449
- };
450
- await hook.completed.emit({ projectId, ...out });
451
- return out;
585
+ loopId: currentLoopId,
586
+ message: verdict.reason,
587
+ });
452
588
  }
453
-
454
- const progress = healer.assessProgress(result);
455
- if (progress.continue) continue;
456
-
457
- await this.#db.update_run_status.run({
458
- id: currentRunId,
459
- status: 200,
460
- });
461
589
  const out = {
462
590
  run: currentAlias,
463
- status: 200,
591
+ status: verdict.status,
464
592
  turn: result.turn,
593
+ reason: verdict.reason,
465
594
  };
466
595
  await hook.completed.emit({ projectId, ...out });
467
596
  return out;
468
597
  }
469
598
 
470
- await this.#db.update_run_status.run({
471
- id: currentRunId,
472
- 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,
473
611
  });
474
612
  const out = {
475
613
  run: currentAlias,
476
- status: 200,
614
+ status: 499,
477
615
  turn: loopIteration,
478
616
  };
479
617
  await hook.completed.emit({ projectId, ...out });
480
618
  return out;
481
619
  } catch (err) {
482
- if (signal.aborted) {
483
- await this.#db.update_run_status.run({
484
- id: currentRunId,
485
- 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}`,
486
637
  });
487
- return { run: currentAlias, status: 499, turn: loopIteration };
488
638
  }
489
- console.warn(`[RUMMY] Run failed: ${err.message}`);
490
- console.warn(`[RUMMY] Stack: ${err.stack}`);
491
- await this.#db.update_run_status.run({
492
- id: currentRunId,
493
- status: 500,
494
- });
495
- try {
496
- await this.#knownStore.upsert(
497
- currentRunId,
498
- loopIteration,
499
- `error://${loopIteration}`,
500
- `${err.message}\n${err.stack}`,
501
- 500,
502
- { loopId: currentLoopId },
503
- );
504
- } catch {}
505
- const out = {
506
- run: currentAlias,
507
- status: 500,
508
- turn: loopIteration,
509
- error: err.message,
510
- };
639
+ const out = { run: currentAlias, status, turn: loopIteration };
640
+ if (status === 500) out.error = err.message;
511
641
  await hook.completed.emit({ projectId, ...out });
512
642
  return out;
513
643
  } finally {
514
- await this.#hooks.loop.completed
515
- .emit({
516
- runId: currentRunId,
517
- loopId: currentLoopId,
518
- mode,
519
- turns: loopIteration,
520
- })
521
- .catch(() => {});
644
+ await this.#hooks.loop.completed.emit({
645
+ runId: currentRunId,
646
+ loopId: currentLoopId,
647
+ mode,
648
+ turns: loopIteration,
649
+ });
522
650
  }
523
651
  }
524
652
 
@@ -530,119 +658,120 @@ export default class AgentLoop {
530
658
 
531
659
  const { path, action, output } = resolution;
532
660
 
533
- if (action === "accept" || action === "error") {
534
- const attrs = await this.#knownStore.getAttributes(runId, path);
535
- 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({
536
674
  runId,
675
+ runRow,
537
676
  path,
538
- attrs,
539
677
  output,
540
- );
541
- const status = action === "error" ? 500 : 200;
542
- await this.#knownStore.resolve(runId, path, status, resolvedBody);
543
-
544
- // Store answer in attributes for ask_user
545
- if (path.startsWith("ask_user://") && output) {
546
- const turn = (await this.#db.get_run_by_id.get({ id: runId }))
547
- .next_turn;
548
- await this.#knownStore.upsert(runId, turn, path, resolvedBody, status, {
549
- attributes: { ...attrs, answer: output },
550
- });
551
- }
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
+ }
552
688
 
553
- if (action === "accept") {
554
- const projectId = runRow.project_id;
555
- const project = await this.#db.get_project_by_id.get({
556
- 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,
557
716
  });
558
- const projectRoot = project?.project_root;
559
-
560
- if (path.startsWith("set://") && attrs?.file && attrs?.merge) {
561
- const fileBody = await this.#knownStore.getBody(runId, attrs.file);
562
- if (fileBody != null) {
563
- const blocks = attrs.merge.split(/(?=<<<<<<< SEARCH)/);
564
- let patched = fileBody;
565
- for (const block of blocks) {
566
- const m = block.match(
567
- /<<<<<<< SEARCH\n?([\s\S]*?)\n?=======\n?([\s\S]*?)\n?>>>>>>> REPLACE/,
568
- );
569
- if (m) patched = patched.replace(m[1], m[2]);
570
- }
571
- const turn = (await this.#db.get_run_by_id.get({ id: runId }))
572
- .next_turn;
573
- await this.#knownStore.upsert(
574
- runId,
575
- turn,
576
- attrs.file,
577
- patched,
578
- 200,
579
- );
580
- // Write patched content to disk
581
- if (projectRoot) {
582
- const { writeFile } = await import("node:fs/promises");
583
- const { join } = await import("node:path");
584
- await writeFile(join(projectRoot, attrs.file), patched).catch(
585
- () => {},
586
- );
587
- }
588
- }
589
- }
590
-
591
- if (path.startsWith("rm://")) {
592
- if (attrs?.path) {
593
- await this.#knownStore.remove(runId, attrs.path);
594
- if (projectRoot) {
595
- const { unlink } = await import("node:fs/promises");
596
- const { join } = await import("node:path");
597
- await unlink(join(projectRoot, attrs.path)).catch(() => {});
598
- }
599
- }
600
- }
601
-
602
- if (path.startsWith("mv://")) {
603
- if (attrs?.isMove && attrs?.from) {
604
- await this.#knownStore.remove(runId, attrs.from);
605
- }
606
- }
717
+ return { ok: true, state: "failed", outcome: veto.outcome };
607
718
  }
608
- } else if (action === "reject") {
609
- await this.#knownStore.resolve(runId, path, 403, output || "rejected");
610
- } else {
611
- throw new Error(msg("error.resolution_invalid", { action }));
612
719
  }
613
720
 
614
- // The dispatch loop is awaiting resolution. This unblocks it.
615
- // Dispatch continuation is handled by the loop, not here.
616
- return { run: runAlias, status: 200 };
617
- }
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
+ });
618
741
 
619
- async #composeResolvedContent(runId, path, _attrs, output) {
620
- const scheme = path.split("://")[0];
621
- switch (scheme) {
622
- case "set": {
623
- const existing = await this.#knownStore.getBody(runId, path);
624
- return existing || output || "";
625
- }
626
- default:
627
- return output || "";
628
- }
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 };
629
752
  }
630
753
 
631
- 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
+ }
632
760
  const runRow = await this.#db.get_run_by_alias.get({ alias: runAlias });
633
761
  if (!runRow)
634
762
  throw new Error(msg("error.run_not_found", { runId: runAlias }));
635
763
 
636
764
  const nextTurn = runRow.next_turn;
637
765
 
638
- await this.#knownStore.upsert(
639
- runRow.id,
640
- nextTurn,
641
- `prompt://${nextTurn}`,
642
- message,
643
- 200,
644
- { attributes: { mode: "ask" } },
645
- );
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
+ });
646
775
 
647
776
  if (this.#activeRuns.has(runRow.id)) {
648
777
  return { run: runAlias, status: runRow.status, injected: "next_turn" };
@@ -652,7 +781,7 @@ export default class AgentLoop {
652
781
  await this.#db.enqueue_loop.get({
653
782
  run_id: runRow.id,
654
783
  sequence: injectLoopSeq.sequence,
655
- mode: "ask",
784
+ mode,
656
785
  model: runRow.model,
657
786
  prompt: message,
658
787
  config: "{}",
@@ -660,23 +789,23 @@ export default class AgentLoop {
660
789
 
661
790
  const projectId = runRow.project_id;
662
791
  const project = await this.#db.get_project_by_id.get({ id: projectId });
663
- 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;
664
803
  }
665
804
 
666
805
  async getRunHistory(runAlias) {
667
806
  const runRow = await this.#db.get_run_by_alias.get({ alias: runAlias });
668
807
  if (!runRow)
669
808
  throw new Error(msg("error.run_not_found", { runId: runAlias }));
670
- return this.#knownStore.getLog(runRow.id);
809
+ return this.#entries.getLog(runRow.id);
671
810
  }
672
811
  }
673
-
674
- /**
675
- * Pure recovery state transition — exported for testing.
676
- *
677
- * @param {object|null} recovery Current recovery state (mutated copy returned).
678
- * @param {{ assembledTokens: number, budgetRecovery?: { target: number, promptPath: string|null } }} result
679
- * @returns {{ next: object|null, action: null|'restore'|'hard413', promptPath: string|null }}
680
- */
681
- // Re-export for backward compatibility with tests
682
- export { advanceRecovery } from "../plugins/budget/recovery.js";