teapot-coding-agent 0.29.11 → 0.29.13

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
@@ -248,8 +248,10 @@ an agent may still pin its own `model` to override the preset's.
248
248
  }
249
249
  ```
250
250
 
251
- Anything OpenAI-compatible works: OpenRouter / OpenAI / Ollama / vLLM / llama.cpp.
252
- Requests to openrouter.ai automatically carry app-attribution headers.
251
+ Anything OpenAI-compatible works: OpenRouter / OpenAI / OpenCode Go / Ollama / vLLM / llama.cpp.
252
+ Requests to openrouter.ai automatically carry app-attribution headers; requests to
253
+ opencode.ai carry a stable `x-opencode-session`, which OpenCode Go refuses to route
254
+ a conversation without.
253
255
 
254
256
  Model context windows are inferred from `/v1/models` at startup and drive the runtime gauge plus the derived compaction budget (manual override supported).
255
257
 
@@ -11,6 +11,7 @@ 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 { effortChoosable } from "./llm.js";
14
15
  import { skillRootsFingerprint, foreignSkillRoots } from "./skills.js";
15
16
  import { bus } from "../bus.js";
16
17
  /**
@@ -101,6 +102,22 @@ const KEEP_RECENT_TOKENS = 15_000;
101
102
  const SUMMARIZE_MIN_SHARE = 0.02;
102
103
  /** the absolute floor for the bar above (see the note where it is applied) */
103
104
  const SUMMARIZE_MIN_TOKENS = 2_000;
105
+ /**
106
+ * Bounded retries when the summarizer's own request is over the window, and how much
107
+ * of the shadowed region to shed each time. ZCode handles the same case with a bounded
108
+ * `prompt-too-long` retry ("compact 请求自身超窗时丢旧 API round 重试"): unbounded would
109
+ * burn tokens against a provider that refuses every size.
110
+ */
111
+ const SUMMARIZE_OVERFLOW_RETRIES = 3;
112
+ const SUMMARIZE_SHRINK = 0.6;
113
+ /**
114
+ * Consecutive AUTO compaction failures before auto-compaction stops trying (ZCode:
115
+ * `max auto failures: 3`). The failure is usually environmental — an unavailable
116
+ * model, a provider outage — so retrying it every turn re-burns the provider for a
117
+ * pass that cannot succeed. A success resets the count, so one bad stretch does not
118
+ * disable compaction for the session.
119
+ */
120
+ const MAX_AUTO_COMPACT_FAILURES = 3;
104
121
  /**
105
122
  * How much of the budget to KEEP on each successive overflow recovery.
106
123
  *
@@ -111,6 +128,59 @@ const SUMMARIZE_MIN_TOKENS = 2_000;
111
128
  * shrink it enough. The last entry repeats for any further attempt.
112
129
  */
113
130
  const OVERFLOW_SHRINK_STEPS = [0.5, 0.35, 0.2];
131
+ /**
132
+ * The compaction directive, delivered as the FINAL user message after the replayed
133
+ * conversation rather than as a distinct summarizer `system` prompt.
134
+ *
135
+ * This position is the whole point: the request is then a genuine prefix-EXTENSION
136
+ * of the last routed request, so the provider's KV cache is reused instead of being
137
+ * discarded at the first token. Measured before the change — a bespoke summarizer
138
+ * system prompt shares nothing with the conversation, and the entire history is
139
+ * re-prefilled at full price exactly when the conversation is largest. DeepSeek
140
+ * Harness reaches the same conclusion in its `compaction-summary-prefix-cache-reuse`
141
+ * note.
142
+ *
143
+ * It opens with the sentence tests use to RECOGNISE a summarizer call; keeping that
144
+ * stable matters (an earlier version reworded it and silently made every update pass
145
+ * look like an ordinary turn).
146
+ */
147
+ const COMPACT_INSTRUCTION = [
148
+ "You compress a coding agent's conversation into dense notes for it to continue working.",
149
+ "Condense the conversation ABOVE into a structured checkpoint so another model can",
150
+ "resume the work with no loss of essential context.",
151
+ "",
152
+ "First think inside an <analysis> block: what mattered, what changed, what is unresolved.",
153
+ "Then output ONLY the following sections, each starting with its exact heading:",
154
+ "",
155
+ "## Primary request and intent",
156
+ "## Key technical concepts",
157
+ "## Files and code sections",
158
+ "## Errors and fixes",
159
+ "## Problem solving",
160
+ "## All user messages", // verbatim-ish: intent survives repeated compaction
161
+ "## Pending tasks",
162
+ "## Current work",
163
+ "## Next step",
164
+ "## Durable lessons",
165
+ "",
166
+ "Rules: be terse bullet points, no prose flourishes. Quote file paths and key numbers exactly.",
167
+ "All user messages are listed nearly verbatim — they carry intent that must survive every future compaction.",
168
+ "Durable lessons lists reusable insights worth keeping forever (gotchas, preferences, what worked); omit the section if empty.",
169
+ "Output only the checkpoint text: do not call any tool, and do not mention this request or that the context was compacted.",
170
+ "The <analysis> block is discarded before your notes are stored, so think freely there.",
171
+ ].join("\n");
172
+ /** Appended to COMPACT_INSTRUCTION when notes from an earlier pass are present. */
173
+ const UPDATE_INSTRUCTION = [
174
+ "",
175
+ "The notes you produced last time are above, framed as a checkpoint. This is an",
176
+ "UPDATE: PRESERVE what is still true, ADD what the new messages establish, and",
177
+ "REMOVE only what they resolve or supersede. Never drop a constraint or a user",
178
+ "request just because the new messages do not repeat it: an unanswered request",
179
+ "stays, an answered one goes. Return the FULL updated notes under the same headings.",
180
+ ].join("\n");
181
+ /** framing around the prior notes when an update pass carries them forward */
182
+ const SUMMARY_OPEN = "<conversation_checkpoint>\n";
183
+ const SUMMARY_CLOSE = "\n</conversation_checkpoint>";
114
184
  const SYSTEM_TEMPLATE = `You are a coding agent working autonomously inside a workspace.
115
185
 
116
186
  Most session state is not injected into prompts — fetch it with tools instead:
@@ -203,6 +273,12 @@ export class Agent {
203
273
  /** running cost estimate in USD (pricing known only) */
204
274
  costUsd: 0,
205
275
  };
276
+ /**
277
+ * Everything has a default EXCEPT `supportedParameters`, which must stay optional:
278
+ * `undefined` means "the endpoint published no capability list", and that is a
279
+ * different answer from an empty list ("published, and excludes it") — #150 turns
280
+ * on that distinction.
281
+ */
206
282
  opts;
207
283
  messages = [];
208
284
  /**
@@ -252,6 +328,14 @@ export class Agent {
252
328
  consecutiveToolErrors = 0;
253
329
  /** consecutive round-fatal API errors (drives the retry backoff) */
254
330
  consecutiveApiErrors = 0;
331
+ /**
332
+ * True while the round-level retry backoff is sleeping (#152).
333
+ *
334
+ * Separate from `status`, which must keep reading "error" so the operator sees WHAT
335
+ * failed while the retry is pending — but an agent waiting to retry is stoppable
336
+ * work in flight, so `isLive()` has to say so or the UI offers "start" and no stop.
337
+ */
338
+ retrySleeping = false;
255
339
  /**
256
340
  * Consecutive auto-continued rounds that did no work (#106).
257
341
  *
@@ -307,8 +391,13 @@ export class Agent {
307
391
  // #47: no effort and no catalogue entry means "provider default" — the
308
392
  // effort field is then never sent, which is the safe default
309
393
  reasoningEffort: "",
310
- supportedParameters: [],
311
- reasoningEffortOptIn: false,
394
+ // undefined, NOT []: "the endpoint published no capability list" and "the
395
+ // endpoint published a list that excludes this parameter" are different
396
+ // answers, and only the second is a definite no (#150). An empty array here
397
+ // would make every silent endpoint look like an explicit refusal — measured:
398
+ // commandcode and google publish no list at all, so `[]` hid the very control
399
+ // #150 was about.
400
+ supportedParameters: undefined,
312
401
  maxConsecutiveToolErrors: 5,
313
402
  maxTurnsPerRound: 200,
314
403
  maxAuditRejects: 3, // #128: bound the reject loop
@@ -383,6 +472,12 @@ export class Agent {
383
472
  lastCompactSummary;
384
473
  /** overflow recoveries used in the current turn; reset when a turn succeeds */
385
474
  overflowRecoveries = 0;
475
+ /**
476
+ * Consecutive auto-compaction failures; at MAX_AUTO_COMPACT_FAILURES auto passes are
477
+ * skipped until one succeeds (ZCode's circuit breaker). Manual and overflow recovery
478
+ * are exempt — those are not "keep trying", they are "the operator/provider asked".
479
+ */
480
+ consecutiveCompactFailures = 0;
386
481
  /**
387
482
  * Rescan skill roots — but only when something actually changed (#14).
388
483
  * This used to run a readdir per root plus a read of every SKILL.md at the
@@ -422,18 +517,27 @@ export class Agent {
422
517
  /* keep previous cache */
423
518
  }
424
519
  }
520
+ /**
521
+ * Request config for a call made in THIS conversation: the session id rides
522
+ * along so OpenCode's endpoints can route and prompt-cache the turn (without
523
+ * it they answer 400 — see providerHeaders()). Read from `currentSession`
524
+ * rather than copied onto opts.llm, because a fork/incarnation switch moves
525
+ * the agent to a new conversation while the endpoint settings stay put.
526
+ */
527
+ requestConfig(extra = {}) {
528
+ return { ...this.opts.llm, sessionId: this.currentSession, ...extra };
529
+ }
425
530
  callLlm(messages, tools, onDelta) {
426
531
  const fn = this.opts.chatFn ?? chatStream;
427
532
  // #47: carry the effort setting + the model's advertised capabilities into
428
533
  // the request config. chat() applies effortForRequest(), which sends the
429
534
  // field ONLY for OpenRouter endpoints whose catalogue lists support.
430
- return fn({
431
- ...this.opts.llm,
535
+ return fn(this.requestConfig({
432
536
  ...(this.opts.reasoningEffort ? { reasoningEffort: this.opts.reasoningEffort } : {}),
433
537
  ...(this.opts.supportedParameters
434
538
  ? { supportedParameters: this.opts.supportedParameters }
435
539
  : {}),
436
- }, messages, tools, this.abort?.signal, onDelta);
540
+ }), messages, tools, this.abort?.signal, onDelta);
437
541
  }
438
542
  /** expose id for metrics */
439
543
  opts_id() {
@@ -566,12 +670,21 @@ export class Agent {
566
670
  // press start. #9 settled that a stop must neither deliver nor silently
567
671
  // DISCARD those prompts, so they are kept — but keeping them must not make the
568
672
  // agent read as running. A stop outranks them.
673
+ //
674
+ // #152: and one more, from the same family. The round-level retry backoff sets
675
+ // status "error" while it sleeps ("… — retrying in 60s"), and `error` is not a
676
+ // live status — so the UI offered "▶ start" for an agent that was waiting to
677
+ // retry, and there was NO stop control at all. The operator pressed what was
678
+ // there and nothing stopped, because nothing could. A scheduled retry is work
679
+ // in flight, so it counts as live; the dedicated flag keeps that independent of
680
+ // the display status, which still has to read "error".
569
681
  if (this.stopRequested)
570
682
  return false;
571
683
  return (this.status === "running" ||
572
684
  this.parkedByTool ||
573
685
  this.pendingPrompts.length > 0 ||
574
686
  this.compactPhase !== "" ||
687
+ this.retrySleeping ||
575
688
  // ask_user: the loop is parked on the operator's answer — stoppable work
576
689
  // in flight, not idleness. Without this the UI offered "start", and
577
690
  // pressing it cleared awaitingUser, silently discarding the open question
@@ -1265,8 +1378,11 @@ export class Agent {
1265
1378
  provider: this.opts.provider,
1266
1379
  // #47/#150: the effort in force, and whether this model can take one at all
1267
1380
  ...(this.opts.reasoningEffort ? { reasoningEffort: this.opts.reasoningEffort } : {}),
1268
- effortSupported: (this.opts.supportedParameters ?? []).includes("reasoning_effort") ||
1269
- this.opts.reasoningEffortOptIn === true,
1381
+ effortSupported: effortChoosable(this.opts.supportedParameters),
1382
+ // the levels to OFFER, when the endpoint publishes them: a model with
1383
+ // ["max","high","low"] must not be shown minimal/medium, which would either be
1384
+ // refused or silently clamped (#150)
1385
+ ...(this.opts.supportedEfforts?.length ? { effortOptions: this.opts.supportedEfforts } : {}),
1270
1386
  sessionDir: this.opts.sessionDir,
1271
1387
  ctx: {
1272
1388
  usedTokens: this.lastUsage?.input ?? this.estimateTokens(),
@@ -1826,7 +1942,17 @@ export class Agent {
1826
1942
  kind: isRunaway ? "runaway" : "api",
1827
1943
  });
1828
1944
  this.setStatus("error", `${msg.slice(0, 220)} — retrying in ${Math.round(wait / 1000)}s`);
1829
- await this.sleepInterruptible(wait);
1945
+ // #152: while this sleeps the agent is NOT idle — it is going to try again,
1946
+ // and the operator must be able to stop it. Without the flag `isLive()` read
1947
+ // false (status is "error"), so the UI showed "▶ start" and offered no stop
1948
+ // at all while a 5s/5s/30s…30min staircase ran.
1949
+ this.retrySleeping = true;
1950
+ try {
1951
+ await this.sleepInterruptible(wait);
1952
+ }
1953
+ finally {
1954
+ this.retrySleeping = false;
1955
+ }
1830
1956
  if (this.stopRequested)
1831
1957
  break;
1832
1958
  // the NEXT round is live work again — leaving "error" set made
@@ -2468,6 +2594,11 @@ export class Agent {
2468
2594
  const sys = this.systemPrompt();
2469
2595
  const hasSystem = this.messages[0]?.role === "system";
2470
2596
  const head = [{ role: "system", content: sys }];
2597
+ // NOTE (#152): tool-call pairing is guaranteed one layer down, in `sanitize()`,
2598
+ // because EVERY request path passes through there (turns, the summarizer, the ACP
2599
+ // adapter) while this builds only an Agent's own turn. A violated pairing is refused
2600
+ // with a bare "invalid request error", which is not recognisable as an overflow and
2601
+ // so burns the whole retry staircase before failing with nothing to act on.
2471
2602
  return hasSystem ? [...head, ...this.messages.slice(1)] : [...head, ...this.messages];
2472
2603
  }
2473
2604
  /**
@@ -3116,6 +3247,14 @@ export class Agent {
3116
3247
  return;
3117
3248
  if (!force && this.opts.autoCompact === false)
3118
3249
  return;
3250
+ // CIRCUIT BREAKER (ZCode: `max auto failures: 3`). A compaction that keeps
3251
+ // failing would otherwise retry on EVERY turn, re-burning the provider for a pass
3252
+ // that cannot succeed — and the failure is usually environmental (an unavailable
3253
+ // model, a provider outage), so retrying it eagerly achieves nothing. Auto
3254
+ // attempts only: a manual `/compact` is the operator asking, and an overflow
3255
+ // recovery is the provider having just refused, so both must still try.
3256
+ if (!force && this.consecutiveCompactFailures >= MAX_AUTO_COMPACT_FAILURES)
3257
+ return;
3119
3258
  // a forced pass on an already-tiny history would just summarize the
3120
3259
  // summary — report ran=false instead
3121
3260
  if (force && this.messages.length <= this.compactedAtLen)
@@ -3149,7 +3288,7 @@ export class Agent {
3149
3288
  cut = this.safeCut(cut);
3150
3289
  if (cut <= 0)
3151
3290
  return; // nothing safely compactable (phase never armed yet)
3152
- const old = this.messages.slice(0, cut);
3291
+ let old = this.messages.slice(0, cut);
3153
3292
  const oldCount = old.length;
3154
3293
  // The prefix's share of the size the budget check ACTED on (which floors our
3155
3294
  // estimate against the provider's reported prompt_tokens). Scaling by character
@@ -3162,26 +3301,23 @@ export class Agent {
3162
3301
  let mode = "summarize";
3163
3302
  let summarizedCount = oldCount;
3164
3303
  let droppedCount = 0;
3165
- // COST GATE (measured). The summarize call shares NO cacheable prefix with the
3166
- // main loop: the summarizer's system prompt IS its instructions, so message[0]
3167
- // differs and the entire history is re-prefilled and re-billed at full size.
3168
- // Sharing the prefix would mean gutting the summarizer's role, so the lever is
3169
- // not to PAY for a shallow saving.
3304
+ // COST GATE. The criterion is the ABSOLUTE amount of context the cut would
3305
+ // discard, not how far over the budget we are: a ratio test gets it backwards,
3306
+ // since a small window overflowing slightly is exactly the case where a
3307
+ // summary buys almost nothing (there is little knowledge to lose), while a large
3308
+ // window overflowing slightly still has a lot to lose.
3170
3309
  //
3171
- // The criterion is the ABSOLUTE amount of context the cut would discard, not
3172
- // how far over the budget we are. A ratio test gets it backwards: a small
3173
- // window overflowing slightly is exactly the case where a full-history call
3174
- // buys almost nothing (there is little knowledge to lose), while a large
3175
- // window overflowing slightly still has a lot to lose. `oldTokens` is the
3176
- // summarizable prefix — the thing the call is actually paid for.
3310
+ // Since the summarizer now REPLAYS the conversation prefix, the call reuses the
3311
+ // provider's KV cache, so it is far cheaper than it used to be — but it is not
3312
+ // free (the shadowed region is re-read, and the summary itself is generated), so
3313
+ // a shallow cut is still better served by the free drop.
3177
3314
  //
3178
- // Below the threshold the DROP alone reclaims the budget, for free.
3315
+ // MANUAL is exempt: an explicit /compact is a user command, not a cost decision,
3316
+ // and opencode draws the line in the same place ("only the user compacts when
3317
+ // automatic compaction is off"). An overflow recovery is exempt too — the
3318
+ // provider just told us the prompt was too big, so the question is not whether
3319
+ // the saving is worth a call.
3179
3320
  //
3180
- // MANUAL is exempt: an explicit /compact is a user command, not a cost
3181
- // decision, and opencode draws the line in the same place ("only the user
3182
- // compacts when automatic compaction is off"). An overflow recovery is exempt
3183
- // too — the provider just told us the prompt was too big, so the question is
3184
- // not whether the saving is worth a call.
3185
3321
  // Use BOTH sizes. `oldTokens` is our estimate of the prefix being summarized;
3186
3322
  // `before` is the size the budget check acted on, which already floors that
3187
3323
  // estimate against the provider's REPORTED prompt_tokens. Gating on the local
@@ -3245,16 +3381,53 @@ export class Agent {
3245
3381
  });
3246
3382
  try {
3247
3383
  if (worthSummarizing) {
3248
- summary = await this.summarize(old, (text) => {
3249
- bus.emit("update", {
3250
- kind: "llm-delta",
3251
- agentId: this.opts.id,
3252
- // #146: the bubble is session-scoped, so the stream must say which session
3253
- sessionId: this.currentSession,
3254
- text: `[compact] ${text}`,
3255
- reasoning: "",
3256
- });
3257
- }, previousSummary);
3384
+ // OVERFLOW RETRY on the summarizer itself. The replayed prefix is the head
3385
+ // of a request that has already succeeded, so it normally fits — but the
3386
+ // cut can land such that the shadowed region is nearly the whole history
3387
+ // (measured: with a recent-keep target smaller than one message, cut lands
3388
+ // at the far end and `old` is everything). When the conversation was over
3389
+ // its window to begin with — the overflow-recovery case — the auxiliary
3390
+ // request can then be over it too, and there is no truncation to save it
3391
+ // any more.
3392
+ //
3393
+ // The fix keeps the property the replay exists for: REDUCE THE CUT, which
3394
+ // sends FEWER LEADING messages, so the request stays a cacheable prefix of
3395
+ // the routed request while shrinking. (Dropping the middle would break
3396
+ // alignment; dropping the tail would discard the newest context.) ZCode
3397
+ // handles the same case with a bounded `prompt-too-long` retry, and this is
3398
+ // bounded for the same reason: an unbounded loop here would burn tokens
3399
+ // against a provider that will refuse every size.
3400
+ for (let attempt = 0; attempt <= SUMMARIZE_OVERFLOW_RETRIES; attempt++) {
3401
+ try {
3402
+ summary = await this.summarize(old, (text) => {
3403
+ bus.emit("update", {
3404
+ kind: "llm-delta",
3405
+ agentId: this.opts.id,
3406
+ // #146: the bubble is session-scoped, so the stream must say which session
3407
+ sessionId: this.currentSession,
3408
+ text: `[compact] ${text}`,
3409
+ reasoning: "",
3410
+ });
3411
+ }, previousSummary);
3412
+ break;
3413
+ }
3414
+ catch (e) {
3415
+ const shrinkable = isContextOverflow(e) && attempt < SUMMARIZE_OVERFLOW_RETRIES;
3416
+ if (!shrinkable)
3417
+ throw e;
3418
+ const smaller = this.safeCut(Math.max(1, Math.floor(old.length * SUMMARIZE_SHRINK)));
3419
+ if (smaller <= 0 || smaller >= old.length)
3420
+ throw e; // cannot shrink further
3421
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
3422
+ event: "compaction-summarize-overflow",
3423
+ attempt: attempt + 1,
3424
+ from: old.length,
3425
+ to: smaller,
3426
+ });
3427
+ old = this.messages.slice(0, smaller);
3428
+ cut = smaller;
3429
+ }
3430
+ }
3258
3431
  }
3259
3432
  else {
3260
3433
  // shallow saving: truncation reclaims the budget for free, so skip the
@@ -3275,6 +3448,11 @@ export class Agent {
3275
3448
  await this.log.append("error", this.currentSession, this.currentBranch, {
3276
3449
  message: `compaction summarize failed, falling back to truncation: ${err.message}`,
3277
3450
  });
3451
+ // ZCode's circuit breaker counts the failure and never fails the turn for it
3452
+ // ("Auto compact 失败不应让当前用户 turn 失败"): the pass still falls back to a
3453
+ // truncation below, so work continues, and after MAX_AUTO_COMPACT_FAILURES
3454
+ // consecutive auto failures the retry stops burning the provider.
3455
+ this.consecutiveCompactFailures += 1;
3278
3456
  }
3279
3457
  if (!summary) {
3280
3458
  // fallback: drop the oldest half without LLM help so work can continue
@@ -3329,6 +3507,13 @@ export class Agent {
3329
3507
  }
3330
3508
  const after = this.estimateTokens();
3331
3509
  this.stats.compactions++;
3510
+ // A pass that got here reclaimed the budget, so autos may try again — ZCode
3511
+ // resets its count on success for the same reason. But NOT when the summary
3512
+ // failed: the truncate fallback still lands here, and resetting on it made the
3513
+ // breaker unreachable (measured: 217 consecutive summarize failures, counter
3514
+ // stuck at 0, so the provider was re-burned on every turn).
3515
+ if (mode === "summarize")
3516
+ this.consecutiveCompactFailures = 0;
3332
3517
  this.compactedAtLen = this.messages.length;
3333
3518
  // Post-compact file restore (Claude Code style): re-attach the most
3334
3519
  // recently read files so the model doesn't burn its first turns re-reading
@@ -3417,92 +3602,67 @@ export class Agent {
3417
3602
  }
3418
3603
  }
3419
3604
  /** Ask the model for dense continuation notes over the compacted range. */
3605
+ /**
3606
+ * Summarize `old` into continuation notes.
3607
+ *
3608
+ * PREFIX-CACHE SHAPE (measured): the summarizer request is built as the
3609
+ * conversation's OWN prefix — the session's system prompt, the same tool schemas,
3610
+ * and the messages being shadowed — with the compaction instruction appended as a
3611
+ * trailing `user` message. That makes the auxiliary call a genuine prefix-EXTENSION
3612
+ * of the last routed request, so the provider reuses the KV cache instead of
3613
+ * invalidating it at the first token.
3614
+ *
3615
+ * The alternative (a bespoke summarizer `system` prompt + a flattened transcript)
3616
+ * was measured to share NOTHING with the conversation request: a provider caches on
3617
+ * the leading token sequence, so a different first message discards the whole
3618
+ * prefix and the entire history is re-prefilled at full price — exactly when the
3619
+ * conversation is largest. DeepSeek Harness documents the same finding and the same
3620
+ * fix ("the summarization directive moves from the front of the request to the end
3621
+ * of the conversation"); the reasoning is reproduced here because it is not
3622
+ * obvious, and the previous version of this function did the expensive thing.
3623
+ *
3624
+ * `tools` ride along even though the summarizer never calls one: they are part of
3625
+ * the cached token sequence, so omitting them would misalign everything after them.
3626
+ */
3420
3627
  async summarize(old, onDelta, previousSummary) {
3421
- const full = old
3422
- .map((m) => {
3423
- const who = m.role === "tool" ? "tool" : m.role;
3424
- const tc = m.tool_calls?.map((t) => `\n[calls ${t.function.name}(${t.function.arguments})]`).join("");
3425
- return `${who}: ${m.content ?? ""}${tc}`;
3426
- })
3427
- .join("\n\n");
3428
- // #127: this was a bare `.slice(-120_000)` — the TAIL only.
3429
- //
3430
- // The question the issue raises is the right one: when a compaction starts,
3431
- // the context window opens dramatically, and the summarizer saw only the last
3432
- // 120k characters of what it was asked to summarise. Anything older was not
3433
- // summarised and not kept — it was silently DISCARDED. So the original user
3434
- // requirement, which sits at the very front of the history, was the single
3435
- // most likely thing to be lost, and losing it is the one failure compaction
3436
- // cannot recover from: the summary is now the only record.
3437
- //
3438
- // Head + tail, with the seam marked so the model can tell a gap from a
3439
- // boundary. The head is where the goal, the constraints and the original ask
3440
- // live; the tail is where the recent state and any in-flight error do.
3441
- const BUDGET = 120_000;
3442
- const HEAD = Math.floor(BUDGET * 0.35);
3443
- const TAIL = BUDGET - HEAD;
3444
- let transcript;
3445
- if (full.length <= BUDGET) {
3446
- transcript = full;
3447
- }
3448
- else {
3449
- transcript = [
3450
- full.slice(0, HEAD),
3451
- `\n\n[... ${full.length - BUDGET} characters elided from the middle of this history ...]\n\n`,
3452
- full.slice(full.length - TAIL),
3453
- ].join("");
3454
- }
3455
3628
  const fn = this.opts.chatFn ?? chat;
3629
+ // the conversation's own head: system prompt + the shadowed region, in order.
3630
+ // `buildMessages()` supplies the live system prompt, so the replayed prefix is
3631
+ // byte-identical to what the last routed request carried.
3632
+ const prefix = this.buildMessages().slice(0, 1 + old.length);
3633
+ // #127 kept the head: dropping the OLDEST text silently discarded the original
3634
+ // request, which sits at the front of the history and is the one thing compaction
3635
+ // cannot recover from. Replaying the prefix VERBATIM (rather than a flattened,
3636
+ // head+tail-elided transcript) keeps that property AND the cache: the model sees
3637
+ // the real messages, in their real order.
3638
+ //
3639
+ // The older head+tail truncation guarded against a summarizer input larger than
3640
+ // the model accepts; that is now handled by what gets shadowed instead — the
3641
+ // region is what the conversation itself just carried, so it already fits the
3642
+ // window that request fitted.
3643
+ const instruction = COMPACT_INSTRUCTION + (previousSummary ? UPDATE_INSTRUCTION : "");
3456
3644
  // Structured sections (Claude Code style): fixed headings survive MULTIPLE
3457
3645
  // successive compactions far better than free-form prose — each pass knows
3458
3646
  // where "pending tasks" vs "user messages" live, so nothing silently
3459
3647
  // merges away. The <analysis> scratchpad gives the model a thinking space;
3460
3648
  // it is stripped from the stored summary (formatCompactSummary).
3461
- const res = await fn({ ...this.opts.llm, oneOff: true }, // one-off prompt: ask the provider not to retain it
3462
- [
3463
- {
3464
- role: "system",
3465
- content:
3466
- // Both variants OPEN with the same sentence. Tests (and any future caller)
3467
- // identify the summarizer by this text, and a shared prefix keeps that
3468
- // detection working across the initial/update split — the first version of
3469
- // update mode changed it to "You UPDATE…", which silently made every
3470
- // update pass look like an ordinary turn.
3471
- "You compress a coding agent's conversation into dense notes for it to continue working.\n\n" +
3472
- (previousSummary
3473
- ? "This is an UPDATE: the current notes are under `## Current notes`. They are your\n" +
3474
- "output from an earlier compaction and are the ONLY record of everything before the\n" +
3475
- "messages you are about to read — so carry them forward. PRESERVE what is still\n" +
3476
- "true, ADD what the new messages establish, and REMOVE only what they resolve or\n" +
3477
- "supersede. Never drop a constraint or a user request just because the new\n" +
3478
- "messages do not repeat it: an unanswered request stays, an answered one goes.\n\n"
3479
- : "") +
3480
- "First think inside an <analysis> block: what mattered, what changed, what is unresolved.\n" +
3481
- "Then output ONLY the following sections, each starting with its exact heading:\n\n" +
3482
- "## Primary request and intent\n" +
3483
- "## Key technical concepts\n" +
3484
- "## Files and code sections\n" +
3485
- "## Errors and fixes\n" +
3486
- "## Problem solving\n" +
3487
- "## All user messages\n" + // verbatim-ish: intent survives repeated compaction
3488
- "## Pending tasks\n" +
3489
- "## Current work\n" +
3490
- "## Next step\n" +
3491
- "## Durable lessons\n\n" +
3492
- "Rules: be terse bullet points, no prose flourishes. Quote file paths and key numbers exactly. " +
3493
- "All user messages are listed nearly verbatim — they carry intent that must survive every future compaction. " +
3494
- 'Durable lessons lists reusable insights worth keeping forever (gotchas, preferences, what worked); omit the section if empty. ' +
3495
- "The <analysis> block is discarded before your notes are stored, so think freely there.",
3496
- },
3649
+ //
3650
+ // `tools` are forwarded even though the summarizer never calls one: they are
3651
+ // part of the cached token sequence, so dropping them (or sending a different
3652
+ // set) misaligns everything after them and costs the cache the whole point of
3653
+ // this shape. MEASURED while writing this: `toolSpecs()` returns 15 and the
3654
+ // conversation sends `allToolSpecs()`'s 29, so the obvious call was wrong — the
3655
+ // exact array the main loop uses is what must ride along.
3656
+ const res = await fn(
3657
+ // one-off prompt: ask the provider not to retain it — but it is still the
3658
+ // SAME conversation, so it carries the same session id
3659
+ this.requestConfig({ oneOff: true }), [
3660
+ ...prefix,
3497
3661
  {
3498
3662
  role: "user",
3499
- content: (previousSummary ? `## Current notes\n\n${previousSummary}\n\n` : "") +
3500
- `## Conversation\n\n${transcript}\n\n` +
3501
- (previousSummary
3502
- ? "Update the current notes to cover this conversation as well. Return the FULL updated notes."
3503
- : "Write the continuation notes now."),
3663
+ content: (previousSummary ? `${SUMMARY_OPEN}${previousSummary}${SUMMARY_CLOSE}\n\n` : "") + instruction,
3504
3664
  },
3505
- ], [], undefined, // never abortable by stop(): losing the summary would lose history
3665
+ ], allToolSpecs(), undefined, // never abortable by stop(): losing the summary would lose history
3506
3666
  onDelta
3507
3667
  ? (s) => onDelta(s.text)
3508
3668
  : undefined);
@@ -3981,14 +4141,30 @@ function rebuildMessagesFrom(list) {
3981
4141
  msgs.splice(0, Math.min(msgs.length, Number(d.dropped ?? 0)));
3982
4142
  }
3983
4143
  else {
4144
+ const sum = typeof d.summary === "string" ? d.summary.trim() : "";
3984
4145
  const summarized = Math.min(msgs.length, Number(d.summarized ?? 0) || msgs.length);
3985
- const sum = typeof d.summary === "string" ? d.summary : "";
3986
- msgs.splice(0, summarized, {
3987
- role: "user",
3988
- content: `[harness] Context was compacted: ${summarized} earlier messages were summarized. ` +
3989
- "Goal and notes are managed by the harness (AGENTS.md / memory.md are injected into your prompt when present).\n\n" +
3990
- `## Summary of earlier conversation\n${sum || "(summary unavailable)"}`,
3991
- });
4146
+ // A `summarize` boundary with no summary text is a HALF-WRITTEN record, and
4147
+ // acting on it would delete history to replace it with nothing — the worst
4148
+ // failure compaction can have, because the discarded prefix holds the original
4149
+ // request and the summary is supposed to be its only record.
4150
+ //
4151
+ // So a damaged/absent summary falls back CONSERVATIVELY: the compaction is not
4152
+ // applied at all, the full history stays, and the operator sees why. (ZCode
4153
+ // draws the same line for a damaged preserved segment — "返回保守 fallback,并产生
4154
+ // 结构化 warning" — and refuses to write a half boundary in the first place.)
4155
+ // Reaching this state needs a hand-edited or truncated log, since the live path
4156
+ // only writes `mode: "summarize"` when it has a summary.
4157
+ if (!sum) {
4158
+ console.warn(`[teapot] compaction at seq ${e.seq} has no summary text — keeping the full history rather than discarding it`);
4159
+ }
4160
+ else {
4161
+ msgs.splice(0, summarized, {
4162
+ role: "user",
4163
+ content: `[harness] Context was compacted: ${summarized} earlier messages were summarized. ` +
4164
+ "Goal and notes are managed by the harness (AGENTS.md / memory.md are injected into your prompt when present).\n\n" +
4165
+ `## Summary of earlier conversation\n${sum}`,
4166
+ });
4167
+ }
3992
4168
  }
3993
4169
  }
3994
4170
  else if (e.type === "progress") {