teapot-coding-agent 0.26.3 → 0.26.4

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", …).
@@ -1018,6 +1029,29 @@ export class Agent {
1018
1029
  // Bounded like the rest of the snapshot, and ids are opaque, so this
1019
1030
  // cannot carry prompt text.
1020
1031
  pendingPromptIds: this.pendingPrompts.map((p) => p.id).filter((x) => !!x).slice(0, 64),
1032
+ // #87: the QUEUE itself, not just its ids. The UI's echo rows are
1033
+ // memory-only (like liveByAgent and timelineCache), so after a reload the
1034
+ // queue survives on the server while the UI has nothing to render — the
1035
+ // queued message then appears only as its log row, i.e. already sent.
1036
+ //
1037
+ // Ids alone cannot fix that: they identify a prompt, they do not carry its
1038
+ // text, so the UI cannot rebuild an echo from them. This is the queue the
1039
+ // operator is looking at, already on screen in the ⏳ badge, so shipping it
1040
+ // is not new exposure — it is the same data in a shape the timeline can
1041
+ // use.
1042
+ //
1043
+ // `at` is the enqueue time so the echo keeps a stable position and id
1044
+ // across rebuilds; `sent` mirrors the echo flag the UI already tracks.
1045
+ pendingPromptQueue: this.pendingPrompts
1046
+ .filter((p) => !!p.id)
1047
+ .slice(0, 64)
1048
+ .map((p, i) => ({
1049
+ id: p.id,
1050
+ text: p.text.slice(0, 8000),
1051
+ source: p.source,
1052
+ at: Date.now() - (this.pendingPrompts.length - i),
1053
+ ...(p.images?.length ? { images: p.images.slice(0, 8) } : {}),
1054
+ })),
1021
1055
  todo: this.todo.slice(0, 32_000), // match set_todo's cap — no silent truncation
1022
1056
  parent: this.opts.parent,
1023
1057
  awaiting: this.awaitingUser,
@@ -1252,6 +1286,8 @@ export class Agent {
1252
1286
  }
1253
1287
  /** monotonic counter: a start() bumps it, so a pending stop status write loses */
1254
1288
  stopSeq = 0;
1289
+ /** #95: set when this round already injected a harness progress request */
1290
+ progressRequestedThisRound = false;
1255
1291
  setStatus(s, reason = "") {
1256
1292
  // Update the field FIRST. log.append fires onEvent synchronously, and the
1257
1293
  // master's child-event hook wakes parked wait_children callers — those
@@ -1349,6 +1385,22 @@ export class Agent {
1349
1385
  await this.sleepInterruptible(this.opts.continueDelayMs);
1350
1386
  if (this.stopRequested)
1351
1387
  break;
1388
+ // #95: do NOT pair this with a progress request. Both fire at the turn
1389
+ // boundary and both inject a user message, so the model received two
1390
+ // harness instructions back to back — "Continue working toward the
1391
+ // current goal" immediately followed by "Please give a brief progress
1392
+ // report now", which reads as the harness talking to itself.
1393
+ //
1394
+ // The progress request already re-enters the loop, so it IS the
1395
+ // continuation: suppressing the nudge when one is pending costs nothing
1396
+ // and leaves exactly one instruction per boundary.
1397
+ if (this.progressRequestedThisRound) {
1398
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
1399
+ event: "auto-continue-suppressed",
1400
+ detail: "a progress report was already requested this round (#95)",
1401
+ });
1402
+ break;
1403
+ }
1352
1404
  const isSub = !!this.opts.parent;
1353
1405
  const nudge = isSub
1354
1406
  ? `[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 +1524,11 @@ export class Agent {
1472
1524
  // skills may have been created last turn — refresh the prompt listing
1473
1525
  await this.refreshSkills();
1474
1526
  // periodic progress report at turn boundary (no mid-turn interruption)
1527
+ // #95: the flag is per-TURN, not per-round. `maybeRequestProgress` sits
1528
+ // inside the turn loop and auto-continue is checked after the round
1529
+ // ends, so the flag has to mean "this boundary already carried an
1530
+ // instruction" — reset here, set below if the request fires.
1531
+ this.progressRequestedThisRound = false;
1475
1532
  await this.maybeRequestProgress();
1476
1533
  // keep the context window bounded before spending tokens on a turn
1477
1534
  await this.maybeCompact();
@@ -2284,6 +2341,9 @@ export class Agent {
2284
2341
  this.turnsSinceProgress >= this.opts.progressMaxQuietTurns;
2285
2342
  if (!elapsedOk || !activityOk)
2286
2343
  return; // stalling provider → don't waste a turn asking
2344
+ // #95: remember that this boundary already carries an instruction, so the
2345
+ // auto-continue nudge can stand down instead of pairing with it.
2346
+ this.progressRequestedThisRound = true;
2287
2347
  this.lastProgressAt = Date.now();
2288
2348
  this.activityChars = 0;
2289
2349
  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.4",
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",