teapot-coding-agent 0.22.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) {
@@ -1792,14 +1817,33 @@ export class Agent {
1792
1817
  break;
1793
1818
  }
1794
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
+ ]);
1795
1828
  let saved = 0;
1796
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
+ }
1797
1838
  for (let i = 0; i < boundary; i++) {
1798
1839
  const m = this.messages[i];
1799
- 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) {
1800
1844
  const len = m.content.length;
1801
1845
  saved += len - 400;
1802
- 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]`;
1803
1847
  count++;
1804
1848
  }
1805
1849
  }
@@ -1903,6 +1947,31 @@ export class Agent {
1903
1947
  const after = this.estimateTokens();
1904
1948
  this.stats.compactions++;
1905
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
+ }
1906
1975
  // the operator can inspect WHAT was remembered — a dedicated typed event
1907
1976
  // (not an agent message) keeps it out of the model's own voice
1908
1977
  await this.log.append("compaction", this.currentSession, this.currentBranch, {
@@ -1935,21 +2004,44 @@ export class Agent {
1935
2004
  .join("\n\n")
1936
2005
  .slice(-120_000);
1937
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).
1938
2012
  const res = await fn(this.opts.llm, [
1939
2013
  {
1940
2014
  role: "system",
1941
- content: "You compress a coding agent's conversation into dense notes for it to continue working. " +
1942
- "Preserve: current goal state, key decisions AND the reasoning behind them, files created/changed, " +
1943
- "important command results, open problems, and the next step. Be terse bullet points, no prose flourishes. " +
1944
- 'Finish with a section "## Durable lessons" listing reusable insights worth keeping forever ' +
1945
- "(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.",
1946
2032
  },
1947
2033
  { role: "user", content: `Conversation:\n\n${transcript}\n\nWrite the continuation notes now.` },
1948
2034
  ], [], undefined, // never abortable by stop(): losing the summary would lose history
1949
2035
  onDelta
1950
2036
  ? (s) => onDelta(s.text)
1951
2037
  : undefined);
1952
- 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();
1953
2045
  }
1954
2046
  /**
1955
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.22.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",