teapot-coding-agent 0.23.0 → 0.24.1

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.
@@ -11,8 +11,38 @@ import path from "node:path";
11
11
  import { EventLog, readEvents } from "../log/events.js";
12
12
  import { chat, chatStream } from "./llm.js";
13
13
  import { executeTool, toolSpecs, currentSkills, safeJoin, isContextOverflow, hasRunningBgShells } from "./tools.js";
14
- import { skillRootsFingerprint } from "./skills.js";
14
+ import { skillRootsFingerprint, foreignSkillRoots } from "./skills.js";
15
15
  import { bus } from "../bus.js";
16
+ /**
17
+ * Harness nudges, named and exported so they can be asserted on directly.
18
+ *
19
+ * Both were fixed for a reason invisible in the prose:
20
+ *
21
+ * - AUTO_CONTINUE_NUDGE (#43) used to say only "continue working". The model
22
+ * was never told HOW TO STOP, so it worked forever: it replied in prose, the
23
+ * loop saw an active goal and no finish(), and nudged again. The sub-agent
24
+ * variant already named finish() — the ROOT one did not.
25
+ * - PROGRESS_REQUEST (#45) used to DESCRIBE the report instead of asking for
26
+ * the tool, so the model answered in prose, then — not knowing that had
27
+ * satisfied the request — sent a voluntary report_progress right after, and
28
+ * every requested report appeared TWICE on the timeline.
29
+ */
30
+ export const AUTO_CONTINUE_NUDGE = "Continue working toward the current goal. If you are blocked, explain why briefly. " +
31
+ // name the tool explicitly, the way the sub-agent nudge already does
32
+ "When the goal is genuinely met — or truly blocked — call finish() " +
33
+ "(finish(goalComplete=true) when met, finish(goalComplete=false) when blocked) " +
34
+ "instead of replying in prose; prose here does not stop the loop.";
35
+ export const PROGRESS_REQUEST =
36
+ // The leading sentence is kept VERBATIM: it is what existing harness prompts,
37
+ // tests and any operator muscle-memory key off. #45 changed the INSTRUCTION
38
+ // (call the tool, don't answer in prose), not the identification of the request.
39
+ "[harness] Please give a brief progress report now: what you are doing, goal progress, " +
40
+ "what you recently tried, any problems, and your next step. Keep it under 10 lines. " +
41
+ // ask for the TOOL, not prose — a prose answer left the model unsure whether it
42
+ // had satisfied the request, so it sent a voluntary report_progress right after
43
+ // and every requested report appeared TWICE on the timeline (#45)
44
+ "Record it with the report_progress TOOL (doing, goalStatus, recent, problems, next) — " +
45
+ "call that tool; do NOT answer in prose, which would just duplicate this request.";
16
46
  /**
17
47
  * SYSTEM_TEMPLATE must stay byte-identical across every request of a session:
18
48
  * provider prefix caches key on it, so changing it (or injecting per-turn
@@ -114,6 +144,14 @@ export class Agent {
114
144
  modelPricing;
115
145
  /** messages.length right after the last successful compaction */
116
146
  compactedAtLen = 0;
147
+ /**
148
+ * A sub-agent's latest bare text turn, held back until the round ends.
149
+ * Models usually end a sub-agent task with a plain message instead of calling
150
+ * finish(); only finish() forwards a report to the parent, so without this the
151
+ * parent got nothing (#42). Held (not sent immediately) so a mid-task
152
+ * narration never pre-empts a real finish() summary.
153
+ */
154
+ pendingSubReport = "";
117
155
  /** live compaction phase for UI progress ("summarizing"/"harvesting") */
118
156
  compactPhase = "";
119
157
  /** set by ask_user: the loop is parked until the operator replies */
@@ -132,6 +170,10 @@ export class Agent {
132
170
  autoContinue: true,
133
171
  continueDelayMs: 15_000,
134
172
  autoCompact: true,
173
+ // #47: no effort and no catalogue entry means "provider default" — the
174
+ // effort field is then never sent, which is the safe default
175
+ reasoningEffort: "",
176
+ supportedParameters: [],
135
177
  maxConsecutiveToolErrors: 5,
136
178
  maxTurnsPerRound: 200,
137
179
  onError: "stop",
@@ -160,6 +202,13 @@ export class Agent {
160
202
  this.skillRoots = [
161
203
  { dir: path.join(opts.workspace, "skills"), source: "workspace" },
162
204
  ...(opts.globalSkillsDir ? [{ dir: opts.globalSkillsDir, source: "global" }] : []),
205
+ // OTHER HARNESSES' skill directories (#28). The SKILL.md format is shared,
206
+ // so these are readable as-is — verified against real third-party skills
207
+ // (terminalskills/skills, trailofbits/skills, …). They sit below the
208
+ // workspace and teapot's own global dir so a project always wins, and
209
+ // missing directories are skipped silently. Paths are the documented
210
+ // native locations plus the cross-tool ones several tools also read.
211
+ ...foreignSkillRoots(opts.workspace),
163
212
  // shipped-with-package skills: lowest priority, always discoverable
164
213
  ...(opts.bundledSkillsDir ? [{ dir: opts.bundledSkillsDir, source: "bundled" }] : []),
165
214
  ];
@@ -192,6 +241,24 @@ export class Agent {
192
241
  * skill was added, edited or removed (save_skill writes one, so a
193
242
  * self-created skill still shows up on the very next turn).
194
243
  */
244
+ /**
245
+ * Render the skill catalogue for the system prompt (#31).
246
+ *
247
+ * Name + description only, one line each — the full SKILL.md is what
248
+ * load_skill() is for, and this block goes into EVERY request of the session,
249
+ * so it has to stay small. The description is what the model matches its task
250
+ * against, so a skill with a poor description is effectively undiscoverable;
251
+ * that is worth knowing when authoring one.
252
+ */
253
+ skillCatalogueText() {
254
+ if (!this.skillsCache.length)
255
+ return "";
256
+ const lines = this.skillsCache.map((s) => {
257
+ const desc = (s.description || "(no description)").replace(/\s+/g, " ").slice(0, 200);
258
+ return `- ${s.name}: ${desc}`;
259
+ });
260
+ return lines.join("\n");
261
+ }
195
262
  async refreshSkills() {
196
263
  try {
197
264
  const stamp = await skillRootsFingerprint(this.skillRoots);
@@ -206,7 +273,16 @@ export class Agent {
206
273
  }
207
274
  callLlm(messages, tools, onDelta) {
208
275
  const fn = this.opts.chatFn ?? chatStream;
209
- return fn(this.opts.llm, messages, tools, this.abort?.signal, onDelta);
276
+ // #47: carry the effort setting + the model's advertised capabilities into
277
+ // the request config. chat() applies effortForRequest(), which sends the
278
+ // field ONLY for OpenRouter endpoints whose catalogue lists support.
279
+ return fn({
280
+ ...this.opts.llm,
281
+ ...(this.opts.reasoningEffort ? { reasoningEffort: this.opts.reasoningEffort } : {}),
282
+ ...(this.opts.supportedParameters
283
+ ? { supportedParameters: this.opts.supportedParameters }
284
+ : {}),
285
+ }, messages, tools, this.abort?.signal, onDelta);
210
286
  }
211
287
  /** expose id for metrics */
212
288
  opts_id() {
@@ -566,7 +642,13 @@ export class Agent {
566
642
  * must not be running — editing under a live loop would race its history.
567
643
  */
568
644
  async editPromptAt(eventId, text, tail) {
569
- if (this.status === "running")
645
+ // "running" is not the only live state: parkForTool() rewrites the status to
646
+ // "idle" while the loop is still very much alive inside a parked tool
647
+ // (wait_children does this), and queued prompts have not been consumed yet.
648
+ // Editing underneath either of those replaces the very `messages` array the
649
+ // agent is about to resume into, so its old-branch work kept running
650
+ // against the new branch (#38).
651
+ if (this.status === "running" || this.parkedByTool || this.pendingPrompts.length)
570
652
  throw new Error("agent is running — stop it before editing history");
571
653
  const all = await readEvents(this.log.filePath);
572
654
  const target = all.find((e) => e.id === eventId);
@@ -599,7 +681,17 @@ export class Agent {
599
681
  }
600
682
  msgs.push({ role: "user", content: text });
601
683
  const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
602
- await this.log.append("fork", this.currentSession, newBranch, {
684
+ // Quiesce BEFORE rewriting history: an in-flight LLM call, a parked tool or
685
+ // a queued prompt would otherwise resume into the NEW messages array and
686
+ // keep running the old branch's work here (#38). Mirrors stop(), which is
687
+ // the only other place that cancels this state.
688
+ await this.quiesceForHistoryEdit();
689
+ // The fork event must be appended while currentBranch is still the OLD
690
+ // one. EventLog links parents PER BRANCH (lastByBranch), so appending it
691
+ // under the new branch gave it parent:null — severing the chain, and
692
+ // lineageOf() then returned only the post-fork events, so a restart rebuilt
693
+ // the history with none of the pre-edit context (#38).
694
+ await this.log.append("fork", this.currentSession, this.currentBranch, {
603
695
  fromSession: this.currentSession,
604
696
  fromBranch: this.currentBranch,
605
697
  fromEvent: kept.at(-1)?.id ?? null,
@@ -608,12 +700,49 @@ export class Agent {
608
700
  droppedEvents: dropped.length,
609
701
  tailMode: tail,
610
702
  });
703
+ // Seed the new branch's parent link to the fork event just written, so its
704
+ // first event chains across instead of starting a disconnected parent:null
705
+ // history (#38).
706
+ this.log.seedBranch(newBranch, this.log.lastEventId(this.currentBranch));
611
707
  this.currentBranch = newBranch;
612
708
  this.messages = msgs;
613
709
  await this.log.append("prompt", this.currentSession, this.currentBranch, { source: "user", text });
614
710
  bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
615
711
  return { droppedEvents: dropped.length, branch: newBranch };
616
712
  }
713
+ /**
714
+ * Cancel everything that could resume against the history we are about to
715
+ * replace, then wait for the loop to actually stop.
716
+ *
717
+ * An edit rewinds the conversation, so any work still in flight belongs to
718
+ * the branch being discarded: an LLM call whose result would append to the
719
+ * new messages, a parked tool, and queued prompts that were never delivered
720
+ * (#38). stopRequested is set and cleared around the wait so the agent is
721
+ * usable again once the edit lands.
722
+ */
723
+ async quiesceForHistoryEdit() {
724
+ if (!this.stopRequested) {
725
+ this.stopRequested = true;
726
+ this.abort?.abort(); // interrupt an in-flight LLM call
727
+ this.toolAbort.abort(); // kill subprocesses / interrupt a parked tool
728
+ this.wake?.(); // release wait_children
729
+ try {
730
+ await this.settled();
731
+ }
732
+ catch {
733
+ /* the loop is being abandoned; its errors are not ours to report */
734
+ }
735
+ this.parkedByTool = false;
736
+ this.pendingPrompts = []; // undelivered prompts belong to the old branch
737
+ this.stopRequested = false;
738
+ // a fresh signal: toolCtx holds a copy of the reference, so republish
739
+ if (this.toolAbort.signal.aborted) {
740
+ this.toolAbort = new AbortController();
741
+ this.toolCtx.signal = this.toolAbort.signal;
742
+ }
743
+ this.bgExits = []; // old-branch job exits must not be folded in
744
+ }
745
+ }
617
746
  parseGoalFile(text) {
618
747
  // humans and agents may append their own status lines — latest wins
619
748
  const all = [...text.matchAll(/status:\s*(\w+)/gi)];
@@ -761,6 +890,9 @@ export class Agent {
761
890
  stats: { ...this.stats, costUsd: this.modelPricing ? this.stats.costUsd : undefined },
762
891
  model: this.opts.llm.model,
763
892
  provider: this.opts.provider,
893
+ // #47: the effort in force, and whether this model can take one at all
894
+ ...(this.opts.reasoningEffort ? { reasoningEffort: this.opts.reasoningEffort } : {}),
895
+ effortSupported: (this.opts.supportedParameters ?? []).includes("reasoning_effort"),
764
896
  sessionDir: this.opts.sessionDir,
765
897
  ctx: {
766
898
  usedTokens: this.lastUsage?.input ?? this.estimateTokens(),
@@ -904,8 +1036,22 @@ export class Agent {
904
1036
  // suppressed while bg jobs are alive) — its exit IS the wake-up call.
905
1037
  // Without this the report would sit unread until the operator poked
906
1038
  // the agent. Stopped agents stay stopped (a deliberate human stop).
907
- if (this.status === "idle" && !this.stopRequested) {
908
- this.start(`background job ${info.id} finished`);
1039
+ //
1040
+ // Checking status once is NOT enough: the exit can land in the window
1041
+ // between the loop's hasRunningBgShells() check (which breaks the round
1042
+ // out) and the loop actually flipping to idle. The callback then sees
1043
+ // "running", skips start(), and the agent goes idle with the report
1044
+ // unread — wedged until a human pokes it. So remember that the loop was
1045
+ // still winding down, and re-check once it has settled.
1046
+ const windingDown = this.status === "running";
1047
+ if (!this.stopRequested && (this.status === "idle" || windingDown)) {
1048
+ // park the decision until the in-flight round has actually settled
1049
+ void this.enqueue(async () => {
1050
+ if (this.stopRequested)
1051
+ return; // a stop raced us — respect it
1052
+ if (this.status === "idle")
1053
+ this.start(`background job ${info.id} finished`);
1054
+ });
909
1055
  }
910
1056
  }
911
1057
  /** fold queued background-exit reports into the next LLM call (once each) */
@@ -1030,6 +1176,18 @@ export class Agent {
1030
1176
  });
1031
1177
  break;
1032
1178
  }
1179
+ // A SUB-AGENT that ended its round on a bare message (no finish call)
1180
+ // still owes the parent a report — nothing else will carry it (#42).
1181
+ // Emitted HERE, after the loop, so a real finish() on a later turn
1182
+ // always wins and this never pre-empts a better summary.
1183
+ if (this.pendingSubReport) {
1184
+ const report = this.pendingSubReport;
1185
+ this.pendingSubReport = "";
1186
+ if (!finished) {
1187
+ await this.handleFinish(JSON.stringify({ goalComplete: true, summary: report }));
1188
+ finished = true; // it IS finished now — don't auto-nudge a reporter
1189
+ }
1190
+ }
1033
1191
  // auto-continue only makes sense with an active goal to continue toward
1034
1192
  if (finished ||
1035
1193
  !this.opts.autoContinue ||
@@ -1047,7 +1205,7 @@ export class Agent {
1047
1205
  const isSub = !!this.opts.parent;
1048
1206
  const nudge = isSub
1049
1207
  ? `[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().`
1050
- : "Continue working toward the current goal. If you are blocked, explain why briefly.";
1208
+ : AUTO_CONTINUE_NUDGE;
1051
1209
  await this.log.append("prompt", this.currentSession, this.currentBranch, {
1052
1210
  source: "harness",
1053
1211
  text: nudge,
@@ -1261,12 +1419,63 @@ export class Agent {
1261
1419
  this.messages.push(m);
1262
1420
  this.turnsSinceProgress++;
1263
1421
  this.activityChars += m.content?.length ?? 0;
1264
- if (!m.tool_calls?.length)
1422
+ // A round that ends with a plain assistant message and NO tool calls is
1423
+ // the end of the agent's work. For a ROOT agent that is just "done" — but
1424
+ // for a SUB-AGENT it means the work is over and the parent is waiting.
1425
+ //
1426
+ // Models overwhelmingly prefer writing their report as the final message
1427
+ // over calling finish(), and only handleFinish() emits the `final: true`
1428
+ // event that onChildEvent() forwards to the parent. So that habit silently
1429
+ // cost the parent the ENTIRE result (#42) — the report existed only in the
1430
+ // child's own log, which the parent's model context never replays.
1431
+ // Harvest it here so both paths share one code path.
1432
+ if (!m.tool_calls?.length) {
1433
+ // A turn with no tool calls and real text is how models overwhelmingly
1434
+ // signal "I'm done" — far more often than by calling finish(), which
1435
+ // the spawn directive asks for but they routinely ignore.
1436
+ //
1437
+ // Two things were wrong (#42):
1438
+ // 1. only handleFinish() emits the `final: true` event that
1439
+ // onChildEvent() forwards to the parent, so a sub-agent that ended
1440
+ // with a plain message sent the parent NOTHING — the report lived
1441
+ // only in the child's log, which the parent's context never
1442
+ // replays;
1443
+ // 2. the round then returned "not finished" with an active goal, so
1444
+ // auto-continue re-nudged and the child looped forever (measured:
1445
+ // 257 consecutive turns) re-reporting the same text.
1446
+ //
1447
+ // A bare message might be a mid-task narration though — sub-agents
1448
+ // often say "here's what I found so far…" and call finish() later with
1449
+ // a better summary. So do NOT report yet: remember it and give the child
1450
+ // ONE more turn to call finish() properly. The harness nudge makes that
1451
+ // turn explicit, so a child that just wanted to narrate ends here (and
1452
+ // its held report is forwarded by the loop's end-of-round path), while
1453
+ // a child that has a real summary delivers it instead. A real finish()
1454
+ // clears the held note, so it is forwarded exactly once (#42).
1455
+ if (this.opts.parent) {
1456
+ if (this.pendingSubReport) {
1457
+ // already offered a turn after a bare message — take the held report
1458
+ this.messages.push({
1459
+ role: "user",
1460
+ content: "[harness] Report delivered. If this IS your final answer, call finish() now with a summary for your parent; otherwise continue the task.",
1461
+ });
1462
+ }
1463
+ else {
1464
+ this.pendingSubReport = (m.content ?? "").trim();
1465
+ this.messages.push({
1466
+ role: "user",
1467
+ content: "[harness] If you are done, call finish() with a summary for your parent so it receives your report. Otherwise continue the task.",
1468
+ });
1469
+ }
1470
+ continue; // one more turn before we treat the message as final
1471
+ }
1265
1472
  return finished;
1473
+ }
1266
1474
  for (const call of m.tool_calls) {
1267
1475
  if (this.stopRequested)
1268
1476
  return finished;
1269
1477
  if (call.function.name === "finish") {
1478
+ this.pendingSubReport = ""; // the real summary supersedes it
1270
1479
  await this.handleFinish(call.function.arguments);
1271
1480
  // surface still-running children so the operator knows work may
1272
1481
  // continue after this agent goes idle
@@ -1520,9 +1729,16 @@ export class Agent {
1520
1729
  }
1521
1730
  /** AGENTS.md content cached for the session (empty string = none/read-failed) */
1522
1731
  agentsMd = "";
1732
+ /** captured once, with the rest of the session-frozen context */
1523
1733
  agentsMdLoaded = false;
1524
1734
  /** one-time workspace listing injected with the system prompt */
1525
1735
  wsSnapshot = "";
1736
+ /**
1737
+ * one-time catalogue of available skills, injected with the system prompt
1738
+ * (#31). Built once alongside wsSnapshot so the prompt stays byte-identical
1739
+ * for the session and the provider prefix cache survives.
1740
+ */
1741
+ skillCatalogue = "";
1526
1742
  systemPrompt() {
1527
1743
  if (!this.agentsMdLoaded) {
1528
1744
  this.agentsMdLoaded = true;
@@ -1593,6 +1809,9 @@ export class Agent {
1593
1809
  this.agentsMd = "";
1594
1810
  }
1595
1811
  }
1812
+ // #31: capture the skill catalogue in the SAME one-shot block, so the
1813
+ // system prompt stays byte-identical for the session (prefix cache).
1814
+ this.skillCatalogue = this.skillCatalogueText();
1596
1815
  }
1597
1816
  let sys = SYSTEM_TEMPLATE;
1598
1817
  if (this.agentsMd.trim()) {
@@ -1601,6 +1820,22 @@ export class Agent {
1601
1820
  if (this.wsSnapshot) {
1602
1821
  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.`;
1603
1822
  }
1823
+ // #31: the catalogue of AVAILABLE SKILLS. The prompt previously named the
1824
+ // skill TOOLS but never the skills themselves, so the only way to discover
1825
+ // one was to spend a tool call on list_skills() — and a model that does not
1826
+ // know a skill exists will never think to look.
1827
+ //
1828
+ // Appended ONCE, in the same one-shot block as AGENTS.md and the workspace
1829
+ // snapshot, so SYSTEM_TEMPLATE stays byte-identical for the session and the
1830
+ // provider prefix cache survives (#14's constraint). list_skills() remains
1831
+ // for the full detail (a skill saved mid-session shows up there).
1832
+ if (this.skillCatalogue) {
1833
+ sys +=
1834
+ `\n\n## Skills available in this workspace\n${this.skillCatalogue}\n` +
1835
+ `Load one with load_skill(name, instructions) when its description matches your task — ` +
1836
+ `pass what you want done so the instructions arrive with the playbook. ` +
1837
+ `list_skills() shows the full detail.`;
1838
+ }
1604
1839
  return sys;
1605
1840
  }
1606
1841
  async handleFinish(argsJson) {
@@ -1629,18 +1864,37 @@ export class Agent {
1629
1864
  const text = String(res.message.content ?? "");
1630
1865
  const approved = /^APPROVED\b/i.test(text.trim());
1631
1866
  const feedback = text.replace(/^(APPROVED|CHANGES-REQUIRED)\s*[:—-]?\s*/i, "").trim();
1632
- await this.setGoalAudit(approved ? "approved" : "changes-required", feedback || "(no detail)");
1867
+ // #51: storing the literal "(no detail)" made the UI draw a card
1868
+ // whose whole content was the absence of a reason, and that same
1869
+ // string landed in goal.md for the right panel. An empty string is
1870
+ // the honest value: the auditor returned a verdict and no reason,
1871
+ // which every consumer can already render as "label only".
1872
+ await this.setGoalAudit(approved ? "approved" : "changes-required", feedback);
1873
+ // `operatorFacing: true` — the verdict is written for the TIMELINE
1874
+ // only. The audit ran in its own side conversation (a tools-less call
1875
+ // over buildMessages() + an audit prompt), so it was never in
1876
+ // `this.messages` and must not come back through a restore: doing so
1877
+ // injected an assistant turn that answers nothing into the model's
1878
+ // context, which providers answer with a 200 and no choices (#50).
1879
+ // The rejection feedback reaches the agent as a real prompt below.
1633
1880
  await this.log.append("message", this.currentSession, this.currentBranch, {
1634
1881
  role: "assistant",
1882
+ operatorFacing: true,
1635
1883
  content: approved
1636
1884
  ? `✅ completion audit: APPROVED — ${feedback}`
1637
1885
  : `🔍 completion audit: CHANGES REQUIRED — ${feedback}`,
1638
1886
  });
1639
1887
  if (!approved) {
1640
- // hand the gaps back to the worker as its next instruction
1888
+ // hand the gaps back to the worker as its next instruction.
1889
+ // #51: with the "(no detail)" placeholder gone, an empty verdict
1890
+ // would leave this prompt saying "address these gaps" and then
1891
+ // naming none — so restate the contract as the thing to re-check.
1892
+ const gaps = feedback.trim()
1893
+ ? feedback.slice(0, 2000)
1894
+ : `The auditor returned no specific gaps. Re-read the verification contract above and re-run it yourself, item by item, before finishing again:\n\n${this.goal.verify?.slice(0, 2000) || this.goal.text.slice(0, 2000)}`;
1641
1895
  this.pendingPrompts.push({
1642
1896
  source: "harness",
1643
- text: `[harness] The completion audit REJECTED this finish. Address these gaps, then finish again with goalComplete=true:\n\n${feedback.slice(0, 2000)}`,
1897
+ text: `[harness] The completion audit REJECTED this finish. Address these gaps, then finish again with goalComplete=true:\n\n${gaps}`,
1644
1898
  });
1645
1899
  }
1646
1900
  }
@@ -1675,7 +1929,11 @@ export class Agent {
1675
1929
  // the raw content is what matters here, and cap hard: these can be huge
1676
1930
  line = `[${who}] ${(m.content ?? "").trim().slice(0, 1200)}`;
1677
1931
  }
1678
- recent.unshift(line.slice(0, 1500));
1932
+ // A generous cap, but not unbounded: one runaway turn must not blow up
1933
+ // the parent's context. The old 1500 char clip cut findings in half —
1934
+ // a review's conclusions routinely live past that mark (#42) — and this
1935
+ // text is bounded by 6 turns, so the worst case is ~120k chars.
1936
+ recent.unshift(line.slice(0, 20_000));
1679
1937
  }
1680
1938
  await this.log.append("message", this.currentSession, this.currentBranch, {
1681
1939
  role: "assistant",
@@ -1693,8 +1951,7 @@ export class Agent {
1693
1951
  this.lastProgressAt = Date.now();
1694
1952
  this.activityChars = 0;
1695
1953
  this.turnsSinceProgress = 0;
1696
- const request = "[harness] Please give a brief progress report now: what you are doing, goal progress, " +
1697
- "what you recently tried, any problems, and your next step. Keep it under 10 lines.";
1954
+ const request = PROGRESS_REQUEST;
1698
1955
  // log both sides so a session restore replays this exchange faithfully
1699
1956
  await this.log.append("prompt", this.currentSession, this.currentBranch, {
1700
1957
  source: "harness",
@@ -1938,8 +2195,21 @@ export class Agent {
1938
2195
  // fallback: drop the oldest half without LLM help so work can continue
1939
2196
  mode = "truncate";
1940
2197
  summarizedCount = 0;
1941
- const halfCut = this.safeCut(Math.floor(oldCount / 2));
2198
+ // Cut at a safe boundary, but never at index 0 — that would drop the
2199
+ // ENTIRE history (including the prompt just delivered) and leave the agent
2200
+ // with nothing. On a short history floor(oldCount/2) can round to 0, and
2201
+ // the old code then bailed out silently: compaction "ran", reported
2202
+ // nothing, and the context stayed over budget, so the very next provider
2203
+ // call overflowed — the "unstable after compact" report (#8). Always drop
2204
+ // at least one message, and say so.
2205
+ const halfCut = this.safeCut(Math.max(1, Math.floor(oldCount / 2)));
1942
2206
  if (halfCut <= 0) {
2207
+ // nothing in the prefix is safe to drop (e.g. it is all tool results).
2208
+ // Report it instead of pretending compaction happened.
2209
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
2210
+ event: "context-compaction-skipped",
2211
+ reason: "summarize failed and no safe prefix to truncate",
2212
+ });
1943
2213
  this.compactPhase = "";
1944
2214
  bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "done" });
1945
2215
  return;
@@ -2435,13 +2705,28 @@ function rebuildMessagesFrom(list) {
2435
2705
  msgs.push(mk());
2436
2706
  }
2437
2707
  else if (e.type === "message") {
2438
- // final summaries are operator-facing only — the live loop never puts
2439
- // them in model history, so restores must skip them too
2440
- if (d.final === true)
2708
+ // Operator-facing rows are TIMELINE content, not conversation: the live
2709
+ // loop never put them in `messages`, so a restore must not either.
2710
+ // `final` is the finish() summary; `operatorFacing` covers harness notes
2711
+ // such as the completion audit verdict. Replaying either produced a
2712
+ // history shape no live run emits, and a provider given that answers
2713
+ // 200 with zero choices (#50).
2714
+ if (d.final === true || d.operatorFacing === true)
2441
2715
  continue;
2442
2716
  const role = d.role === "assistant" ? "assistant" : "user";
2443
- const m = { role, content: typeof d.content === "string" ? d.content : "" };
2444
- if (Array.isArray(d.toolCalls) && d.toolCalls.length > 0) {
2717
+ const content = typeof d.content === "string" ? d.content : "";
2718
+ const hasCalls = Array.isArray(d.toolCalls) && d.toolCalls.length > 0;
2719
+ // A blank assistant turn that issued NO tool calls is an artefact of a
2720
+ // kill mid-stream: the model streamed nothing, so the live run never had
2721
+ // this turn, yet the restore replays it as a real utterance. sanitize()
2722
+ // then rewrites blank assistant content to the literal "(no content)",
2723
+ // so the agent is told it previously said a sentence it never said — a
2724
+ // history shape no live run produces (#50). A blank turn that DID call
2725
+ // tools is legitimate and must stay, or its results are orphaned.
2726
+ if (role === "assistant" && !content && !hasCalls)
2727
+ continue;
2728
+ const m = { role, content };
2729
+ if (hasCalls) {
2445
2730
  m.tool_calls = d.toolCalls.map((c) => ({
2446
2731
  id: c.id,
2447
2732
  type: "function",
package/dist/agent/llm.js CHANGED
@@ -7,6 +7,37 @@
7
7
  */
8
8
  import OpenAI from "openai";
9
9
  const clients = new WeakMap();
10
+ /**
11
+ * Reasoning effort for a request body (#47).
12
+ *
13
+ * Kept local and deliberately conservative: the setting is only honoured when
14
+ * the endpoint is OpenRouter, the value is one OpenRouter documents, and the
15
+ * catalogue says this model advertises `reasoning_effort`. Support is far from
16
+ * universal (verified against the live /models endpoint: 196 of 462 models),
17
+ * so an unsent field is much safer than a wrong one.
18
+ *
19
+ * Implemented here rather than via model-meta to avoid an import cycle —
20
+ * model-meta already imports providerHeaders from this file.
21
+ */
22
+ export function effortForRequest(cfg, supported = cfg.supportedParameters) {
23
+ const e = cfg.reasoningEffort;
24
+ if (!e || !REASONING_EFFORTS.includes(e))
25
+ return {};
26
+ if (!isOpenRouterEndpoint(cfg.baseUrl))
27
+ return {};
28
+ if (!supported?.includes("reasoning_effort"))
29
+ return {};
30
+ return { reasoning_effort: e };
31
+ }
32
+ function isOpenRouterEndpoint(baseUrl) {
33
+ try {
34
+ return /(^|\.)openrouter\.ai$/i.test(new URL(String(baseUrl ?? "")).host);
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ const REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
10
41
  /**
11
42
  * OpenRouter app attribution (https://openrouter.ai/docs/app-attribution):
12
43
  * `HTTP-Referer` is the required identifier that puts teapot's usage on the
@@ -103,6 +134,7 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
103
134
  model: cfg.model,
104
135
  messages: sanitize(messages),
105
136
  ...(tools.length ? { tools } : {}),
137
+ ...effortForRequest(cfg),
106
138
  }, { signal });
107
139
  const raw = res.choices?.[0];
108
140
  const rm = raw?.message;
@@ -188,6 +220,7 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
188
220
  model: cfg.model,
189
221
  messages: sanitize(messages),
190
222
  ...(tools.length ? { tools } : {}),
223
+ ...effortForRequest(cfg),
191
224
  stream: true,
192
225
  // ask providers to include the final usage chunk in streaming mode
193
226
  // (OpenAI-compatible; harmless where unsupported)