teapot-coding-agent 0.29.11 → 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,8 +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: [],
311
- reasoningEffortOptIn: false,
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,
312
393
  maxConsecutiveToolErrors: 5,
313
394
  maxTurnsPerRound: 200,
314
395
  maxAuditRejects: 3, // #128: bound the reject loop
@@ -383,6 +464,12 @@ export class Agent {
383
464
  lastCompactSummary;
384
465
  /** overflow recoveries used in the current turn; reset when a turn succeeds */
385
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;
386
473
  /**
387
474
  * Rescan skill roots — but only when something actually changed (#14).
388
475
  * This used to run a readdir per root plus a read of every SKILL.md at the
@@ -422,18 +509,27 @@ export class Agent {
422
509
  /* keep previous cache */
423
510
  }
424
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
+ }
425
522
  callLlm(messages, tools, onDelta) {
426
523
  const fn = this.opts.chatFn ?? chatStream;
427
524
  // #47: carry the effort setting + the model's advertised capabilities into
428
525
  // the request config. chat() applies effortForRequest(), which sends the
429
526
  // field ONLY for OpenRouter endpoints whose catalogue lists support.
430
- return fn({
431
- ...this.opts.llm,
527
+ return fn(this.requestConfig({
432
528
  ...(this.opts.reasoningEffort ? { reasoningEffort: this.opts.reasoningEffort } : {}),
433
529
  ...(this.opts.supportedParameters
434
530
  ? { supportedParameters: this.opts.supportedParameters }
435
531
  : {}),
436
- }, messages, tools, this.abort?.signal, onDelta);
532
+ }), messages, tools, this.abort?.signal, onDelta);
437
533
  }
438
534
  /** expose id for metrics */
439
535
  opts_id() {
@@ -1265,8 +1361,11 @@ export class Agent {
1265
1361
  provider: this.opts.provider,
1266
1362
  // #47/#150: the effort in force, and whether this model can take one at all
1267
1363
  ...(this.opts.reasoningEffort ? { reasoningEffort: this.opts.reasoningEffort } : {}),
1268
- effortSupported: (this.opts.supportedParameters ?? []).includes("reasoning_effort") ||
1269
- this.opts.reasoningEffortOptIn === true,
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 } : {}),
1270
1369
  sessionDir: this.opts.sessionDir,
1271
1370
  ctx: {
1272
1371
  usedTokens: this.lastUsage?.input ?? this.estimateTokens(),
@@ -3116,6 +3215,14 @@ export class Agent {
3116
3215
  return;
3117
3216
  if (!force && this.opts.autoCompact === false)
3118
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;
3119
3226
  // a forced pass on an already-tiny history would just summarize the
3120
3227
  // summary — report ran=false instead
3121
3228
  if (force && this.messages.length <= this.compactedAtLen)
@@ -3149,7 +3256,7 @@ export class Agent {
3149
3256
  cut = this.safeCut(cut);
3150
3257
  if (cut <= 0)
3151
3258
  return; // nothing safely compactable (phase never armed yet)
3152
- const old = this.messages.slice(0, cut);
3259
+ let old = this.messages.slice(0, cut);
3153
3260
  const oldCount = old.length;
3154
3261
  // The prefix's share of the size the budget check ACTED on (which floors our
3155
3262
  // estimate against the provider's reported prompt_tokens). Scaling by character
@@ -3162,26 +3269,23 @@ export class Agent {
3162
3269
  let mode = "summarize";
3163
3270
  let summarizedCount = oldCount;
3164
3271
  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.
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.
3170
3277
  //
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.
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.
3177
3282
  //
3178
- // 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.
3179
3288
  //
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
3289
  // Use BOTH sizes. `oldTokens` is our estimate of the prefix being summarized;
3186
3290
  // `before` is the size the budget check acted on, which already floors that
3187
3291
  // estimate against the provider's REPORTED prompt_tokens. Gating on the local
@@ -3245,16 +3349,53 @@ export class Agent {
3245
3349
  });
3246
3350
  try {
3247
3351
  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);
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
+ }
3258
3399
  }
3259
3400
  else {
3260
3401
  // shallow saving: truncation reclaims the budget for free, so skip the
@@ -3275,6 +3416,11 @@ export class Agent {
3275
3416
  await this.log.append("error", this.currentSession, this.currentBranch, {
3276
3417
  message: `compaction summarize failed, falling back to truncation: ${err.message}`,
3277
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;
3278
3424
  }
3279
3425
  if (!summary) {
3280
3426
  // fallback: drop the oldest half without LLM help so work can continue
@@ -3329,6 +3475,13 @@ export class Agent {
3329
3475
  }
3330
3476
  const after = this.estimateTokens();
3331
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;
3332
3485
  this.compactedAtLen = this.messages.length;
3333
3486
  // Post-compact file restore (Claude Code style): re-attach the most
3334
3487
  // recently read files so the model doesn't burn its first turns re-reading
@@ -3417,92 +3570,67 @@ export class Agent {
3417
3570
  }
3418
3571
  }
3419
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
+ */
3420
3595
  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
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 : "");
3456
3612
  // Structured sections (Claude Code style): fixed headings survive MULTIPLE
3457
3613
  // successive compactions far better than free-form prose — each pass knows
3458
3614
  // where "pending tasks" vs "user messages" live, so nothing silently
3459
3615
  // merges away. The <analysis> scratchpad gives the model a thinking space;
3460
3616
  // 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
- },
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,
3497
3629
  {
3498
3630
  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."),
3631
+ content: (previousSummary ? `${SUMMARY_OPEN}${previousSummary}${SUMMARY_CLOSE}\n\n` : "") + instruction,
3504
3632
  },
3505
- ], [], undefined, // never abortable by stop(): losing the summary would lose history
3633
+ ], allToolSpecs(), undefined, // never abortable by stop(): losing the summary would lose history
3506
3634
  onDelta
3507
3635
  ? (s) => onDelta(s.text)
3508
3636
  : undefined);
@@ -3981,14 +4109,30 @@ function rebuildMessagesFrom(list) {
3981
4109
  msgs.splice(0, Math.min(msgs.length, Number(d.dropped ?? 0)));
3982
4110
  }
3983
4111
  else {
4112
+ const sum = typeof d.summary === "string" ? d.summary.trim() : "";
3984
4113
  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
- });
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
+ }
3992
4136
  }
3993
4137
  }
3994
4138
  else if (e.type === "progress") {