teapot-coding-agent 0.14.4 → 0.14.6

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.
@@ -51,7 +51,10 @@ Session state is not injected into prompts — fetch it with tools instead:
51
51
  save_skill(name, description, content, files=[{name, content}]) so future
52
52
  sessions can load_skill them — helper scripts go through files and are made
53
53
  executable automatically.
54
- - When you make meaningful progress, call report_progress.
54
+ - When you make meaningful progress, call report_progress. Its output is
55
+ shown to the operator directly (rendered as markdown in the progress
56
+ panel) — do NOT also repeat the same content as a chat message; end the
57
+ turn right after the call (a short "reported." is fine).
55
58
  - Be frugal: prefer small precise edits, avoid runaway loops.`;
56
59
  export class Agent {
57
60
  log;
@@ -619,11 +622,18 @@ export class Agent {
619
622
  this.goal.status !== "active" ||
620
623
  !this.goal.text.trim())
621
624
  break;
622
- // auto-continue: wait quietly, then nudge with a fresh round
625
+ // auto-continue: wait quietly, then nudge with a fresh round.
626
+ // Sub-agents get a SCOPE-ANCHORED nudge instead of a generic one:
627
+ // "Continue working toward the current goal" made a forked sub-agent
628
+ // re-read its inherited context and drift into one of the parent's
629
+ // older tasks — it would keep going forever without ever finish()ing.
623
630
  await this.sleepInterruptible(this.opts.continueDelayMs);
624
631
  if (this.stopRequested)
625
632
  break;
626
- const nudge = "Continue working toward the current goal. If you are blocked, explain why briefly.";
633
+ const isSub = !!this.opts.parent;
634
+ const nudge = isSub
635
+ ? `[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().`
636
+ : "Continue working toward the current goal. If you are blocked, explain why briefly.";
627
637
  await this.log.append("prompt", this.currentSession, this.currentBranch, {
628
638
  source: "harness",
629
639
  text: nudge,
@@ -819,11 +829,15 @@ export class Agent {
819
829
  const a = safeParse(call.function.arguments);
820
830
  const question = String(a.question ?? "").slice(0, 2000);
821
831
  const options = Array.isArray(a.options) ? a.options.map(String).slice(0, 6) : [];
832
+ // the callId lets the UI tell "still open" from "already answered"
833
+ // (a settled tool_result for this id exists) and disable re-answering
822
834
  await this.log.append("question", this.currentSession, this.currentBranch, {
823
835
  question,
824
836
  options,
837
+ callId: call.id,
825
838
  });
826
- await this.answerMeta(call, "question shown to the operator — the loop is parked until they reply");
839
+ await this.answerMeta(call, "question shown to the operator — the loop is parked until they reply. " +
840
+ "Their reply (possibly free text, not one of the options) arrives as your next user message.");
827
841
  this.awaitingUser = true;
828
842
  this.setStatus("waiting", question.slice(0, 80));
829
843
  // control flow: park the loop; the operator's next prompt resumes it
@@ -993,10 +1007,16 @@ export class Agent {
993
1007
  this.messages.push({ role: "user", content: request });
994
1008
  const res = await this.llmCall(this.buildMessages(), []); // no tools: pure report
995
1009
  await this.recordProgress(JSON.stringify({ freeform: res.message.content }));
1010
+ // The report's content is ALREADY shown by the progress embed — logging a
1011
+ // plain assistant message with the same text made the timeline show every
1012
+ // requested report twice. Keep logging it (session restores replay the
1013
+ // exchange faithfully and rebuildMessages needs it) but mark it as an echo;
1014
+ // the web UI filters marked rows out.
996
1015
  await this.log.append("message", this.currentSession, this.currentBranch, {
997
1016
  role: "assistant",
998
1017
  content: res.message.content ?? "",
999
1018
  reasoning: res.reasoning,
1019
+ progressEcho: true,
1000
1020
  });
1001
1021
  this.messages.push(res.message);
1002
1022
  }
@@ -809,9 +809,13 @@ export const TOOLS = [
809
809
  // park VISIBLY: the UI shows idle ("waiting on sub-agents") instead of a
810
810
  // running spinner, and any user prompt / stop wakes us instantly
811
811
  ctx.onIdlePark?.(`waiting on sub-agent${ids?.length === 1 ? ` ${ids[0]}` : "s"}`);
812
+ // the child's report is about to land in our mailbox — a progress report
813
+ // right after it would be pure noise, so re-arm the gate here
814
+ ctx.onProgressGateReset?.();
812
815
  try {
813
816
  const r = await sa.wait(ids, ms);
814
817
  ctx.onIdleUnpark?.();
818
+ ctx.onProgressGateReset?.(); // also after: waiting itself isn't "activity"
815
819
  return { ok: true, result: r.note };
816
820
  }
817
821
  catch (e) {
package/dist/master.js CHANGED
@@ -483,10 +483,25 @@ export class Master {
483
483
  child.importMessages(parent.exportMessages());
484
484
  }
485
485
  await child.setGoal(`${directive}${o.task}`.slice(0, 2000));
486
+ // SCOPE GUARD: a forked child inherits the parent's whole conversation as
487
+ // reference context — without an explicit boundary, models have been seen
488
+ // "continuing" some EARLIER task that appears in that history instead of
489
+ // the task they were actually spawned for. State the boundary twice
490
+ // (goal + first prompt) so it survives goal-driven auto-continue.
486
491
  await child.enqueuePrompt((o.context === "fork"
487
- ? `[harness] You are sub-agent ${id}, spawned by @${parentId} with the conversation above. `
488
- : `[harness] You are sub-agent ${id}, spawned by @${parentId}. `) +
489
- `Work solely on this task:\n\n${directive}${o.task}`, "harness");
492
+ ? `[harness] You are sub-agent ${id}, spawned by @${parentId}.
493
+
494
+ The conversation above is REFERENCE CONTEXT ONLY — background on why this work is needed.
495
+ It contains older tasks and discussions that are NOT yours. Do not resume, continue, or
496
+ finish any of them; other agents own those. If you find yourself working on anything other
497
+ than the task below, STOP and return to it.
498
+
499
+ YOUR ONE AND ONLY TASK — everything else in the inherited context is out of scope:
500
+
501
+ ${directive}${o.task}
502
+
503
+ When this task is done (or truly blocked), call finish() with a summary for @${parentId}.`
504
+ : `[harness] You are sub-agent ${id}, spawned by @${parentId}. Work solely on this task:\n\n${directive}${o.task}\n\nWhen it is done (or truly blocked), call finish() with a summary for @${parentId}.`), "harness");
490
505
  child.start(`spawned by ${parentId}`);
491
506
  console.log(`[teapot] sub-agent ${id} spawned by ${parentId} (context: ${o.context ?? "none"}${persona ? `, persona: ${persona}` : ""})`);
492
507
  return { id };
@@ -322,6 +322,57 @@ export function buildApp(master) {
322
322
  return c.json({ error: err.message }, 400);
323
323
  }
324
324
  });
325
+ // write a file from the web UI's file-tree editor (human action, not an
326
+ // agent tool — allowed even for read-only personas, never notifies the
327
+ // agent). Optional baseContent enables optimistic-concurrency checking.
328
+ app.put("/api/agents/:id/file", async (c) => {
329
+ const a = master.agents.get(c.req.param("id"));
330
+ if (!a)
331
+ return c.json({ error: "not found" }, 404);
332
+ const rel = c.req.query("path") ?? "";
333
+ if (!rel.trim())
334
+ return c.json({ error: "path required" }, 400);
335
+ const body = await c.req.json().catch(() => null);
336
+ if (body === null || typeof body.content !== "string")
337
+ return c.json({ error: "content required" }, 400);
338
+ let abs;
339
+ try {
340
+ abs = safeJoin(a.workspace, rel);
341
+ }
342
+ catch (err) {
343
+ return c.json({ error: err.message }, 400);
344
+ }
345
+ try {
346
+ let existing = null;
347
+ try {
348
+ existing = await fs.readFile(abs);
349
+ }
350
+ catch (e) {
351
+ if (e.code !== "ENOENT")
352
+ throw e;
353
+ }
354
+ // refuse to clobber binary files (same 8KB NUL probe as GET)
355
+ if (existing && existing.subarray(0, 8192).includes(0))
356
+ return c.json({ error: "binary file — edit refused" }, 400);
357
+ // conflict check: the editor sends what it loaded; if disk moved on,
358
+ // bounce with the fresh content instead of silently overwriting
359
+ if (typeof body.baseContent === "string" &&
360
+ existing &&
361
+ existing.subarray(0, 100_000).toString("utf8") !== body.baseContent) {
362
+ return c.json({
363
+ error: "file changed on disk",
364
+ current: existing.subarray(0, 100_000).toString("utf8"),
365
+ }, 409);
366
+ }
367
+ await fs.mkdir(path.dirname(abs), { recursive: true });
368
+ const content = Buffer.from(body.content, "utf8");
369
+ await fs.writeFile(abs, content);
370
+ return c.json({ ok: true, path: rel, size: content.length });
371
+ }
372
+ catch (err) {
373
+ return c.json({ error: err.message }, 400);
374
+ }
375
+ });
325
376
  // ---- config (view/edit from the web UI) ----
326
377
  app.get("/api/config", (c) => {
327
378
  const mask = (p) => Object.fromEntries(Object.entries(p ?? {}).map(([k, v]) => [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.14.4",
3
+ "version": "0.14.6",
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",