@possumtech/rummy 0.2.8 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.env.example +13 -2
  2. package/EXCEPTIONS.md +46 -0
  3. package/PLUGINS.md +422 -188
  4. package/SPEC.md +440 -106
  5. package/migrations/001_initial_schema.sql +5 -3
  6. package/package.json +17 -5
  7. package/service.js +5 -3
  8. package/src/agent/AgentLoop.js +252 -55
  9. package/src/agent/ContextAssembler.js +20 -4
  10. package/src/agent/KnownStore.js +82 -25
  11. package/src/agent/ProjectAgent.js +4 -1
  12. package/src/agent/ResponseHealer.js +86 -32
  13. package/src/agent/TurnExecutor.js +542 -207
  14. package/src/agent/XmlParser.js +77 -41
  15. package/src/agent/known_store.sql +68 -4
  16. package/src/agent/schemes.sql +3 -0
  17. package/src/agent/tokens.js +7 -21
  18. package/src/agent/turns.sql +15 -1
  19. package/src/hooks/HookRegistry.js +7 -0
  20. package/src/hooks/Hooks.js +15 -0
  21. package/src/hooks/PluginContext.js +14 -1
  22. package/src/hooks/RummyContext.js +16 -4
  23. package/src/hooks/ToolRegistry.js +77 -19
  24. package/src/llm/LlmProvider.js +27 -8
  25. package/src/llm/OpenAiClient.js +20 -0
  26. package/src/llm/OpenRouterClient.js +24 -2
  27. package/src/llm/XaiClient.js +47 -2
  28. package/src/plugins/ask_user/README.md +4 -4
  29. package/src/plugins/ask_user/ask_user.js +5 -5
  30. package/src/plugins/ask_user/ask_userDoc.js +29 -0
  31. package/src/plugins/budget/README.md +31 -0
  32. package/src/plugins/budget/budget.js +55 -0
  33. package/src/plugins/cp/README.md +5 -4
  34. package/src/plugins/cp/cp.js +10 -6
  35. package/src/plugins/cp/cpDoc.js +29 -0
  36. package/src/plugins/engine/engine.sql +1 -8
  37. package/src/plugins/engine/turn_context.sql +4 -9
  38. package/src/plugins/env/README.md +3 -4
  39. package/src/plugins/env/env.js +5 -5
  40. package/src/plugins/env/envDoc.js +29 -0
  41. package/src/plugins/file/README.md +9 -12
  42. package/src/plugins/file/file.js +34 -35
  43. package/src/plugins/get/README.md +2 -2
  44. package/src/plugins/get/get.js +77 -6
  45. package/src/plugins/get/getDoc.js +51 -0
  46. package/src/plugins/hedberg/hedberg.js +2 -1
  47. package/src/plugins/hedberg/matcher.js +10 -29
  48. package/src/plugins/hedberg/normalize.js +28 -0
  49. package/src/plugins/hedberg/patterns.js +25 -27
  50. package/src/plugins/hedberg/sed.js +17 -10
  51. package/src/plugins/index.js +66 -14
  52. package/src/plugins/instructions/README.md +6 -2
  53. package/src/plugins/instructions/instructions.js +20 -4
  54. package/src/plugins/instructions/preamble.md +19 -5
  55. package/src/plugins/known/README.md +10 -7
  56. package/src/plugins/known/known.js +23 -17
  57. package/src/plugins/known/knownDoc.js +34 -0
  58. package/src/plugins/mv/README.md +5 -4
  59. package/src/plugins/mv/mv.js +27 -6
  60. package/src/plugins/mv/mvDoc.js +45 -0
  61. package/src/plugins/performed/README.md +15 -0
  62. package/src/plugins/performed/performed.js +45 -0
  63. package/src/plugins/persona/persona.js +78 -0
  64. package/src/plugins/previous/README.md +3 -2
  65. package/src/plugins/previous/previous.js +33 -24
  66. package/src/plugins/progress/README.md +1 -2
  67. package/src/plugins/progress/progress.js +33 -21
  68. package/src/plugins/prompt/README.md +5 -5
  69. package/src/plugins/prompt/prompt.js +15 -17
  70. package/src/plugins/rm/README.md +4 -4
  71. package/src/plugins/rm/rm.js +32 -20
  72. package/src/plugins/rm/rmDoc.js +30 -0
  73. package/src/plugins/rpc/README.md +15 -28
  74. package/src/plugins/rpc/rpc.js +42 -77
  75. package/src/plugins/set/README.md +13 -12
  76. package/src/plugins/set/set.js +107 -16
  77. package/src/plugins/set/setDoc.js +49 -0
  78. package/src/plugins/sh/README.md +4 -4
  79. package/src/plugins/sh/sh.js +5 -5
  80. package/src/plugins/sh/shDoc.js +29 -0
  81. package/src/plugins/{skills/skills.js → skill/skill.js} +10 -51
  82. package/src/plugins/summarize/README.md +6 -5
  83. package/src/plugins/summarize/summarize.js +7 -6
  84. package/src/plugins/summarize/summarizeDoc.js +33 -0
  85. package/src/plugins/telemetry/telemetry.js +16 -9
  86. package/src/plugins/think/README.md +20 -0
  87. package/src/plugins/think/think.js +5 -0
  88. package/src/plugins/unknown/README.md +6 -5
  89. package/src/plugins/unknown/unknown.js +12 -9
  90. package/src/plugins/unknown/unknownDoc.js +31 -0
  91. package/src/plugins/update/README.md +3 -8
  92. package/src/plugins/update/update.js +7 -6
  93. package/src/plugins/update/updateDoc.js +33 -0
  94. package/src/server/ClientConnection.js +59 -45
  95. package/src/server/RpcRegistry.js +52 -4
  96. package/src/sql/v_model_context.sql +10 -25
  97. package/src/plugins/ask_user/docs.md +0 -2
  98. package/src/plugins/cp/docs.md +0 -2
  99. package/src/plugins/current/README.md +0 -14
  100. package/src/plugins/current/current.js +0 -47
  101. package/src/plugins/env/docs.md +0 -4
  102. package/src/plugins/get/docs.md +0 -10
  103. package/src/plugins/known/docs.md +0 -3
  104. package/src/plugins/mv/docs.md +0 -2
  105. package/src/plugins/rm/docs.md +0 -6
  106. package/src/plugins/set/docs.md +0 -6
  107. package/src/plugins/sh/docs.md +0 -2
  108. package/src/plugins/skills/README.md +0 -25
  109. package/src/plugins/store/README.md +0 -20
  110. package/src/plugins/store/docs.md +0 -6
  111. package/src/plugins/store/store.js +0 -63
  112. package/src/plugins/summarize/docs.md +0 -4
  113. package/src/plugins/unknown/docs.md +0 -5
  114. package/src/plugins/update/docs.md +0 -4
@@ -85,6 +85,8 @@ CREATE TABLE IF NOT EXISTS turns (
85
85
  , run_id INTEGER NOT NULL REFERENCES runs (id) ON DELETE CASCADE
86
86
  , loop_id INTEGER NOT NULL REFERENCES loops (id) ON DELETE CASCADE
87
87
  , sequence INTEGER NOT NULL CHECK (sequence >= 1)
88
+ , context_tokens INTEGER NOT NULL DEFAULT 0 CHECK (context_tokens >= 0)
89
+ , reasoning_content TEXT
88
90
  , prompt_tokens INTEGER NOT NULL DEFAULT 0 CHECK (prompt_tokens >= 0)
89
91
  , cached_tokens INTEGER NOT NULL DEFAULT 0 CHECK (cached_tokens >= 0)
90
92
  , completion_tokens INTEGER NOT NULL DEFAULT 0 CHECK (completion_tokens >= 0)
@@ -118,12 +120,12 @@ CREATE TABLE IF NOT EXISTS known_entries (
118
120
  , run_id INTEGER NOT NULL REFERENCES runs (id) ON DELETE CASCADE
119
121
  , loop_id INTEGER REFERENCES loops (id) ON DELETE CASCADE
120
122
  , turn INTEGER NOT NULL DEFAULT 0 CHECK (turn >= 0)
121
- , path TEXT NOT NULL
123
+ , path TEXT NOT NULL CHECK (length(path) <= 2048)
122
124
  , body TEXT NOT NULL DEFAULT ''
123
125
  , scheme TEXT GENERATED ALWAYS AS (schemeOf(path)) STORED
124
126
  , status INTEGER NOT NULL DEFAULT 200 CHECK (status BETWEEN 100 AND 599)
125
127
  , fidelity TEXT NOT NULL DEFAULT 'full' CHECK (
126
- fidelity IN ('full', 'summary', 'index', 'stored')
128
+ fidelity IN ('full', 'summary', 'index', 'archive')
127
129
  )
128
130
  , hash TEXT
129
131
  , attributes JSON NOT NULL DEFAULT '{}' CHECK (json_valid(attributes))
@@ -169,7 +171,7 @@ CREATE TABLE IF NOT EXISTS turn_context (
169
171
  , body TEXT NOT NULL DEFAULT ''
170
172
  , tokens INTEGER NOT NULL DEFAULT 0 CHECK (tokens >= 0)
171
173
  , attributes JSON NOT NULL DEFAULT '{}' CHECK (json_valid(attributes))
172
- , category TEXT NOT NULL DEFAULT 'result'
174
+ , category TEXT NOT NULL DEFAULT 'logging'
173
175
  , source_turn INTEGER DEFAULT 0
174
176
  );
175
177
  CREATE INDEX IF NOT EXISTS idx_turn_context_run_turn
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@possumtech/rummy",
3
- "version": "0.2.8",
3
+ "version": "0.3.1",
4
4
  "description": "Relational Unknowns Memory Management Yoke",
5
5
  "keywords": [
6
6
  "llm"
@@ -37,10 +37,21 @@
37
37
  "fix:sql": ". .venv/bin/activate && sqlfluff fix . --force",
38
38
  "test": "npm run lint && npm run test:unit && npm run test:intg",
39
39
  "test:all": "npm run lint && npm run test:unit && npm run test:intg && npm run test:e2e",
40
- "test:unit": "node --env-file-if-exists=.env.example --env-file-if-exists=.env --env-file-if-exists=.env.test --experimental-test-coverage --test-coverage-lines=70 --test-coverage-branches=70 --test-coverage-functions=70 --test-concurrency=1 --test $(find src -name '*.test.js')",
40
+ "test:unit": "node --env-file-if-exists=.env.example --env-file-if-exists=.env --env-file-if-exists=.env.test --experimental-test-coverage --test-coverage-lines=50 --test-coverage-branches=50 --test-coverage-functions=50 --test-concurrency=1 --test-force-exit --test $(find src -name '*.test.js')",
41
41
  "test:intg": "node --env-file-if-exists=.env.example --env-file-if-exists=.env --env-file-if-exists=.env.test --test-concurrency=1 --test $(find test/integration -name '*.test.js')",
42
- "test:e2e": "node --env-file-if-exists=.env.example --env-file-if-exists=.env --env-file-if-exists=.env.test --test-concurrency=1 --test-force-exit --test-reporter=spec --test $(find test/e2e -name '*.test.js')",
43
- "test:live": "node --env-file-if-exists=.env.example --env-file-if-exists=.env --env-file-if-exists=.env.test --test-concurrency=1 --test-force-exit --test-reporter=spec --test $(find test/live -name '*.test.js')"
42
+ "test:e2e": "mkdir -p /tmp/rummy_test_diag && node --env-file-if-exists=.env.example --env-file-if-exists=.env --env-file-if-exists=.env.test --test-concurrency=1 --test-force-exit --test-reporter=spec --test $(find test/e2e -name '*.test.js') 2>&1 | tee /tmp/rummy_test_diag/e2e_$(date +%Y%m%dT%H%M%S).log",
43
+ "test:live": "mkdir -p /tmp/rummy_test_diag && node --env-file-if-exists=.env.example --env-file-if-exists=.env --env-file-if-exists=.env.test --test-concurrency=1 --test-force-exit --test-reporter=spec --test $(find test/live -name '*.test.js') 2>&1 | tee /tmp/rummy_test_diag/live_$(date +%Y%m%dT%H%M%S).log",
44
+ "test:clean": "rm -rf test/lme/results test/mab/results test/tmp /tmp/rummy_test_diag /tmp/rummy_test_*.db /tmp/rummy_test_*.db-shm /tmp/rummy_test_*.db-wal && echo 'Test artifacts cleaned.'",
45
+ "test:mab:get": "node --env-file-if-exists=.env.example --env-file-if-exists=.env --env-file-if-exists=.env.test test/mab/download.js",
46
+ "test:mab": "bash -c 'mkdir -p /tmp/rummy_test_diag && node --env-file-if-exists=.env.example --env-file-if-exists=.env --env-file-if-exists=.env.test test/mab/runner.js \"$@\" 2>&1 | tee /tmp/rummy_test_diag/mab_$(date +%Y%m%dT%H%M%S).log' --",
47
+ "test:grok": "bash -c 'mkdir -p /tmp/rummy_test_diag && node --env-file-if-exists=.env.example --env-file-if-exists=.env --env-file-if-exists=.env.test --env-file-if-exists=.env.grok test/mab/runner.js \"$@\" 2>&1 | tee /tmp/rummy_test_diag/mab_grok_$(date +%Y%m%dT%H%M%S).log' --",
48
+ "test:mab:taxonomy": "bash -c 'mkdir -p /tmp/rummy_test_diag && node --env-file-if-exists=.env.example --env-file-if-exists=.env --env-file-if-exists=.env.test test/mab/runner.js --split Conflict_Resolution --row 0 --no-questions 2>&1 | tee /tmp/rummy_test_diag/taxonomy_$(date +%Y%m%dT%H%M%S).log' --",
49
+ "test:grok:taxonomy": "bash -c 'mkdir -p /tmp/rummy_test_diag && node --env-file-if-exists=.env.example --env-file-if-exists=.env --env-file-if-exists=.env.test --env-file-if-exists=.env.grok test/mab/runner.js --split Conflict_Resolution --row 0 --no-questions 2>&1 | tee /tmp/rummy_test_diag/taxonomy_grok_$(date +%Y%m%dT%H%M%S).log' --",
50
+ "test:lme:get": "node --env-file-if-exists=.env.example --env-file-if-exists=.env --env-file-if-exists=.env.test test/lme/download.js",
51
+ "test:lme": "bash -c 'mkdir -p /tmp/rummy_test_diag && node --env-file-if-exists=.env.example --env-file-if-exists=.env --env-file-if-exists=.env.test test/lme/runner.js \"$@\" 2>&1 | tee /tmp/rummy_test_diag/lme_$(date +%Y%m%dT%H%M%S).log' --",
52
+ "test:mab:clean": "rm -rf test/mab/results/*/",
53
+ "test:lme:clean": "rm -rf test/lme/results/*/",
54
+ "test:clear": "rm -rf /tmp/rummy_test_diag /tmp/rummy_test_*.db /tmp/rummy_test_*.db-shm /tmp/rummy_test_*.db-wal /tmp/rummy-stories-*"
44
55
  },
45
56
  "devDependencies": {
46
57
  "@biomejs/biome": "^2.4.6"
@@ -48,8 +59,9 @@
48
59
  "dependencies": {
49
60
  "@possumtech/sqlrite": "^3.1.0",
50
61
  "@xmldom/xmldom": "^0.9.9",
62
+ "diff": "^8.0.4",
51
63
  "htmlparser2": "^12.0.0",
52
- "tiktoken": "^1.0.22",
64
+ "picomatch": "^4.0.4",
53
65
  "ws": "^8.19.0",
54
66
  "xpath": "^0.0.34"
55
67
  }
package/service.js CHANGED
@@ -18,13 +18,13 @@ if (gitCheck.error || gitCheck.status !== 0) {
18
18
  console.warn("[RUMMY] WARNING: 'git' not found. File tracking will use manual activation only.");
19
19
  }
20
20
 
21
- let SqlRite, SocketServer, registerPlugins, createHooks, RpcRegistry;
21
+ let SqlRite, SocketServer, registerPlugins, initPlugins, createHooks, RpcRegistry;
22
22
  try {
23
23
  SqlRite = (await import("@possumtech/sqlrite")).default;
24
24
  SocketServer = (await import("./src/server/SocketServer.js")).default;
25
25
  const pluginIndex = await import("./src/plugins/index.js");
26
26
  registerPlugins = pluginIndex.registerPlugins;
27
- var initPlugins = pluginIndex.initPlugins;
27
+ initPlugins = pluginIndex.initPlugins;
28
28
  createHooks = (await import("./src/hooks/Hooks.js")).default;
29
29
  RpcRegistry = (await import("./src/server/RpcRegistry.js")).default;
30
30
  } catch (err) {
@@ -81,10 +81,12 @@ async function main() {
81
81
  if (!key.startsWith("RUMMY_MODEL_")) continue;
82
82
  const alias = key.replace("RUMMY_MODEL_", "");
83
83
  const actual = process.env[key];
84
+ const contextEnv = process.env[`RUMMY_CONTEXT_${alias}`];
85
+ const context_length = contextEnv ? Number.parseInt(contextEnv, 10) : null;
84
86
  await db.upsert_model.get({
85
87
  alias,
86
88
  actual,
87
- context_length: null,
89
+ context_length,
88
90
  });
89
91
  modelAliases.push(alias);
90
92
  }
@@ -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,7 +118,10 @@ 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;
124
+ const noProposals = options?.noProposals === true;
116
125
  const requestedModel = model;
117
126
 
118
127
  const runInfo = await this.#ensureRun(projectId, model, run, options);
@@ -134,7 +143,13 @@ export default class AgentLoop {
134
143
  mode,
135
144
  model: requestedModel,
136
145
  prompt: prompt || "",
137
- config: JSON.stringify({ noContext, temperature: options?.temperature }),
146
+ config: JSON.stringify({
147
+ noRepo,
148
+ noInteraction,
149
+ noWeb,
150
+ noProposals,
151
+ temperature: options?.temperature,
152
+ }),
138
153
  });
139
154
 
140
155
  if (this.#activeRuns.has(currentRunId)) {
@@ -151,38 +166,76 @@ export default class AgentLoop {
151
166
  }
152
167
 
153
168
  async #drainQueue(currentRunId, currentAlias, projectId, project, options) {
154
- while (true) {
155
- const loop = await this.#db.claim_next_loop.get({
156
- run_id: currentRunId,
157
- });
158
- if (!loop) break;
159
-
160
- const loopConfig = loop.config ? JSON.parse(loop.config) : {};
161
- const result = await this.#executeLoop({
162
- mode: loop.mode,
163
- project,
164
- projectId,
165
- currentRunId,
166
- currentAlias,
167
- currentLoopId: loop.id,
168
- requestedModel: loop.model,
169
- prompt: loop.prompt,
170
- noContext: loopConfig.noContext || false,
171
- options: { ...options, temperature: loopConfig.temperature },
172
- hook: loop.mode === "ask" ? this.#hooks.ask : this.#hooks.act,
173
- });
169
+ const controller = new AbortController();
170
+ this.#activeRuns.set(currentRunId, controller);
174
171
 
175
- await this.#db.complete_loop.run({
176
- id: loop.id,
177
- status: result.status === 202 ? 202 : result.status,
178
- result: JSON.stringify(result),
179
- });
172
+ try {
173
+ while (true) {
174
+ const loop = await this.#db.claim_next_loop.get({
175
+ run_id: currentRunId,
176
+ });
177
+ if (!loop) break;
178
+
179
+ const loopConfig = loop.config ? JSON.parse(loop.config) : {};
180
+ const hook = loop.mode === "ask" ? this.#hooks.ask : this.#hooks.act;
181
+
182
+ let result;
183
+ try {
184
+ result = await this.#executeLoop({
185
+ mode: loop.mode,
186
+ project,
187
+ projectId,
188
+ currentRunId,
189
+ currentAlias,
190
+ currentLoopId: loop.id,
191
+ requestedModel: loop.model,
192
+ prompt: loop.prompt,
193
+ noRepo: loopConfig.noRepo || false,
194
+ noInteraction: loopConfig.noInteraction || false,
195
+ noWeb: loopConfig.noWeb || false,
196
+ noProposals: loopConfig.noProposals || false,
197
+ options: { ...options, temperature: loopConfig.temperature },
198
+ hook,
199
+ signal: controller.signal,
200
+ });
201
+ } catch (err) {
202
+ await this.#db.complete_loop.run({
203
+ id: loop.id,
204
+ status: 500,
205
+ result: JSON.stringify({ error: err.message }),
206
+ });
207
+ throw err;
208
+ }
180
209
 
181
- if (result.status === 202) return result;
182
- }
210
+ if (result.status === 413) {
211
+ await this.#db.complete_loop.run({
212
+ id: loop.id,
213
+ status: 413,
214
+ result: JSON.stringify(result),
215
+ });
216
+ return {
217
+ run: currentAlias,
218
+ status: 413,
219
+ error: `Context full (${result.overflow} tokens over).`,
220
+ };
221
+ }
222
+
223
+ await this.#db.complete_loop.run({
224
+ id: loop.id,
225
+ status: result.status === 202 ? 202 : result.status,
226
+ result: JSON.stringify(result),
227
+ });
228
+
229
+ if (result.status === 202) return result;
230
+ }
183
231
 
184
- const runRow = await this.#db.get_run_by_alias.get({ alias: currentAlias });
185
- return { run: currentAlias, status: runRow?.status ?? 200 };
232
+ const runRow = await this.#db.get_run_by_alias.get({
233
+ alias: currentAlias,
234
+ });
235
+ return { run: currentAlias, status: runRow?.status ?? 200 };
236
+ } finally {
237
+ this.#activeRuns.delete(currentRunId);
238
+ }
186
239
  }
187
240
 
188
241
  async #executeLoop({
@@ -194,9 +247,13 @@ export default class AgentLoop {
194
247
  currentLoopId,
195
248
  requestedModel,
196
249
  prompt,
197
- noContext,
250
+ noRepo,
251
+ noInteraction,
252
+ noWeb,
253
+ noProposals,
198
254
  options,
199
255
  hook,
256
+ signal,
200
257
  }) {
201
258
  const runRow = await this.#db.get_run_by_id.get({ id: currentRunId });
202
259
  if (runRow.status !== 102) {
@@ -212,16 +269,41 @@ export default class AgentLoop {
212
269
  ? Math.min(runRow.context_limit, modelContextSize)
213
270
  : modelContextSize;
214
271
 
272
+ const toolSet = this.#hooks.tools.resolveForLoop(mode, {
273
+ noInteraction,
274
+ noWeb,
275
+ noProposals,
276
+ });
277
+
215
278
  let loopIteration = 0;
216
279
  const MAX_LOOP_ITERATIONS = Number(process.env.RUMMY_MAX_TURNS) || 15;
217
280
  const healer = new ResponseHealer();
218
281
 
219
- const controller = new AbortController();
220
- this.#activeRuns.set(currentRunId, controller);
282
+ let _lastAssembledTokens = 0;
283
+ let recovery = null; // { target, promptPath, strikes, lastTokens }
284
+
285
+ // Demote full logging entries from previous loops to summary before
286
+ // they appear in <previous>. General policy: keep <previous> compact.
287
+ await this.#knownStore.demotePreviousLoopLogging(
288
+ currentRunId,
289
+ currentLoopId,
290
+ );
291
+
292
+ // Restore any prompt entries left at summary fidelity by a recovery
293
+ // phase that was interrupted (server crash, restart). If the full
294
+ // prompt would overflow, Prompt Demotion on turn 1 handles it.
295
+ await this.#knownStore.restoreSummarizedPrompts(currentRunId);
296
+
297
+ await this.#hooks.loop.started.emit({
298
+ runId: currentRunId,
299
+ loopId: currentLoopId,
300
+ mode,
301
+ prompt,
302
+ });
221
303
 
222
304
  try {
223
305
  while (loopIteration < MAX_LOOP_ITERATIONS) {
224
- if (controller.signal.aborted) {
306
+ if (signal.aborted) {
225
307
  await this.#db.update_run_status.run({
226
308
  id: currentRunId,
227
309
  status: 499,
@@ -255,12 +337,52 @@ export default class AgentLoop {
255
337
  currentLoopId,
256
338
  requestedModel,
257
339
  loopPrompt: turnPrompt,
258
- noContext,
340
+ loopIteration,
341
+ noRepo,
342
+ toolSet,
343
+ inRecovery: recovery !== null,
259
344
  contextSize,
260
345
  options: { ...options, isContinuation: loopIteration > 1 },
261
- signal: controller.signal,
346
+ signal,
262
347
  });
263
348
 
349
+ if (result.status === 413) {
350
+ return {
351
+ run: currentAlias,
352
+ status: 413,
353
+ overflow: result.overflow,
354
+ assembledTokens: result.assembledTokens,
355
+ contextSize: result.contextSize,
356
+ turn: result.turn,
357
+ };
358
+ }
359
+
360
+ _lastAssembledTokens = result.assembledTokens;
361
+
362
+ // Budget recovery: enforce progress toward context target.
363
+ const ra = advanceRecovery(recovery, result);
364
+ recovery = ra.next;
365
+ if (ra.action === "restore" && ra.promptPath) {
366
+ await this.#knownStore.setFidelity(
367
+ currentRunId,
368
+ ra.promptPath,
369
+ "full",
370
+ );
371
+ }
372
+ if (ra.action === "hard413") {
373
+ await this.#db.update_run_status.run({
374
+ id: currentRunId,
375
+ status: 413,
376
+ });
377
+ const out = {
378
+ run: currentAlias,
379
+ status: 413,
380
+ turn: result.turn,
381
+ };
382
+ await hook.completed.emit({ projectId, ...out });
383
+ return out;
384
+ }
385
+
264
386
  const runUsage = await this.#db.get_run_usage.get({
265
387
  run_id: currentRunId,
266
388
  });
@@ -292,12 +414,13 @@ export default class AgentLoop {
292
414
  model: result.model,
293
415
  temperature: result.temperature,
294
416
  context_size: result.contextSize,
295
- context_tokens: (
296
- await this.#db.get_turn_budget.get({
297
- run_id: currentRunId,
298
- turn: result.turn,
299
- })
300
- ).total,
417
+ context_tokens:
418
+ (
419
+ await this.#db.get_turn_context_tokens.get({
420
+ run_id: currentRunId,
421
+ sequence: result.turn,
422
+ })
423
+ )?.context_tokens ?? 0,
301
424
  prompt_tokens: runUsage.prompt_tokens,
302
425
  cached_tokens: runUsage.cached_tokens,
303
426
  completion_tokens: runUsage.completion_tokens,
@@ -332,6 +455,9 @@ export default class AgentLoop {
332
455
  flags: result.flags,
333
456
  });
334
457
 
458
+ // Don't exit while budget recovery is still active.
459
+ if (recovery !== null) continue;
460
+
335
461
  const repetition = healer.assessRepetition(result);
336
462
  if (!repetition.continue) {
337
463
  await this.#db.update_run_status.run({
@@ -376,7 +502,7 @@ export default class AgentLoop {
376
502
  await hook.completed.emit({ projectId, ...out });
377
503
  return out;
378
504
  } catch (err) {
379
- if (controller.signal.aborted) {
505
+ if (signal.aborted) {
380
506
  await this.#db.update_run_status.run({
381
507
  id: currentRunId,
382
508
  status: 499,
@@ -408,7 +534,14 @@ export default class AgentLoop {
408
534
  await hook.completed.emit({ projectId, ...out });
409
535
  return out;
410
536
  } finally {
411
- this.#activeRuns.delete(currentRunId);
537
+ await this.#hooks.loop.completed
538
+ .emit({
539
+ runId: currentRunId,
540
+ loopId: currentLoopId,
541
+ mode,
542
+ turns: loopIteration,
543
+ })
544
+ .catch(() => {});
412
545
  }
413
546
  }
414
547
 
@@ -441,6 +574,29 @@ export default class AgentLoop {
441
574
  }
442
575
 
443
576
  if (action === "accept") {
577
+ if (path.startsWith("set://") && attrs?.file && attrs?.merge) {
578
+ const fileBody = await this.#knownStore.getBody(runId, attrs.file);
579
+ if (fileBody != null) {
580
+ const blocks = attrs.merge.split(/(?=<<<<<<< SEARCH)/);
581
+ let patched = fileBody;
582
+ for (const block of blocks) {
583
+ const m = block.match(
584
+ /<<<<<<< SEARCH\n?([\s\S]*?)\n?=======\n?([\s\S]*?)\n?>>>>>>> REPLACE/,
585
+ );
586
+ if (m) patched = patched.replace(m[1], m[2]);
587
+ }
588
+ const turn = (await this.#db.get_run_by_id.get({ id: runId }))
589
+ .next_turn;
590
+ await this.#knownStore.upsert(
591
+ runId,
592
+ turn,
593
+ attrs.file,
594
+ patched,
595
+ 200,
596
+ );
597
+ }
598
+ }
599
+
444
600
  if (path.startsWith("rm://")) {
445
601
  if (attrs?.path) {
446
602
  await this.#knownStore.remove(runId, attrs.path);
@@ -518,7 +674,7 @@ export default class AgentLoop {
518
674
  mode: resumeMode,
519
675
  model: runRow.model,
520
676
  prompt: "",
521
- config: "{}",
677
+ config: currentLoop?.config || "{}",
522
678
  });
523
679
  return this.#drainQueue(runId, runAlias, projectId, project, {});
524
680
  }
@@ -546,16 +702,9 @@ export default class AgentLoop {
546
702
  runRow.id,
547
703
  nextTurn,
548
704
  `prompt://${nextTurn}`,
549
- "",
550
- 200,
551
- { attributes: { mode: "ask" } },
552
- );
553
- await this.#knownStore.upsert(
554
- runRow.id,
555
- nextTurn,
556
- `ask://${nextTurn}`,
557
705
  message,
558
706
  200,
707
+ { attributes: { mode: "ask" } },
559
708
  );
560
709
 
561
710
  if (this.#activeRuns.has(runRow.id)) {
@@ -584,3 +733,51 @@ export default class AgentLoop {
584
733
  return this.#knownStore.getLog(runRow.id);
585
734
  }
586
735
  }
736
+
737
+ /**
738
+ * Pure recovery state transition — exported for testing.
739
+ *
740
+ * @param {object|null} recovery Current recovery state (mutated copy returned).
741
+ * @param {{ assembledTokens: number, budgetRecovery?: { target: number, promptPath: string|null } }} result
742
+ * @returns {{ next: object|null, action: null|'restore'|'hard413', promptPath: string|null }}
743
+ */
744
+ export function advanceRecovery(recovery, result) {
745
+ // Initialise or update recovery state from a new Turn Demotion event.
746
+ if (result.budgetRecovery) {
747
+ if (!recovery) {
748
+ recovery = {
749
+ target: result.budgetRecovery.target,
750
+ promptPath: result.budgetRecovery.promptPath,
751
+ strikes: 0,
752
+ lastTokens: result.assembledTokens,
753
+ };
754
+ } else {
755
+ // Re-overflow during recovery: tighten target, don't count as strike.
756
+ recovery = {
757
+ ...recovery,
758
+ target: Math.min(recovery.target, result.budgetRecovery.target),
759
+ };
760
+ }
761
+ }
762
+
763
+ if (recovery === null) return { next: null, action: null, promptPath: null };
764
+
765
+ const current = result.assembledTokens;
766
+
767
+ if (current <= recovery.target) {
768
+ return { next: null, action: "restore", promptPath: recovery.promptPath };
769
+ }
770
+
771
+ const noProgress = current >= recovery.lastTokens && !result.budgetRecovery;
772
+ const strikes = noProgress ? recovery.strikes + 1 : 0;
773
+
774
+ if (strikes >= 3) {
775
+ return { next: null, action: "hard413", promptPath: null };
776
+ }
777
+
778
+ return {
779
+ next: { ...recovery, strikes, lastTokens: current },
780
+ action: null,
781
+ promptPath: null,
782
+ };
783
+ }
@@ -6,17 +6,33 @@
6
6
  export default class ContextAssembler {
7
7
  static async assembleFromTurnContext(
8
8
  rows,
9
- { type = "ask", systemPrompt = "", contextSize = 0, demoted = [] } = {},
9
+ {
10
+ type = "ask",
11
+ systemPrompt = "",
12
+ contextSize = 0,
13
+ demoted = [],
14
+ toolSet = null,
15
+ lastContextTokens = 0,
16
+ turn = 1,
17
+ } = {},
10
18
  hooks,
11
19
  ) {
12
20
  // Find loop boundary from active prompt
13
21
  const promptEntry = rows.findLast(
14
- (r) =>
15
- r.category === "prompt" && (r.scheme === "ask" || r.scheme === "act"),
22
+ (r) => r.category === "prompt" && r.scheme === "prompt",
16
23
  );
17
24
  const loopStartTurn = promptEntry?.source_turn ?? 0;
18
25
 
19
- const ctx = { rows, loopStartTurn, type, contextSize, demoted };
26
+ const ctx = {
27
+ rows,
28
+ loopStartTurn,
29
+ type,
30
+ contextSize,
31
+ lastContextTokens,
32
+ demoted,
33
+ toolSet,
34
+ turn,
35
+ };
20
36
 
21
37
  const system = await hooks.assembly.system.filter(systemPrompt, ctx);
22
38
  const user = await hooks.assembly.user.filter("", ctx);