teapot-coding-agent 0.20.3 → 0.21.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.
@@ -6,7 +6,7 @@
6
6
  * periodic behaviour (progress reports, scheduled tasks) is driven by the
7
7
  * master's single low-frequency scheduler tick or by turn boundaries.
8
8
  */
9
- import { promises as fs } from "node:fs";
9
+ import { promises as fs, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
10
10
  import path from "node:path";
11
11
  import { EventLog, readEvents } from "../log/events.js";
12
12
  import { chat, chatStream } from "./llm.js";
@@ -170,6 +170,7 @@ export class Agent {
170
170
  readOnly: this.opts.readOnlyTools,
171
171
  onIdlePark: (reason) => this.parkForTool(reason),
172
172
  onIdleUnpark: () => this.unparkFromTool(),
173
+ onBackgroundExit: (info) => void this.onBackgroundJobExit(info),
173
174
  };
174
175
  // the session id IS the directory name — one directory per incarnation
175
176
  this.mainSession = path.basename(opts.sessionDir);
@@ -256,6 +257,28 @@ export class Agent {
256
257
  * operator isn't staring at a running spinner for minutes. The run chain is
257
258
  * still parked inside the tool — but any prompt/stop wakes it immediately.
258
259
  */
260
+ /**
261
+ * A background shell (bash background=true) finished. Two channels:
262
+ * 1. a system_note row in the timeline — the operator sees the outcome
263
+ * without opening anything;
264
+ * 2. a queued harness prompt, delivered at the next turn boundary — the
265
+ * agent learns exit code + output tail WITHOUT having to poll
266
+ * bash_output blindly. Failed jobs say so loudly so the model reacts.
267
+ */
268
+ onBackgroundJobExit(info) {
269
+ const ok = info.code === 0;
270
+ const one = info.cmd.replace(/\s+/g, " ").slice(0, 80);
271
+ void this.log.append("system_note", this.currentSession, this.currentBranch, {
272
+ event: "background-exit",
273
+ jobId: info.id,
274
+ code: info.code,
275
+ durationMs: info.durationMs,
276
+ cmd: one,
277
+ failed: !ok,
278
+ });
279
+ // queue the report for the next turn boundary (agent-visible channel)
280
+ this.onBackgroundJobExitQueued(info);
281
+ }
259
282
  parkForTool(reason) {
260
283
  if (this.parkedByTool || this.status !== "running")
261
284
  return;
@@ -808,6 +831,7 @@ export class Agent {
808
831
  * call payload — not merely when it was logged.
809
832
  */
810
833
  drainPendingPrompts() {
834
+ this.drainBackgroundExits();
811
835
  for (const p of this.pendingPrompts.splice(0)) {
812
836
  // multimodal: images ride as OpenAI content parts (text first, then
813
837
  // images); plain prompts keep the string form for cache friendliness
@@ -843,6 +867,34 @@ export class Agent {
843
867
  }
844
868
  /** ids of user prompts that have entered an LLM call payload (recent first-capped) */
845
869
  deliveredPrompts = [];
870
+ /**
871
+ * Background jobs that exited since the last LLM call. Their outcomes are
872
+ * folded into ONE harness message at the turn boundary — the agent learns
873
+ * exit codes and output tails without polling bash_output.
874
+ */
875
+ bgExits = [];
876
+ onBackgroundJobExitQueued(info) {
877
+ this.bgExits.push(info);
878
+ if (this.bgExits.length > 32)
879
+ this.bgExits.shift(); // cap runaway spam
880
+ bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
881
+ }
882
+ /** fold queued background-exit reports into the next LLM call (once each) */
883
+ drainBackgroundExits() {
884
+ if (!this.bgExits.length)
885
+ return;
886
+ const exits = this.bgExits.splice(0);
887
+ const lines = exits.map((e) => {
888
+ const status = e.code === 0 ? "finished OK" : `FAILED with exit code ${e.code ?? "?"}`;
889
+ const tail = e.outputTail.trim();
890
+ return (`- job ${e.id}: ${status} after ${(e.durationMs / 1000).toFixed(1)}s — ${e.cmd.slice(0, 100)}` +
891
+ (tail ? `\n output tail:\n${tail.split("\n").slice(-8).map((l) => " | " + l).join("\n")}` : ""));
892
+ });
893
+ const text = `[harness] Background job report:\n${lines.join("\n")}\n\n` +
894
+ "React to failures now if needed. Full output stays available via bash_output(job_id).";
895
+ this.messages.push({ role: "user", content: text });
896
+ void this.log.append("prompt", this.currentSession, this.currentBranch, { source: "harness", text }).catch(() => { });
897
+ }
846
898
  /** Resolves when all queued work (including a running loop) has settled. */
847
899
  settled() {
848
900
  return this.runChain.catch(() => { });
@@ -1418,11 +1470,100 @@ export class Agent {
1418
1470
  return finished;
1419
1471
  }
1420
1472
  buildMessages() {
1421
- // deliberately static: [system] + append-only history keeps prefix caches hot
1473
+ // deliberately static: [system] + append-only history keeps prefix caches hot.
1474
+ // The workspace context block (AGENTS.md + ls snapshot) is appended to the
1475
+ // system message ONCE per session — changing it mid-session would re-price
1476
+ // the whole prefix cache, so it is captured at first use and frozen.
1477
+ const sys = this.systemPrompt();
1422
1478
  const hasSystem = this.messages[0]?.role === "system";
1423
- const head = [{ role: "system", content: SYSTEM_TEMPLATE }];
1479
+ const head = [{ role: "system", content: sys }];
1424
1480
  return hasSystem ? [...head, ...this.messages.slice(1)] : [...head, ...this.messages];
1425
1481
  }
1482
+ /** AGENTS.md content cached for the session (empty string = none/read-failed) */
1483
+ agentsMd = "";
1484
+ agentsMdLoaded = false;
1485
+ /** one-time workspace listing injected with the system prompt */
1486
+ wsSnapshot = "";
1487
+ systemPrompt() {
1488
+ if (!this.agentsMdLoaded) {
1489
+ this.agentsMdLoaded = true;
1490
+ // AGENTS.md + workspace listing are captured ONCE and PERSISTED to the
1491
+ // session dir, so a restart re-issues the identical system prompt (the
1492
+ // prefix cache survives; the snapshot is a session artifact, not live
1493
+ // truth — list_dir shows current state).
1494
+ const snapPath = path.join(this.opts.sessionDir, "workspace-snapshot.txt");
1495
+ try {
1496
+ // a previous incarnation already froze a snapshot → reuse it verbatim.
1497
+ // The sentinel covers EMPTY workspaces too ("" would look like "no
1498
+ // file" and make the restart re-list, breaking prefix-cache identity).
1499
+ this.wsSnapshot = readFileSync(snapPath, "utf8");
1500
+ }
1501
+ catch {
1502
+ this.wsSnapshot = "";
1503
+ }
1504
+ if (this.wsSnapshot === "" && existsSync(snapPath)) {
1505
+ this.wsSnapshot = "(empty workspace)"; // the file exists but was empty
1506
+ }
1507
+ if (!this.wsSnapshot) {
1508
+ try {
1509
+ this.agentsMd = existsSync(path.join(this.opts.workspace, "AGENTS.md"))
1510
+ ? readFileSync(path.join(this.opts.workspace, "AGENTS.md"), "utf8").slice(0, 12_000)
1511
+ : "";
1512
+ }
1513
+ catch {
1514
+ this.agentsMd = "";
1515
+ }
1516
+ try {
1517
+ const entries = readdirSync(this.opts.workspace, { withFileTypes: true });
1518
+ const lines = [];
1519
+ for (const e of entries.slice(0, 60)) {
1520
+ if (e.name.startsWith("."))
1521
+ continue;
1522
+ let size = "";
1523
+ if (!e.isDirectory()) {
1524
+ try {
1525
+ size = `${(statSync(path.join(this.opts.workspace, e.name)).size / 1024).toFixed(1)}K`;
1526
+ }
1527
+ catch {
1528
+ /* vanished */
1529
+ }
1530
+ }
1531
+ lines.push(e.isDirectory() ? `${e.name}/` : `${e.name} (${size})`);
1532
+ }
1533
+ this.wsSnapshot = lines.join("\n");
1534
+ if (!this.wsSnapshot)
1535
+ this.wsSnapshot = "(empty workspace)";
1536
+ try {
1537
+ void fs.writeFile(snapPath, this.wsSnapshot, "utf8");
1538
+ }
1539
+ catch {
1540
+ /* best-effort persistence */
1541
+ }
1542
+ }
1543
+ catch {
1544
+ this.wsSnapshot = "(workspace not readable)";
1545
+ }
1546
+ }
1547
+ else {
1548
+ try {
1549
+ this.agentsMd = existsSync(path.join(this.opts.workspace, "AGENTS.md"))
1550
+ ? readFileSync(path.join(this.opts.workspace, "AGENTS.md"), "utf8").slice(0, 12_000)
1551
+ : "";
1552
+ }
1553
+ catch {
1554
+ this.agentsMd = "";
1555
+ }
1556
+ }
1557
+ }
1558
+ let sys = SYSTEM_TEMPLATE;
1559
+ if (this.agentsMd.trim()) {
1560
+ sys += `\n\n## Project instructions (AGENTS.md)\n\n${this.agentsMd}`;
1561
+ }
1562
+ if (this.wsSnapshot) {
1563
+ sys += `\n\n## Workspace snapshot (top level)\n${this.wsSnapshot}\nUse list_dir/read_file for anything deeper — do NOT list again what is already here.`;
1564
+ }
1565
+ return sys;
1566
+ }
1426
1567
  async handleFinish(argsJson) {
1427
1568
  const args = safeParse(argsJson);
1428
1569
  if (args.goalComplete === true && this.goal.status !== "done") {
@@ -83,7 +83,23 @@ function startBackgroundShell(cmd, ctx) {
83
83
  };
84
84
  child.stdout?.on("data", collect);
85
85
  child.stderr?.on("data", collect);
86
- child.on("close", (code) => { sh.exited = true; sh.code = code; });
86
+ child.on("close", (code) => {
87
+ sh.exited = true;
88
+ sh.code = code;
89
+ // tell the harness so the agent learns the outcome WITHOUT polling
90
+ try {
91
+ ctx.onBackgroundExit?.({
92
+ id,
93
+ code,
94
+ cmd,
95
+ durationMs: Date.now() - sh.startedAt,
96
+ outputTail: sh.out.slice(-2000),
97
+ });
98
+ }
99
+ catch {
100
+ /* notification is best-effort */
101
+ }
102
+ });
87
103
  map.set(id, sh);
88
104
  return id;
89
105
  }
@@ -709,8 +725,9 @@ export const TOOLS = [
709
725
  timeout_ms: { type: "number", description: `default ${DEFAULT_TIMEOUT_MS}` },
710
726
  background: {
711
727
  type: "boolean",
712
- description: "true = start the command and return IMMEDIATELY with a job id. Poll its output later via " +
713
- "bash_output. Use for dev servers, watchers, long builds — anything you don't need to wait for.",
728
+ description: "true = start the command and return IMMEDIATELY with a job id. You are NOTIFIED at your " +
729
+ "next turn boundary when it exits (exit code + output tail) — no polling needed. " +
730
+ "Use bash_output only to fetch FULL output mid-run or after. Use for dev servers, watchers, long builds.",
714
731
  },
715
732
  },
716
733
  required: ["command"],
@@ -720,8 +737,9 @@ export const TOOLS = [
720
737
  const id = startBackgroundShell(str(args.command), ctx);
721
738
  return {
722
739
  ok: true,
723
- result: `started in background (job ${id}). Continue with other work; read its output with ` +
724
- `bash_output(job_id="${id}"). Kill it with bash_output(job_id="${id}", action="kill").`,
740
+ result: `started in background (job ${id}). It runs while you work — you'll be notified here ` +
741
+ `when it exits (exit code + output tail). Full output: bash_output(job_id="${id}"). ` +
742
+ `Kill: bash_output(job_id="${id}", action="kill").`,
725
743
  };
726
744
  }
727
745
  return runShell(str(args.command), ctx, Math.min(num(args.timeout_ms, ctx.defaultTimeoutMs), 600_000));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.20.3",
3
+ "version": "0.21.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",