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 +4 -2
- package/dist/agent/agent.js +263 -117
- package/dist/agent/llm.js +154 -21
- package/dist/master.js +5 -1
- package/dist/model-meta.js +10 -0
- package/package.json +1 -1
- package/public/assets/{index-C5nwg8RO.js → index-LiaEnt9Q.js} +6 -6
- package/public/assets/index-LiaEnt9Q.js.map +1 -0
- package/public/assets/{shiki-BSQfUh7j.js → shiki-kbuISzpz.js} +2 -2
- package/public/assets/{shiki-BSQfUh7j.js.map → shiki-kbuISzpz.js.map} +1 -1
- package/public/index.html +1 -1
- package/public/assets/index-C5nwg8RO.js.map +0 -1
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
|
|
package/dist/agent/agent.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
3164
|
-
//
|
|
3165
|
-
//
|
|
3166
|
-
//
|
|
3167
|
-
//
|
|
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
|
-
//
|
|
3170
|
-
//
|
|
3171
|
-
//
|
|
3172
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
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
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
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 ?
|
|
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
|
-
],
|
|
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
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
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") {
|