teapot-coding-agent 0.26.2 → 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.
@@ -88,6 +88,35 @@ Most session state is not injected into prompts — fetch it with tools instead:
88
88
  panel) — do NOT also repeat the same content as a chat message; end the
89
89
  turn right after the call (a short "reported." is fine).
90
90
  - Be frugal: prefer small precise edits, avoid runaway loops.`;
91
+ /**
92
+ * A message whose ENTIRE text is parenthesised is an acknowledgement, not work.
93
+ *
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 = /^\(.*\)$/;
111
+ /**
112
+ * Harness acknowledgements — meta-lines about the run itself rather than the
113
+ * work ("decision recorded to decisions.md", "progress recorded", …).
114
+ *
115
+ * #94: these are addressed to the OPERATOR, not to whoever reads the next
116
+ * agent's report, and they consumed slots in a six-deep window that the parent
117
+ * reads as its only account of the work.
118
+ */
119
+ export const HARNESS_ACK = /^(decision recorded|progress recorded|goal saved|feedback saved|memory saved|todo saved|no skills yet|skills created|read_memory|memory|\(tool call\)|\(no content\)|\(no output\)|true|false|null|none)\b/i;
91
120
  export class Agent {
92
121
  log;
93
122
  toolCtx;
@@ -1000,6 +1029,29 @@ export class Agent {
1000
1029
  // Bounded like the rest of the snapshot, and ids are opaque, so this
1001
1030
  // cannot carry prompt text.
1002
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
+ })),
1003
1055
  todo: this.todo.slice(0, 32_000), // match set_todo's cap — no silent truncation
1004
1056
  parent: this.opts.parent,
1005
1057
  awaiting: this.awaitingUser,
@@ -1234,6 +1286,8 @@ export class Agent {
1234
1286
  }
1235
1287
  /** monotonic counter: a start() bumps it, so a pending stop status write loses */
1236
1288
  stopSeq = 0;
1289
+ /** #95: set when this round already injected a harness progress request */
1290
+ progressRequestedThisRound = false;
1237
1291
  setStatus(s, reason = "") {
1238
1292
  // Update the field FIRST. log.append fires onEvent synchronously, and the
1239
1293
  // master's child-event hook wakes parked wait_children callers — those
@@ -1331,6 +1385,22 @@ export class Agent {
1331
1385
  await this.sleepInterruptible(this.opts.continueDelayMs);
1332
1386
  if (this.stopRequested)
1333
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
+ }
1334
1404
  const isSub = !!this.opts.parent;
1335
1405
  const nudge = isSub
1336
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().`
@@ -1454,6 +1524,11 @@ export class Agent {
1454
1524
  // skills may have been created last turn — refresh the prompt listing
1455
1525
  await this.refreshSkills();
1456
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;
1457
1532
  await this.maybeRequestProgress();
1458
1533
  // keep the context window bounded before spending tokens on a turn
1459
1534
  await this.maybeCompact();
@@ -2098,10 +2173,29 @@ export class Agent {
2098
2173
  const m = this.messages[i];
2099
2174
  if (m.role === "user")
2100
2175
  continue; // prompts/noise — keep agent output only
2101
- if (m.role === "assistant" && !(m.content ?? "").trim())
2176
+ const text = String(m.content ?? "").trim();
2177
+ // #94: a message whose text is a PLACEHOLDER carries no information.
2178
+ //
2179
+ // `llm.ts` rewrites any assistant/tool message that has no usable text to
2180
+ // the literal "(tool call)" or "(no content)". Those are display
2181
+ // artifacts, and the parent's report was landing them verbatim — in the
2182
+ // real log, TWO of the six entries in an accepted report were literally
2183
+ // "[assistant] (tool call)", so half of what the parent was told about the
2184
+ // work was a placeholder.
2185
+ //
2186
+ // A tool CALL is real work, but it is represented in the tool RESULT that
2187
+ // follows, so dropping the placeholder loses nothing.
2188
+ if (!text || PLACEHOLDER_TEXT.test(text))
2189
+ continue;
2190
+ // #94: a window this small must not be spent on BOOKKEEPING. The real log
2191
+ // showed a 6-slot window where two 23-char "(tool call)" placeholders and a
2192
+ // 40-char "decision recorded to decisions.md" took half the slots, pushing
2193
+ // out the reasoning a parent would need to judge the work. Harness
2194
+ // acknowledgements carry no findings, so they cost a slot for nothing.
2195
+ if (HARNESS_ACK.test(text))
2102
2196
  continue;
2103
2197
  const who = m.role === "tool" ? "tool" : m.role;
2104
- let line = `[${who}] ${(m.content ?? "").trim()}`;
2198
+ let line = `[${who}] ${text}`;
2105
2199
  if (m.role === "tool") {
2106
2200
  // tool results are prefixed "(failed)" by the logger when they fail —
2107
2201
  // the raw content is what matters here, and cap hard: these can be huge
@@ -2247,6 +2341,9 @@ export class Agent {
2247
2341
  this.turnsSinceProgress >= this.opts.progressMaxQuietTurns;
2248
2342
  if (!elapsedOk || !activityOk)
2249
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;
2250
2347
  this.lastProgressAt = Date.now();
2251
2348
  this.activityChars = 0;
2252
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.2",
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",