teapot-coding-agent 0.29.10 → 0.29.12

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
  /**
@@ -307,7 +383,13 @@ export class Agent {
307
383
  // #47: no effort and no catalogue entry means "provider default" — the
308
384
  // effort field is then never sent, which is the safe default
309
385
  reasoningEffort: "",
310
- supportedParameters: [],
386
+ // undefined, NOT []: "the endpoint published no capability list" and "the
387
+ // endpoint published a list that excludes this parameter" are different
388
+ // answers, and only the second is a definite no (#150). An empty array here
389
+ // would make every silent endpoint look like an explicit refusal — measured:
390
+ // commandcode and google publish no list at all, so `[]` hid the very control
391
+ // #150 was about.
392
+ supportedParameters: undefined,
311
393
  maxConsecutiveToolErrors: 5,
312
394
  maxTurnsPerRound: 200,
313
395
  maxAuditRejects: 3, // #128: bound the reject loop
@@ -382,6 +464,12 @@ export class Agent {
382
464
  lastCompactSummary;
383
465
  /** overflow recoveries used in the current turn; reset when a turn succeeds */
384
466
  overflowRecoveries = 0;
467
+ /**
468
+ * Consecutive auto-compaction failures; at MAX_AUTO_COMPACT_FAILURES auto passes are
469
+ * skipped until one succeeds (ZCode's circuit breaker). Manual and overflow recovery
470
+ * are exempt — those are not "keep trying", they are "the operator/provider asked".
471
+ */
472
+ consecutiveCompactFailures = 0;
385
473
  /**
386
474
  * Rescan skill roots — but only when something actually changed (#14).
387
475
  * This used to run a readdir per root plus a read of every SKILL.md at the
@@ -421,18 +509,27 @@ export class Agent {
421
509
  /* keep previous cache */
422
510
  }
423
511
  }
512
+ /**
513
+ * Request config for a call made in THIS conversation: the session id rides
514
+ * along so OpenCode's endpoints can route and prompt-cache the turn (without
515
+ * it they answer 400 — see providerHeaders()). Read from `currentSession`
516
+ * rather than copied onto opts.llm, because a fork/incarnation switch moves
517
+ * the agent to a new conversation while the endpoint settings stay put.
518
+ */
519
+ requestConfig(extra = {}) {
520
+ return { ...this.opts.llm, sessionId: this.currentSession, ...extra };
521
+ }
424
522
  callLlm(messages, tools, onDelta) {
425
523
  const fn = this.opts.chatFn ?? chatStream;
426
524
  // #47: carry the effort setting + the model's advertised capabilities into
427
525
  // the request config. chat() applies effortForRequest(), which sends the
428
526
  // field ONLY for OpenRouter endpoints whose catalogue lists support.
429
- return fn({
430
- ...this.opts.llm,
527
+ return fn(this.requestConfig({
431
528
  ...(this.opts.reasoningEffort ? { reasoningEffort: this.opts.reasoningEffort } : {}),
432
529
  ...(this.opts.supportedParameters
433
530
  ? { supportedParameters: this.opts.supportedParameters }
434
531
  : {}),
435
- }, messages, tools, this.abort?.signal, onDelta);
532
+ }), messages, tools, this.abort?.signal, onDelta);
436
533
  }
437
534
  /** expose id for metrics */
438
535
  opts_id() {
@@ -1262,9 +1359,13 @@ export class Agent {
1262
1359
  stats: { ...this.stats, costUsd: this.modelPricing ? this.stats.costUsd : undefined },
1263
1360
  model: this.opts.llm.model,
1264
1361
  provider: this.opts.provider,
1265
- // #47: the effort in force, and whether this model can take one at all
1362
+ // #47/#150: the effort in force, and whether this model can take one at all
1266
1363
  ...(this.opts.reasoningEffort ? { reasoningEffort: this.opts.reasoningEffort } : {}),
1267
- effortSupported: (this.opts.supportedParameters ?? []).includes("reasoning_effort"),
1364
+ effortSupported: effortChoosable(this.opts.supportedParameters),
1365
+ // the levels to OFFER, when the endpoint publishes them: a model with
1366
+ // ["max","high","low"] must not be shown minimal/medium, which would either be
1367
+ // refused or silently clamped (#150)
1368
+ ...(this.opts.supportedEfforts?.length ? { effortOptions: this.opts.supportedEfforts } : {}),
1268
1369
  sessionDir: this.opts.sessionDir,
1269
1370
  ctx: {
1270
1371
  usedTokens: this.lastUsage?.input ?? this.estimateTokens(),
@@ -3114,6 +3215,14 @@ export class Agent {
3114
3215
  return;
3115
3216
  if (!force && this.opts.autoCompact === false)
3116
3217
  return;
3218
+ // CIRCUIT BREAKER (ZCode: `max auto failures: 3`). A compaction that keeps
3219
+ // failing would otherwise retry on EVERY turn, re-burning the provider for a pass
3220
+ // that cannot succeed — and the failure is usually environmental (an unavailable
3221
+ // model, a provider outage), so retrying it eagerly achieves nothing. Auto
3222
+ // attempts only: a manual `/compact` is the operator asking, and an overflow
3223
+ // recovery is the provider having just refused, so both must still try.
3224
+ if (!force && this.consecutiveCompactFailures >= MAX_AUTO_COMPACT_FAILURES)
3225
+ return;
3117
3226
  // a forced pass on an already-tiny history would just summarize the
3118
3227
  // summary — report ran=false instead
3119
3228
  if (force && this.messages.length <= this.compactedAtLen)
@@ -3147,7 +3256,7 @@ export class Agent {
3147
3256
  cut = this.safeCut(cut);
3148
3257
  if (cut <= 0)
3149
3258
  return; // nothing safely compactable (phase never armed yet)
3150
- const old = this.messages.slice(0, cut);
3259
+ let old = this.messages.slice(0, cut);
3151
3260
  const oldCount = old.length;
3152
3261
  // The prefix's share of the size the budget check ACTED on (which floors our
3153
3262
  // estimate against the provider's reported prompt_tokens). Scaling by character
@@ -3160,26 +3269,23 @@ export class Agent {
3160
3269
  let mode = "summarize";
3161
3270
  let summarizedCount = oldCount;
3162
3271
  let droppedCount = 0;
3163
- // COST GATE (measured). The summarize call shares NO cacheable prefix with the
3164
- // main loop: the summarizer's system prompt IS its instructions, so message[0]
3165
- // differs and the entire history is re-prefilled and re-billed at full size.
3166
- // Sharing the prefix would mean gutting the summarizer's role, so the lever is
3167
- // not to PAY for a shallow saving.
3272
+ // COST GATE. The criterion is the ABSOLUTE amount of context the cut would
3273
+ // discard, not how far over the budget we are: a ratio test gets it backwards,
3274
+ // since a small window overflowing slightly is exactly the case where a
3275
+ // summary buys almost nothing (there is little knowledge to lose), while a large
3276
+ // window overflowing slightly still has a lot to lose.
3168
3277
  //
3169
- // The criterion is the ABSOLUTE amount of context the cut would discard, not
3170
- // how far over the budget we are. A ratio test gets it backwards: a small
3171
- // window overflowing slightly is exactly the case where a full-history call
3172
- // buys almost nothing (there is little knowledge to lose), while a large
3173
- // window overflowing slightly still has a lot to lose. `oldTokens` is the
3174
- // summarizable prefix — the thing the call is actually paid for.
3278
+ // Since the summarizer now REPLAYS the conversation prefix, the call reuses the
3279
+ // provider's KV cache, so it is far cheaper than it used to be — but it is not
3280
+ // free (the shadowed region is re-read, and the summary itself is generated), so
3281
+ // a shallow cut is still better served by the free drop.
3175
3282
  //
3176
- // Below the threshold the DROP alone reclaims the budget, for free.
3283
+ // MANUAL is exempt: an explicit /compact is a user command, not a cost decision,
3284
+ // and opencode draws the line in the same place ("only the user compacts when
3285
+ // automatic compaction is off"). An overflow recovery is exempt too — the
3286
+ // provider just told us the prompt was too big, so the question is not whether
3287
+ // the saving is worth a call.
3177
3288
  //
3178
- // MANUAL is exempt: an explicit /compact is a user command, not a cost
3179
- // decision, and opencode draws the line in the same place ("only the user
3180
- // compacts when automatic compaction is off"). An overflow recovery is exempt
3181
- // too — the provider just told us the prompt was too big, so the question is
3182
- // not whether the saving is worth a call.
3183
3289
  // Use BOTH sizes. `oldTokens` is our estimate of the prefix being summarized;
3184
3290
  // `before` is the size the budget check acted on, which already floors that
3185
3291
  // estimate against the provider's REPORTED prompt_tokens. Gating on the local
@@ -3243,16 +3349,53 @@ export class Agent {
3243
3349
  });
3244
3350
  try {
3245
3351
  if (worthSummarizing) {
3246
- summary = await this.summarize(old, (text) => {
3247
- bus.emit("update", {
3248
- kind: "llm-delta",
3249
- agentId: this.opts.id,
3250
- // #146: the bubble is session-scoped, so the stream must say which session
3251
- sessionId: this.currentSession,
3252
- text: `[compact] ${text}`,
3253
- reasoning: "",
3254
- });
3255
- }, previousSummary);
3352
+ // OVERFLOW RETRY on the summarizer itself. The replayed prefix is the head
3353
+ // of a request that has already succeeded, so it normally fits — but the
3354
+ // cut can land such that the shadowed region is nearly the whole history
3355
+ // (measured: with a recent-keep target smaller than one message, cut lands
3356
+ // at the far end and `old` is everything). When the conversation was over
3357
+ // its window to begin with — the overflow-recovery case — the auxiliary
3358
+ // request can then be over it too, and there is no truncation to save it
3359
+ // any more.
3360
+ //
3361
+ // The fix keeps the property the replay exists for: REDUCE THE CUT, which
3362
+ // sends FEWER LEADING messages, so the request stays a cacheable prefix of
3363
+ // the routed request while shrinking. (Dropping the middle would break
3364
+ // alignment; dropping the tail would discard the newest context.) ZCode
3365
+ // handles the same case with a bounded `prompt-too-long` retry, and this is
3366
+ // bounded for the same reason: an unbounded loop here would burn tokens
3367
+ // against a provider that will refuse every size.
3368
+ for (let attempt = 0; attempt <= SUMMARIZE_OVERFLOW_RETRIES; attempt++) {
3369
+ try {
3370
+ summary = await this.summarize(old, (text) => {
3371
+ bus.emit("update", {
3372
+ kind: "llm-delta",
3373
+ agentId: this.opts.id,
3374
+ // #146: the bubble is session-scoped, so the stream must say which session
3375
+ sessionId: this.currentSession,
3376
+ text: `[compact] ${text}`,
3377
+ reasoning: "",
3378
+ });
3379
+ }, previousSummary);
3380
+ break;
3381
+ }
3382
+ catch (e) {
3383
+ const shrinkable = isContextOverflow(e) && attempt < SUMMARIZE_OVERFLOW_RETRIES;
3384
+ if (!shrinkable)
3385
+ throw e;
3386
+ const smaller = this.safeCut(Math.max(1, Math.floor(old.length * SUMMARIZE_SHRINK)));
3387
+ if (smaller <= 0 || smaller >= old.length)
3388
+ throw e; // cannot shrink further
3389
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
3390
+ event: "compaction-summarize-overflow",
3391
+ attempt: attempt + 1,
3392
+ from: old.length,
3393
+ to: smaller,
3394
+ });
3395
+ old = this.messages.slice(0, smaller);
3396
+ cut = smaller;
3397
+ }
3398
+ }
3256
3399
  }
3257
3400
  else {
3258
3401
  // shallow saving: truncation reclaims the budget for free, so skip the
@@ -3273,6 +3416,11 @@ export class Agent {
3273
3416
  await this.log.append("error", this.currentSession, this.currentBranch, {
3274
3417
  message: `compaction summarize failed, falling back to truncation: ${err.message}`,
3275
3418
  });
3419
+ // ZCode's circuit breaker counts the failure and never fails the turn for it
3420
+ // ("Auto compact 失败不应让当前用户 turn 失败"): the pass still falls back to a
3421
+ // truncation below, so work continues, and after MAX_AUTO_COMPACT_FAILURES
3422
+ // consecutive auto failures the retry stops burning the provider.
3423
+ this.consecutiveCompactFailures += 1;
3276
3424
  }
3277
3425
  if (!summary) {
3278
3426
  // fallback: drop the oldest half without LLM help so work can continue
@@ -3327,6 +3475,13 @@ export class Agent {
3327
3475
  }
3328
3476
  const after = this.estimateTokens();
3329
3477
  this.stats.compactions++;
3478
+ // A pass that got here reclaimed the budget, so autos may try again — ZCode
3479
+ // resets its count on success for the same reason. But NOT when the summary
3480
+ // failed: the truncate fallback still lands here, and resetting on it made the
3481
+ // breaker unreachable (measured: 217 consecutive summarize failures, counter
3482
+ // stuck at 0, so the provider was re-burned on every turn).
3483
+ if (mode === "summarize")
3484
+ this.consecutiveCompactFailures = 0;
3330
3485
  this.compactedAtLen = this.messages.length;
3331
3486
  // Post-compact file restore (Claude Code style): re-attach the most
3332
3487
  // recently read files so the model doesn't burn its first turns re-reading
@@ -3415,92 +3570,67 @@ export class Agent {
3415
3570
  }
3416
3571
  }
3417
3572
  /** Ask the model for dense continuation notes over the compacted range. */
3573
+ /**
3574
+ * Summarize `old` into continuation notes.
3575
+ *
3576
+ * PREFIX-CACHE SHAPE (measured): the summarizer request is built as the
3577
+ * conversation's OWN prefix — the session's system prompt, the same tool schemas,
3578
+ * and the messages being shadowed — with the compaction instruction appended as a
3579
+ * trailing `user` message. That makes the auxiliary call a genuine prefix-EXTENSION
3580
+ * of the last routed request, so the provider reuses the KV cache instead of
3581
+ * invalidating it at the first token.
3582
+ *
3583
+ * The alternative (a bespoke summarizer `system` prompt + a flattened transcript)
3584
+ * was measured to share NOTHING with the conversation request: a provider caches on
3585
+ * the leading token sequence, so a different first message discards the whole
3586
+ * prefix and the entire history is re-prefilled at full price — exactly when the
3587
+ * conversation is largest. DeepSeek Harness documents the same finding and the same
3588
+ * fix ("the summarization directive moves from the front of the request to the end
3589
+ * of the conversation"); the reasoning is reproduced here because it is not
3590
+ * obvious, and the previous version of this function did the expensive thing.
3591
+ *
3592
+ * `tools` ride along even though the summarizer never calls one: they are part of
3593
+ * the cached token sequence, so omitting them would misalign everything after them.
3594
+ */
3418
3595
  async summarize(old, onDelta, previousSummary) {
3419
- const full = old
3420
- .map((m) => {
3421
- const who = m.role === "tool" ? "tool" : m.role;
3422
- const tc = m.tool_calls?.map((t) => `\n[calls ${t.function.name}(${t.function.arguments})]`).join("");
3423
- return `${who}: ${m.content ?? ""}${tc}`;
3424
- })
3425
- .join("\n\n");
3426
- // #127: this was a bare `.slice(-120_000)` — the TAIL only.
3427
- //
3428
- // The question the issue raises is the right one: when a compaction starts,
3429
- // the context window opens dramatically, and the summarizer saw only the last
3430
- // 120k characters of what it was asked to summarise. Anything older was not
3431
- // summarised and not kept — it was silently DISCARDED. So the original user
3432
- // requirement, which sits at the very front of the history, was the single
3433
- // most likely thing to be lost, and losing it is the one failure compaction
3434
- // cannot recover from: the summary is now the only record.
3435
- //
3436
- // Head + tail, with the seam marked so the model can tell a gap from a
3437
- // boundary. The head is where the goal, the constraints and the original ask
3438
- // live; the tail is where the recent state and any in-flight error do.
3439
- const BUDGET = 120_000;
3440
- const HEAD = Math.floor(BUDGET * 0.35);
3441
- const TAIL = BUDGET - HEAD;
3442
- let transcript;
3443
- if (full.length <= BUDGET) {
3444
- transcript = full;
3445
- }
3446
- else {
3447
- transcript = [
3448
- full.slice(0, HEAD),
3449
- `\n\n[... ${full.length - BUDGET} characters elided from the middle of this history ...]\n\n`,
3450
- full.slice(full.length - TAIL),
3451
- ].join("");
3452
- }
3453
3596
  const fn = this.opts.chatFn ?? chat;
3597
+ // the conversation's own head: system prompt + the shadowed region, in order.
3598
+ // `buildMessages()` supplies the live system prompt, so the replayed prefix is
3599
+ // byte-identical to what the last routed request carried.
3600
+ const prefix = this.buildMessages().slice(0, 1 + old.length);
3601
+ // #127 kept the head: dropping the OLDEST text silently discarded the original
3602
+ // request, which sits at the front of the history and is the one thing compaction
3603
+ // cannot recover from. Replaying the prefix VERBATIM (rather than a flattened,
3604
+ // head+tail-elided transcript) keeps that property AND the cache: the model sees
3605
+ // the real messages, in their real order.
3606
+ //
3607
+ // The older head+tail truncation guarded against a summarizer input larger than
3608
+ // the model accepts; that is now handled by what gets shadowed instead — the
3609
+ // region is what the conversation itself just carried, so it already fits the
3610
+ // window that request fitted.
3611
+ const instruction = COMPACT_INSTRUCTION + (previousSummary ? UPDATE_INSTRUCTION : "");
3454
3612
  // Structured sections (Claude Code style): fixed headings survive MULTIPLE
3455
3613
  // successive compactions far better than free-form prose — each pass knows
3456
3614
  // where "pending tasks" vs "user messages" live, so nothing silently
3457
3615
  // merges away. The <analysis> scratchpad gives the model a thinking space;
3458
3616
  // it is stripped from the stored summary (formatCompactSummary).
3459
- const res = await fn({ ...this.opts.llm, oneOff: true }, // one-off prompt: ask the provider not to retain it
3460
- [
3461
- {
3462
- role: "system",
3463
- content:
3464
- // Both variants OPEN with the same sentence. Tests (and any future caller)
3465
- // identify the summarizer by this text, and a shared prefix keeps that
3466
- // detection working across the initial/update split — the first version of
3467
- // update mode changed it to "You UPDATE…", which silently made every
3468
- // update pass look like an ordinary turn.
3469
- "You compress a coding agent's conversation into dense notes for it to continue working.\n\n" +
3470
- (previousSummary
3471
- ? "This is an UPDATE: the current notes are under `## Current notes`. They are your\n" +
3472
- "output from an earlier compaction and are the ONLY record of everything before the\n" +
3473
- "messages you are about to read — so carry them forward. PRESERVE what is still\n" +
3474
- "true, ADD what the new messages establish, and REMOVE only what they resolve or\n" +
3475
- "supersede. Never drop a constraint or a user request just because the new\n" +
3476
- "messages do not repeat it: an unanswered request stays, an answered one goes.\n\n"
3477
- : "") +
3478
- "First think inside an <analysis> block: what mattered, what changed, what is unresolved.\n" +
3479
- "Then output ONLY the following sections, each starting with its exact heading:\n\n" +
3480
- "## Primary request and intent\n" +
3481
- "## Key technical concepts\n" +
3482
- "## Files and code sections\n" +
3483
- "## Errors and fixes\n" +
3484
- "## Problem solving\n" +
3485
- "## All user messages\n" + // verbatim-ish: intent survives repeated compaction
3486
- "## Pending tasks\n" +
3487
- "## Current work\n" +
3488
- "## Next step\n" +
3489
- "## Durable lessons\n\n" +
3490
- "Rules: be terse bullet points, no prose flourishes. Quote file paths and key numbers exactly. " +
3491
- "All user messages are listed nearly verbatim — they carry intent that must survive every future compaction. " +
3492
- 'Durable lessons lists reusable insights worth keeping forever (gotchas, preferences, what worked); omit the section if empty. ' +
3493
- "The <analysis> block is discarded before your notes are stored, so think freely there.",
3494
- },
3617
+ //
3618
+ // `tools` are forwarded even though the summarizer never calls one: they are
3619
+ // part of the cached token sequence, so dropping them (or sending a different
3620
+ // set) misaligns everything after them and costs the cache the whole point of
3621
+ // this shape. MEASURED while writing this: `toolSpecs()` returns 15 and the
3622
+ // conversation sends `allToolSpecs()`'s 29, so the obvious call was wrong — the
3623
+ // exact array the main loop uses is what must ride along.
3624
+ const res = await fn(
3625
+ // one-off prompt: ask the provider not to retain it — but it is still the
3626
+ // SAME conversation, so it carries the same session id
3627
+ this.requestConfig({ oneOff: true }), [
3628
+ ...prefix,
3495
3629
  {
3496
3630
  role: "user",
3497
- content: (previousSummary ? `## Current notes\n\n${previousSummary}\n\n` : "") +
3498
- `## Conversation\n\n${transcript}\n\n` +
3499
- (previousSummary
3500
- ? "Update the current notes to cover this conversation as well. Return the FULL updated notes."
3501
- : "Write the continuation notes now."),
3631
+ content: (previousSummary ? `${SUMMARY_OPEN}${previousSummary}${SUMMARY_CLOSE}\n\n` : "") + instruction,
3502
3632
  },
3503
- ], [], undefined, // never abortable by stop(): losing the summary would lose history
3633
+ ], allToolSpecs(), undefined, // never abortable by stop(): losing the summary would lose history
3504
3634
  onDelta
3505
3635
  ? (s) => onDelta(s.text)
3506
3636
  : undefined);
@@ -3979,14 +4109,30 @@ function rebuildMessagesFrom(list) {
3979
4109
  msgs.splice(0, Math.min(msgs.length, Number(d.dropped ?? 0)));
3980
4110
  }
3981
4111
  else {
4112
+ const sum = typeof d.summary === "string" ? d.summary.trim() : "";
3982
4113
  const summarized = Math.min(msgs.length, Number(d.summarized ?? 0) || msgs.length);
3983
- const sum = typeof d.summary === "string" ? d.summary : "";
3984
- msgs.splice(0, summarized, {
3985
- role: "user",
3986
- content: `[harness] Context was compacted: ${summarized} earlier messages were summarized. ` +
3987
- "Goal and notes are managed by the harness (AGENTS.md / memory.md are injected into your prompt when present).\n\n" +
3988
- `## Summary of earlier conversation\n${sum || "(summary unavailable)"}`,
3989
- });
4114
+ // A `summarize` boundary with no summary text is a HALF-WRITTEN record, and
4115
+ // acting on it would delete history to replace it with nothing — the worst
4116
+ // failure compaction can have, because the discarded prefix holds the original
4117
+ // request and the summary is supposed to be its only record.
4118
+ //
4119
+ // So a damaged/absent summary falls back CONSERVATIVELY: the compaction is not
4120
+ // applied at all, the full history stays, and the operator sees why. (ZCode
4121
+ // draws the same line for a damaged preserved segment — "返回保守 fallback,并产生
4122
+ // 结构化 warning" — and refuses to write a half boundary in the first place.)
4123
+ // Reaching this state needs a hand-edited or truncated log, since the live path
4124
+ // only writes `mode: "summarize"` when it has a summary.
4125
+ if (!sum) {
4126
+ console.warn(`[teapot] compaction at seq ${e.seq} has no summary text — keeping the full history rather than discarding it`);
4127
+ }
4128
+ else {
4129
+ msgs.splice(0, summarized, {
4130
+ role: "user",
4131
+ content: `[harness] Context was compacted: ${summarized} earlier messages were summarized. ` +
4132
+ "Goal and notes are managed by the harness (AGENTS.md / memory.md are injected into your prompt when present).\n\n" +
4133
+ `## Summary of earlier conversation\n${sum}`,
4134
+ });
4135
+ }
3990
4136
  }
3991
4137
  }
3992
4138
  else if (e.type === "progress") {