@possumtech/rummy 0.2.7 → 0.3.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 (119) hide show
  1. package/.env.example +12 -3
  2. package/EXCEPTIONS.md +46 -0
  3. package/PLUGINS.md +454 -197
  4. package/SPEC.md +284 -93
  5. package/migrations/001_initial_schema.sql +57 -70
  6. package/package.json +16 -10
  7. package/service.js +1 -1
  8. package/src/agent/AgentLoop.js +254 -70
  9. package/src/agent/ContextAssembler.js +18 -4
  10. package/src/agent/KnownStore.js +156 -23
  11. package/src/agent/ProjectAgent.js +5 -4
  12. package/src/agent/ResponseHealer.js +21 -1
  13. package/src/agent/TurnExecutor.js +393 -115
  14. package/src/agent/XmlParser.js +92 -39
  15. package/src/agent/known_checks.sql +5 -4
  16. package/src/agent/known_queries.sql +4 -3
  17. package/src/agent/known_store.sql +45 -15
  18. package/src/agent/loops.sql +63 -0
  19. package/src/agent/runs.sql +7 -7
  20. package/src/agent/schemes.sql +5 -2
  21. package/src/agent/tokens.js +6 -21
  22. package/src/agent/turns.sql +13 -4
  23. package/src/hooks/Hooks.js +18 -0
  24. package/src/hooks/PluginContext.js +14 -10
  25. package/src/hooks/RummyContext.js +30 -10
  26. package/src/hooks/ToolRegistry.js +83 -19
  27. package/src/llm/LlmProvider.js +27 -8
  28. package/src/llm/OpenAiClient.js +20 -0
  29. package/src/llm/OpenRouterClient.js +24 -2
  30. package/src/llm/XaiClient.js +47 -2
  31. package/src/plugins/ask_user/README.md +4 -4
  32. package/src/plugins/ask_user/ask_user.js +8 -7
  33. package/src/plugins/ask_user/ask_userDoc.js +29 -0
  34. package/src/plugins/budget/BudgetGuard.js +74 -0
  35. package/src/plugins/budget/README.md +43 -0
  36. package/src/plugins/budget/budget.js +79 -0
  37. package/src/plugins/cp/README.md +5 -4
  38. package/src/plugins/cp/cp.js +16 -12
  39. package/src/plugins/cp/cpDoc.js +29 -0
  40. package/src/plugins/current/README.md +4 -4
  41. package/src/plugins/current/current.js +12 -10
  42. package/src/plugins/engine/engine.sql +5 -10
  43. package/src/plugins/engine/turn_context.sql +13 -13
  44. package/src/plugins/env/README.md +3 -4
  45. package/src/plugins/env/env.js +8 -7
  46. package/src/plugins/env/envDoc.js +29 -0
  47. package/src/plugins/file/README.md +9 -12
  48. package/src/plugins/file/file.js +34 -45
  49. package/src/plugins/get/README.md +2 -2
  50. package/src/plugins/get/get.js +28 -11
  51. package/src/plugins/get/getDoc.js +41 -0
  52. package/src/plugins/hedberg/docs.md +0 -9
  53. package/src/plugins/hedberg/hedberg.js +4 -6
  54. package/src/plugins/hedberg/matcher.js +1 -1
  55. package/src/plugins/hedberg/normalize.js +28 -0
  56. package/src/plugins/hedberg/patterns.js +31 -33
  57. package/src/plugins/hedberg/sed.js +17 -10
  58. package/src/plugins/helpers.js +2 -2
  59. package/src/plugins/index.js +93 -28
  60. package/src/plugins/instructions/README.md +6 -2
  61. package/src/plugins/instructions/instructions.js +21 -5
  62. package/src/plugins/instructions/preamble.md +9 -5
  63. package/src/plugins/known/README.md +10 -7
  64. package/src/plugins/known/known.js +33 -23
  65. package/src/plugins/known/knownDoc.js +33 -0
  66. package/src/plugins/mv/README.md +5 -4
  67. package/src/plugins/mv/mv.js +16 -12
  68. package/src/plugins/mv/mvDoc.js +31 -0
  69. package/src/plugins/persona/persona.js +78 -0
  70. package/src/plugins/previous/README.md +2 -2
  71. package/src/plugins/previous/previous.js +12 -8
  72. package/src/plugins/progress/progress.js +44 -12
  73. package/src/plugins/prompt/README.md +5 -5
  74. package/src/plugins/prompt/prompt.js +23 -19
  75. package/src/plugins/rm/README.md +4 -4
  76. package/src/plugins/rm/rm.js +29 -12
  77. package/src/plugins/rm/rmDoc.js +30 -0
  78. package/src/plugins/rpc/README.md +15 -28
  79. package/src/plugins/rpc/rpc.js +63 -107
  80. package/src/plugins/set/README.md +13 -12
  81. package/src/plugins/set/set.js +82 -21
  82. package/src/plugins/set/setDoc.js +45 -0
  83. package/src/plugins/sh/README.md +4 -4
  84. package/src/plugins/sh/sh.js +8 -7
  85. package/src/plugins/sh/shDoc.js +29 -0
  86. package/src/plugins/{skills/skills.js → skill/skill.js} +12 -54
  87. package/src/plugins/summarize/README.md +6 -5
  88. package/src/plugins/summarize/summarize.js +7 -6
  89. package/src/plugins/summarize/summarizeDoc.js +33 -0
  90. package/src/plugins/telemetry/telemetry.js +20 -8
  91. package/src/plugins/think/README.md +20 -0
  92. package/src/plugins/think/think.js +5 -0
  93. package/src/plugins/unknown/README.md +5 -5
  94. package/src/plugins/unknown/unknown.js +11 -8
  95. package/src/plugins/unknown/unknownDoc.js +31 -0
  96. package/src/plugins/update/README.md +3 -8
  97. package/src/plugins/update/update.js +7 -6
  98. package/src/plugins/update/updateDoc.js +33 -0
  99. package/src/server/ClientConnection.js +3 -5
  100. package/src/server/RpcRegistry.js +52 -4
  101. package/src/sql/v_model_context.sql +31 -39
  102. package/src/sql/v_run_log.sql +3 -3
  103. package/src/agent/prompt_queue.sql +0 -39
  104. package/src/plugins/ask_user/docs.md +0 -2
  105. package/src/plugins/cp/docs.md +0 -2
  106. package/src/plugins/env/docs.md +0 -2
  107. package/src/plugins/get/docs.md +0 -6
  108. package/src/plugins/known/docs.md +0 -3
  109. package/src/plugins/mv/docs.md +0 -2
  110. package/src/plugins/rm/docs.md +0 -4
  111. package/src/plugins/set/docs.md +0 -4
  112. package/src/plugins/sh/docs.md +0 -2
  113. package/src/plugins/skills/README.md +0 -25
  114. package/src/plugins/store/README.md +0 -20
  115. package/src/plugins/store/docs.md +0 -5
  116. package/src/plugins/store/store.js +0 -52
  117. package/src/plugins/summarize/docs.md +0 -4
  118. package/src/plugins/unknown/docs.md +0 -5
  119. package/src/plugins/update/docs.md +0 -4
@@ -32,7 +32,7 @@ export default class AgentLoop {
32
32
  }
33
33
 
34
34
  async #ensureRun(projectId, model, run, options) {
35
- const _noContext = options?.noContext === true;
35
+ const _noRepo = options?.noRepo === true;
36
36
  const isFork = options?.fork === true;
37
37
  const requestedModel = model;
38
38
 
@@ -54,6 +54,11 @@ export default class AgentLoop {
54
54
  new_run_id: runRow.id,
55
55
  parent_run_id: existingRun.id,
56
56
  });
57
+ await this.#hooks.run.created.emit({
58
+ runId: runRow.id,
59
+ alias,
60
+ forkedFrom: existingRun.id,
61
+ });
57
62
  return { runId: runRow.id, alias };
58
63
  }
59
64
 
@@ -87,6 +92,7 @@ export default class AgentLoop {
87
92
  persona: options?.persona ?? null,
88
93
  context_limit: options?.contextLimit ?? null,
89
94
  });
95
+ await this.#hooks.run.created.emit({ runId: runRow.id, alias });
90
96
  return { runId: runRow.id, alias };
91
97
  }
92
98
 
@@ -112,14 +118,16 @@ export default class AgentLoop {
112
118
  if (!project)
113
119
  throw new Error(msg("error.project_not_found", { projectId }));
114
120
 
115
- const noContext = options?.noContext === true;
121
+ const noRepo = options?.noRepo === true;
122
+ const noInteraction = options?.noInteraction === true;
123
+ const noWeb = options?.noWeb === true;
116
124
  const requestedModel = model;
117
125
 
118
126
  const runInfo = await this.#ensureRun(projectId, model, run, options);
119
127
  if (runInfo.blocked) {
120
128
  return {
121
129
  run: runInfo.alias,
122
- status: "proposed",
130
+ status: 202,
123
131
  remainingCount: runInfo.proposed.length,
124
132
  proposed: runInfo.proposed,
125
133
  };
@@ -127,16 +135,23 @@ export default class AgentLoop {
127
135
 
128
136
  const { runId: currentRunId, alias: currentAlias } = runInfo;
129
137
 
130
- await this.#db.enqueue_prompt.get({
138
+ const loopSeq = await this.#db.next_loop.get({ run_id: currentRunId });
139
+ await this.#db.enqueue_loop.get({
131
140
  run_id: currentRunId,
141
+ sequence: loopSeq.sequence,
132
142
  mode,
133
143
  model: requestedModel,
134
144
  prompt: prompt || "",
135
- config: JSON.stringify({ noContext, temperature: options?.temperature }),
145
+ config: JSON.stringify({
146
+ noRepo,
147
+ noInteraction,
148
+ noWeb,
149
+ temperature: options?.temperature,
150
+ }),
136
151
  });
137
152
 
138
153
  if (this.#activeRuns.has(currentRunId)) {
139
- return { run: currentAlias, status: "queued" };
154
+ return { run: currentAlias, status: 100 };
140
155
  }
141
156
 
142
157
  return this.#drainQueue(
@@ -149,36 +164,100 @@ export default class AgentLoop {
149
164
  }
150
165
 
151
166
  async #drainQueue(currentRunId, currentAlias, projectId, project, options) {
167
+ let panicAttempted = false;
168
+
152
169
  while (true) {
153
- const queued = await this.#db.claim_next_prompt.get({
170
+ const loop = await this.#db.claim_next_loop.get({
154
171
  run_id: currentRunId,
155
172
  });
156
- if (!queued) break;
173
+ if (!loop) break;
174
+
175
+ const loopConfig = loop.config ? JSON.parse(loop.config) : {};
176
+ const hook =
177
+ loop.mode === "panic"
178
+ ? this.#hooks.panic
179
+ : loop.mode === "ask"
180
+ ? this.#hooks.ask
181
+ : this.#hooks.act;
157
182
 
158
- const promptConfig = queued.config ? JSON.parse(queued.config) : {};
159
183
  const result = await this.#executeLoop({
160
- mode: queued.mode,
184
+ mode: loop.mode,
161
185
  project,
162
186
  projectId,
163
187
  currentRunId,
164
188
  currentAlias,
165
- requestedModel: queued.model,
166
- prompt: queued.prompt,
167
- noContext: promptConfig.noContext || false,
168
- options: { ...options, temperature: promptConfig.temperature },
169
- hook: queued.mode === "ask" ? this.#hooks.ask : this.#hooks.act,
189
+ currentLoopId: loop.id,
190
+ requestedModel: loop.model,
191
+ prompt: loop.prompt,
192
+ noRepo: loopConfig.noRepo || false,
193
+ noInteraction: loopConfig.noInteraction || false,
194
+ noWeb: loopConfig.noWeb || false,
195
+ options: { ...options, temperature: loopConfig.temperature },
196
+ hook,
170
197
  });
171
198
 
172
- await this.#db.complete_prompt.run({
173
- id: queued.id,
199
+ if (result.status === 413) {
200
+ await this.#db.complete_loop.run({
201
+ id: loop.id,
202
+ status: 413,
203
+ result: JSON.stringify(result),
204
+ });
205
+
206
+ // One panic attempt per drain cycle
207
+ if (loop.mode === "panic" || panicAttempted) {
208
+ return {
209
+ run: currentAlias,
210
+ status: 413,
211
+ error:
212
+ loop.mode === "panic"
213
+ ? `Panic mode failed to free enough space (${result.overflow} tokens over).`
214
+ : `Context full (${result.overflow} tokens over).`,
215
+ };
216
+ }
217
+
218
+ panicAttempted = true;
219
+
220
+ const panicPrompt = this.#hooks.budget.panicPrompt({
221
+ assembledTokens: result.assembledTokens,
222
+ contextSize: result.contextSize,
223
+ });
224
+
225
+ // Enqueue panic loop
226
+ const panicSeq = await this.#db.next_loop.get({ run_id: currentRunId });
227
+ await this.#db.enqueue_loop.get({
228
+ run_id: currentRunId,
229
+ sequence: panicSeq.sequence,
230
+ mode: "panic",
231
+ model: loop.model,
232
+ prompt: panicPrompt,
233
+ config: JSON.stringify({ noRepo: true }),
234
+ });
235
+
236
+ // Re-enqueue the original loop to retry after panic
237
+ const retrySeq = await this.#db.next_loop.get({ run_id: currentRunId });
238
+ await this.#db.enqueue_loop.get({
239
+ run_id: currentRunId,
240
+ sequence: retrySeq.sequence,
241
+ mode: loop.mode,
242
+ model: loop.model,
243
+ prompt: loop.prompt,
244
+ config: loop.config,
245
+ });
246
+
247
+ continue;
248
+ }
249
+
250
+ await this.#db.complete_loop.run({
251
+ id: loop.id,
252
+ status: result.status === 202 ? 202 : result.status,
174
253
  result: JSON.stringify(result),
175
254
  });
176
255
 
177
- if (result.status === "proposed") return result;
256
+ if (result.status === 202) return result;
178
257
  }
179
258
 
180
259
  const runRow = await this.#db.get_run_by_alias.get({ alias: currentAlias });
181
- return { run: currentAlias, status: runRow?.status || "completed" };
260
+ return { run: currentAlias, status: runRow?.status ?? 200 };
182
261
  }
183
262
 
184
263
  async #executeLoop({
@@ -187,17 +266,20 @@ export default class AgentLoop {
187
266
  projectId,
188
267
  currentRunId,
189
268
  currentAlias,
269
+ currentLoopId,
190
270
  requestedModel,
191
271
  prompt,
192
- noContext,
272
+ noRepo,
273
+ noInteraction,
274
+ noWeb,
193
275
  options,
194
276
  hook,
195
277
  }) {
196
278
  const runRow = await this.#db.get_run_by_id.get({ id: currentRunId });
197
- if (runRow.status !== "running") {
279
+ if (runRow.status !== 102) {
198
280
  await this.#db.update_run_status.run({
199
281
  id: currentRunId,
200
- status: "running",
282
+ status: 102,
201
283
  });
202
284
  }
203
285
 
@@ -207,6 +289,11 @@ export default class AgentLoop {
207
289
  ? Math.min(runRow.context_limit, modelContextSize)
208
290
  : modelContextSize;
209
291
 
292
+ const toolSet = this.#hooks.tools.resolveForLoop(mode, {
293
+ noInteraction,
294
+ noWeb,
295
+ });
296
+
210
297
  let loopIteration = 0;
211
298
  const MAX_LOOP_ITERATIONS = Number(process.env.RUMMY_MAX_TURNS) || 15;
212
299
  const healer = new ResponseHealer();
@@ -214,16 +301,27 @@ export default class AgentLoop {
214
301
  const controller = new AbortController();
215
302
  this.#activeRuns.set(currentRunId, controller);
216
303
 
304
+ let _lastAssembledTokens = 0;
305
+ let _panicStrikes = 0;
306
+ let _lastPanicTokens = null;
307
+
308
+ await this.#hooks.loop.started.emit({
309
+ runId: currentRunId,
310
+ loopId: currentLoopId,
311
+ mode,
312
+ prompt,
313
+ });
314
+
217
315
  try {
218
316
  while (loopIteration < MAX_LOOP_ITERATIONS) {
219
317
  if (controller.signal.aborted) {
220
318
  await this.#db.update_run_status.run({
221
319
  id: currentRunId,
222
- status: "aborted",
320
+ status: 499,
223
321
  });
224
322
  const out = {
225
323
  run: currentAlias,
226
- status: "aborted",
324
+ status: 499,
227
325
  turn: loopIteration,
228
326
  };
229
327
  await hook.completed.emit({ projectId, ...out });
@@ -234,6 +332,8 @@ export default class AgentLoop {
234
332
  let turnPrompt;
235
333
  if (loopIteration === 1) {
236
334
  turnPrompt = prompt;
335
+ } else if (mode === "panic") {
336
+ turnPrompt = "Continue freeing space. Check <knowns> token counts.";
237
337
  } else {
238
338
  turnPrompt = this.#buildContinuationPrompt(
239
339
  loopIteration,
@@ -247,14 +347,70 @@ export default class AgentLoop {
247
347
  projectId,
248
348
  currentRunId,
249
349
  currentAlias,
350
+ currentLoopId,
250
351
  requestedModel,
251
352
  loopPrompt: turnPrompt,
252
- noContext,
353
+ noRepo,
354
+ toolSet,
253
355
  contextSize,
254
356
  options: { ...options, isContinuation: loopIteration > 1 },
255
357
  signal: controller.signal,
256
358
  });
257
359
 
360
+ // Budget overflow — return 413 to drainQueue for panic mode
361
+ if (result.status === 413) {
362
+ return {
363
+ run: currentAlias,
364
+ status: 413,
365
+ overflow: result.overflow,
366
+ assembledTokens: result.assembledTokens,
367
+ contextSize: result.contextSize,
368
+ turn: result.turn,
369
+ };
370
+ }
371
+
372
+ _lastAssembledTokens = result.assembledTokens;
373
+
374
+ // Panic mode: target check + strike counting
375
+ if (mode === "panic") {
376
+ const panicTarget = Math.floor(contextSize * 0.75);
377
+ if (result.assembledTokens <= panicTarget) {
378
+ await this.#db.update_run_status.run({
379
+ id: currentRunId,
380
+ status: 200,
381
+ });
382
+ const out = {
383
+ run: currentAlias,
384
+ status: 200,
385
+ turn: result.turn,
386
+ };
387
+ await hook.completed.emit({ projectId, ...out });
388
+ return out;
389
+ }
390
+ if (_lastPanicTokens !== null) {
391
+ if (result.assembledTokens < _lastPanicTokens) {
392
+ _panicStrikes = 0;
393
+ } else {
394
+ _panicStrikes++;
395
+ if (_panicStrikes >= 3) {
396
+ await this.#db.update_run_status.run({
397
+ id: currentRunId,
398
+ status: 200,
399
+ });
400
+ return {
401
+ run: currentAlias,
402
+ status: 413,
403
+ overflow: result.assembledTokens - contextSize,
404
+ assembledTokens: result.assembledTokens,
405
+ contextSize,
406
+ turn: result.turn,
407
+ };
408
+ }
409
+ }
410
+ }
411
+ _lastPanicTokens = result.assembledTokens;
412
+ }
413
+
258
414
  const runUsage = await this.#db.get_run_usage.get({
259
415
  run_id: currentRunId,
260
416
  });
@@ -265,14 +421,14 @@ export default class AgentLoop {
265
421
  const unresolved = await this.#knownStore.getUnresolved(currentRunId);
266
422
 
267
423
  const latestSummary = history
268
- .filter((e) => e.status === "summary")
424
+ .filter((e) => e.status === 200 && e.path?.startsWith("summarize://"))
269
425
  .at(-1);
270
426
 
271
427
  await this.#hooks.run.state.emit({
272
428
  projectId,
273
429
  run: currentAlias,
274
430
  turn: result.turn,
275
- status: unresolved.length > 0 ? "proposed" : "running",
431
+ status: unresolved.length > 0 ? 202 : 102,
276
432
  summary: latestSummary?.body || "",
277
433
  history,
278
434
  unknowns: unknowns.map((u) => ({ path: u.path, body: u.body })),
@@ -286,6 +442,12 @@ export default class AgentLoop {
286
442
  model: result.model,
287
443
  temperature: result.temperature,
288
444
  context_size: result.contextSize,
445
+ context_tokens: (
446
+ await this.#db.get_turn_budget.get({
447
+ run_id: currentRunId,
448
+ turn: result.turn,
449
+ })
450
+ ).total,
289
451
  prompt_tokens: runUsage.prompt_tokens,
290
452
  cached_tokens: runUsage.cached_tokens,
291
453
  completion_tokens: runUsage.completion_tokens,
@@ -301,11 +463,11 @@ export default class AgentLoop {
301
463
  if (unresolved.length > 0) {
302
464
  await this.#db.update_run_status.run({
303
465
  id: currentRunId,
304
- status: "proposed",
466
+ status: 202,
305
467
  });
306
468
  const out = {
307
469
  run: currentAlias,
308
- status: "proposed",
470
+ status: 202,
309
471
  turn: result.turn,
310
472
  proposed: unresolved,
311
473
  };
@@ -324,11 +486,11 @@ export default class AgentLoop {
324
486
  if (!repetition.continue) {
325
487
  await this.#db.update_run_status.run({
326
488
  id: currentRunId,
327
- status: "completed",
489
+ status: 200,
328
490
  });
329
491
  const out = {
330
492
  run: currentAlias,
331
- status: "completed",
493
+ status: 200,
332
494
  turn: result.turn,
333
495
  reason: repetition.reason,
334
496
  };
@@ -341,11 +503,11 @@ export default class AgentLoop {
341
503
 
342
504
  await this.#db.update_run_status.run({
343
505
  id: currentRunId,
344
- status: "completed",
506
+ status: 200,
345
507
  });
346
508
  const out = {
347
509
  run: currentAlias,
348
- status: "completed",
510
+ status: 200,
349
511
  turn: result.turn,
350
512
  };
351
513
  await hook.completed.emit({ projectId, ...out });
@@ -354,11 +516,11 @@ export default class AgentLoop {
354
516
 
355
517
  await this.#db.update_run_status.run({
356
518
  id: currentRunId,
357
- status: "completed",
519
+ status: 200,
358
520
  });
359
521
  const out = {
360
522
  run: currentAlias,
361
- status: "completed",
523
+ status: 200,
362
524
  turn: loopIteration,
363
525
  };
364
526
  await hook.completed.emit({ projectId, ...out });
@@ -367,15 +529,15 @@ export default class AgentLoop {
367
529
  if (controller.signal.aborted) {
368
530
  await this.#db.update_run_status.run({
369
531
  id: currentRunId,
370
- status: "aborted",
532
+ status: 499,
371
533
  });
372
- return { run: currentAlias, status: "aborted", turn: loopIteration };
534
+ return { run: currentAlias, status: 499, turn: loopIteration };
373
535
  }
374
536
  console.warn(`[RUMMY] Run failed: ${err.message}`);
375
537
  console.warn(`[RUMMY] Stack: ${err.stack}`);
376
538
  await this.#db.update_run_status.run({
377
539
  id: currentRunId,
378
- status: "failed",
540
+ status: 500,
379
541
  });
380
542
  try {
381
543
  await this.#knownStore.upsert(
@@ -383,12 +545,13 @@ export default class AgentLoop {
383
545
  loopIteration,
384
546
  `error://${loopIteration}`,
385
547
  `${err.message}\n${err.stack}`,
386
- "info",
548
+ 500,
549
+ { loopId: currentLoopId },
387
550
  );
388
551
  } catch {}
389
552
  const out = {
390
553
  run: currentAlias,
391
- status: "failed",
554
+ status: 500,
392
555
  turn: loopIteration,
393
556
  error: err.message,
394
557
  };
@@ -396,6 +559,14 @@ export default class AgentLoop {
396
559
  return out;
397
560
  } finally {
398
561
  this.#activeRuns.delete(currentRunId);
562
+ await this.#hooks.loop.completed
563
+ .emit({
564
+ runId: currentRunId,
565
+ loopId: currentLoopId,
566
+ mode,
567
+ turns: loopIteration,
568
+ })
569
+ .catch(() => {});
399
570
  }
400
571
  }
401
572
 
@@ -415,14 +586,14 @@ export default class AgentLoop {
415
586
  attrs,
416
587
  output,
417
588
  );
418
- const state = action === "error" ? "error" : "pass";
419
- await this.#knownStore.resolve(runId, path, state, resolvedBody);
589
+ const status = action === "error" ? 500 : 200;
590
+ await this.#knownStore.resolve(runId, path, status, resolvedBody);
420
591
 
421
592
  // Store answer in attributes for ask_user
422
593
  if (path.startsWith("ask_user://") && output) {
423
594
  const turn = (await this.#db.get_run_by_id.get({ id: runId }))
424
595
  .next_turn;
425
- await this.#knownStore.upsert(runId, turn, path, resolvedBody, state, {
596
+ await this.#knownStore.upsert(runId, turn, path, resolvedBody, status, {
426
597
  attributes: { ...attrs, answer: output },
427
598
  });
428
599
  }
@@ -441,12 +612,7 @@ export default class AgentLoop {
441
612
  }
442
613
  }
443
614
  } else if (action === "reject") {
444
- await this.#knownStore.resolve(
445
- runId,
446
- path,
447
- "rejected",
448
- output || "rejected",
449
- );
615
+ await this.#knownStore.resolve(runId, path, 403, output || "rejected");
450
616
  } else {
451
617
  throw new Error(msg("error.resolution_invalid", { action }));
452
618
  }
@@ -455,23 +621,46 @@ export default class AgentLoop {
455
621
  if (unresolved.length > 0) {
456
622
  return {
457
623
  run: runAlias,
458
- status: "proposed",
624
+ status: 202,
459
625
  remainingCount: unresolved.length,
460
626
  proposed: unresolved,
461
627
  };
462
628
  }
463
629
 
464
- if (await this.#knownStore.hasRejections(runId)) {
465
- await this.#db.update_run_status.run({ id: runId, status: "completed" });
466
- return { run: runAlias, status: "completed" };
630
+ // Scope completion checks to the current loop
631
+ const currentLoop = await this.#db.get_current_loop.get({ run_id: runId });
632
+ const loopId = currentLoop?.id ?? null;
633
+
634
+ if (await this.#knownStore.hasRejections(runId, loopId)) {
635
+ if (currentLoop)
636
+ await this.#db.complete_loop.run({
637
+ id: loopId,
638
+ status: 200,
639
+ result: null,
640
+ });
641
+ await this.#db.update_run_status.run({ id: runId, status: 200 });
642
+ return { run: runAlias, status: 200 };
467
643
  }
468
644
 
469
- const hasSummary = await this.#db.get_latest_summary.get({ run_id: runId });
645
+ const hasSummary = await this.#db.get_latest_summary.get({
646
+ run_id: runId,
647
+ loop_id: loopId,
648
+ });
470
649
  if (hasSummary?.body) {
471
- await this.#db.update_run_status.run({ id: runId, status: "completed" });
472
- return { run: runAlias, status: "completed" };
650
+ if (currentLoop)
651
+ await this.#db.complete_loop.run({
652
+ id: loopId,
653
+ status: 200,
654
+ result: null,
655
+ });
656
+ await this.#db.update_run_status.run({ id: runId, status: 200 });
657
+ return { run: runAlias, status: 200 };
473
658
  }
474
659
 
660
+ // No summary and no rejections in this loop — resume it
661
+ const projectId = runRow.project_id;
662
+ const project = await this.#db.get_project_by_id.get({ id: projectId });
663
+
475
664
  const latestPrompt = await this.#db.get_latest_prompt.get({
476
665
  run_id: runId,
477
666
  });
@@ -479,11 +668,11 @@ export default class AgentLoop {
479
668
  ? JSON.parse(latestPrompt.attributes).mode
480
669
  : "ask";
481
670
 
482
- const projectId = runRow.project_id;
483
- const project = await this.#db.get_project_by_id.get({ id: projectId });
484
-
485
- await this.#db.enqueue_prompt.get({
671
+ // Re-enqueue the current loop's prompt to continue it
672
+ const loopSeq = await this.#db.next_loop.get({ run_id: runId });
673
+ await this.#db.enqueue_loop.get({
486
674
  run_id: runId,
675
+ sequence: loopSeq.sequence,
487
676
  mode: resumeMode,
488
677
  model: runRow.model,
489
678
  prompt: "",
@@ -515,24 +704,19 @@ export default class AgentLoop {
515
704
  runRow.id,
516
705
  nextTurn,
517
706
  `prompt://${nextTurn}`,
518
- "",
519
- "info",
520
- { attributes: { mode: "ask" } },
521
- );
522
- await this.#knownStore.upsert(
523
- runRow.id,
524
- nextTurn,
525
- `ask://${nextTurn}`,
526
707
  message,
527
- "info",
708
+ 200,
709
+ { attributes: { mode: "ask" } },
528
710
  );
529
711
 
530
712
  if (this.#activeRuns.has(runRow.id)) {
531
713
  return { run: runAlias, status: runRow.status, injected: "next_turn" };
532
714
  }
533
715
 
534
- await this.#db.enqueue_prompt.get({
716
+ const injectLoopSeq = await this.#db.next_loop.get({ run_id: runRow.id });
717
+ await this.#db.enqueue_loop.get({
535
718
  run_id: runRow.id,
719
+ sequence: injectLoopSeq.sequence,
536
720
  mode: "ask",
537
721
  model: runRow.model,
538
722
  prompt: message,
@@ -6,17 +6,31 @@
6
6
  export default class ContextAssembler {
7
7
  static async assembleFromTurnContext(
8
8
  rows,
9
- { type = "ask", systemPrompt = "", contextSize = 0 } = {},
9
+ {
10
+ type = "ask",
11
+ systemPrompt = "",
12
+ contextSize = 0,
13
+ demoted = [],
14
+ toolSet = null,
15
+ lastContextTokens = 0,
16
+ } = {},
10
17
  hooks,
11
18
  ) {
12
19
  // Find loop boundary from active prompt
13
20
  const promptEntry = rows.findLast(
14
- (r) =>
15
- r.category === "prompt" && (r.scheme === "ask" || r.scheme === "act"),
21
+ (r) => r.category === "prompt" && r.scheme === "prompt",
16
22
  );
17
23
  const loopStartTurn = promptEntry?.source_turn ?? 0;
18
24
 
19
- const ctx = { rows, loopStartTurn, type, contextSize };
25
+ const ctx = {
26
+ rows,
27
+ loopStartTurn,
28
+ type,
29
+ contextSize,
30
+ lastContextTokens,
31
+ demoted,
32
+ toolSet,
33
+ };
20
34
 
21
35
  const system = await hooks.assembly.system.filter(systemPrompt, ctx);
22
36
  const user = await hooks.assembly.user.filter("", ctx);