teapot-coding-agent 0.26.3 → 0.26.5

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.
@@ -89,14 +89,25 @@ Most session state is not injected into prompts — fetch it with tools instead:
89
89
  turn right after the call (a short "reported." is fine).
90
90
  - Be frugal: prefer small precise edits, avoid runaway loops.`;
91
91
  /**
92
- * Placeholder text a provider adapter substitutes for a message with no usable
93
- * content (`llm.ts`).
92
+ * A message whose ENTIRE text is parenthesised is an acknowledgement, not work.
94
93
  *
95
- * #94: these are DISPLAY ARTIFACTS. They were reaching the parent inside a
96
- * sub-agent's final report, where they occupied two of six entries while saying
97
- * nothing — the parent was told "[assistant] (tool call)" as if it were content.
98
- */
99
- export const PLACEHOLDER_TEXT = /^\((tool call|no content|no output)\)$/i;
94
+ * #94: the first version of this listed three literals — `(tool call)`,
95
+ * `(no content)`, `(no output)` — and caught 2 of the 10 forms actually reaching
96
+ * a parent. Measured across every session log, the rest are harness
97
+ * acknowledgements written by `answerMeta` and the meta-tool handlers:
98
+ *
99
+ * (goal complete) (round ended) (tool try) (no sub-agents)
100
+ * (tool_call) (nothing running to stop) (no result recorded)
101
+ * (todo.md is empty …) (decisions.md is empty …)
102
+ *
103
+ * So it matches the SHAPE — a whole line wrapped in parentheses — rather than an
104
+ * enumeration that is stale the moment another ack is added.
105
+ *
106
+ * The trade: a genuine message that happens to be entirely parenthesised would
107
+ * also be dropped. That is vanishingly rare in this feed, and the alternative —
108
+ * an enumeration — was measured to be wrong 80% of the time. If it ever matters,
109
+ * the ack should be marked structurally rather than by its punctuation.
110
+ */ export const PLACEHOLDER_TEXT = /^\(.*\)$/;
100
111
  /**
101
112
  * Harness acknowledgements — meta-lines about the run itself rather than the
102
113
  * work ("decision recorded to decisions.md", "progress recorded", …).
@@ -525,6 +536,43 @@ export class Agent {
525
536
  async load() {
526
537
  await this.ensureReady();
527
538
  }
539
+ /**
540
+ * Answer tool_calls in a RESTORED history that have no tool_result (#54).
541
+ *
542
+ * The ordinary backstop (`answerUnansweredToolCalls`) works on an assistant
543
+ * turn inside a live loop. After a restart there is no loop: the call is in
544
+ * the log, the process that was running it is gone, and nothing else will ever
545
+ * write its result.
546
+ */
547
+ async answerUnansweredRestoredCalls() {
548
+ const seen = new Set();
549
+ for (const ev of await readEvents(this.log.filePath)) {
550
+ const d = (ev.data ?? {});
551
+ const id = d.callId ? String(d.callId) : null;
552
+ if (!id)
553
+ continue;
554
+ if (ev.type === "tool_call")
555
+ seen.add(id);
556
+ else if (ev.type === "tool_result")
557
+ seen.delete(id);
558
+ }
559
+ for (const id of seen) {
560
+ await this.log.append("tool_result", this.currentSession, this.currentBranch, {
561
+ callId: id,
562
+ name: "unknown",
563
+ ok: false,
564
+ durationMs: 0,
565
+ result: "not completed — the agent restarted while this tool was running",
566
+ synthesized: true,
567
+ });
568
+ }
569
+ if (seen.size)
570
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
571
+ event: "unfinished-tools-closed",
572
+ count: seen.size,
573
+ detail: "a restart left these tool calls without a result; they are marked not completed (#54)",
574
+ });
575
+ }
528
576
  /** harness-managed files inside the session directory */
529
577
  get goalFile() {
530
578
  return path.join(this.opts.sessionDir, "goal.md");
@@ -694,6 +742,24 @@ export class Agent {
694
742
  // seen and acted on that output, and the pruned form keeps the head plus
695
743
  // a marker, exactly as it did live.
696
744
  this.maybePrune();
745
+ // #54: answer tool_calls the log says are still open.
746
+ //
747
+ // The restore path rebuilds `msgs` straight from the log, so a call whose
748
+ // process died mid-command comes back as an assistant turn with
749
+ // `tool_calls` and NO matching `tool_result`. Nothing ever answers it —
750
+ // not `stop()` (there is no turn to stop) and not `dispose()` (the process
751
+ // is already gone). The row then renders as still running for good.
752
+ //
753
+ // Measured across every session log: 43,483 tool_calls, and 4 unanswered —
754
+ // every one a `bash`, each followed immediately by
755
+ // `session-restored` / `idle->stopped disposed`. The shape is a restart
756
+ // (or a crash) landing while a long command was in flight, which is
757
+ // precisely when a human is most likely to notice "it never finished".
758
+ //
759
+ // So close them ON RESTORE, before the restored history is used. This is
760
+ // the same backstop as the mid-turn paths, reached from the one place
761
+ // those could not: after the process is gone.
762
+ await this.answerUnansweredRestoredCalls();
697
763
  await this.log.append("system_note", this.currentSession, this.currentBranch, {
698
764
  event: "session-restored",
699
765
  branch: last.branch,
@@ -1018,6 +1084,29 @@ export class Agent {
1018
1084
  // Bounded like the rest of the snapshot, and ids are opaque, so this
1019
1085
  // cannot carry prompt text.
1020
1086
  pendingPromptIds: this.pendingPrompts.map((p) => p.id).filter((x) => !!x).slice(0, 64),
1087
+ // #87: the QUEUE itself, not just its ids. The UI's echo rows are
1088
+ // memory-only (like liveByAgent and timelineCache), so after a reload the
1089
+ // queue survives on the server while the UI has nothing to render — the
1090
+ // queued message then appears only as its log row, i.e. already sent.
1091
+ //
1092
+ // Ids alone cannot fix that: they identify a prompt, they do not carry its
1093
+ // text, so the UI cannot rebuild an echo from them. This is the queue the
1094
+ // operator is looking at, already on screen in the ⏳ badge, so shipping it
1095
+ // is not new exposure — it is the same data in a shape the timeline can
1096
+ // use.
1097
+ //
1098
+ // `at` is the enqueue time so the echo keeps a stable position and id
1099
+ // across rebuilds; `sent` mirrors the echo flag the UI already tracks.
1100
+ pendingPromptQueue: this.pendingPrompts
1101
+ .filter((p) => !!p.id)
1102
+ .slice(0, 64)
1103
+ .map((p, i) => ({
1104
+ id: p.id,
1105
+ text: p.text.slice(0, 8000),
1106
+ source: p.source,
1107
+ at: Date.now() - (this.pendingPrompts.length - i),
1108
+ ...(p.images?.length ? { images: p.images.slice(0, 8) } : {}),
1109
+ })),
1021
1110
  todo: this.todo.slice(0, 32_000), // match set_todo's cap — no silent truncation
1022
1111
  parent: this.opts.parent,
1023
1112
  awaiting: this.awaitingUser,
@@ -1252,6 +1341,8 @@ export class Agent {
1252
1341
  }
1253
1342
  /** monotonic counter: a start() bumps it, so a pending stop status write loses */
1254
1343
  stopSeq = 0;
1344
+ /** #95: set when this round already injected a harness progress request */
1345
+ progressRequestedThisRound = false;
1255
1346
  setStatus(s, reason = "") {
1256
1347
  // Update the field FIRST. log.append fires onEvent synchronously, and the
1257
1348
  // master's child-event hook wakes parked wait_children callers — those
@@ -1349,6 +1440,22 @@ export class Agent {
1349
1440
  await this.sleepInterruptible(this.opts.continueDelayMs);
1350
1441
  if (this.stopRequested)
1351
1442
  break;
1443
+ // #95: do NOT pair this with a progress request. Both fire at the turn
1444
+ // boundary and both inject a user message, so the model received two
1445
+ // harness instructions back to back — "Continue working toward the
1446
+ // current goal" immediately followed by "Please give a brief progress
1447
+ // report now", which reads as the harness talking to itself.
1448
+ //
1449
+ // The progress request already re-enters the loop, so it IS the
1450
+ // continuation: suppressing the nudge when one is pending costs nothing
1451
+ // and leaves exactly one instruction per boundary.
1452
+ if (this.progressRequestedThisRound) {
1453
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
1454
+ event: "auto-continue-suppressed",
1455
+ detail: "a progress report was already requested this round (#95)",
1456
+ });
1457
+ break;
1458
+ }
1352
1459
  const isSub = !!this.opts.parent;
1353
1460
  const nudge = isSub
1354
1461
  ? `[harness] Auto-nudge. Re-anchor on YOUR task — and only that:\n\n${this.goal.text.slice(0, 1500)}\n\nThe inherited conversation is reference only; other agents own any older tasks in it. If your task is complete, call finish() now instead of continuing. If blocked, explain why briefly and finish().`
@@ -1472,6 +1579,11 @@ export class Agent {
1472
1579
  // skills may have been created last turn — refresh the prompt listing
1473
1580
  await this.refreshSkills();
1474
1581
  // periodic progress report at turn boundary (no mid-turn interruption)
1582
+ // #95: the flag is per-TURN, not per-round. `maybeRequestProgress` sits
1583
+ // inside the turn loop and auto-continue is checked after the round
1584
+ // ends, so the flag has to mean "this boundary already carried an
1585
+ // instruction" — reset here, set below if the request fires.
1586
+ this.progressRequestedThisRound = false;
1475
1587
  await this.maybeRequestProgress();
1476
1588
  // keep the context window bounded before spending tokens on a turn
1477
1589
  await this.maybeCompact();
@@ -2284,6 +2396,9 @@ export class Agent {
2284
2396
  this.turnsSinceProgress >= this.opts.progressMaxQuietTurns;
2285
2397
  if (!elapsedOk || !activityOk)
2286
2398
  return; // stalling provider → don't waste a turn asking
2399
+ // #95: remember that this boundary already carries an instruction, so the
2400
+ // auto-continue nudge can stand down instead of pairing with it.
2401
+ this.progressRequestedThisRound = true;
2287
2402
  this.lastProgressAt = Date.now();
2288
2403
  this.activityChars = 0;
2289
2404
  this.turnsSinceProgress = 0;
@@ -414,7 +414,51 @@ function clipText(s, max) {
414
414
  const n = Math.max(1000, Math.min(max, 80_000));
415
415
  return s.length <= n ? s : `${s.slice(0, n)}\n… [truncated, ${s.length} chars total]`;
416
416
  }
417
+ /**
418
+ * Patterns that backtrack catastrophically (#93).
419
+ *
420
+ * A nested quantifier — a quantified group whose body is itself quantified, or
421
+ * alternation that can match the same text more than one way — makes the engine
422
+ * explore exponentially many splits. Measured on this codebase: `(a+)+b` against
423
+ * 28 characters of "a" took 3.8s, roughly DOUBLING per character added, and a
424
+ * 40-character line would run for days.
425
+ *
426
+ * There is no timeout on this path: `ctx.defaultTimeoutMs` guards `bash` only,
427
+ * so the scan is a synchronous `re.test(line)` with nothing able to interrupt it.
428
+ * The agent process blocks and takes the UI and every other agent with it.
429
+ *
430
+ * A model is poorly placed to reason about backtracking cost and nothing tells it
431
+ * not to try, so the pattern is checked rather than trusted. This rejects the
432
+ * SHAPE rather than trying to bound the runtime, because a synchronous regex
433
+ * cannot be abandoned once `test` starts — the only reliable protection is to
434
+ * never compile one.
435
+ */
436
+ const BACKTRACKING_PATTERNS = [
437
+ // (a+)+ (a*)* (a+)* (a?)* … — a quantifier wrapping another quantifier
438
+ { re: /\([^()]*[+*}][^()]*\)\s*[+*{]/, why: "a quantifier nested inside another quantifier" },
439
+ // (a|a)+ — alternation whose branches can match the SAME text. Distinct
440
+ // branches like (foo|bar)+ are fine and common, so this only fires when two
441
+ // branches are literally identical, which is the shape that explodes.
442
+ { re: /\(([^()|]*)\|\1\)\s*[+*{]/, why: "an alternation with identical branches inside a quantifier" },
443
+ // (.*)* (.+)+ — a wildcard quantified inside a quantifier
444
+ { re: /\(\s*[.][*+]\s*\)\s*[+*{]/, why: "a wildcard quantified inside a quantifier" },
445
+ ];
446
+ /** Above this, the O(lines x pattern) scan itself becomes the cost. */
447
+ const MAX_PATTERN_LENGTH = 1000;
448
+ /**
449
+ * Compile a model-supplied regex, refusing ones that can backtrack
450
+ * catastrophically.
451
+ *
452
+ * Returns an error STRING rather than a RegExp so every existing call site —
453
+ * which already handles that shape for an invalid pattern — needs no change.
454
+ */
417
455
  function compileRegex(pattern, ignoreCase) {
456
+ if (pattern.length > MAX_PATTERN_LENGTH)
457
+ return `pattern too long (${pattern.length} chars, max ${MAX_PATTERN_LENGTH}) — narrow it, or grep for a literal substring instead`;
458
+ for (const { re, why } of BACKTRACKING_PATTERNS) {
459
+ if (re.test(pattern))
460
+ return `pattern rejected: ${why} — that shape backtracks exponentially and would freeze the agent (e.g. "(a+)+b" against 28 characters takes 3.8s). Use a literal search, or simplify the pattern.`;
461
+ }
418
462
  try {
419
463
  return new RegExp(pattern, ignoreCase ? "i" : "");
420
464
  }
@@ -1027,6 +1027,15 @@ export function buildApp(master) {
1027
1027
  return c.json({ error: "eventId and text required" }, 400);
1028
1028
  try {
1029
1029
  const r = await a.editPromptAt(body.eventId, body.text, body.tail === "summarize" ? "summarize" : "discard");
1030
+ // #96: the UI button is labelled "fork & RESEND", but nothing resent.
1031
+ // `editPromptAt` rewrites history and returns, leaving the agent idle —
1032
+ // so the edited prompt sat in the timeline looking sent while the model
1033
+ // had never seen it, and the operator had to notice and press start.
1034
+ //
1035
+ // start() here rather than in the client: the route already knows the edit
1036
+ // succeeded, and a client that failed to call it would leave the agent
1037
+ // silently idle with no error anywhere.
1038
+ a.start("edited prompt resent");
1030
1039
  return c.json({ ok: true, ...r });
1031
1040
  }
1032
1041
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.26.3",
3
+ "version": "0.26.5",
4
4
  "description": "A lightweight, always-on multi-agent harness for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",