@possumtech/rummy 0.3.1 → 0.4.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 (46) hide show
  1. package/.env.example +11 -0
  2. package/README.md +5 -1
  3. package/SPEC.md +31 -17
  4. package/migrations/001_initial_schema.sql +2 -3
  5. package/package.json +1 -1
  6. package/src/agent/AgentLoop.js +50 -151
  7. package/src/agent/KnownStore.js +15 -7
  8. package/src/agent/TurnExecutor.js +75 -318
  9. package/src/agent/XmlParser.js +25 -4
  10. package/src/agent/known_queries.sql +1 -1
  11. package/src/agent/known_store.sql +11 -61
  12. package/src/agent/runs.sql +2 -2
  13. package/src/hooks/Hooks.js +1 -0
  14. package/src/hooks/ToolRegistry.js +6 -5
  15. package/src/plugins/ask_user/ask_userDoc.js +3 -8
  16. package/src/plugins/budget/README.md +26 -18
  17. package/src/plugins/budget/budget.js +60 -3
  18. package/src/plugins/budget/recovery.js +47 -0
  19. package/src/plugins/cp/cpDoc.js +4 -9
  20. package/src/plugins/env/envDoc.js +3 -8
  21. package/src/plugins/get/get.js +2 -4
  22. package/src/plugins/get/getDoc.js +11 -18
  23. package/src/plugins/helpers.js +2 -2
  24. package/src/plugins/instructions/instructions.js +3 -2
  25. package/src/plugins/instructions/preamble.md +27 -16
  26. package/src/plugins/known/known.js +63 -8
  27. package/src/plugins/known/knownDoc.js +10 -14
  28. package/src/plugins/mv/mvDoc.js +6 -21
  29. package/src/plugins/policy/policy.js +47 -0
  30. package/src/plugins/progress/progress.js +9 -45
  31. package/src/plugins/prompt/prompt.js +10 -1
  32. package/src/plugins/rm/rmDoc.js +5 -10
  33. package/src/plugins/rpc/rpc.js +3 -1
  34. package/src/plugins/set/set.js +82 -85
  35. package/src/plugins/set/setDoc.js +28 -41
  36. package/src/plugins/sh/shDoc.js +2 -7
  37. package/src/plugins/summarize/summarize.js +7 -0
  38. package/src/plugins/summarize/summarizeDoc.js +6 -11
  39. package/src/plugins/think/think.js +12 -0
  40. package/src/plugins/think/thinkDoc.js +18 -0
  41. package/src/plugins/unknown/unknown.js +21 -0
  42. package/src/plugins/unknown/unknownDoc.js +9 -14
  43. package/src/plugins/update/update.js +7 -0
  44. package/src/plugins/update/updateDoc.js +6 -11
  45. package/src/server/ClientConnection.js +11 -1
  46. package/src/sql/v_model_context.sql +4 -4
@@ -6,6 +6,10 @@ import ResponseHealer from "./ResponseHealer.js";
6
6
  import { countTokens } from "./tokens.js";
7
7
  import XmlParser from "./XmlParser.js";
8
8
 
9
+ const ACTION_SCHEMES = new Set(["get", "set", "rm", "mv", "cp", "sh", "env", "search"]);
10
+ const MUTATION_SCHEMES = new Set(["set", "rm", "sh", "mv", "cp"]);
11
+ const READ_SCHEMES = new Set(["get", "env", "search"]);
12
+
9
13
  export default class TurnExecutor {
10
14
  #db;
11
15
  #llmProvider;
@@ -117,13 +121,6 @@ export default class TurnExecutor {
117
121
  sequence: turn,
118
122
  });
119
123
 
120
- const unresolved = await this.#knownStore.getUnresolved(currentRunId);
121
- if (unresolved.length > 0) {
122
- throw new Error(
123
- msg("error.unresolved_proposed", { count: unresolved.length }),
124
- );
125
- }
126
-
127
124
  // Build RummyContext before turn.started so plugins can write entries
128
125
  const rummy = new RummyContext(
129
126
  {
@@ -297,6 +294,10 @@ export default class TurnExecutor {
297
294
  /\b(503|429|timeout|ECONNREFUSED|ECONNRESET|unavailable)\b/i.test(
298
295
  e.message,
299
296
  );
297
+ const isContextExceeded = (e) =>
298
+ /\b(context.*(size|length|limit)|token.*(limit|exceed)|too.*(long|large))\b/i.test(
299
+ e.message,
300
+ );
300
301
 
301
302
  for (let llmAttempt = 0; ; llmAttempt++) {
302
303
  try {
@@ -315,6 +316,18 @@ export default class TurnExecutor {
315
316
  await new Promise((r) => setTimeout(r, delay));
316
317
  continue;
317
318
  }
319
+ if (isContextExceeded(err)) {
320
+ console.warn(
321
+ `[RUMMY] LLM context exceeded: ${err.message.slice(0, 120)}. Returning 413.`,
322
+ );
323
+ return {
324
+ turn,
325
+ turnId: turnRow.id,
326
+ status: 413,
327
+ assembledTokens,
328
+ contextSize,
329
+ };
330
+ }
318
331
  throw err;
319
332
  }
320
333
  }
@@ -371,15 +384,7 @@ export default class TurnExecutor {
371
384
  });
372
385
 
373
386
  // --- PHASE 1: RECORD ---
374
- // Split lifecycle signals from action commands.
375
- // Lifecycle signals (summarize, update, unknown, known) are state
376
- // declarations — always recorded, never 409'd by sequential dispatch.
377
- const LIFECYCLE = new Set(["summarize", "update", "unknown", "known"]);
378
-
379
387
  const recorded = [];
380
- const lifecycle = [];
381
- const actions = [];
382
-
383
388
  for (const cmd of commands) {
384
389
  const entry = await this.#record(
385
390
  currentRunId,
@@ -388,33 +393,19 @@ export default class TurnExecutor {
388
393
  mode,
389
394
  cmd,
390
395
  );
391
- if (!entry) continue;
392
- recorded.push(entry);
393
-
394
- if (LIFECYCLE.has(entry.scheme)) {
395
- lifecycle.push(entry);
396
- } else {
397
- actions.push(entry);
398
- }
396
+ if (entry) recorded.push(entry);
399
397
  }
400
398
 
401
399
  // --- PHASE 2: DISPATCH ---
400
+ // Sequential queue. Each tool completes before the next starts.
401
+ // On failure: abort remaining. On proposal: notify client, await
402
+ // resolution, continue.
402
403
  let hasErrors = false;
403
- let hasProposed = false;
404
404
  let abortAfter = null;
405
- const dispatched = [...lifecycle];
406
-
407
- // Lifecycle signals first — always dispatched, never aborted.
408
- for (const entry of lifecycle) {
409
- await this.#hooks.tool.before.emit({ entry, rummy });
410
- await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
411
- await this.#hooks.tool.after.emit({ entry, rummy });
412
- await this.#hooks.entry.created.emit(entry);
413
- }
414
405
 
415
- for (const entry of actions) {
406
+ for (const entry of recorded) {
416
407
  if (abortAfter) {
417
- const errorMsg = `Aborted — preceding <${abortAfter}> requires resolution.`;
408
+ const errorMsg = `Aborted — preceding <${abortAfter}> failed.`;
418
409
  await this.#knownStore.upsert(
419
410
  currentRunId,
420
411
  turn,
@@ -431,35 +422,40 @@ export default class TurnExecutor {
431
422
  await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
432
423
  await this.#hooks.tool.after.emit({ entry, rummy });
433
424
  await this.#hooks.entry.created.emit(entry);
434
- dispatched.push(entry);
435
425
 
436
- const row = await this.#db.get_entry_state.get({
437
- run_id: currentRunId,
438
- path: entry.resultPath || entry.path,
439
- });
440
- if (row?.status === 202) {
441
- hasProposed = true;
442
- abortAfter = entry.scheme;
443
- } else if (row?.status >= 400) {
444
- hasErrors = true;
445
- abortAfter = entry.scheme;
446
- }
447
- }
426
+ // Materialize proposals for this entry (set revisions → 202)
427
+ await this.#hooks.turn.proposing.emit({ rummy, recorded: [entry] });
448
428
 
449
- // Materialize proposals only if we dispatched actions
450
- if (!abortAfter || hasProposed) {
451
- await this.#hooks.turn.proposing.emit({ rummy, recorded: dispatched });
452
- }
429
+ // Check for any proposals created by this entry's dispatch
430
+ const proposed = await this.#knownStore.getUnresolved(currentRunId);
431
+ for (const p of proposed) {
432
+ await this.#hooks.turn.proposal.emit({
433
+ projectId,
434
+ run: currentAlias,
435
+ proposed: [p],
436
+ });
437
+ await this.#knownStore.waitForResolution(currentRunId, p.path);
438
+ const resolved = await this.#db.get_entry_state.get({
439
+ run_id: currentRunId,
440
+ path: p.path,
441
+ });
442
+ if (resolved?.status >= 400) {
443
+ hasErrors = true;
444
+ abortAfter = entry.scheme;
445
+ }
446
+ }
453
447
 
454
- // Recheck after materialization (set handler may create proposals)
455
- if (!hasProposed && !hasErrors) {
456
- for (const entry of actions) {
448
+ // Also check the entry itself for direct failures
449
+ if (!hasErrors) {
450
+ const entryPath = entry.resultPath || entry.path;
457
451
  const row = await this.#db.get_entry_state.get({
458
452
  run_id: currentRunId,
459
- path: entry.resultPath || entry.path,
453
+ path: entryPath,
460
454
  });
461
- if (row?.status === 202) hasProposed = true;
462
- if (row?.status >= 400) hasErrors = true;
455
+ if (row?.status >= 400) {
456
+ hasErrors = true;
457
+ abortAfter = entry.scheme;
458
+ }
463
459
  }
464
460
  }
465
461
 
@@ -468,8 +464,7 @@ export default class TurnExecutor {
468
464
  // budget recovery phase before continuing.
469
465
  let budgetRecovery = null;
470
466
  // Use actual prompt_tokens from this turn's LLM response as the ground-truth
471
- // token count for post-turn budget checksmore accurate than the estimate.
472
- const currentPromptTokens = result.usage?.prompt_tokens ?? 0;
467
+ // Post-dispatch budget checkdemotion handled by budget plugin
473
468
  if (contextSize) {
474
469
  const postMat = await this.#materializeTurnContext({
475
470
  runId: currentRunId,
@@ -481,92 +476,31 @@ export default class TurnExecutor {
481
476
  contextSize,
482
477
  demoted,
483
478
  });
484
- const postBudget = await this.#hooks.budget.enforce({
479
+ budgetRecovery = await this.#hooks.budget.postDispatch({
485
480
  contextSize,
486
481
  messages: postMat.messages,
487
482
  rows: postMat.rows,
488
- lastPromptTokens: currentPromptTokens,
483
+ runId: currentRunId,
484
+ loopId: currentLoopId,
485
+ turn,
486
+ db: this.#db,
487
+ store: this.#knownStore,
489
488
  });
490
- if (postBudget.status === 413) {
491
- // Demote this turn's data entries.
492
- const demotedEntries = await this.#db.demote_turn_data_entries.all({
493
- run_id: currentRunId,
494
- turn,
495
- });
496
- const paths = demotedEntries.map((r) => r.path).join(", ");
497
-
498
- // Also summarize the prompt — forces the model to earn it back.
499
- const promptRow = postMat.rows.find((r) => r.scheme === "prompt");
500
- if (promptRow) {
501
- await this.#knownStore.setFidelity(
502
- currentRunId,
503
- promptRow.path,
504
- "summary",
505
- );
506
- }
507
-
508
- // Re-materialize after both demotions for accurate token count.
509
- const recoveryMat = await this.#materializeTurnContext({
510
- runId: currentRunId,
511
- loopId: currentLoopId,
512
- turn,
513
- systemPrompt,
514
- mode,
515
- toolSet: effectiveToolSet,
516
- contextSize,
517
- demoted,
518
- });
519
- const recoveryBudget = await this.#hooks.budget.enforce({
520
- contextSize,
521
- messages: recoveryMat.messages,
522
- rows: recoveryMat.rows,
523
- lastPromptTokens: currentPromptTokens,
524
- });
525
- const safeLevel = Math.floor(contextSize * 0.9);
526
- const tokensToFree = Math.max(
527
- 0,
528
- recoveryBudget.assembledTokens - safeLevel,
529
- );
530
-
531
- const promptLine =
532
- tokensToFree > 0
533
- ? `Info: Prompt auto-summarized. Full prompt restores automatically when you free ${tokensToFree} tokens.`
534
- : "Info: Prompt auto-summarized. It will restore automatically.";
535
- const body = [
536
- "Error 413: Context Size Exceeded",
537
- "",
538
- "Required: YOU MUST demote larger and/or less relevant items to optimize your context.",
539
- `Info: ${paths} have been automatically summarized to avoid overflow.`,
540
- promptLine,
541
- "Info: YOU MAY use bulk patterns to demote and promote entries by pattern.",
542
- "Info: Well-designed paths and summaries improve context management.",
543
- 'Example: <set path="known://people/*" fidelity="summary"/>',
544
- ].join("\n");
545
-
546
- await this.#knownStore.upsert(
547
- currentRunId,
548
- turn,
549
- `budget://${currentLoopId}/${turn}`,
550
- body,
551
- 413,
552
- { loopId: currentLoopId },
553
- );
554
-
555
- budgetRecovery = {
556
- target: safeLevel,
557
- promptPath: promptRow?.path ?? null,
558
- };
559
- }
560
489
  }
561
490
 
562
- // Lifecycle signals are always available never 409'd.
563
- const summaryEntry = lifecycle.find((e) => e.scheme === "summarize");
564
- const updateEntry = lifecycle.find((e) => e.scheme === "update");
491
+ const summaryEntry = recorded.findLast((e) => e.scheme === "summarize");
492
+ const updateEntry = recorded.findLast((e) => e.scheme === "update");
565
493
  let summaryText = summaryEntry?.body || null;
566
494
  let updateText = updateEntry?.body || null;
567
495
 
568
- // If model sent both, update wins — if it can't decide, it's not done
569
- if (summaryText && updateText) summaryText = null;
496
+ // If model sent both, last signal wins — respects the model's final intent
497
+ if (summaryText && updateText) {
498
+ const lastLifecycle = recorded.findLast(
499
+ (e) => e.scheme === "summarize" || e.scheme === "update",
500
+ );
501
+ if (lastLifecycle.scheme === "summarize") updateText = null;
502
+ else summaryText = null;
503
+ }
570
504
 
571
505
  // If model says "done" but actions failed, override — the model's
572
506
  // assertion that it's done is false if it failed to do what it tried.
@@ -598,11 +532,7 @@ export default class TurnExecutor {
598
532
 
599
533
  // --- Classify for return value ---
600
534
 
601
- const actionCalls = recorded.filter((e) =>
602
- ["get", "set", "rm", "mv", "cp", "sh", "env", "search"].includes(
603
- e.scheme,
604
- ),
605
- );
535
+ const actionCalls = recorded.filter((e) => ACTION_SCHEMES.has(e.scheme));
606
536
  const writeCalls = recorded.filter(
607
537
  (e) =>
608
538
  e.scheme === "known" ||
@@ -610,12 +540,8 @@ export default class TurnExecutor {
610
540
  );
611
541
  const unknownCalls = recorded.filter((e) => e.scheme === "unknown");
612
542
 
613
- const hasAct = actionCalls.some((c) =>
614
- ["set", "rm", "sh", "mv", "cp"].includes(c.scheme),
615
- );
616
- const hasReads = actionCalls.some((c) =>
617
- ["get", "env", "search"].includes(c.scheme),
618
- );
543
+ const hasAct = actionCalls.some((c) => MUTATION_SCHEMES.has(c.scheme));
544
+ const hasReads = actionCalls.some((c) => READ_SCHEMES.has(c.scheme));
619
545
  const hasWrites = writeCalls.length > 0 || unknownCalls.length > 0;
620
546
  const flags = { hasAct, hasReads, hasWrites };
621
547
 
@@ -651,83 +577,7 @@ export default class TurnExecutor {
651
577
  * Returns the recorded entry descriptor, or null if rejected/skipped.
652
578
  */
653
579
  async #record(runId, loopId, turn, mode, cmd) {
654
- if (mode === "ask") {
655
- if (cmd.name === "sh") {
656
- console.warn("[RUMMY] Rejected <sh> in ask mode");
657
- return null;
658
- }
659
- if (cmd.name === "set" && cmd.path && cmd.body) {
660
- const scheme = KnownStore.scheme(cmd.path);
661
- if (scheme === null) {
662
- console.warn(
663
- `[RUMMY] Rejected file edit to ${cmd.path} in ${mode} mode`,
664
- );
665
- return null;
666
- }
667
- }
668
- if (cmd.name === "rm" && cmd.path) {
669
- const scheme = KnownStore.scheme(cmd.path);
670
- if (scheme === null) {
671
- console.warn(`[RUMMY] Rejected file rm of ${cmd.path} in ask mode`);
672
- return null;
673
- }
674
- }
675
- if ((cmd.name === "mv" || cmd.name === "cp") && cmd.to) {
676
- const destScheme = KnownStore.scheme(cmd.to);
677
- if (destScheme === null) {
678
- console.warn(
679
- `[RUMMY] Rejected ${cmd.name} to file ${cmd.to} in ask mode`,
680
- );
681
- return null;
682
- }
683
- }
684
- }
685
-
686
580
  const scheme = cmd.name;
687
-
688
- // Structural tags — recorded like any other entry
689
- if (scheme === "summarize" || scheme === "update") {
690
- const statusPath = await this.#knownStore.slugPath(
691
- runId,
692
- scheme,
693
- cmd.body,
694
- );
695
- await this.#knownStore.upsert(runId, turn, statusPath, cmd.body, 200, {
696
- loopId,
697
- });
698
- return {
699
- scheme,
700
- body: cmd.body,
701
- path: statusPath,
702
- resultPath: statusPath,
703
- attributes: null,
704
- };
705
- }
706
-
707
- // Unknown — deduplicated, sticky
708
- if (scheme === "unknown") {
709
- const existingValues = await this.#knownStore.getUnknownValues(runId);
710
- if (existingValues.has(cmd.body)) {
711
- console.warn(`[RUMMY] Unknown deduped: "${cmd.body.slice(0, 60)}"`);
712
- return null;
713
- }
714
- const unknownPath = await this.#knownStore.slugPath(
715
- runId,
716
- "unknown",
717
- cmd.body,
718
- );
719
- await this.#knownStore.upsert(runId, turn, unknownPath, cmd.body, 200, {
720
- loopId,
721
- });
722
- return {
723
- scheme,
724
- path: unknownPath,
725
- body: cmd.body,
726
- resultPath: unknownPath,
727
- attributes: null,
728
- };
729
- }
730
-
731
581
  const rawTarget = cmd.path || cmd.command || cmd.question || "";
732
582
  // Reject paths that are likely reasoning bleed — too long or contain non-printing chars
733
583
  if (rawTarget.length > 512 || /\p{Cc}/u.test(rawTarget)) {
@@ -766,108 +616,15 @@ export default class TurnExecutor {
766
616
  const { name: _, ...attributes } = cmd;
767
617
  if (cmd.path) attributes.path = target;
768
618
 
769
- // known tool or naked write → known:// slug from body
770
- if (scheme === "known" || (scheme === "set" && !cmd.path)) {
771
- if (!cmd.body) return null;
772
-
773
- // Size gate: reject entries > 512 tokens — force atomic entries
774
- const entryTokens = countTokens(cmd.body);
775
- const MAX_ENTRY_TOKENS = 512;
776
- if (scheme === "known" && entryTokens > MAX_ENTRY_TOKENS) {
777
- const rejectPath = await this.#knownStore.slugPath(
778
- runId,
779
- scheme,
780
- cmd.body,
781
- );
782
- await this.#knownStore.upsert(
783
- runId,
784
- turn,
785
- rejectPath,
786
- `Entry too large (${entryTokens} tokens, max ${MAX_ENTRY_TOKENS}). Sort the information, ideas, or plans carefully into multiple entries.`,
787
- 413,
788
- { loopId },
789
- );
790
- return {
791
- scheme,
792
- path: rejectPath,
793
- body: "",
794
- resultPath: rejectPath,
795
- attributes,
796
- status: 413,
797
- };
798
- }
799
-
800
- let knownPath = cmd.path;
801
- if (!knownPath) {
802
- knownPath = await this.#knownStore.slugPath(
803
- runId,
804
- "known",
805
- cmd.body,
806
- cmd.summary,
807
- );
808
- }
809
- // Dedup: if this exact path already exists, update rather than duplicate
810
- const existing = await this.#knownStore.getEntriesByPattern(
811
- runId,
812
- knownPath,
813
- null,
814
- );
815
- if (existing.length > 0) {
816
- // Path exists — update body and turn, skip creating a new entry
817
- await this.#knownStore.upsert(
818
- runId,
819
- turn,
820
- existing[0].path,
821
- cmd.body || existing[0].body,
822
- 200,
823
- {
824
- attributes,
825
- loopId,
826
- },
827
- );
828
- return {
829
- scheme: "known",
830
- path: existing[0].path,
831
- body: cmd.body || existing[0].body,
832
- resultPath: existing[0].path,
833
- attributes,
834
- };
835
- }
836
- await this.#knownStore.upsert(runId, turn, knownPath, cmd.body, 200, {
837
- attributes,
838
- loopId,
839
- });
840
- return {
841
- scheme: "known",
842
- path: knownPath,
843
- body: cmd.body,
844
- resultPath: knownPath,
845
- attributes,
846
- };
847
- }
848
-
849
619
  const body = cmd.body || cmd.command || cmd.question || "";
850
620
 
851
621
  // Filter: plugins can validate/transform before recording
852
622
  const filtered = await this.#hooks.entry.recording.filter(
853
623
  { scheme, path: resultPath, body, attributes, status: 200 },
854
- { runId, turn, loopId },
624
+ { runId, turn, loopId, mode },
855
625
  );
856
626
  if (filtered.status >= 400) return filtered;
857
627
 
858
- // Record the entry — 200 OK, handlers change status during dispatch
859
- await this.#knownStore.upsert(
860
- runId,
861
- turn,
862
- filtered.path,
863
- filtered.body,
864
- 200,
865
- {
866
- attributes: filtered.attributes,
867
- loopId,
868
- },
869
- );
870
-
871
628
  return {
872
629
  scheme: filtered.scheme,
873
630
  path: filtered.path,
@@ -14,8 +14,6 @@ export const ALL_TOOLS = new Set([
14
14
  "update",
15
15
  "unknown",
16
16
  "think",
17
- "thought",
18
- "mcp",
19
17
  ]);
20
18
 
21
19
  /**
@@ -145,6 +143,8 @@ export default class XmlParser {
145
143
  * @param {string} content - Raw model response text
146
144
  * @returns {{ commands: Array, warnings: string[], unparsed: string }}
147
145
  */
146
+ static MAX_COMMANDS = Number(process.env.RUMMY_MAX_COMMANDS) || 99;
147
+
148
148
  static parse(content) {
149
149
  if (!content) return { commands: [], warnings: [], unparsed: "" };
150
150
 
@@ -156,13 +156,20 @@ export default class XmlParser {
156
156
  const textChunks = [];
157
157
  let current = null;
158
158
  let ended = false;
159
+ let capped = false;
159
160
 
160
161
  const parser = new Parser(
161
162
  {
162
163
  onopentag(name, attrs) {
164
+ if (capped) return;
163
165
  if (!ALL_TOOLS.has(name)) {
164
166
  if (current) {
165
- current.rawBody += `<${name}>`;
167
+ const attrStr = Object.entries(attrs)
168
+ .map(([k, v]) => v === "" ? k : `${k}="${v}"`)
169
+ .join(" ");
170
+ current.rawBody += attrStr
171
+ ? `<${name} ${attrStr}>`
172
+ : `<${name}>`;
166
173
  }
167
174
  return;
168
175
  }
@@ -177,10 +184,17 @@ export default class XmlParser {
177
184
  );
178
185
  }
179
186
 
187
+ if (commands.length >= XmlParser.MAX_COMMANDS) {
188
+ capped = true;
189
+ current = null;
190
+ return;
191
+ }
192
+
180
193
  current = { name, attrs, rawBody: "" };
181
194
  },
182
195
 
183
196
  ontext(text) {
197
+ if (capped) return;
184
198
  if (current) {
185
199
  current.rawBody += text;
186
200
  } else {
@@ -189,6 +203,7 @@ export default class XmlParser {
189
203
  },
190
204
 
191
205
  onclosetag(name, isImplied) {
206
+ if (capped) return;
192
207
  if (current && name === current.name) {
193
208
  if (ended) {
194
209
  warnings.push(`Unclosed <${name}> tag — content captured anyway`);
@@ -230,7 +245,7 @@ export default class XmlParser {
230
245
  parser.end();
231
246
 
232
247
  // Flush any unclosed tool tag
233
- if (current) {
248
+ if (current && !capped) {
234
249
  warnings.push(`Unclosed <${current.name}> tag — content captured anyway`);
235
250
  commands.push(
236
251
  resolveCommand(current.name, current.attrs, current.rawBody),
@@ -238,6 +253,12 @@ export default class XmlParser {
238
253
  current = null;
239
254
  }
240
255
 
256
+ if (capped) {
257
+ warnings.push(
258
+ `Tool call limit (${XmlParser.MAX_COMMANDS}) reached — remaining commands dropped`,
259
+ );
260
+ }
261
+
241
262
  const unparsed = textChunks.join("").trim();
242
263
  return { commands, warnings, unparsed };
243
264
  }
@@ -1,5 +1,5 @@
1
1
  -- PREP: get_known_entries
2
- SELECT path, scheme, status, fidelity, body, turn, hash, attributes
2
+ SELECT path, scheme, status, fidelity, body, turn, hash, attributes, tokens
3
3
  FROM known_entries
4
4
  WHERE run_id = :run_id
5
5
  ORDER BY path;