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