teapot-coding-agent 0.17.10 → 0.19.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.
@@ -107,6 +107,8 @@ export class Agent {
107
107
  lastUsage;
108
108
  /** messages.length right after the last successful compaction */
109
109
  compactedAtLen = 0;
110
+ /** live compaction phase for UI progress ("summarizing"/"harvesting") */
111
+ compactPhase = "";
110
112
  /** set by ask_user: the loop is parked until the operator replies */
111
113
  awaitingUser = false;
112
114
  /** real assistant output since the last progress report (chars / turns) */
@@ -122,6 +124,7 @@ export class Agent {
122
124
  continueDelayMs: 15_000,
123
125
  autoCompact: true,
124
126
  maxConsecutiveToolErrors: 5,
127
+ maxTurnsPerRound: 200,
125
128
  contextTokenBudget: 96_000,
126
129
  contextWindowTokens: 0,
127
130
  restoreSession: true,
@@ -675,6 +678,9 @@ export class Agent {
675
678
  // mislabeling every inferred-window model "manual override"
676
679
  compactAtIsManual: this.compactBudgetIsManual,
677
680
  window: this.opts.contextWindowTokens || 0,
681
+ // live compaction phase ("summarizing"/"harvesting"/"done") — lets any
682
+ // client show progress even if it missed the bus event
683
+ ...(this.compactPhase ? { compacting: this.compactPhase } : {}),
678
684
  },
679
685
  pendingPrompts: this.pendingPrompts.length,
680
686
  todo: this.todo.slice(0, 32_000), // match set_todo's cap — no silent truncation
@@ -833,7 +839,37 @@ export class Agent {
833
839
  async loop() {
834
840
  while (!this.stopRequested) {
835
841
  try {
836
- const finished = await this.runTurnsUntilIdle();
842
+ const turnsAtStart = this.stats.turns;
843
+ let finished = await this.runTurnsUntilIdle();
844
+ // the round ended on the per-round turn cap (not finish/stop): tell
845
+ // the model to report and wrap up, then fall through to auto-continue
846
+ // — a soft checkpoint instead of the old hard runaway error
847
+ const cap = Math.max(0, this.opts.maxTurnsPerRound ?? 200);
848
+ if (!finished &&
849
+ cap > 0 &&
850
+ this.stats.turns - turnsAtStart >= cap &&
851
+ !this.stopRequested &&
852
+ !this.awaitingUser &&
853
+ !this.pendingPrompts.length // real user input outranks the wrap-up nudge
854
+ ) {
855
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
856
+ event: "round-turn-cap",
857
+ turns: cap,
858
+ });
859
+ await this.maybeRequestProgress();
860
+ const nudge = `[harness] This round ran ${cap} turns, so it ends here. ` +
861
+ "Report progress via report_progress now; then either call finish() if the goal is met, " +
862
+ "or state exactly where you will resume. Work CONTINUES in the next round automatically.";
863
+ this.messages.push({ role: "user", content: nudge });
864
+ await this.log.append("prompt", this.currentSession, this.currentBranch, {
865
+ source: "harness",
866
+ text: nudge,
867
+ });
868
+ // the wrap-up nudge IS this round's continuation prompt — starting
869
+ // another round on top of it would stack a generic auto-continue
870
+ // nudge too, so go straight to the next round instead
871
+ continue;
872
+ }
837
873
  if (this.stopRequested)
838
874
  break;
839
875
  // fresh user input arrived while we were finishing up — another round now
@@ -935,7 +971,12 @@ export class Agent {
935
971
  */
936
972
  async runTurnsUntilIdle() {
937
973
  let finished = false;
938
- for (let guard = 0; guard < 200; guard++) {
974
+ // maxTurnsPerRound is a SOFT cap (0 disables): reaching it nudges the
975
+ // model to wrap up instead of hard-failing the round — long autonomous
976
+ // stretches are normal here, and a throw used to leave status=error with
977
+ // no auto-recovery even though autoContinue would happily keep going.
978
+ const cap = Math.max(0, this.opts.maxTurnsPerRound ?? 200);
979
+ for (let guard = 0; cap === 0 || guard < cap; guard++) {
939
980
  if (this.stopRequested)
940
981
  return finished;
941
982
  // deliver prompts queued while the previous turn was running
@@ -1244,7 +1285,9 @@ export class Agent {
1244
1285
  if (finished)
1245
1286
  return true;
1246
1287
  }
1247
- throw new Error("runaway detection: too many turns in one round (>200)");
1288
+ // cap reached — loop() logs the note, asks for a progress report and
1289
+ // nudges the model; this round simply ends (no hard error)
1290
+ return finished;
1248
1291
  }
1249
1292
  buildMessages() {
1250
1293
  // deliberately static: [system] + append-only history keeps prefix caches hot
@@ -1330,20 +1373,47 @@ export class Agent {
1330
1373
  text: request,
1331
1374
  });
1332
1375
  this.messages.push({ role: "user", content: request });
1333
- const res = await this.llmCall(this.buildMessages(), []); // no tools: pure report
1334
- await this.recordProgress(JSON.stringify({ freeform: res.message.content }));
1335
- // The report's content is ALREADY shown by the progress embed — logging a
1336
- // plain assistant message with the same text made the timeline show every
1337
- // requested report twice. Keep logging it (session restores replay the
1338
- // exchange faithfully and rebuildMessages needs it) but mark it as an echo;
1339
- // the web UI filters marked rows out.
1340
- await this.log.append("message", this.currentSession, this.currentBranch, {
1341
- role: "assistant",
1342
- content: res.message.content ?? "",
1343
- reasoning: res.reasoning,
1344
- progressEcho: true,
1345
- });
1346
- this.messages.push(res.message);
1376
+ // Give the model the report_progress TOOL here. The old no-tools call
1377
+ // made the model answer in plain text while its own mental "did I report?"
1378
+ // ledger stayed unsolved — it then re-sent a voluntary report_progress
1379
+ // right after, and the timeline showed every requested report TWICE.
1380
+ const res = await this.llmCall(this.buildMessages(), allToolSpecs());
1381
+ let reported = false;
1382
+ for (const call of res.message.tool_calls ?? []) {
1383
+ if (call.function.name !== "report_progress")
1384
+ continue;
1385
+ await this.recordProgress(call.function.arguments);
1386
+ this.messages.push({ role: "tool", tool_call_id: call.id, content: "progress recorded" });
1387
+ // log both sides so restores replay the exact exchange (meta-style)
1388
+ await this.log.append("tool_call", this.currentSession, this.currentBranch, {
1389
+ callId: call.id,
1390
+ name: call.function.name,
1391
+ args: safeParse(call.function.arguments),
1392
+ argsRaw: call.function.arguments,
1393
+ });
1394
+ await this.log.append("tool_result", this.currentSession, this.currentBranch, {
1395
+ callId: call.id,
1396
+ name: call.function.name,
1397
+ ok: true,
1398
+ durationMs: 0,
1399
+ result: "progress recorded",
1400
+ });
1401
+ reported = true;
1402
+ }
1403
+ if (!reported && (res.message.content ?? "").trim()) {
1404
+ // fallback: the model answered in prose anyway — record it as before.
1405
+ // Its content is ALREADY shown by the progress embed; logging the plain
1406
+ // message too used to duplicate rows, so mark it as an echo (the feed
1407
+ // filters marked rows out).
1408
+ await this.recordProgress(JSON.stringify({ freeform: res.message.content }));
1409
+ await this.log.append("message", this.currentSession, this.currentBranch, {
1410
+ role: "assistant",
1411
+ content: res.message.content ?? "",
1412
+ reasoning: res.reasoning,
1413
+ progressEcho: true,
1414
+ });
1415
+ this.messages.push(res.message);
1416
+ }
1347
1417
  }
1348
1418
  /* ---------- context compaction ---------- */
1349
1419
  /**
@@ -1480,15 +1550,34 @@ export class Agent {
1480
1550
  }
1481
1551
  cut = this.safeCut(cut);
1482
1552
  if (cut <= 0)
1483
- return; // nothing safely compactable (should not happen)
1553
+ return; // nothing safely compactable (phase never armed yet)
1484
1554
  const old = this.messages.slice(0, cut);
1485
1555
  const oldCount = old.length;
1486
1556
  let summary = "";
1487
1557
  let mode = "summarize";
1488
1558
  let summarizedCount = oldCount;
1489
1559
  let droppedCount = 0;
1560
+ // progress is visible: announce the phase up front, then stream the
1561
+ // summarizer's output as it generates (a long summarize used to look like
1562
+ // the agent silently hung — hundreds of thousands of tokens take a while)
1563
+ bus.emit("update", {
1564
+ kind: "compaction-progress",
1565
+ agentId: this.opts.id,
1566
+ phase: "summarizing",
1567
+ summarized: oldCount,
1568
+ });
1569
+ this.compactPhase = "summarizing";
1490
1570
  try {
1491
- summary = await this.summarize(old);
1571
+ summary = await this.summarize(old, (text) => {
1572
+ bus.emit("update", {
1573
+ kind: "llm-delta",
1574
+ agentId: this.opts.id,
1575
+ text: `[compact] ${text}`,
1576
+ reasoning: "",
1577
+ });
1578
+ });
1579
+ this.compactPhase = "harvesting";
1580
+ bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "harvesting" });
1492
1581
  if (summary)
1493
1582
  await this.harvestLessons(summary); // durable knowledge → memory.md
1494
1583
  }
@@ -1502,8 +1591,11 @@ export class Agent {
1502
1591
  mode = "truncate";
1503
1592
  summarizedCount = 0;
1504
1593
  const halfCut = this.safeCut(Math.floor(oldCount / 2));
1505
- if (halfCut <= 0)
1594
+ if (halfCut <= 0) {
1595
+ this.compactPhase = "";
1596
+ bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "done" });
1506
1597
  return;
1598
+ }
1507
1599
  droppedCount = halfCut;
1508
1600
  this.messages = this.messages.slice(halfCut);
1509
1601
  }
@@ -1539,9 +1631,11 @@ export class Agent {
1539
1631
  dropped: droppedCount,
1540
1632
  mode,
1541
1633
  });
1634
+ this.compactPhase = "";
1635
+ bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "done" });
1542
1636
  }
1543
1637
  /** Ask the model for dense continuation notes over the compacted range. */
1544
- async summarize(old) {
1638
+ async summarize(old, onDelta) {
1545
1639
  const transcript = old
1546
1640
  .map((m) => {
1547
1641
  const who = m.role === "tool" ? "tool" : m.role;
@@ -1561,7 +1655,10 @@ export class Agent {
1561
1655
  "(gotchas, user preferences, what worked) — or omit the section if there are none.",
1562
1656
  },
1563
1657
  { role: "user", content: `Conversation:\n\n${transcript}\n\nWrite the continuation notes now.` },
1564
- ], [], undefined);
1658
+ ], [], undefined, // never abortable by stop(): losing the summary would lose history
1659
+ onDelta
1660
+ ? (s) => onDelta(s.text)
1661
+ : undefined);
1565
1662
  return res.message.content ?? "";
1566
1663
  }
1567
1664
  /**
@@ -1961,6 +2058,30 @@ function rebuildMessagesFrom(list) {
1961
2058
  openCalls.delete(String(d.callId ?? ""));
1962
2059
  flushUsers();
1963
2060
  }
2061
+ else if (e.type === "compaction") {
2062
+ // A compaction REPLACED everything before this point with a summary.
2063
+ // Replaying the raw prefix as well resurrected the full pre-compact
2064
+ // history after every restart/fork (one live session came back at
2065
+ // ~580k tokens after a 580k→207k compaction): the model re-read weeks
2066
+ // of settled work it was told had been summarized, and its behavior
2067
+ // fell apart. Mirror the runtime transform instead — the summary note
2068
+ // becomes the history's head. Compactions only run at turn boundaries
2069
+ // (serialized on the run chain), so no tool batch can be split here.
2070
+ if (d.mode === "truncate") {
2071
+ // `dropped` IS the number of leading messages the live pass removed
2072
+ msgs.splice(0, Math.min(msgs.length, Number(d.dropped ?? 0)));
2073
+ }
2074
+ else {
2075
+ const summarized = Math.min(msgs.length, Number(d.summarized ?? 0) || msgs.length);
2076
+ const sum = typeof d.summary === "string" ? d.summary : "";
2077
+ msgs.splice(0, summarized, {
2078
+ role: "user",
2079
+ content: `[harness] Context was compacted: ${summarized} earlier messages were summarized. ` +
2080
+ "Goal and notes are managed by the harness (AGENTS.md / memory.md are injected into your prompt when present).\n\n" +
2081
+ `## Summary of earlier conversation\n${sum || "(summary unavailable)"}`,
2082
+ });
2083
+ }
2084
+ }
1964
2085
  else if (e.type === "progress") {
1965
2086
  // progress events may follow an assistant report_progress call that
1966
2087
  // has no logged tool result — patch it in when present
@@ -41,6 +41,9 @@ export const ConfigPatchSchema = z.object({
41
41
  contextTokenBudget: z.number().int().min(1_000).nullable().optional(),
42
42
  contextWindowTokens: z.number().int().min(1_000).optional(),
43
43
  maxSpawnDepth: z.number().int().min(0).max(8).optional(),
44
+ // soft cap on LLM turns in one round; reaching it nudges the model to wrap
45
+ // up (not an error) — 0 disables the cap
46
+ maxTurnsPerRound: z.number().int().min(0).optional(),
44
47
  tasks: z.array(TaskSchema).optional(),
45
48
  });
46
49
  /** Format a ZodError into a compact operator-readable message. */
package/dist/master.js CHANGED
@@ -166,6 +166,8 @@ function printAgentEvent(e) {
166
166
  line = `${c("33", "⟲ restore")} branch ${d.branch}, ${d.messages} messages`;
167
167
  else if (d.event === "model-changed")
168
168
  line = `${c("33", "🧦 model")} → ${d.model} (${d.provider})`;
169
+ else if (d.event === "round-turn-cap")
170
+ line = `${c("33", "⏸ round cap")} ${d.turns} turns — nudging report + wrap-up, next round continues`;
169
171
  break;
170
172
  }
171
173
  default:
@@ -226,6 +228,8 @@ export class Master {
226
228
  }
227
229
  if (patch.maxSpawnDepth !== undefined)
228
230
  this.config.maxSpawnDepth = patch.maxSpawnDepth;
231
+ if (patch.maxTurnsPerRound !== undefined)
232
+ this.config.maxTurnsPerRound = patch.maxTurnsPerRound;
229
233
  if (patch.tasks) {
230
234
  this.config.tasks = patch.tasks;
231
235
  // rebuild schedule table live
@@ -375,6 +379,7 @@ export class Master {
375
379
  sessionDir,
376
380
  progressIntervalMs: this.config.progressIntervalMs,
377
381
  ...(this.config.progressMinChars ? { progressMinChars: this.config.progressMinChars } : {}),
382
+ ...(this.config.maxTurnsPerRound !== undefined ? { maxTurnsPerRound: this.config.maxTurnsPerRound } : {}),
378
383
  autoContinue: ac.autoContinue ?? true,
379
384
  ...(this.config.contextTokenBudget ? { contextTokenBudget: this.config.contextTokenBudget } : {}),
380
385
  ...(ac.contextWindowTokens || this.config.contextWindowTokens || inferredWindow
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.17.10",
3
+ "version": "0.19.0",
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",