teapot-coding-agent 0.25.1 → 0.26.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.
@@ -345,6 +345,46 @@ export class Agent {
345
345
  get compactBudgetIsManual() {
346
346
  return this.manualCompactBudget;
347
347
  }
348
+ /**
349
+ * A context size we are willing to act on.
350
+ *
351
+ * #73: `lastUsage.input` is the provider's own count, so it is normally the
352
+ * most accurate number available — but a provider that under-reports (a proxy
353
+ * that omits the field's real value, a broken gateway, a model served through
354
+ * a shim) makes BOTH safety gates believe the context is nearly empty:
355
+ *
356
+ * maybePrune() est < budget * 0.6 -> 0 prunes, forever
357
+ * maybeCompact() before < budget -> 0 compactions, forever
358
+ *
359
+ * Reproduced: ~40k characters of real conversation, provider reporting
360
+ * `prompt_tokens: 10` — zero prunes, zero compactions, and a gauge reading 10,
361
+ * so nothing looks wrong. The context then overflows for real, with the
362
+ * defence that exists to stop exactly that switched off.
363
+ *
364
+ * So the reported number is used, but never as the ONLY number: it is floored
365
+ * against the local estimate. The estimate is a heuristic; the point is that
366
+ * `??` was the wrong way to combine a measurement with a floor — it only falls
367
+ * back when the value is ABSENT, so a confidently wrong value was trusted more
368
+ * than the guess.
369
+ *
370
+ * The floor is a HALF of the local estimate. A quarter was tried first and
371
+ * was too weak to clear the 0.6-of-budget threshold for a realistic session
372
+ * (estimate 5108 → floored 1277 against a 3600 bar), so the guard still would
373
+ * not have fired. Half keeps the intent — catch "reported 10 when the real
374
+ * prompt is large" — without arguing with a provider whose tokenizer
375
+ * legitimately differs from ours, since a provider within 2x of the estimate
376
+ * is not the failure mode being defended against.
377
+ */
378
+ contextSizeForBudgeting() {
379
+ const estimated = this.estimateTokens();
380
+ const reported = this.lastUsage?.input;
381
+ if (reported === undefined)
382
+ return estimated;
383
+ // a provider reporting nothing at all is a different bug; treat it as unknown
384
+ if (!Number.isFinite(reported) || reported <= 0)
385
+ return estimated;
386
+ return Math.max(reported, Math.floor(estimated / 2));
387
+ }
348
388
  /**
349
389
  * Is this agent doing work that `stop()` would actually stop?
350
390
  *
@@ -722,10 +762,19 @@ export class Agent {
722
762
  droppedEvents: dropped.length,
723
763
  tailMode: tail,
724
764
  });
725
- // Seed the new branch's parent link to the fork event just written, so its
726
- // first event chains across instead of starting a disconnected parent:null
727
- // history (#38).
728
- this.log.seedBranch(newBranch, this.log.lastEventId(this.currentBranch));
765
+ // Seed the new branch's parent link so its first event chains across
766
+ // instead of starting a disconnected parent:null history (#38).
767
+ //
768
+ // #69: seed from the CUT POINT, not from this branch's tip. The fork event
769
+ // records where the edit was made (`fromEvent` = the last KEPT event), but
770
+ // `lastEventId(currentBranch)` is the newest event on the branch — which is
771
+ // one of the events this edit just DISCARDED. Seeding from the tip chained
772
+ // the new branch to the discarded tail, so lineageOf() walked straight back
773
+ // through it and a restart resurrected every dropped turn.
774
+ //
775
+ // The cut point is the right parent: it is the last event that survives.
776
+ const cutPoint = kept.at(-1)?.id ?? null;
777
+ this.log.seedBranch(newBranch, cutPoint);
729
778
  this.currentBranch = newBranch;
730
779
  this.messages = msgs;
731
780
  await this.log.append("prompt", this.currentSession, this.currentBranch, { source: "user", text });
@@ -940,6 +989,17 @@ export class Agent {
940
989
  ...(this.compactPhase ? { compacting: this.compactPhase } : {}),
941
990
  },
942
991
  pendingPrompts: this.pendingPrompts.length,
992
+ // #78: the IDS as well as the count. The web UI reconciles its local echo
993
+ // list against the live queue, and it was doing that by POSITION
994
+ // (slice(0, queued)) because only a count was published — so whenever the
995
+ // count dipped below the true queue length (a mid-queue cancellation, or a
996
+ // momentary gap), the NEWEST still-queued echoes were dropped and their
997
+ // undelivered log rows reappeared as settled "sent" rows. Identity needs
998
+ // the ids to travel; the count alone cannot express "these two".
999
+ //
1000
+ // Bounded like the rest of the snapshot, and ids are opaque, so this
1001
+ // cannot carry prompt text.
1002
+ pendingPromptIds: this.pendingPrompts.map((p) => p.id).filter((x) => !!x).slice(0, 64),
943
1003
  todo: this.todo.slice(0, 32_000), // match set_todo's cap — no silent truncation
944
1004
  parent: this.opts.parent,
945
1005
  awaiting: this.awaitingUser,
@@ -1114,7 +1174,20 @@ export class Agent {
1114
1174
  }
1115
1175
  /** Start (or resume) autonomous operation toward the goal. */
1116
1176
  start(reason = "start") {
1117
- if (this.status === "running")
1177
+ // #76: a stop() immediately followed by start() USED TO BE SILENTLY LOST.
1178
+ //
1179
+ // `stop()` only flips the status via an ENQUEUED task, so between the two
1180
+ // calls the status is still "running" and the guard below returned early —
1181
+ // before `stopRequested = false` could run. The stop then landed, the loop
1182
+ // exited, and the agent ended "stopped" with the restart discarded. The same
1183
+ // window made a stop+restart abort the new round's first tool.
1184
+ //
1185
+ // So a pending stop is not a reason to refuse a start. The loop's own
1186
+ // guards (`stopRequested` re-checked after every await) keep the old turn
1187
+ // from running on, and enqueue() serialises the two tasks, so the restart
1188
+ // is ordered after the stop rather than racing it.
1189
+ const pendingStop = this.stopRequested;
1190
+ if (this.status === "running" && !pendingStop)
1118
1191
  return;
1119
1192
  this.stopRequested = false;
1120
1193
  this.awaitingUser = false; // a fresh start answers/resumes past any ask_user
@@ -1318,6 +1391,13 @@ export class Agent {
1318
1391
  const name = err.name;
1319
1392
  if (name === "StopRequested" || name === "AbortError" || this.stopRequested)
1320
1393
  throw err;
1394
+ // #76: a context-overflow error is the caller's to handle, not ours to
1395
+ // retry. Retrying the SAME oversized request four times with 5s/5s/30s
1396
+ // waits cannot succeed, and it delays the compact-and-retry that would
1397
+ // — so the overflow path never even ran. This is the same
1398
+ // "present, correct, and inert" shape as #54.
1399
+ if (isContextOverflow(err))
1400
+ throw err;
1321
1401
  if (attempt >= maxAttempts)
1322
1402
  throw err;
1323
1403
  const waitMs = waits[Math.min(attempt - 1, waits.length - 1)];
@@ -1743,6 +1823,20 @@ export class Agent {
1743
1823
  `(last: ${call.function.name})`);
1744
1824
  }
1745
1825
  }
1826
+ // #76: every tool_call an assistant message issued must have a matching
1827
+ // tool result, or the provider REJECTS the next request — which is the
1828
+ // "`200 with no choices`" shape #50 traced to a malformed history.
1829
+ //
1830
+ // Two exits could leave some unanswered: `stopRequested` returns early
1831
+ // mid-batch, and a handler that throws (ask_user parking the loop is the
1832
+ // one that bites in practice) propagates out of the whole loop. Restore
1833
+ // has a hole-filler for exactly this; the LIVE array did not, so a session
1834
+ // that hit it produced an invalid next request and then failed with
1835
+ // something that looked like a provider fault.
1836
+ //
1837
+ // So the invariant is enforced here rather than at each exit: whatever
1838
+ // happened, answer what is still outstanding before moving on.
1839
+ await this.answerUnansweredToolCalls(m);
1746
1840
  if (finished)
1747
1841
  return true;
1748
1842
  }
@@ -1750,6 +1844,37 @@ export class Agent {
1750
1844
  // nudges the model; this round simply ends (no hard error)
1751
1845
  return finished;
1752
1846
  }
1847
+ /**
1848
+ * Append a tool result for every tool_call in `m` that has not been answered.
1849
+ *
1850
+ * Scans the tail of `messages` for `tool_call_id`s, so it is correct whichever
1851
+ * path left the batch unfinished — a stop, a throw, or a normal completion
1852
+ * where a handler returned early. Answered calls are left alone.
1853
+ */
1854
+ async answerUnansweredToolCalls(m) {
1855
+ const calls = m.tool_calls ?? [];
1856
+ if (!calls.length)
1857
+ return;
1858
+ const answered = new Set();
1859
+ for (const existing of this.messages) {
1860
+ if (existing.role === "tool" && existing.tool_call_id)
1861
+ answered.add(existing.tool_call_id);
1862
+ }
1863
+ for (const call of calls) {
1864
+ if (answered.has(call.id))
1865
+ continue;
1866
+ const content = "(not completed — the tool did not run)";
1867
+ this.messages.push({ role: "tool", tool_call_id: call.id, content });
1868
+ await this.log.append("tool_result", this.currentSession, this.currentBranch, {
1869
+ callId: call.id,
1870
+ name: call.function.name,
1871
+ ok: false,
1872
+ durationMs: 0,
1873
+ result: content,
1874
+ synthesized: true,
1875
+ });
1876
+ }
1877
+ }
1753
1878
  buildMessages() {
1754
1879
  // deliberately static: [system] + append-only history keeps prefix caches hot.
1755
1880
  // The workspace context block (AGENTS.md + ls snapshot) is appended to the
@@ -2139,7 +2264,7 @@ export class Agent {
2139
2264
  * protected so current work never loses its footing.
2140
2265
  */
2141
2266
  maybePrune() {
2142
- const est = this.lastUsage?.input ?? this.estimateTokens();
2267
+ const est = this.contextSizeForBudgeting(); // floored — see #73
2143
2268
  const budget = this.opts.contextTokenBudget;
2144
2269
  if (!budget || est < budget * 0.6)
2145
2270
  return 0; // prune only when it matters
@@ -2199,7 +2324,7 @@ export class Agent {
2199
2324
  // for providers that omit usage
2200
2325
  // stage 1: clip oversized old tool outputs before considering a summarize
2201
2326
  this.maybePrune();
2202
- const before = this.lastUsage?.input ?? this.estimateTokens();
2327
+ const before = this.contextSizeForBudgeting(); // floored — see #73
2203
2328
  if (!force && before < this.opts.contextTokenBudget)
2204
2329
  return;
2205
2330
  if (!force && this.opts.autoCompact === false)
@@ -2492,12 +2617,24 @@ export class Agent {
2492
2617
  async fork(fromEventId) {
2493
2618
  const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
2494
2619
  const parentBranch = this.currentBranch;
2495
- await this.log.append("fork", this.currentSession, newBranch, {
2620
+ // Where the fork leaves the source branch. Recorded on the event AND used
2621
+ // to seed the new branch, so the two can never disagree.
2622
+ const fromEvent = fromEventId ?? this.log.lastEventId(parentBranch);
2623
+ // #69: the fork event must be appended under the SOURCE branch. Appending it
2624
+ // under the new one gave it parent:null (nothing had been written to that
2625
+ // branch yet), so lineageOf() walked an empty chain and a restart rebuilt
2626
+ // the fork with NO history at all — the same class of bug editPromptAt had,
2627
+ // fixed on one path only. editPromptAt already appends under the old
2628
+ // branch for exactly this reason.
2629
+ await this.log.append("fork", this.currentSession, parentBranch, {
2496
2630
  fromSession: this.currentSession,
2497
2631
  fromBranch: parentBranch,
2498
- fromEvent: fromEventId ?? this.log.lastEventId(parentBranch),
2632
+ fromEvent,
2499
2633
  newBranch,
2500
2634
  });
2635
+ // Seed the new branch's parent to the point it leaves the source, so its
2636
+ // first event chains across instead of starting at parent:null.
2637
+ this.log.seedBranch(newBranch, fromEvent);
2501
2638
  this.currentBranch = newBranch;
2502
2639
  this.messages = [...this.messages]; // independent history copy
2503
2640
  return { session: this.currentSession, branch: newBranch };
@@ -3,7 +3,7 @@
3
3
  * Tool specs are plain JSON-schema function definitions — provider-agnostic.
4
4
  */
5
5
  import { spawn } from "node:child_process";
6
- import { existsSync, promises as fs, realpathSync } from "node:fs";
6
+ import { existsSync, promises as fs, readlinkSync, realpathSync } from "node:fs";
7
7
  import path from "node:path";
8
8
  import { discoverSkills, isValidSkillName, readSkillFile, saveSkill, SKILL_FILE, } from "./skills.js";
9
9
  const str = (v, fallback = "") => (typeof v === "string" ? v : fallback);
@@ -60,14 +60,64 @@ export function safeJoin(cwd, p) {
60
60
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
61
61
  throw new Error(`path escapes workspace: ${p}`);
62
62
  }
63
- // symlinks inside the workspace can point anywhere — resolve the real
64
- // target and confine THAT too (path.resolve alone doesn't follow links)
63
+ // symlinks inside the workspace can point anywhere — resolve the real target
64
+ // and confine THAT too (path.resolve alone doesn't follow links)
65
+ //
66
+ // #74: the old code resolved `abs` and swallowed a throw, treating failure as
67
+ // "fine". That is exactly backwards, and the failure is the NORMAL case:
68
+ // `realpathSync` throws ENOENT for a file that does not exist yet, which is
69
+ // every write_file creating something new. So `real` stayed as the LEXICAL
70
+ // path — which is inside the workspace — and the symlink guard never ran.
71
+ //
72
+ // Demonstrated: with `esc -> /outside` (a real directory), safeJoin allowed
73
+ // `esc/pwned.txt` and write_file wrote straight through it, landing outside
74
+ // the workspace. The dangling case happened to fail later with ENOTDIR, which
75
+ // is luck, not a control.
76
+ //
77
+ // So on ENOENT/ENOTDIR, resolve the nearest EXISTING ancestor instead of
78
+ // giving up. That still catches the escape — the ancestor IS the symlink —
79
+ // while allowing genuinely new files inside the workspace.
65
80
  let real = abs;
66
81
  try {
67
82
  real = realpathSync(abs);
68
83
  }
69
- catch {
70
- /* target may not exist yet (write_file) — lexical check above still holds */
84
+ catch (err) {
85
+ const code = err.code;
86
+ if (code === "ENOENT" || code === "ENOTDIR") {
87
+ // Walk up one component at a time. A component that is itself a SYMLINK
88
+ // must be resolved even when its target does not exist — realpathSync
89
+ // throws ENOENT for the dangling link itself, so naively walking to the
90
+ // first existing ancestor steps OVER the symlink and loses it. So read
91
+ // the link and check its target directly.
92
+ let probe = path.dirname(abs);
93
+ for (;;) {
94
+ try {
95
+ real = realpathSync(probe);
96
+ break;
97
+ }
98
+ catch (inner) {
99
+ if (inner.code !== "ENOENT")
100
+ break;
101
+ // this component is a dangling symlink: resolve where it POINTS
102
+ try {
103
+ const target = readlinkSync(probe);
104
+ real = path.isAbsolute(target) ? target : path.resolve(path.dirname(probe), target);
105
+ break;
106
+ }
107
+ catch {
108
+ /* not a symlink after all — keep walking */
109
+ }
110
+ const next = path.dirname(probe);
111
+ if (next === probe)
112
+ break; // reached the filesystem root
113
+ probe = next;
114
+ }
115
+ }
116
+ // keep the un-resolved tail so the comparison below sees the full path
117
+ const tail = path.relative(probe, abs);
118
+ if (tail && !tail.startsWith(".."))
119
+ real = path.join(real, tail);
120
+ }
71
121
  }
72
122
  const relReal = path.relative(cwd, real);
73
123
  if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
package/dist/master.js CHANGED
@@ -229,6 +229,22 @@ function printAgentEvent(e) {
229
229
  if (line)
230
230
  console.log(`${dim(ts)} ${who} ${line}`);
231
231
  }
232
+ /**
233
+ * Reduce an agent id to a safe single path segment.
234
+ *
235
+ * Agent ids become directory names, so anything path-like in one is an escape
236
+ * from the sessions root (#72). Kept in step with the per-call-site
237
+ * `.replace(/[^\w.-]/g, "-")` calls, and applied in `addAgent` too so the
238
+ * guarantee holds for every caller.
239
+ */
240
+ export function sanitizeAgentId(id) {
241
+ const cleaned = String(id ?? "")
242
+ .replace(/[^\w.-]/g, "-")
243
+ // a leading dot would create a hidden dir; ".." would climb out
244
+ .replace(/^\.+/, "")
245
+ .slice(0, 60);
246
+ return cleaned || "agent";
247
+ }
232
248
  export class Master {
233
249
  agents = new Map();
234
250
  /**
@@ -402,6 +418,21 @@ export class Master {
402
418
  * an older one's history.
403
419
  */
404
420
  async addAgent(ac, opts = {}) {
421
+ // #72: an agent id becomes a DIRECTORY name (sessions/<id>-<hash>), so it
422
+ // must be a safe path segment. Every caller sanitised its own id — the HTTP
423
+ // route, spawnChildFor's child name — except the ACP adapter, which passed
424
+ // the raw client string through, so `session/load {"sessionId":"../../x"}`
425
+ // created an agent logged outside <dataDir>/sessions/.
426
+ //
427
+ // Sanitised HERE as well as at the call sites: this is the one place that
428
+ // guarantees the property, so a future caller cannot reintroduce the escape
429
+ // by forgetting. A caller that already sanitised is unaffected — the
430
+ // transform is idempotent on a safe id.
431
+ const safeId = sanitizeAgentId(ac.id);
432
+ if (safeId !== ac.id) {
433
+ console.warn(`[teapot] agent id ${JSON.stringify(ac.id)} contained unsafe characters; using ${JSON.stringify(safeId)}`);
434
+ }
435
+ ac = { ...ac, id: safeId };
405
436
  if (this.agents.has(ac.id))
406
437
  throw new Error(`agent id already exists: ${ac.id}`);
407
438
  // provider resolution: inline overrides > named provider > legacy llm block
@@ -106,6 +106,16 @@ export function buildApp(master) {
106
106
  });
107
107
  // per-agent terminal spawn guard
108
108
  const termCounts = new Map();
109
+ /**
110
+ * Agent ids reserved by an in-flight create (#71).
111
+ *
112
+ * The uniqueness loop is a check, not a reservation, and `addAgent` only
113
+ * publishes the id after awaiting — so concurrent creates all computed the
114
+ * same "unique" id and all persisted an entry. Reserving before the first
115
+ * await closes the window. Entries are released as soon as the agent is
116
+ * published or the create fails, so this never grows.
117
+ */
118
+ const idReservations = new Set();
109
119
  // keyed by CHILD, not agent: one agent may have up to 10 concurrent
110
120
  // terminals, each with its own size and its own foreground program
111
121
  const termSizes = new TermSizeTracker();
@@ -169,6 +179,12 @@ export function buildApp(master) {
169
179
  app.get("/api/agents/:id/term", upgradeWebSocket((c) => {
170
180
  const agentId = c.req.param("id") ?? "";
171
181
  let child = null;
182
+ // #70: whether THIS connection was admitted to the per-agent terminal
183
+ // budget. Declared per connection (not inside onOpen) because onClose is a
184
+ // sibling handler: a connection refused at the cap never incremented the
185
+ // counter, so onClose must not decrement for it — that asymmetry walked
186
+ // the counter below the live-shell count and disabled the cap entirely.
187
+ let admitted = false;
172
188
  const cleanup = () => {
173
189
  if (!child)
174
190
  return;
@@ -198,7 +214,12 @@ export function buildApp(master) {
198
214
  return;
199
215
  }
200
216
  termCounts.set(agentId, cur + 1);
217
+ admitted = true;
201
218
  if (!agent) {
219
+ // the slot was just taken and this connection owns it, so release
220
+ // it here rather than leaving onClose to work it out
221
+ termCounts.set(agentId, cur);
222
+ admitted = false;
202
223
  send({ kind: "exit", error: `no such agent: ${agentId}` });
203
224
  return;
204
225
  }
@@ -250,6 +271,11 @@ export function buildApp(master) {
250
271
  if (dead)
251
272
  termSizes.forget(dead);
252
273
  cleanup();
274
+ // #70: only an ADMITTED connection owns a slot. A connection refused
275
+ // at the cap never incremented, so decrementing for it is exactly
276
+ // what drove the counter negative and disabled the limit.
277
+ if (!admitted)
278
+ return;
253
279
  const n = (termCounts.get(agentId) ?? 1) - 1;
254
280
  if (n <= 0)
255
281
  termCounts.delete(agentId);
@@ -281,10 +307,27 @@ export function buildApp(master) {
281
307
  let n = 2;
282
308
  // ids must be unique among RUNNING agents AND persisted config entries —
283
309
  // a config-only agent (not yet loaded) would otherwise collide on addAgent
284
- while (master.agents.has(id) || master.config.agents.some((a) => a.id === id))
310
+ //
311
+ // #71: the loop below is a CHECK, not a RESERVATION, and addAgent only
312
+ // publishes the id after several awaits (mkdirSync, agent.init()). Every
313
+ // concurrent request therefore passed the check, computed the same "unique"
314
+ // id, and all persisted an entry — measured: 3 concurrent creates → 3 config
315
+ // entries, and with 5 concurrent spawns each minting its own session dir,
316
+ // `resolveSessionDir` binds to the first entry on restart and the rest are
317
+ // orphaned.
318
+ //
319
+ // So the id is RESERVED synchronously, before the first await. The set is
320
+ // per-process and only guards the window; a real agent arriving through
321
+ // another path still collides correctly via addAgent's own check.
322
+ while ((master.agents.has(id) || master.config.agents.some((a) => a.id === id) || idReservations.has(id)))
285
323
  id = `${base.slice(0, 38)}-${n++}`;
324
+ idReservations.add(id);
325
+ const releaseId = () => idReservations.delete(id);
286
326
  try {
327
+ // the reservation covers the awaits inside addAgent; once the agent is
328
+ // published the reservation is redundant and must be released
287
329
  const agent = await master.addAgent({ id, workspace: ws, provider: body.provider, model: body.model }, { persist: true, fresh: true });
330
+ releaseId();
288
331
  // NOTE: creation no longer auto-starts an LLM loop — the agent sits in
289
332
  // "stopped" (a lazy, zero-cost session) until the operator sends the
290
333
  // first prompt or presses ▶ start. The web UI relies on this.
@@ -293,6 +336,7 @@ export function buildApp(master) {
293
336
  return c.json({ ok: true, agent: agent.snapshot() });
294
337
  }
295
338
  catch (err) {
339
+ releaseId(); // never leak a reservation on failure
296
340
  return c.json({ error: err.message }, 400);
297
341
  }
298
342
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.25.1",
3
+ "version": "0.26.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",