teapot-coding-agent 0.27.1 → 0.28.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.
@@ -27,11 +27,35 @@ import { bus } from "../bus.js";
27
27
  * satisfied the request — sent a voluntary report_progress right after, and
28
28
  * every requested report appeared TWICE on the timeline.
29
29
  */
30
- export const AUTO_CONTINUE_NUDGE = "Continue working toward the current goal. If you are blocked, explain why briefly. " +
30
+ export const AUTO_CONTINUE_NUDGE =
31
+ // #136: the prefix marks this as harness text, not something the operator typed.
32
+ // Unprefixed, the model read it as a request to reply — and prose ends the round
33
+ // without finishing, which starts the next one.
34
+ "[harness] " +
35
+ "Continue working toward the current goal. If you are blocked, explain why briefly. " +
31
36
  // name the tool explicitly, the way the sub-agent nudge already does
32
37
  "When the goal is genuinely met — or truly blocked — call finish() " +
33
38
  "(finish(goalComplete=true) when met, finish(goalComplete=false) when blocked) " +
34
39
  "instead of replying in prose; prose here does not stop the loop.";
40
+ /**
41
+ * The auto-continue nudge with the CURRENT GOAL restated.
42
+ *
43
+ * #136: the nudge said "the current goal" and never said what it was. The sub-agent
44
+ * variant has always included its task text for exactly this reason — a forked
45
+ * child otherwise drifts back into the inherited conversation and keeps going
46
+ * forever (#106) — but the ROOT nudge had no such reminder, so a root agent that
47
+ * had lost the thread had nothing to re-anchor on and simply continued until the
48
+ * `maxConsecutiveIdleRounds` valve tripped.
49
+ *
50
+ * The goal is clipped like the sub-agent's: a 20k-character goal would otherwise
51
+ * re-enter the context on EVERY round.
52
+ */
53
+ export function autoContinueNudge(goalText) {
54
+ const goal = goalText.trim();
55
+ if (!goal)
56
+ return AUTO_CONTINUE_NUDGE;
57
+ return `${AUTO_CONTINUE_NUDGE}\nCurrent goal:\n=====\n${goal.slice(0, 1500)}\n=====`;
58
+ }
35
59
  export const PROGRESS_REQUEST =
36
60
  // The leading sentence is kept VERBATIM: it is what existing harness prompts,
37
61
  // tests and any operator muscle-memory key off. #45 changed the INSTRUCTION
@@ -439,7 +463,26 @@ export class Agent {
439
463
  * (#59) — the same trap #38 documented for editing history.
440
464
  */
441
465
  isLive() {
442
- return this.status === "running" || this.parkedByTool || this.pendingPrompts.length > 0;
466
+ // #127: a MANUAL compaction on an idle agent was rewriting `messages` — a
467
+ // large, non-cancellable operation — while `isLive()` reported false, so the
468
+ // UI offered "start".
469
+ //
470
+ // #133: and the reverse — after a STOP with prompts still queued, `live` stayed
471
+ // true forever, so the controls kept offering "stop" for an agent that was
472
+ // already stopped, and no amount of reloading helped. Measured:
473
+ //
474
+ // after stop : status = stopped live = true pendingPrompts = 1
475
+ //
476
+ // A queued prompt is not work in progress: it is waiting for the operator to
477
+ // press start. #9 settled that a stop must neither deliver nor silently
478
+ // DISCARD those prompts, so they are kept — but keeping them must not make the
479
+ // agent read as running. A stop outranks them.
480
+ if (this.stopRequested)
481
+ return false;
482
+ return (this.status === "running" ||
483
+ this.parkedByTool ||
484
+ this.pendingPrompts.length > 0 ||
485
+ this.compactPhase !== "");
443
486
  }
444
487
  /**
445
488
  * A background shell (bash background=true) finished. Two channels:
@@ -1567,9 +1610,13 @@ export class Agent {
1567
1610
  break;
1568
1611
  }
1569
1612
  const isSub = !!this.opts.parent;
1613
+ // #136: the ROOT nudge now restates the goal too, exactly as the sub-agent
1614
+ // one always has. Both branches need it — a child drifts back into the
1615
+ // inherited conversation, a root agent loses the thread, and in both cases
1616
+ // "continue toward the current goal" is unactionable without the goal text.
1570
1617
  const nudge = isSub
1571
1618
  ? `[harness] Auto-nudge. Re-anchor on YOUR task — and only that:\n\n${this.goal.text.slice(0, 1500)}\n\nThe inherited conversation is reference only; other agents own any older tasks in it. If your task is complete, call finish() now instead of continuing. If blocked, explain why briefly and finish().`
1572
- : AUTO_CONTINUE_NUDGE;
1619
+ : autoContinueNudge(this.goal.text);
1573
1620
  await this.log.append("prompt", this.currentSession, this.currentBranch, {
1574
1621
  source: "harness",
1575
1622
  text: nudge,
@@ -1613,11 +1660,6 @@ export class Agent {
1613
1660
  if (!this.stopRequested && !this.awaitingUser)
1614
1661
  this.setStatus("idle", "round complete");
1615
1662
  }
1616
- /**
1617
- * One LLM call with a fresh abort controller so stop() can interrupt it
1618
- * immediately, plus loop-level retries for provider flakiness (the SDK
1619
- * already backsoff 429/5xx; this covers exhausted rate limits and 400s).
1620
- */
1621
1663
  /**
1622
1664
  * One LLM call with a fresh abort controller so stop() can interrupt it
1623
1665
  * immediately, plus loop-level retries for provider flakiness (the SDK
@@ -2927,14 +2969,40 @@ export class Agent {
2927
2969
  }
2928
2970
  /** Ask the model for dense continuation notes over the compacted range. */
2929
2971
  async summarize(old, onDelta) {
2930
- const transcript = old
2972
+ const full = old
2931
2973
  .map((m) => {
2932
2974
  const who = m.role === "tool" ? "tool" : m.role;
2933
2975
  const tc = m.tool_calls?.map((t) => `\n[calls ${t.function.name}(${t.function.arguments})]`).join("");
2934
2976
  return `${who}: ${m.content ?? ""}${tc}`;
2935
2977
  })
2936
- .join("\n\n")
2937
- .slice(-120_000);
2978
+ .join("\n\n");
2979
+ // #127: this was a bare `.slice(-120_000)` — the TAIL only.
2980
+ //
2981
+ // The question the issue raises is the right one: when a compaction starts,
2982
+ // the context window opens dramatically, and the summarizer saw only the last
2983
+ // 120k characters of what it was asked to summarise. Anything older was not
2984
+ // summarised and not kept — it was silently DISCARDED. So the original user
2985
+ // requirement, which sits at the very front of the history, was the single
2986
+ // most likely thing to be lost, and losing it is the one failure compaction
2987
+ // cannot recover from: the summary is now the only record.
2988
+ //
2989
+ // Head + tail, with the seam marked so the model can tell a gap from a
2990
+ // boundary. The head is where the goal, the constraints and the original ask
2991
+ // live; the tail is where the recent state and any in-flight error do.
2992
+ const BUDGET = 120_000;
2993
+ const HEAD = Math.floor(BUDGET * 0.35);
2994
+ const TAIL = BUDGET - HEAD;
2995
+ let transcript;
2996
+ if (full.length <= BUDGET) {
2997
+ transcript = full;
2998
+ }
2999
+ else {
3000
+ transcript = [
3001
+ full.slice(0, HEAD),
3002
+ `\n\n[... ${full.length - BUDGET} characters elided from the middle of this history ...]\n\n`,
3003
+ full.slice(full.length - TAIL),
3004
+ ].join("");
3005
+ }
2938
3006
  const fn = this.opts.chatFn ?? chat;
2939
3007
  // Structured sections (Claude Code style): fixed headings survive MULTIPLE
2940
3008
  // successive compactions far better than free-form prose — each pass knows
@@ -1195,7 +1195,8 @@ export const TOOLS = [
1195
1195
  // #126: the single most common edit_file failure in the real logs (22
1196
1196
  // occurrences) said only "old_text is required", while the tool's own
1197
1197
  // schema already documents the better form. Name it and say what to do —
1198
- // the pattern master.ts uses for "not your sub-agent" (master.ts:979),
1198
+ // the pattern master.ts uses for "not your sub-agent" (its
1199
+ // `messageChild`),
1199
1200
  // which turned a confusing refusal into a re-addressable one.
1200
1201
  if (!oldText)
1201
1202
  return {
package/dist/index.js CHANGED
@@ -90,24 +90,49 @@ function supervise(pid) {
90
90
  process.on(sig, () => forward(sig));
91
91
  }
92
92
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
93
- async function main() {
94
- // CLI: teapot [--port N] [-p N] [--config file] [-c file] [config.json]
93
+ /**
94
+ * Parse `teapot [--port N] [-p N] [--host addr] [--config file] [config.json]`.
95
+ *
96
+ * Extracted from main() and exported so it can be tested. It was inline before,
97
+ * which left `index.ts` at 48% branch coverage with NO test importing it at all —
98
+ * the parser decides which config file is read and which port is bound, so a
99
+ * regression here starts the wrong server on the wrong port with no error anyone
100
+ * would read.
101
+ *
102
+ * The bare-word rule keeps the FIRST positional as the config path: a later bare
103
+ * word is a stray argument, not an override, and silently replacing the config
104
+ * with it would be worse than ignoring it.
105
+ */
106
+ export function parseArgs(argv) {
95
107
  let cfgArg;
96
108
  let portOverride;
97
109
  let hostOverride;
98
- const args = process.argv.slice(2);
99
- for (let i = 0; i < args.length; i++) {
100
- const a = args[i];
101
- if (a === "--port" || a === "-p")
102
- portOverride = Number(args[++i]);
103
- else if (a === "--host")
104
- hostOverride = args[++i];
105
- else if (a === "--config" || a === "-c")
106
- cfgArg = args[++i];
107
- else if (!cfgArg && !a.startsWith("-"))
110
+ for (let i = 0; i < argv.length; i++) {
111
+ const a = argv[i];
112
+ if (a === "--port" || a === "-p") {
113
+ const raw = argv[++i];
114
+ const n = Number(raw);
115
+ // NaN is dropped rather than applied: `config.port = NaN` would fall through
116
+ // to the listen call and produce a far more confusing failure than ignoring
117
+ // a mistyped flag
118
+ if (!Number.isNaN(n))
119
+ portOverride = n;
120
+ }
121
+ else if (a === "--host") {
122
+ hostOverride = argv[++i];
123
+ }
124
+ else if (a === "--config" || a === "-c") {
125
+ cfgArg = argv[++i];
126
+ }
127
+ else if (!a.startsWith("-") && cfgArg === undefined) {
108
128
  cfgArg = a;
129
+ }
109
130
  }
110
- if (process.argv.includes("--help") || process.argv.includes("-h")) {
131
+ return { cfgArg, portOverride, hostOverride, help: argv.includes("--help") || argv.includes("-h") };
132
+ }
133
+ async function main() {
134
+ const { cfgArg, portOverride, hostOverride, help } = parseArgs(process.argv.slice(2));
135
+ if (help) {
111
136
  console.log("teapot [--port N] [--host addr] [--config file.json] [config.json]");
112
137
  console.log(" --host 127.0.0.1 (default) · 0.0.0.0 exposes the UI to your network");
113
138
  console.log(" --acp speak the Agent Client Protocol on stdio instead of serving HTTP (#15)");
@@ -229,4 +254,15 @@ async function main() {
229
254
  process.on("SIGINT", () => void shutdown("SIGINT"));
230
255
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
231
256
  }
232
- void main();
257
+ // Only when RUN, not when imported. `void main()` at module scope meant importing
258
+ // this file for `parseArgs` started a whole server — which is why index.ts had no
259
+ // test importing it and sat at 48% branch coverage. The check is the standard
260
+ // ESM "am I the entry point" test, and `node --experimental-strip-types src/index.ts`
261
+ // and `node dist/index.js` both still take this branch.
262
+ const isEntry = process.argv[1] !== undefined &&
263
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
264
+ (process.argv[1] === new URL(import.meta.url).pathname ||
265
+ process.argv[1].endsWith("/index.js") ||
266
+ process.argv[1].endsWith("/index.ts"));
267
+ if (isEntry)
268
+ void main();
package/dist/master.js CHANGED
@@ -568,8 +568,30 @@ export class Master {
568
568
  // #130: a spawned child is `idle` until start() runs, and that lazy-load
569
569
  // flip is its FIRST transition — so requiring a prior `running` is what stops
570
570
  // the parent being told the child settled before it began.
571
+ // #135: reworded, because the old line was wrong in two ways.
572
+ //
573
+ // "Continue your task, or finish() if the work is done" was addressed to the
574
+ // PARENT — but the parent did not spawn this child for its own sake, and
575
+ // when the child DID call finish() the parent already receives a proper
576
+ // "Sub-agent X finished. Final report: …" (the `finished. Final report:`
577
+ // prompt below). So the nudge
578
+ // restated as an instruction what the report already stated as fact, and
579
+ // invited the parent to `finish()` ITS OWN goal over a child's status.
580
+ //
581
+ // The new wording says what happened and what to do about it, and points at
582
+ // the report when there is one:
583
+ //
584
+ // child gave a summary -> "…returned without calling finish(); its final
585
+ // message follows." (the report arrives separately)
586
+ // child said nothing -> "…went idle without reporting. If that is
587
+ // unexpected, message it or re-spawn it."
571
588
  if (this.reportedRunning.has(agentId) && SETTLED.has(status))
572
- parent.enqueuePrompt(`[harness] @${agentId} is now ${status}. Continue your task, or finish() if the work is done.`, "harness");
589
+ parent.enqueuePrompt(`[harness] Sub-agent @${agentId} is now ${status} without having called finish(), so it has not reported a result.` +
590
+ (status === "error"
591
+ ? ` Check its last output before deciding whether to re-spawn it.`
592
+ : status === "stopped"
593
+ ? ` It may have been stopped mid-task; message it to resume, or re-spawn it.`
594
+ : ` If you expected a result, message it or re-spawn it.`), "harness");
573
595
  if (status === "running")
574
596
  this.reportedRunning.add(agentId);
575
597
  else if (SETTLED.has(status))
@@ -1134,6 +1134,30 @@ export function buildApp(master) {
1134
1134
  // only used for the "load older" affordance, where approximate is fine.
1135
1135
  const branch = c.req.query("branch");
1136
1136
  const before = c.req.query("before");
1137
+ // #54: `?after=<id>` returns only what landed AFTER that event.
1138
+ //
1139
+ // This is the cursor the reconnect path was missing. Without it a dropped
1140
+ // socket is recovered by re-fetching the whole tail — 2,000 events by default
1141
+ // — and throwing away all but the few that are new. That is what makes the
1142
+ // architecture REST-authoritative: the frontend can only learn "something
1143
+ // changed" and must re-read to find out what.
1144
+ //
1145
+ // It is also the only cursor that can be cheap here: the tail path scans
1146
+ // backwards from EOF, so an `after` lookup that lands near the end reads a
1147
+ // small window instead of parsing the whole log.
1148
+ const after = c.req.query("after");
1149
+ if (after && !before && !branch) {
1150
+ const all = await readEventsCached(filePath);
1151
+ const idx = all.findIndex((e) => e.id === after);
1152
+ // an unknown cursor means the client's view is not a prefix of ours (a
1153
+ // compacted or rotated log), so a full tail is the only safe answer
1154
+ if (idx === -1) {
1155
+ const tail = await readEventsTail(filePath, limit);
1156
+ return c.json({ events: tail.events, total: tail.total, partial: tail.truncated, gap: true });
1157
+ }
1158
+ const events = all.slice(idx + 1);
1159
+ return c.json({ events, total: all.length, appended: events.length > 0 });
1160
+ }
1137
1161
  if (!before && !branch) {
1138
1162
  const tail = await readEventsTail(filePath, limit);
1139
1163
  return c.json({ events: tail.events, total: tail.total, partial: tail.truncated });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.27.1",
3
+ "version": "0.28.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",