teapot-coding-agent 0.21.0 → 0.22.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.
@@ -10,7 +10,7 @@ import { promises as fs, existsSync, readFileSync, readdirSync, statSync } from
10
10
  import path from "node:path";
11
11
  import { EventLog, readEvents } from "../log/events.js";
12
12
  import { chat, chatStream } from "./llm.js";
13
- import { executeTool, toolSpecs, currentSkills } from "./tools.js";
13
+ import { executeTool, toolSpecs, currentSkills, safeJoin, isContextOverflow } from "./tools.js";
14
14
  import { bus } from "../bus.js";
15
15
  /**
16
16
  * SYSTEM_TEMPLATE must stay byte-identical across every request of a session:
@@ -171,6 +171,7 @@ export class Agent {
171
171
  onIdlePark: (reason) => this.parkForTool(reason),
172
172
  onIdleUnpark: () => this.unparkFromTool(),
173
173
  onBackgroundExit: (info) => void this.onBackgroundJobExit(info),
174
+ onFileRead: (p) => this.trackFileRead(p),
174
175
  };
175
176
  // the session id IS the directory name — one directory per incarnation
176
177
  this.mainSession = path.basename(opts.sessionDir);
@@ -265,6 +266,11 @@ export class Agent {
265
266
  * agent learns exit code + output tail WITHOUT having to poll
266
267
  * bash_output blindly. Failed jobs say so loudly so the model reacts.
267
268
  */
269
+ /** recently read workspace files (most recent first) — post-compact restore */
270
+ recentReads = [];
271
+ trackFileRead(p) {
272
+ this.recentReads = [p, ...this.recentReads.filter((x) => x !== p)].slice(0, 5);
273
+ }
268
274
  onBackgroundJobExit(info) {
269
275
  const ok = info.code === 0;
270
276
  const one = info.cmd.replace(/\s+/g, " ").slice(0, 80);
@@ -1182,6 +1188,25 @@ export class Agent {
1182
1188
  detail: "stopped before any output arrived",
1183
1189
  });
1184
1190
  }
1191
+ // Provider context overflow → compact ONCE and retry the turn
1192
+ // (OpenCode-style overflow recovery). even with autoCompact off, an
1193
+ // actual overflow is proof the estimate missed; a second overflow is
1194
+ // returned as a real error instead of looping.
1195
+ if (isContextOverflow(err) && !this.stopRequested) {
1196
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
1197
+ event: "overflow-recovery",
1198
+ detail: String(err.message).slice(0, 200),
1199
+ });
1200
+ const before = this.stats.compactions;
1201
+ await this.maybeCompact(true);
1202
+ if (this.stats.compactions > before) {
1203
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
1204
+ event: "overflow-recovered",
1205
+ });
1206
+ guard--; // the retry does not count against this round's cap
1207
+ continue;
1208
+ }
1209
+ }
1185
1210
  throw err;
1186
1211
  }
1187
1212
  if (res.usage) {
@@ -1619,10 +1644,30 @@ export class Agent {
1619
1644
  await this.setGoalStatus("done");
1620
1645
  }
1621
1646
  }
1647
+ // Capture the last few conversation turns BEFORE the final marker so the
1648
+ // parent gets real substance (findings, file paths, numbers), not just
1649
+ // whatever the model typed into the finish() summary argument.
1650
+ const recent = [];
1651
+ for (let i = this.messages.length - 1; i >= 0 && recent.length < 6; i--) {
1652
+ const m = this.messages[i];
1653
+ if (m.role === "user")
1654
+ continue; // prompts/noise — keep agent output only
1655
+ if (m.role === "assistant" && !(m.content ?? "").trim())
1656
+ continue;
1657
+ const who = m.role === "tool" ? "tool" : m.role;
1658
+ let line = `[${who}] ${(m.content ?? "").trim()}`;
1659
+ if (m.role === "tool") {
1660
+ // tool results are prefixed "(failed)" by the logger when they fail —
1661
+ // the raw content is what matters here, and cap hard: these can be huge
1662
+ line = `[${who}] ${(m.content ?? "").trim().slice(0, 1200)}`;
1663
+ }
1664
+ recent.unshift(line.slice(0, 1500));
1665
+ }
1622
1666
  await this.log.append("message", this.currentSession, this.currentBranch, {
1623
1667
  role: "assistant",
1624
1668
  final: true,
1625
1669
  content: String(args.summary ?? ""),
1670
+ ...(recent.length ? { recentContext: recent } : {}),
1626
1671
  });
1627
1672
  }
1628
1673
  async maybeRequestProgress() {
@@ -1772,14 +1817,33 @@ export class Agent {
1772
1817
  break;
1773
1818
  }
1774
1819
  const PRUNE_MIN = 3_000;
1820
+ // Tool-kind aware pruning (Claude Code MicroCompact style): high-volume,
1821
+ // REPRODUCIBLE results (file reads, shell, grep) are safe to clip because
1822
+ // the model can re-issue them; one-shot results (spawned agent reports,
1823
+ // web fetches) are kept — re-fetching is impossible or expensive.
1824
+ const PRUNABLE_TOOLS = new Set([
1825
+ "bash", "read_file", "list_dir", "grep", "glob", "write_file", "edit_file", "apply_patch",
1826
+ "bash_output",
1827
+ ]);
1775
1828
  let saved = 0;
1776
1829
  let count = 0;
1830
+ // callId → tool name, from the assistant turn that issued each call
1831
+ // (a tool result's PREVIOUS message is not reliably its caller when
1832
+ // parallel calls interleave)
1833
+ const callerTool = new Map();
1834
+ for (const m of this.messages) {
1835
+ for (const t of m.tool_calls ?? [])
1836
+ callerTool.set(t.id, t.function.name);
1837
+ }
1777
1838
  for (let i = 0; i < boundary; i++) {
1778
1839
  const m = this.messages[i];
1779
- if (m.role === "tool" && (m.content?.length ?? 0) > PRUNE_MIN) {
1840
+ const toolName = m.tool_call_id ? callerTool.get(m.tool_call_id) ?? "" : "";
1841
+ if (m.role === "tool" &&
1842
+ PRUNABLE_TOOLS.has(toolName) &&
1843
+ (m.content?.length ?? 0) > PRUNE_MIN) {
1780
1844
  const len = m.content.length;
1781
1845
  saved += len - 400;
1782
- m.content = m.content.slice(0, 400) + `\n…[pruned ${len} bytes of tool output]`;
1846
+ m.content = m.content.slice(0, 400) + `\n…[pruned ${len} bytes of ${toolName || "tool"} output]`;
1783
1847
  count++;
1784
1848
  }
1785
1849
  }
@@ -1883,6 +1947,31 @@ export class Agent {
1883
1947
  const after = this.estimateTokens();
1884
1948
  this.stats.compactions++;
1885
1949
  this.compactedAtLen = this.messages.length;
1950
+ // Post-compact file restore (Claude Code style): re-attach the most
1951
+ // recently read files so the model doesn't burn its first turns re-reading
1952
+ // exactly what it had in context a moment ago. Budget: 3 files × 400 lines.
1953
+ if (this.recentReads.length && mode === "summarize") {
1954
+ const restored = [];
1955
+ for (const rel of this.recentReads.slice(0, 3)) {
1956
+ try {
1957
+ const abs = safeJoin(this.opts.workspace, rel);
1958
+ const text = await fs.readFile(abs, "utf8");
1959
+ const head = text.split("\n").slice(0, 400).join("\n");
1960
+ restored.push(`--- ${rel}${text.length > head.length ? " (first 400 lines)" : ""} ---\n${head}`);
1961
+ }
1962
+ catch {
1963
+ /* file vanished since the read — skip */
1964
+ }
1965
+ }
1966
+ if (restored.length) {
1967
+ this.messages.splice(1, 0, {
1968
+ role: "user",
1969
+ content: `[harness] These files were recently read and are re-attached for continuity ` +
1970
+ `(they may have changed — re-read if precision matters):\n\n${restored.join("\n\n")}`,
1971
+ });
1972
+ this.recentReads = this.recentReads.slice(0, restored.length); // keep order, drop misses
1973
+ }
1974
+ }
1886
1975
  // the operator can inspect WHAT was remembered — a dedicated typed event
1887
1976
  // (not an agent message) keeps it out of the model's own voice
1888
1977
  await this.log.append("compaction", this.currentSession, this.currentBranch, {
@@ -1915,21 +2004,44 @@ export class Agent {
1915
2004
  .join("\n\n")
1916
2005
  .slice(-120_000);
1917
2006
  const fn = this.opts.chatFn ?? chat;
2007
+ // Structured sections (Claude Code style): fixed headings survive MULTIPLE
2008
+ // successive compactions far better than free-form prose — each pass knows
2009
+ // where "pending tasks" vs "user messages" live, so nothing silently
2010
+ // merges away. The <analysis> scratchpad gives the model a thinking space;
2011
+ // it is stripped from the stored summary (formatCompactSummary).
1918
2012
  const res = await fn(this.opts.llm, [
1919
2013
  {
1920
2014
  role: "system",
1921
- content: "You compress a coding agent's conversation into dense notes for it to continue working. " +
1922
- "Preserve: current goal state, key decisions AND the reasoning behind them, files created/changed, " +
1923
- "important command results, open problems, and the next step. Be terse bullet points, no prose flourishes. " +
1924
- 'Finish with a section "## Durable lessons" listing reusable insights worth keeping forever ' +
1925
- "(gotchas, user preferences, what worked) — or omit the section if there are none.",
2015
+ content: "You compress a coding agent's conversation into dense notes for it to continue working.\n\n" +
2016
+ "First think inside an <analysis> block: what mattered, what changed, what is unresolved.\n" +
2017
+ "Then output ONLY the following sections, each starting with its exact heading:\n\n" +
2018
+ "## Primary request and intent\n" +
2019
+ "## Key technical concepts\n" +
2020
+ "## Files and code sections\n" +
2021
+ "## Errors and fixes\n" +
2022
+ "## Problem solving\n" +
2023
+ "## All user messages\n" + // verbatim-ish: intent survives repeated compaction
2024
+ "## Pending tasks\n" +
2025
+ "## Current work\n" +
2026
+ "## Next step\n" +
2027
+ "## Durable lessons\n\n" +
2028
+ "Rules: be terse bullet points, no prose flourishes. Quote file paths and key numbers exactly. " +
2029
+ "All user messages are listed nearly verbatim — they carry intent that must survive every future compaction. " +
2030
+ 'Durable lessons lists reusable insights worth keeping forever (gotchas, preferences, what worked); omit the section if empty. ' +
2031
+ "The <analysis> block is discarded before your notes are stored, so think freely there.",
1926
2032
  },
1927
2033
  { role: "user", content: `Conversation:\n\n${transcript}\n\nWrite the continuation notes now.` },
1928
2034
  ], [], undefined, // never abortable by stop(): losing the summary would lose history
1929
2035
  onDelta
1930
2036
  ? (s) => onDelta(s.text)
1931
2037
  : undefined);
1932
- return res.message.content ?? "";
2038
+ return this.formatCompactSummary(res.message.content ?? "");
2039
+ }
2040
+ /** strip the <analysis> scratchpad — it improves thinking but must not
2041
+ consume post-compact context tokens */
2042
+ formatCompactSummary(raw) {
2043
+ const m = raw.match(/<analysis>[\s\S]*?<\/analysis>\s*/);
2044
+ return m ? raw.slice(m[0].length).trim() : raw.trim();
1933
2045
  }
1934
2046
  /**
1935
2047
  * Extract the "## Durable lessons" block from a compaction summary and
@@ -103,6 +103,12 @@ function startBackgroundShell(cmd, ctx) {
103
103
  map.set(id, sh);
104
104
  return id;
105
105
  }
106
+ /** Heuristic: did this provider error mean "prompt exceeded the context window"?
107
+ * Wording varies by provider (OpenRouter/OpenAI/Anthropic/local servers). */
108
+ export function isContextOverflow(err) {
109
+ const msg = String(err?.message ?? "").toLowerCase();
110
+ return (/context (length|window|limit)|too many tokens|prompt is too long|max.{0,12}tokens.{0,20}(exceed|surpass)|request too large|payload too large|exceeds.{0,20}limit/.test(msg) || /\b(400|413)\b.*token/.test(msg));
111
+ }
106
112
  /** Run a command in its own process group; kill the whole group on timeout. */
107
113
  function runShell(cmd, ctx, timeoutMs) {
108
114
  return new Promise((resolve) => {
@@ -515,6 +521,7 @@ export const TOOLS = [
515
521
  async run(args, ctx) {
516
522
  const p = safeJoin(ctx.cwd, str(args.path));
517
523
  const text = await readText(p);
524
+ ctx.onFileRead?.(str(args.path));
518
525
  const lines = text.split("\n");
519
526
  // grep mode
520
527
  if (typeof args.pattern === "string" && args.pattern !== "") {
package/dist/master.js CHANGED
@@ -666,8 +666,18 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
666
666
  return;
667
667
  // forward outcomes so the parent hears the result without polling
668
668
  if (e.type === "message" && e.data.final) {
669
- const summary = String(e.data.content ?? "").trim();
670
- parent.enqueuePrompt(`[harness] Sub-agent ${ac.id} finished. Final report:\n${summary.slice(0, 2000) || "(no summary)"}`, "harness");
669
+ const d = e.data;
670
+ const summary = String(d.content ?? "").trim();
671
+ // the finish() summary alone is often thin — the agent's last few real
672
+ // conversation turns (findings, paths, numbers) carry the substance.
673
+ // Include them so the parent can act without re-reading the child log.
674
+ let report = summary;
675
+ if (Array.isArray(d.recentContext) && d.recentContext.length) {
676
+ report +=
677
+ "\n\n[Last conversation turns before finishing]\n" +
678
+ d.recentContext.map((r) => r).join("\n\n");
679
+ }
680
+ parent.enqueuePrompt(`[harness] Sub-agent ${ac.id} finished. Final report:\n${report.slice(0, 6000) || "(no summary)"}`, "harness");
671
681
  }
672
682
  else if (e.type === "error") {
673
683
  parent.enqueuePrompt(`[harness] Sub-agent ${ac.id} hit an error: ${String(e.data.message ?? "").slice(0, 500)}`, "harness");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.21.0",
3
+ "version": "0.22.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",