teapot-coding-agent 0.7.0 → 0.9.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.
package/README.md CHANGED
@@ -108,12 +108,17 @@ master (Hono server, src/master.ts + src/server/api.ts)
108
108
  - **LLM** (`src/agent/llm.ts`): official `openai` npm client against any
109
109
  OpenAI-compatible endpoint (OpenRouter, vLLM, Ollama...). Model is config,
110
110
  never hard-coded.
111
- - **Tools** (`src/agent/tools.ts`): provider-agnostic JSON-schema function
112
- specs `read_file`, `write_file`, `edit_file`, `list_dir`, `bash` (git goes
113
- through bash), plus meta tools `finish` / `report_progress` / `get_goal` /
114
- `set_goal` / `read_memory` / `set_memory` / `list_skills`. Paths are
115
- confined to the workspace; bash runs detached in its own process group and
116
- the whole group is SIGKILLed on timeout.
111
+ - **Tools** (`src/agent/tools.ts`): provider-agnostic JSON-schema function specs —
112
+ file work via `read_file` (numbered lines, grep-style `pattern` mode, negative
113
+ offsets), `write_file`, `edit_file` (unique replacement, `replace_all`,
114
+ whitespace-tolerant fallbacks with recovery hints), `apply_patch`
115
+ (Codex-style multi-file add/update/rename/delete patches, validated
116
+ atomically before writing) and `list_dir`; `bash` for git/builds/tests and
117
+ quick bulk transforms; `read_url` fetches a web page's readable content
118
+ (Mozilla Readability + happy-dom); plus meta tools `finish` /
119
+ `report_progress` / `get_goal` / `set_goal` / `read_memory` / `set_memory` /
120
+ `list_skills`. Paths are confined to the workspace; bash runs detached in its
121
+ own process group and the whole group is SIGKILLed on timeout or shutdown.
117
122
  - **Cache-friendly prompt design** — the system prompt is byte-identical on
118
123
  every turn; session state (goal, memory, skills) is fetched via tools, never
119
124
  injected. Combined with the append-only message history this keeps provider
@@ -227,7 +232,14 @@ GET /brew 418 I'm a teapot (RFC 2324)
227
232
  Designed for a dedicated agent Linux user; workspaces are path-confined,
228
233
  subprocesses run in killable process groups with hard timeouts, and resource
229
234
  limits (RLIMIT_* / cgroups) have a natural insertion point in
230
- `src/agent/tools.ts:runShell`. The master survives agent crashes by
235
+ `src/agent/tools.ts:runShell`.
236
+
237
+ **LAN exposure**: the API has no auth by default (localhost-first tool). To
238
+ expose it beyond localhost, set `TEAPOT_API_TOKEN=<secret>` — every `/api/*`
239
+ route then requires `Authorization: Bearer <secret>` (WebSocket handshakes
240
+ accept `?token=<secret>`). In the web UI, open
241
+ `http://host:7788/#token=<secret>` once; the token is stored locally and
242
+ attached automatically. The master survives agent crashes by
231
243
  construction: agent errors never escape their own loop, and global handlers
232
244
  keep the process alive.
233
245
 
@@ -12,32 +12,33 @@ import { EventLog, readEvents } from "../log/events.js";
12
12
  import { chat, chatStream } from "./llm.js";
13
13
  import { executeTool, toolSpecs, currentSkills } from "./tools.js";
14
14
  import { bus } from "../bus.js";
15
+ /**
16
+ * SYSTEM_TEMPLATE must stay byte-identical across every request of a session:
17
+ * provider prefix caches key on it, so changing it (or injecting per-turn
18
+ * state) re-prices the whole context. That is why session state lives behind
19
+ * meta tools instead. The cache rationale itself stays HERE in a comment —
20
+ * the model does not need our cost-engineering notes every turn.
21
+ */
15
22
  const SYSTEM_TEMPLATE = `You are a coding agent working autonomously inside a workspace.
16
23
 
17
- This system prompt is intentionally STATICsession state is never injected
18
- into it, so API prompt caches stay hot across turns. Fetch state with tools
19
- instead; they are cheap and always current.
20
-
21
- ## Goal (harness-managed)
22
- - get_goal() → current objective + status. Call it at session start, after a
24
+ Session state is not injected into prompts fetch it with tools instead:
25
+ - get_goal() current objective + status. Call at session start, after a
23
26
  compaction notice, or whenever you lose the thread.
24
27
  - set_goal(text) → change the objective itself (not routine updates).
25
28
  - finish(goalComplete=true, summary) → goal fully achieved.
26
-
27
- ## Your notes (memory.md, harness-managed)
28
- - read_memory() / set_memory(content) durable notes injected nowhere else.
29
- Keep them terse: decisions, gotchas, where you left off.
30
-
31
- ## Skills (reusable playbooks)
32
- - list_skills() → what exists. load_skill(name) → full playbook.
33
- - save_skill(name, description, content) → distill a reusable procedure.
34
-
35
- ## Project knowledge
36
- - AGENTS.md in the workspace root (optional): conventions & commands. Read it
29
+ - read_memory() / set_memory(content) → your durable notes (memory.md).
30
+ - list_skills() / load_skill(name) / save_skill(...) → reusable playbooks.
31
+ - AGENTS.md in the workspace root (optional) holds project knowledge read it
37
32
  with read_file at session start when present, keep it current.
38
33
 
39
34
  ## Rules
40
35
  - Work step by step with tools. Verify results (run tests/builds) before claiming progress.
36
+ - File changes — pick by scope: write_file (one new file / full rewrite) ·
37
+ edit_file (exactly one small unique replacement) · apply_patch (several
38
+ edits, renames or deletes across one or more files, applied atomically).
39
+ read_file numbers lines and can grep via its pattern option. bash
40
+ text-munging (sed/awk/heredoc) remains available for quick bulk transforms
41
+ when that is genuinely faster.
41
42
  - When a loaded skill matches your task, follow its playbook.
42
43
  - When you make meaningful progress, call report_progress.
43
44
  - Be frugal: prefer small precise edits, avoid runaway loops.`;
@@ -61,9 +62,18 @@ export class Agent {
61
62
  };
62
63
  opts;
63
64
  messages = [];
65
+ /**
66
+ * User prompts waiting for the next turn boundary. Deliberately NOT queued
67
+ * on runChain: that chain holds the long-running loop, so queueing behind
68
+ * it would delay both the log entry (UI) and delivery until the round —
69
+ * sometimes the whole goal — finished.
70
+ */
71
+ pendingPrompts = [];
64
72
  stopRequested = false;
65
73
  wake = null;
66
74
  abort = null;
75
+ /** aborted only by dispose(): kills in-flight subprocess groups instantly */
76
+ toolAbort = new AbortController();
67
77
  runChain = Promise.resolve();
68
78
  lastProgressAt = Date.now();
69
79
  consecutiveToolErrors = 0;
@@ -89,6 +99,7 @@ export class Agent {
89
99
  defaultTimeoutMs: 120_000,
90
100
  maxOutputBytes: 60_000,
91
101
  skillRoots: this.skillRoots,
102
+ signal: this.toolAbort.signal,
92
103
  };
93
104
  // the session id IS the directory name — one directory per incarnation
94
105
  this.mainSession = path.basename(opts.sessionDir);
@@ -200,10 +211,29 @@ export class Agent {
200
211
  while (lineage.length && lineage[0].type === "fork")
201
212
  lineage.shift();
202
213
  const msgs = [];
214
+ // Prompts are logged the moment the user hits send — which can be while a
215
+ // tool batch is still open. Replaying them verbatim would slot a user
216
+ // message BETWEEN an assistant tool_call and its tool_result (API-invalid),
217
+ // so hold them until the open batch is answered, like live delivery does.
218
+ const META_TOOLS = new Set([
219
+ "finish", "report_progress", "set_goal", "get_goal",
220
+ "read_memory", "set_memory", "list_skills",
221
+ ]);
222
+ const openCalls = new Map(); // real tool_call id -> name
223
+ const bufferedUsers = [];
224
+ const flushUsers = () => {
225
+ if (openCalls.size === 0) {
226
+ for (const text of bufferedUsers.splice(0))
227
+ msgs.push({ role: "user", content: text });
228
+ }
229
+ };
203
230
  for (const e of lineage) {
204
231
  const d = e.data;
205
232
  if (e.type === "prompt" && typeof d.text === "string") {
206
- msgs.push({ role: "user", content: d.text });
233
+ if (openCalls.size > 0)
234
+ bufferedUsers.push(d.text);
235
+ else
236
+ msgs.push({ role: "user", content: d.text });
207
237
  }
208
238
  else if (e.type === "message") {
209
239
  const role = d.role === "assistant" ? "assistant" : "user";
@@ -214,6 +244,11 @@ export class Agent {
214
244
  type: "function",
215
245
  function: { name: c.name, arguments: "{}" },
216
246
  }));
247
+ // meta tools are answered inline by the harness (no logged result);
248
+ // the hole-filling pass below synthesizes theirs where they belong
249
+ for (const t of m.tool_calls)
250
+ if (!META_TOOLS.has(t.function.name))
251
+ openCalls.set(t.id, t.function.name);
217
252
  }
218
253
  msgs.push(m);
219
254
  }
@@ -230,6 +265,8 @@ export class Agent {
230
265
  tool_call_id: String(d.callId ?? ""),
231
266
  content: `${d.ok === false ? "(failed) " : ""}${typeof d.result === "string" ? d.result : ""}`,
232
267
  });
268
+ openCalls.delete(String(d.callId ?? ""));
269
+ flushUsers();
233
270
  }
234
271
  else if (e.type === "progress") {
235
272
  // progress events may follow an assistant report_progress call that
@@ -240,7 +277,9 @@ export class Agent {
240
277
  if (!msgs.some((x) => x.role === "tool" && x.tool_call_id === t.id)) {
241
278
  msgs.push({ role: "tool", tool_call_id: t.id, content: "progress recorded" });
242
279
  }
280
+ openCalls.delete(t.id);
243
281
  }
282
+ flushUsers();
244
283
  }
245
284
  }
246
285
  }
@@ -261,6 +300,9 @@ export class Agent {
261
300
  }
262
301
  }
263
302
  }
303
+ // prompts that were still waiting on a hole-filled tail land here
304
+ for (const text of bufferedUsers.splice(0))
305
+ msgs.push({ role: "user", content: text });
264
306
  if (msgs.length > 0) {
265
307
  this.messages = msgs;
266
308
  this.currentBranch = last.branch;
@@ -321,14 +363,27 @@ export class Agent {
321
363
  model: this.opts.llm.model,
322
364
  provider: this.opts.provider,
323
365
  sessionDir: this.opts.sessionDir,
366
+ pendingPrompts: this.pendingPrompts.length,
324
367
  };
325
368
  }
326
- /** Queue a user prompt; wakes the loop if needed. Returns immediately. */
369
+ /**
370
+ * Queue a user prompt. Returns immediately: the event is logged right away
371
+ * (so every connected UI sees it instantly) and the text is handed to the
372
+ * model at the next turn boundary — never mid-turn, and never blocked by
373
+ * the running loop.
374
+ */
327
375
  enqueuePrompt(text, source = "user") {
328
- return this.enqueue(async () => {
329
- await this.log.append("prompt", this.currentSession, this.currentBranch, { source, text });
330
- this.messages.push({ role: "user", content: text });
331
- });
376
+ this.pendingPrompts.push({ source, text });
377
+ void this.log
378
+ .append("prompt", this.currentSession, this.currentBranch, { source, text })
379
+ .then(() => bus.emit("update", { kind: "agent-update", agentId: this.opts.id }))
380
+ .catch(() => { });
381
+ }
382
+ /** Hand queued user prompts to the model at a turn boundary. */
383
+ drainPendingPrompts() {
384
+ for (const p of this.pendingPrompts.splice(0)) {
385
+ this.messages.push({ role: "user", content: p.text });
386
+ }
332
387
  }
333
388
  /** Resolves when all queued work (including a running loop) has settled. */
334
389
  settled() {
@@ -377,9 +432,16 @@ export class Agent {
377
432
  while (!this.stopRequested) {
378
433
  try {
379
434
  const finished = await this.runTurnsUntilIdle();
380
- if (finished || this.stopRequested)
435
+ if (this.stopRequested)
381
436
  break;
382
- if (!this.opts.autoContinue || this.goal.status !== "active")
437
+ // fresh user input arrived while we were finishing up — another round now
438
+ if (this.pendingPrompts.length)
439
+ continue;
440
+ // auto-continue only makes sense with an active goal to continue toward
441
+ if (finished ||
442
+ !this.opts.autoContinue ||
443
+ this.goal.status !== "active" ||
444
+ !this.goal.text.trim())
383
445
  break;
384
446
  // auto-continue: wait quietly, then nudge with a fresh round
385
447
  await this.sleepInterruptible(this.opts.continueDelayMs);
@@ -463,6 +525,8 @@ export class Agent {
463
525
  for (let guard = 0; guard < 200; guard++) {
464
526
  if (this.stopRequested)
465
527
  return finished;
528
+ // deliver prompts queued while the previous turn was running
529
+ this.drainPendingPrompts();
466
530
  // skills may have been created last turn — refresh the prompt listing
467
531
  await this.refreshSkills();
468
532
  // periodic progress report at turn boundary (no mid-turn interruption)
@@ -769,6 +833,9 @@ export class Agent {
769
833
  }
770
834
  async dispose() {
771
835
  this.stop("disposed");
836
+ // kill any in-flight subprocess group NOW so shutdown never waits out a
837
+ // long-running command (up to 10 min otherwise)
838
+ this.toolAbort.abort();
772
839
  await this.runChain.catch(() => { });
773
840
  await this.log.close();
774
841
  }