teapot-coding-agent 0.26.8 → 0.26.10

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.
@@ -191,6 +191,13 @@ export class Agent {
191
191
  consecutiveToolErrors = 0;
192
192
  /** consecutive round-fatal API errors (drives the retry backoff) */
193
193
  consecutiveApiErrors = 0;
194
+ /**
195
+ * Consecutive auto-continued rounds that did no work (#106).
196
+ *
197
+ * Reset by any tool call, any finish, a stop, or fresh operator input — only a
198
+ * round that produced neither a tool call nor a finish increments it.
199
+ */
200
+ consecutiveIdleRounds = 0;
194
201
  constructor(opts) {
195
202
  this.opts = {
196
203
  progressIntervalMs: 10 * 60_000,
@@ -205,6 +212,8 @@ export class Agent {
205
212
  supportedParameters: [],
206
213
  maxConsecutiveToolErrors: 5,
207
214
  maxTurnsPerRound: 200,
215
+ // generous: only a model that never acts and never finishes reaches it
216
+ maxConsecutiveIdleRounds: 50,
208
217
  onError: "stop",
209
218
  retryDelayMs: 60_000,
210
219
  contextTokenBudget: 96_000,
@@ -1145,16 +1154,30 @@ export class Agent {
1145
1154
  // stable id shared by the log event and the UI's pending echo — the UI
1146
1155
  // flips its echo to "sent" when prompt-delivered carries this id back
1147
1156
  const promptId = source === "user" ? `u${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` : "";
1157
+ // #108: the queue entry is registered SYNCHRONOUSLY, before the async work.
1158
+ //
1159
+ // It used to be pushed inside the `.then()`, so it only existed once
1160
+ // `ensureReady()` resolved — while `cancelPrompt` reads the array
1161
+ // synchronously. A cancel issued in that window found nothing, returned null,
1162
+ // got a 409, and the UI discarded the draft: the operator saw a withdrawn
1163
+ // prompt that the model then received and acted on.
1164
+ //
1165
+ // The wide case is worse than the same-tick one: an agent still restoring a
1166
+ // 4,000-event session log holds that promise for real wall-clock time, so the
1167
+ // cancel essentially always loses.
1168
+ //
1169
+ // Registering first is safe because the queue is drained only at a turn
1170
+ // boundary (`drainPendingPrompts`), which cannot run inside this
1171
+ // synchronous stretch. The log append still happens in order, after.
1172
+ const entry = { source, text, images, ...(promptId ? { id: promptId } : {}) };
1173
+ this.pendingPrompts.push(entry);
1148
1174
  void this.ensureReady()
1149
- .then(() => {
1150
- this.pendingPrompts.push({ source, text, images, ...(promptId ? { id: promptId } : {}) });
1151
- return this.log.append("prompt", this.currentSession, this.currentBranch, {
1152
- source,
1153
- text,
1154
- ...(images?.length ? { images: images.map((i) => ({ url: i.url, name: i.name })) } : {}),
1155
- ...(promptId ? { promptId } : {}),
1156
- });
1157
- })
1175
+ .then(() => this.log.append("prompt", this.currentSession, this.currentBranch, {
1176
+ source,
1177
+ text,
1178
+ ...(images?.length ? { images: images.map((i) => ({ url: i.url, name: i.name })) } : {}),
1179
+ ...(promptId ? { promptId } : {}),
1180
+ }))
1158
1181
  .then(() => bus.emit("update", { kind: "agent-update", agentId: this.opts.id }))
1159
1182
  .catch(() => { });
1160
1183
  return promptId;
@@ -1426,6 +1449,11 @@ export class Agent {
1426
1449
  finished = true; // it IS finished now — don't auto-nudge a reporter
1427
1450
  }
1428
1451
  }
1452
+ // #106: a round that did real work resets the idle counter. `finished`
1453
+ // covers the finish() paths; a round that ran tool calls is caught by the
1454
+ // turn counter below, which is checked before this point.
1455
+ if (finished)
1456
+ this.consecutiveIdleRounds = 0;
1429
1457
  // auto-continue only makes sense with an active goal to continue toward
1430
1458
  if (finished ||
1431
1459
  !this.opts.autoContinue ||
@@ -1437,6 +1465,38 @@ export class Agent {
1437
1465
  // "Continue working toward the current goal" made a forked sub-agent
1438
1466
  // re-read its inherited context and drift into one of the parent's
1439
1467
  // older tasks — it would keep going forever without ever finish()ing.
1468
+ // #106: bound CONSECUTIVE auto-continued rounds.
1469
+ //
1470
+ // maxTurnsPerRound cannot bound this loop, and the reason is structural:
1471
+ // a ROOT agent that answers with a bare message ends its round after ONE
1472
+ // turn (`return finished` in runTurnsUntilIdle), so no single round ever
1473
+ // reaches the per-round cap. `finished` stays false, the goal stays
1474
+ // active, and auto-continue starts the next round — forever.
1475
+ //
1476
+ // Measured: maxTurnsPerRound=40 produced 997 turns and ZERO
1477
+ // `round-turn-cap` notes, because the cap is only ever evaluated once a
1478
+ // round ENDS, and these rounds end at turn 1. A tool-calling model does
1479
+ // trip it (8 turns, 1 note) — the gap is specific to the talk-only shape.
1480
+ //
1481
+ // So the bound belongs here, where the real loop is: consecutive rounds
1482
+ // that produced neither a finish() nor a tool call. Any tool call resets
1483
+ // it — that is real work, not stalling — and so does a stop, a fresh
1484
+ // user prompt, or an operator reply.
1485
+ //
1486
+ // This is a SAFETY VALVE, not a policy. It is deliberately generous
1487
+ // (default 50) because a long autonomous stretch that keeps calling tools
1488
+ // resets the counter on every turn and is never affected; only a model
1489
+ // that never acts and never finishes trips it.
1490
+ if (++this.consecutiveIdleRounds > this.opts.maxConsecutiveIdleRounds) {
1491
+ this.consecutiveIdleRounds = 0;
1492
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
1493
+ event: "auto-continue-bounded",
1494
+ rounds: this.opts.maxConsecutiveIdleRounds,
1495
+ detail: "the agent produced no tool calls and no finish() across this many " +
1496
+ "consecutive rounds — stopping the loop rather than letting it spin (#106)",
1497
+ });
1498
+ break;
1499
+ }
1440
1500
  await this.sleepInterruptible(this.opts.continueDelayMs);
1441
1501
  if (this.stopRequested)
1442
1502
  break;
@@ -1764,7 +1824,18 @@ export class Agent {
1764
1824
  // answer the tool_call so a follow-up round stays API-valid
1765
1825
  await this.answerMeta(call, this.goal.status === "done" ? "(goal complete)" : "(round ended)");
1766
1826
  finished = true;
1767
- continue;
1827
+ // #105: `continue` advanced the TOOL-CALL loop, so every later call in
1828
+ // this same assistant message still executed — a model emitting
1829
+ // finish() plus one more call got real filesystem writes AFTER it had
1830
+ // reported `final: true` and the goal was already done. The parent was
1831
+ // told the work finished while the workspace was still being mutated.
1832
+ //
1833
+ // So finish ends the BATCH: answer anything still outstanding (so no
1834
+ // tool_call is left unpaired — the provider rejects the next request
1835
+ // otherwise, #76) and leave the tool loop. The outer loop then returns
1836
+ // on `finished`.
1837
+ await this.answerUnansweredToolCalls(m);
1838
+ return true;
1768
1839
  }
1769
1840
  if (call.function.name === "report_progress") {
1770
1841
  await this.recordProgress(call.function.arguments);
@@ -1981,14 +2052,29 @@ export class Agent {
1981
2052
  result = { ok: false, result: `tool could not run: ${err.message}` };
1982
2053
  }
1983
2054
  this.stats.toolCalls++;
2055
+ // #106: a tool call is real work, so it clears the idle-round counter.
2056
+ this.consecutiveIdleRounds = 0;
2057
+ // #109: ONE clip, applied to BOTH the log and the live message.
2058
+ //
2059
+ // The log stored `result.slice(0, 8000)` while `messages` kept the full
2060
+ // text, and a restore rebuilds `messages` from the log — so a model
2061
+ // resuming work saw a different (and much smaller) tool result than it
2062
+ // had just seen live. Measured: 8000 bytes logged, 436 after restart.
2063
+ //
2064
+ // The 8000-byte log clip is deliberate — it bounds the log file — so the
2065
+ // fix is not to log everything. It is to clip the live message the SAME
2066
+ // way, so the two views agree and a restart is not a silent downgrade.
2067
+ // Restore is byte-exact everywhere else (`answerMeta` preserves its exact
2068
+ // content); this was the one place the replay diverged.
2069
+ const capped = result.result.slice(0, 8000);
1984
2070
  await this.log.append("tool_result", this.currentSession, this.currentBranch, {
1985
2071
  callId: call.id,
1986
2072
  name: call.function.name,
1987
2073
  ok: result.ok,
1988
2074
  durationMs: Date.now() - t0,
1989
- result: result.result.slice(0, 8000),
2075
+ result: capped,
1990
2076
  });
1991
- this.messages.push({ role: "tool", tool_call_id: call.id, content: result.result });
2077
+ this.messages.push({ role: "tool", tool_call_id: call.id, content: capped });
1992
2078
  this.consecutiveToolErrors = result.ok ? 0 : this.consecutiveToolErrors + 1;
1993
2079
  if (this.consecutiveToolErrors >= this.opts.maxConsecutiveToolErrors) {
1994
2080
  // structured, greppable AND visible: the note carries what failed so
@@ -368,7 +368,15 @@ export function buildApp(master) {
368
368
  app.get("/api/agents", (c) => c.json({ agents: [...master.agents.values()].map((a) => a.snapshot()) }));
369
369
  // create + start an agent on an arbitrary directory
370
370
  app.post("/api/agents", async (c) => {
371
- const body = await c.req.json();
371
+ // #110: `.catch(() => null)` like every other body read in this file. An
372
+ // unparseable body used to throw inside the handler and surface as a bare
373
+ // 500 — a client bug reported to the operator as a server fault, and it made
374
+ // the route's own `400 "workspace required"` unreachable.
375
+ const body = await c.req
376
+ .json()
377
+ .catch(() => null);
378
+ if (!body)
379
+ return c.json({ error: "invalid JSON" }, 400);
372
380
  if (!body.workspace?.trim())
373
381
  return c.json({ error: "workspace required" }, 400);
374
382
  const ws = resolveWorkspace(body.workspace, path.dirname(master.configPath));
@@ -757,7 +765,11 @@ export function buildApp(master) {
757
765
  const a = master.agents.get(c.req.param("id"));
758
766
  if (!a)
759
767
  return c.json({ error: "not found" }, 404);
760
- const body = await c.req.json();
768
+ const body = await c.req
769
+ .json()
770
+ .catch(() => null); // #110
771
+ if (!body)
772
+ return c.json({ error: "invalid JSON" }, 400);
761
773
  if (!body.text?.trim() && !body.images?.length)
762
774
  return c.json({ error: "text or images required" }, 400);
763
775
  // validate images: data URLs or http(s), size-capped (a 20MB base64 blob
@@ -782,7 +794,9 @@ export function buildApp(master) {
782
794
  const a = master.agents.get(c.req.param("id"));
783
795
  if (!a)
784
796
  return c.json({ error: "not found" }, 404);
785
- const body = await c.req.json();
797
+ const body = await c.req.json().catch(() => null); // #110
798
+ if (!body)
799
+ return c.json({ error: "invalid JSON" }, 400);
786
800
  if (!body.promptId)
787
801
  return c.json({ error: "promptId required" }, 400);
788
802
  const text = a.cancelPrompt(body.promptId);
@@ -864,7 +878,10 @@ export function buildApp(master) {
864
878
  const a = master.agents.get(c.req.param("id"));
865
879
  if (!a)
866
880
  return c.json({ error: "not found" }, 404);
867
- const body = await c.req.json();
881
+ const body = await c.req.json()
882
+ .catch(() => null); // #110
883
+ if (!body)
884
+ return c.json({ error: "invalid JSON" }, 400);
868
885
  if (body.text) {
869
886
  await a.setGoal(body.text);
870
887
  if (typeof body.verify === "string" && body.verify.trim())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.26.8",
3
+ "version": "0.26.10",
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",