teapot-coding-agent 0.22.13 → 0.24.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.
@@ -11,7 +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, foreignSkillRoots } from "./skills.js";
14
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.";
15
46
  /**
16
47
  * SYSTEM_TEMPLATE must stay byte-identical across every request of a session:
17
48
  * provider prefix caches key on it, so changing it (or injecting per-turn
@@ -21,7 +52,7 @@ import { bus } from "../bus.js";
21
52
  */
22
53
  const SYSTEM_TEMPLATE = `You are a coding agent working autonomously inside a workspace.
23
54
 
24
- Session state is not injected into prompts — fetch it with tools instead:
55
+ Most session state is not injected into prompts — fetch it with tools instead:
25
56
  - get_goal() → current objective + status. Call at session start, after a
26
57
  compaction notice, or whenever you lose the thread.
27
58
  - set_goal(text) → change the objective itself (not routine updates).
@@ -35,10 +66,11 @@ Session state is not injected into prompts — fetch it with tools instead:
35
66
  - Log significant choices with record_decision(decision, rationale,
36
67
  alternatives?) — compaction forgets reasoning, decisions.md doesn't.
37
68
  - list_skills() / load_skill(name) / save_skill(...) → reusable playbooks.
38
- - AGENTS.md in the workspace root (optional) holds project knowledge — read it
39
- with read_file at session start when present, keep it current.
40
69
 
41
70
  ## Rules
71
+ - Project instructions from AGENTS.md (workspace root) are EMBEDDED in this
72
+ prompt below when present — read them here, don't re-read_file it. Keep the
73
+ file current by editing it when it goes stale.
42
74
  - Work step by step with tools. Verify results (run tests/builds) before claiming progress.
43
75
  - File changes — pick by scope: write_file (one new file / full rewrite) ·
44
76
  edit_file (exactly one small unique replacement) · apply_patch (several
@@ -112,6 +144,14 @@ export class Agent {
112
144
  modelPricing;
113
145
  /** messages.length right after the last successful compaction */
114
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 = "";
115
155
  /** live compaction phase for UI progress ("summarizing"/"harvesting") */
116
156
  compactPhase = "";
117
157
  /** set by ask_user: the loop is parked until the operator replies */
@@ -130,6 +170,10 @@ export class Agent {
130
170
  autoContinue: true,
131
171
  continueDelayMs: 15_000,
132
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: [],
133
177
  maxConsecutiveToolErrors: 5,
134
178
  maxTurnsPerRound: 200,
135
179
  onError: "stop",
@@ -158,6 +202,13 @@ export class Agent {
158
202
  this.skillRoots = [
159
203
  { dir: path.join(opts.workspace, "skills"), source: "workspace" },
160
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),
161
212
  // shipped-with-package skills: lowest priority, always discoverable
162
213
  ...(opts.bundledSkillsDir ? [{ dir: opts.bundledSkillsDir, source: "bundled" }] : []),
163
214
  ];
@@ -179,10 +230,42 @@ export class Agent {
179
230
  }
180
231
  skillRoots;
181
232
  skillsCache = [];
182
- /** Rescan skill roots (cheap: a readdir per root, done once per turn). */
233
+ /** fingerprint of the roots that produced skillsCache ("" = not scanned yet) */
234
+ skillsStamp = "";
235
+ /**
236
+ * Rescan skill roots — but only when something actually changed (#14).
237
+ * This used to run a readdir per root plus a read of every SKILL.md at the
238
+ * top of EVERY turn; the cache is only ever read by list_skills, so that
239
+ * was pure I/O on the hot path. A cheap stat fingerprint of the roots and
240
+ * their SKILL.md files gates the real scan, so a turn only pays when a
241
+ * skill was added, edited or removed (save_skill writes one, so a
242
+ * self-created skill still shows up on the very next turn).
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
+ }
183
262
  async refreshSkills() {
184
263
  try {
264
+ const stamp = await skillRootsFingerprint(this.skillRoots);
265
+ if (stamp === this.skillsStamp)
266
+ return; // unchanged → reuse the cache
185
267
  this.skillsCache = await currentSkills(this.toolCtx);
268
+ this.skillsStamp = stamp;
186
269
  }
187
270
  catch {
188
271
  /* keep previous cache */
@@ -190,7 +273,16 @@ export class Agent {
190
273
  }
191
274
  callLlm(messages, tools, onDelta) {
192
275
  const fn = this.opts.chatFn ?? chatStream;
193
- 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);
194
286
  }
195
287
  /** expose id for metrics */
196
288
  opts_id() {
@@ -550,7 +642,13 @@ export class Agent {
550
642
  * must not be running — editing under a live loop would race its history.
551
643
  */
552
644
  async editPromptAt(eventId, text, tail) {
553
- 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)
554
652
  throw new Error("agent is running — stop it before editing history");
555
653
  const all = await readEvents(this.log.filePath);
556
654
  const target = all.find((e) => e.id === eventId);
@@ -583,7 +681,17 @@ export class Agent {
583
681
  }
584
682
  msgs.push({ role: "user", content: text });
585
683
  const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
586
- 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, {
587
695
  fromSession: this.currentSession,
588
696
  fromBranch: this.currentBranch,
589
697
  fromEvent: kept.at(-1)?.id ?? null,
@@ -592,12 +700,49 @@ export class Agent {
592
700
  droppedEvents: dropped.length,
593
701
  tailMode: tail,
594
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));
595
707
  this.currentBranch = newBranch;
596
708
  this.messages = msgs;
597
709
  await this.log.append("prompt", this.currentSession, this.currentBranch, { source: "user", text });
598
710
  bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
599
711
  return { droppedEvents: dropped.length, branch: newBranch };
600
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
+ }
601
746
  parseGoalFile(text) {
602
747
  // humans and agents may append their own status lines — latest wins
603
748
  const all = [...text.matchAll(/status:\s*(\w+)/gi)];
@@ -745,6 +890,9 @@ export class Agent {
745
890
  stats: { ...this.stats, costUsd: this.modelPricing ? this.stats.costUsd : undefined },
746
891
  model: this.opts.llm.model,
747
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"),
748
896
  sessionDir: this.opts.sessionDir,
749
897
  ctx: {
750
898
  usedTokens: this.lastUsage?.input ?? this.estimateTokens(),
@@ -888,8 +1036,22 @@ export class Agent {
888
1036
  // suppressed while bg jobs are alive) — its exit IS the wake-up call.
889
1037
  // Without this the report would sit unread until the operator poked
890
1038
  // the agent. Stopped agents stay stopped (a deliberate human stop).
891
- if (this.status === "idle" && !this.stopRequested) {
892
- 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
+ });
893
1055
  }
894
1056
  }
895
1057
  /** fold queued background-exit reports into the next LLM call (once each) */
@@ -1014,6 +1176,18 @@ export class Agent {
1014
1176
  });
1015
1177
  break;
1016
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
+ }
1017
1191
  // auto-continue only makes sense with an active goal to continue toward
1018
1192
  if (finished ||
1019
1193
  !this.opts.autoContinue ||
@@ -1031,7 +1205,7 @@ export class Agent {
1031
1205
  const isSub = !!this.opts.parent;
1032
1206
  const nudge = isSub
1033
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().`
1034
- : "Continue working toward the current goal. If you are blocked, explain why briefly.";
1208
+ : AUTO_CONTINUE_NUDGE;
1035
1209
  await this.log.append("prompt", this.currentSession, this.currentBranch, {
1036
1210
  source: "harness",
1037
1211
  text: nudge,
@@ -1245,12 +1419,63 @@ export class Agent {
1245
1419
  this.messages.push(m);
1246
1420
  this.turnsSinceProgress++;
1247
1421
  this.activityChars += m.content?.length ?? 0;
1248
- 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
+ }
1249
1472
  return finished;
1473
+ }
1250
1474
  for (const call of m.tool_calls) {
1251
1475
  if (this.stopRequested)
1252
1476
  return finished;
1253
1477
  if (call.function.name === "finish") {
1478
+ this.pendingSubReport = ""; // the real summary supersedes it
1254
1479
  await this.handleFinish(call.function.arguments);
1255
1480
  // surface still-running children so the operator knows work may
1256
1481
  // continue after this agent goes idle
@@ -1504,9 +1729,16 @@ export class Agent {
1504
1729
  }
1505
1730
  /** AGENTS.md content cached for the session (empty string = none/read-failed) */
1506
1731
  agentsMd = "";
1732
+ /** captured once, with the rest of the session-frozen context */
1507
1733
  agentsMdLoaded = false;
1508
1734
  /** one-time workspace listing injected with the system prompt */
1509
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 = "";
1510
1742
  systemPrompt() {
1511
1743
  if (!this.agentsMdLoaded) {
1512
1744
  this.agentsMdLoaded = true;
@@ -1577,6 +1809,9 @@ export class Agent {
1577
1809
  this.agentsMd = "";
1578
1810
  }
1579
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();
1580
1815
  }
1581
1816
  let sys = SYSTEM_TEMPLATE;
1582
1817
  if (this.agentsMd.trim()) {
@@ -1585,6 +1820,22 @@ export class Agent {
1585
1820
  if (this.wsSnapshot) {
1586
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.`;
1587
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
+ }
1588
1839
  return sys;
1589
1840
  }
1590
1841
  async handleFinish(argsJson) {
@@ -1659,7 +1910,11 @@ export class Agent {
1659
1910
  // the raw content is what matters here, and cap hard: these can be huge
1660
1911
  line = `[${who}] ${(m.content ?? "").trim().slice(0, 1200)}`;
1661
1912
  }
1662
- recent.unshift(line.slice(0, 1500));
1913
+ // A generous cap, but not unbounded: one runaway turn must not blow up
1914
+ // the parent's context. The old 1500 char clip cut findings in half —
1915
+ // a review's conclusions routinely live past that mark (#42) — and this
1916
+ // text is bounded by 6 turns, so the worst case is ~120k chars.
1917
+ recent.unshift(line.slice(0, 20_000));
1663
1918
  }
1664
1919
  await this.log.append("message", this.currentSession, this.currentBranch, {
1665
1920
  role: "assistant",
@@ -1677,8 +1932,7 @@ export class Agent {
1677
1932
  this.lastProgressAt = Date.now();
1678
1933
  this.activityChars = 0;
1679
1934
  this.turnsSinceProgress = 0;
1680
- const request = "[harness] Please give a brief progress report now: what you are doing, goal progress, " +
1681
- "what you recently tried, any problems, and your next step. Keep it under 10 lines.";
1935
+ const request = PROGRESS_REQUEST;
1682
1936
  // log both sides so a session restore replays this exchange faithfully
1683
1937
  await this.log.append("prompt", this.currentSession, this.currentBranch, {
1684
1938
  source: "harness",
@@ -1922,8 +2176,21 @@ export class Agent {
1922
2176
  // fallback: drop the oldest half without LLM help so work can continue
1923
2177
  mode = "truncate";
1924
2178
  summarizedCount = 0;
1925
- const halfCut = this.safeCut(Math.floor(oldCount / 2));
2179
+ // Cut at a safe boundary, but never at index 0 — that would drop the
2180
+ // ENTIRE history (including the prompt just delivered) and leave the agent
2181
+ // with nothing. On a short history floor(oldCount/2) can round to 0, and
2182
+ // the old code then bailed out silently: compaction "ran", reported
2183
+ // nothing, and the context stayed over budget, so the very next provider
2184
+ // call overflowed — the "unstable after compact" report (#8). Always drop
2185
+ // at least one message, and say so.
2186
+ const halfCut = this.safeCut(Math.max(1, Math.floor(oldCount / 2)));
1926
2187
  if (halfCut <= 0) {
2188
+ // nothing in the prefix is safe to drop (e.g. it is all tool results).
2189
+ // Report it instead of pretending compaction happened.
2190
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
2191
+ event: "context-compaction-skipped",
2192
+ reason: "summarize failed and no safe prefix to truncate",
2193
+ });
1927
2194
  this.compactPhase = "";
1928
2195
  bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "done" });
1929
2196
  return;
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)