teapot-coding-agent 0.18.2 → 0.19.1

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,48 @@ 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.
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
+ const calls = res.message.tool_calls ?? [];
1382
+ // the assistant turn MUST enter history before its tool answers, or the
1383
+ // sequence [user, tool] is invalid and every later request 400s
1384
+ this.messages.push(res.message);
1340
1385
  await this.log.append("message", this.currentSession, this.currentBranch, {
1341
1386
  role: "assistant",
1342
1387
  content: res.message.content ?? "",
1343
1388
  reasoning: res.reasoning,
1389
+ ...(calls.length ? { toolCalls: calls.map((c) => ({ id: c.id, name: c.function.name })) } : {}),
1344
1390
  progressEcho: true,
1345
1391
  });
1346
- this.messages.push(res.message);
1392
+ let reported = calls.some((c) => c.function.name === "report_progress");
1393
+ for (const call of calls) {
1394
+ if (call.function.name === "report_progress") {
1395
+ await this.recordProgress(call.function.arguments);
1396
+ }
1397
+ this.messages.push({ role: "tool", tool_call_id: call.id, content: "progress recorded" });
1398
+ // log both sides so restores replay the exact exchange (meta-style)
1399
+ await this.log.append("tool_call", this.currentSession, this.currentBranch, {
1400
+ callId: call.id,
1401
+ name: call.function.name,
1402
+ args: safeParse(call.function.arguments),
1403
+ argsRaw: call.function.arguments,
1404
+ });
1405
+ await this.log.append("tool_result", this.currentSession, this.currentBranch, {
1406
+ callId: call.id,
1407
+ name: call.function.name,
1408
+ ok: true,
1409
+ durationMs: 0,
1410
+ result: "progress recorded",
1411
+ });
1412
+ }
1413
+ if (!reported && !(res.message.content ?? "").trim()) {
1414
+ // degenerate: neither a tool call nor prose — record a stub so the
1415
+ // operator sees SOMETHING at the requested moment
1416
+ await this.recordProgress(JSON.stringify({ freeform: "(no report produced)" }));
1417
+ }
1347
1418
  }
1348
1419
  /* ---------- context compaction ---------- */
1349
1420
  /**
@@ -1480,15 +1551,34 @@ export class Agent {
1480
1551
  }
1481
1552
  cut = this.safeCut(cut);
1482
1553
  if (cut <= 0)
1483
- return; // nothing safely compactable (should not happen)
1554
+ return; // nothing safely compactable (phase never armed yet)
1484
1555
  const old = this.messages.slice(0, cut);
1485
1556
  const oldCount = old.length;
1486
1557
  let summary = "";
1487
1558
  let mode = "summarize";
1488
1559
  let summarizedCount = oldCount;
1489
1560
  let droppedCount = 0;
1561
+ // progress is visible: announce the phase up front, then stream the
1562
+ // summarizer's output as it generates (a long summarize used to look like
1563
+ // the agent silently hung — hundreds of thousands of tokens take a while)
1564
+ bus.emit("update", {
1565
+ kind: "compaction-progress",
1566
+ agentId: this.opts.id,
1567
+ phase: "summarizing",
1568
+ summarized: oldCount,
1569
+ });
1570
+ this.compactPhase = "summarizing";
1490
1571
  try {
1491
- summary = await this.summarize(old);
1572
+ summary = await this.summarize(old, (text) => {
1573
+ bus.emit("update", {
1574
+ kind: "llm-delta",
1575
+ agentId: this.opts.id,
1576
+ text: `[compact] ${text}`,
1577
+ reasoning: "",
1578
+ });
1579
+ });
1580
+ this.compactPhase = "harvesting";
1581
+ bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "harvesting" });
1492
1582
  if (summary)
1493
1583
  await this.harvestLessons(summary); // durable knowledge → memory.md
1494
1584
  }
@@ -1502,8 +1592,11 @@ export class Agent {
1502
1592
  mode = "truncate";
1503
1593
  summarizedCount = 0;
1504
1594
  const halfCut = this.safeCut(Math.floor(oldCount / 2));
1505
- if (halfCut <= 0)
1595
+ if (halfCut <= 0) {
1596
+ this.compactPhase = "";
1597
+ bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "done" });
1506
1598
  return;
1599
+ }
1507
1600
  droppedCount = halfCut;
1508
1601
  this.messages = this.messages.slice(halfCut);
1509
1602
  }
@@ -1539,9 +1632,11 @@ export class Agent {
1539
1632
  dropped: droppedCount,
1540
1633
  mode,
1541
1634
  });
1635
+ this.compactPhase = "";
1636
+ bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "done" });
1542
1637
  }
1543
1638
  /** Ask the model for dense continuation notes over the compacted range. */
1544
- async summarize(old) {
1639
+ async summarize(old, onDelta) {
1545
1640
  const transcript = old
1546
1641
  .map((m) => {
1547
1642
  const who = m.role === "tool" ? "tool" : m.role;
@@ -1561,7 +1656,10 @@ export class Agent {
1561
1656
  "(gotchas, user preferences, what worked) — or omit the section if there are none.",
1562
1657
  },
1563
1658
  { role: "user", content: `Conversation:\n\n${transcript}\n\nWrite the continuation notes now.` },
1564
- ], [], undefined);
1659
+ ], [], undefined, // never abortable by stop(): losing the summary would lose history
1660
+ onDelta
1661
+ ? (s) => onDelta(s.text)
1662
+ : undefined);
1565
1663
  return res.message.content ?? "";
1566
1664
  }
1567
1665
  /**
@@ -1961,6 +2059,30 @@ function rebuildMessagesFrom(list) {
1961
2059
  openCalls.delete(String(d.callId ?? ""));
1962
2060
  flushUsers();
1963
2061
  }
2062
+ else if (e.type === "compaction") {
2063
+ // A compaction REPLACED everything before this point with a summary.
2064
+ // Replaying the raw prefix as well resurrected the full pre-compact
2065
+ // history after every restart/fork (one live session came back at
2066
+ // ~580k tokens after a 580k→207k compaction): the model re-read weeks
2067
+ // of settled work it was told had been summarized, and its behavior
2068
+ // fell apart. Mirror the runtime transform instead — the summary note
2069
+ // becomes the history's head. Compactions only run at turn boundaries
2070
+ // (serialized on the run chain), so no tool batch can be split here.
2071
+ if (d.mode === "truncate") {
2072
+ // `dropped` IS the number of leading messages the live pass removed
2073
+ msgs.splice(0, Math.min(msgs.length, Number(d.dropped ?? 0)));
2074
+ }
2075
+ else {
2076
+ const summarized = Math.min(msgs.length, Number(d.summarized ?? 0) || msgs.length);
2077
+ const sum = typeof d.summary === "string" ? d.summary : "";
2078
+ msgs.splice(0, summarized, {
2079
+ role: "user",
2080
+ content: `[harness] Context was compacted: ${summarized} earlier messages were summarized. ` +
2081
+ "Goal and notes are managed by the harness (AGENTS.md / memory.md are injected into your prompt when present).\n\n" +
2082
+ `## Summary of earlier conversation\n${sum || "(summary unavailable)"}`,
2083
+ });
2084
+ }
2085
+ }
1964
2086
  else if (e.type === "progress") {
1965
2087
  // progress events may follow an assistant report_progress call that
1966
2088
  // 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.18.2",
3
+ "version": "0.19.1",
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",