teapot-coding-agent 0.29.9 → 0.29.11
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/dist/agent/agent.js +224 -30
- package/dist/agent/llm.js +54 -11
- package/dist/config-schema.js +9 -0
- package/dist/master.js +7 -0
- package/dist/server/api.js +3 -0
- package/package.json +1 -1
- package/public/assets/{index-zmavtYYT.js → index-CnA7NDLr.js} +5 -5
- package/public/assets/index-CnA7NDLr.js.map +1 -0
- package/public/assets/{shiki-DhnjYQM7.js → shiki-D0aY9msf.js} +2 -2
- package/public/assets/{shiki-DhnjYQM7.js.map → shiki-D0aY9msf.js.map} +1 -1
- package/public/index.html +1 -1
- package/public/assets/index-zmavtYYT.js.map +0 -1
package/dist/agent/agent.js
CHANGED
|
@@ -74,6 +74,43 @@ export const PROGRESS_REQUEST =
|
|
|
74
74
|
* meta tools instead. The cache rationale itself stays HERE in a comment —
|
|
75
75
|
* the model does not need our cost-engineering notes every turn.
|
|
76
76
|
*/
|
|
77
|
+
/**
|
|
78
|
+
* Recent context kept VERBATIM through a compaction, in tokens.
|
|
79
|
+
*
|
|
80
|
+
* Pi defaults `keepRecentTokens` to 20000 and opencode to `keep: 15000` for the
|
|
81
|
+
* same reason: the newest exchanges are what the model needs to continue
|
|
82
|
+
* accurately, and a verbatim copy beats a summary of them.
|
|
83
|
+
*/
|
|
84
|
+
const KEEP_RECENT_TOKENS = 15_000;
|
|
85
|
+
/**
|
|
86
|
+
* The summarize call is skipped when the summarizable prefix is a small share of
|
|
87
|
+
* the budget, because the call cannot reuse any prefix cache (its system prompt is
|
|
88
|
+
* its instructions) and so re-bills the whole history uncached. Below this share,
|
|
89
|
+
* dropping the prefix outright reclaims the budget for free and the model can
|
|
90
|
+
* recover the details by re-reading the goal or the files.
|
|
91
|
+
*
|
|
92
|
+
* A SHARE rather than an absolute token count: what counts as "a lot to lose"
|
|
93
|
+
* scales with the window a session is configured for. An absolute constant
|
|
94
|
+
* calibrated for the 96k default would disable summarization entirely for a
|
|
95
|
+
* session with a small budget (measured: a 200-token budget with a 654-token
|
|
96
|
+
* history is over every bar, yet its prefix is a few hundred tokens).
|
|
97
|
+
*
|
|
98
|
+
* Manual compaction and overflow recovery bypass this — those are not cost
|
|
99
|
+
* decisions.
|
|
100
|
+
*/
|
|
101
|
+
const SUMMARIZE_MIN_SHARE = 0.02;
|
|
102
|
+
/** the absolute floor for the bar above (see the note where it is applied) */
|
|
103
|
+
const SUMMARIZE_MIN_TOKENS = 2_000;
|
|
104
|
+
/**
|
|
105
|
+
* How much of the budget to KEEP on each successive overflow recovery.
|
|
106
|
+
*
|
|
107
|
+
* An overflow means the estimate was too low, so a single fixed cut can still
|
|
108
|
+
* leave the request too large — and the old code turned that second failure into a
|
|
109
|
+
* hard error. opencode uses the same shape (`SHRINK_STEPS [0.7, 0.5, 0.35]`): each
|
|
110
|
+
* retry keeps less, so the request shrinks even when the first attempt did not
|
|
111
|
+
* shrink it enough. The last entry repeats for any further attempt.
|
|
112
|
+
*/
|
|
113
|
+
const OVERFLOW_SHRINK_STEPS = [0.5, 0.35, 0.2];
|
|
77
114
|
const SYSTEM_TEMPLATE = `You are a coding agent working autonomously inside a workspace.
|
|
78
115
|
|
|
79
116
|
Most session state is not injected into prompts — fetch it with tools instead:
|
|
@@ -271,6 +308,7 @@ export class Agent {
|
|
|
271
308
|
// effort field is then never sent, which is the safe default
|
|
272
309
|
reasoningEffort: "",
|
|
273
310
|
supportedParameters: [],
|
|
311
|
+
reasoningEffortOptIn: false,
|
|
274
312
|
maxConsecutiveToolErrors: 5,
|
|
275
313
|
maxTurnsPerRound: 200,
|
|
276
314
|
maxAuditRejects: 3, // #128: bound the reject loop
|
|
@@ -334,6 +372,17 @@ export class Agent {
|
|
|
334
372
|
skillsCache = [];
|
|
335
373
|
/** fingerprint of the roots that produced skillsCache ("" = not scanned yet) */
|
|
336
374
|
skillsStamp = "";
|
|
375
|
+
/**
|
|
376
|
+
* The notes the LAST compaction produced, if any.
|
|
377
|
+
*
|
|
378
|
+
* Compaction UPDATES these rather than re-deriving notes from scratch: a fresh
|
|
379
|
+
* summary each pass loses whatever the previous one distilled (every
|
|
380
|
+
* implementation surveyed — opencode, Kilo, Pi — updates for this reason).
|
|
381
|
+
* Recovered from the log so the continuity survives a restart.
|
|
382
|
+
*/
|
|
383
|
+
lastCompactSummary;
|
|
384
|
+
/** overflow recoveries used in the current turn; reset when a turn succeeds */
|
|
385
|
+
overflowRecoveries = 0;
|
|
337
386
|
/**
|
|
338
387
|
* Rescan skill roots — but only when something actually changed (#14).
|
|
339
388
|
* This used to run a readdir per root plus a read of every SKILL.md at the
|
|
@@ -856,6 +905,13 @@ export class Agent {
|
|
|
856
905
|
if (d.event === "context-compacted")
|
|
857
906
|
this.stats.compactions++;
|
|
858
907
|
}
|
|
908
|
+
else if (e.type === "compaction") {
|
|
909
|
+
// keep the NEWEST notes: they are what the next compaction updates, and
|
|
910
|
+
// without this a restart would silently fall back to re-summarizing
|
|
911
|
+
const d = e.data;
|
|
912
|
+
if (typeof d.summary === "string" && d.summary)
|
|
913
|
+
this.lastCompactSummary = d.summary;
|
|
914
|
+
}
|
|
859
915
|
}
|
|
860
916
|
// newest usage event seeds the context gauge right after reload —
|
|
861
917
|
// inputTokens from the LAST turn is the live context size, and cached
|
|
@@ -1207,9 +1263,10 @@ export class Agent {
|
|
|
1207
1263
|
stats: { ...this.stats, costUsd: this.modelPricing ? this.stats.costUsd : undefined },
|
|
1208
1264
|
model: this.opts.llm.model,
|
|
1209
1265
|
provider: this.opts.provider,
|
|
1210
|
-
// #47: the effort in force, and whether this model can take one at all
|
|
1266
|
+
// #47/#150: the effort in force, and whether this model can take one at all
|
|
1211
1267
|
...(this.opts.reasoningEffort ? { reasoningEffort: this.opts.reasoningEffort } : {}),
|
|
1212
|
-
effortSupported: (this.opts.supportedParameters ?? []).includes("reasoning_effort")
|
|
1268
|
+
effortSupported: (this.opts.supportedParameters ?? []).includes("reasoning_effort") ||
|
|
1269
|
+
this.opts.reasoningEffortOptIn === true,
|
|
1213
1270
|
sessionDir: this.opts.sessionDir,
|
|
1214
1271
|
ctx: {
|
|
1215
1272
|
usedTokens: this.lastUsage?.input ?? this.estimateTokens(),
|
|
@@ -1918,20 +1975,27 @@ export class Agent {
|
|
|
1918
1975
|
detail: "stopped before any output arrived",
|
|
1919
1976
|
});
|
|
1920
1977
|
}
|
|
1921
|
-
// Provider context overflow → compact
|
|
1922
|
-
//
|
|
1923
|
-
//
|
|
1924
|
-
//
|
|
1978
|
+
// Provider context overflow → compact and retry (OpenCode-style recovery).
|
|
1979
|
+
// Even with autoCompact off, an actual overflow is proof the estimate
|
|
1980
|
+
// missed. Staged rather than single-shot (opencode: SHRINK_STEPS
|
|
1981
|
+
// [0.7, 0.5, 0.35]): one fixed cut can still be too shallow, and the old
|
|
1982
|
+
// code returned the second overflow as a hard error even though cutting
|
|
1983
|
+
// deeper would have worked.
|
|
1925
1984
|
if (isContextOverflow(err) && !this.stopRequested) {
|
|
1985
|
+
const attempt = (this.overflowRecoveries += 1);
|
|
1926
1986
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
1927
1987
|
event: "overflow-recovery",
|
|
1988
|
+
attempt,
|
|
1928
1989
|
detail: String(err.message).slice(0, 200),
|
|
1929
1990
|
});
|
|
1930
1991
|
const before = this.stats.compactions;
|
|
1931
|
-
|
|
1992
|
+
// each retry cuts deeper: 1x, then the staged fractions of the budget
|
|
1993
|
+
const shrink = OVERFLOW_SHRINK_STEPS[Math.min(attempt - 1, OVERFLOW_SHRINK_STEPS.length - 1)];
|
|
1994
|
+
await this.maybeCompact(true, shrink);
|
|
1932
1995
|
if (this.stats.compactions > before) {
|
|
1933
1996
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
1934
1997
|
event: "overflow-recovered",
|
|
1998
|
+
attempt,
|
|
1935
1999
|
});
|
|
1936
2000
|
guard--; // the retry does not count against this round's cap
|
|
1937
2001
|
continue;
|
|
@@ -1939,6 +2003,9 @@ export class Agent {
|
|
|
1939
2003
|
}
|
|
1940
2004
|
throw err;
|
|
1941
2005
|
}
|
|
2006
|
+
// a request the provider accepted: the current depth is enough, so the next
|
|
2007
|
+
// overflow starts its staircase over rather than cutting from the deepest step
|
|
2008
|
+
this.overflowRecoveries = 0;
|
|
1942
2009
|
if (res.usage) {
|
|
1943
2010
|
const inTok = res.usage.inputTokens ?? 0;
|
|
1944
2011
|
const cached = res.usage.cachedInputTokens ?? 0;
|
|
@@ -2908,7 +2975,7 @@ export class Agent {
|
|
|
2908
2975
|
* token/char — the old flat /4 underestimated Japanese sessions ~4x.
|
|
2909
2976
|
* Adds a small per-message overhead for role/framing tokens.
|
|
2910
2977
|
*/
|
|
2911
|
-
estimateTokens() {
|
|
2978
|
+
estimateTokens(msgs = this.messages) {
|
|
2912
2979
|
let ascii = 0;
|
|
2913
2980
|
let wide = 0;
|
|
2914
2981
|
const count = (s) => {
|
|
@@ -2919,14 +2986,14 @@ export class Agent {
|
|
|
2919
2986
|
ascii++;
|
|
2920
2987
|
}
|
|
2921
2988
|
};
|
|
2922
|
-
for (const m of
|
|
2989
|
+
for (const m of msgs) {
|
|
2923
2990
|
count(m.content ?? "");
|
|
2924
2991
|
for (const t of m.tool_calls ?? []) {
|
|
2925
2992
|
count(t.function.name);
|
|
2926
2993
|
count(t.function.arguments);
|
|
2927
2994
|
}
|
|
2928
2995
|
}
|
|
2929
|
-
return Math.ceil(ascii / 4 + wide * 1.2 +
|
|
2996
|
+
return Math.ceil(ascii / 4 + wide * 1.2 + msgs.length * 8);
|
|
2930
2997
|
}
|
|
2931
2998
|
/**
|
|
2932
2999
|
* Latest index whose message may START the kept tail of a compacted
|
|
@@ -3032,8 +3099,13 @@ export class Agent {
|
|
|
3032
3099
|
});
|
|
3033
3100
|
return count;
|
|
3034
3101
|
}
|
|
3035
|
-
/**
|
|
3036
|
-
|
|
3102
|
+
/**
|
|
3103
|
+
* Compact history when the real prompt size exceeds the budget.
|
|
3104
|
+
*
|
|
3105
|
+
* @param keepFraction share of the budget to keep verbatim; the overflow path
|
|
3106
|
+
* lowers it on each retry so a too-shallow cut is not fatal
|
|
3107
|
+
*/
|
|
3108
|
+
async maybeCompact(force = false, keepFraction) {
|
|
3037
3109
|
// prefer the provider's own count from the last response — it is exactly
|
|
3038
3110
|
// what would overflow the window; the char heuristic is only a fallback
|
|
3039
3111
|
// for providers that omit usage
|
|
@@ -3050,12 +3122,27 @@ export class Agent {
|
|
|
3050
3122
|
return;
|
|
3051
3123
|
this.lastUsage = undefined; // stale after compaction — re-armed next turn
|
|
3052
3124
|
// keep roughly the most recent quarter of the budget as live context
|
|
3053
|
-
|
|
3054
|
-
|
|
3125
|
+
// Keep the most recent stretch VERBATIM, as every implementation surveyed
|
|
3126
|
+
// does (Pi: keepRecentTokens 20000, opencode: keep 15000). Recency is what the
|
|
3127
|
+
// model needs to continue accurately, and verbatim beats a summary of it.
|
|
3128
|
+
//
|
|
3129
|
+
// Size: a quarter of the budget, but never less than KEEP_RECENT_TOKENS — a
|
|
3130
|
+
// small budget would otherwise keep so little that the model loses the turn it
|
|
3131
|
+
// is in the middle of. Measured in TOKENS rather than characters, so the choice
|
|
3132
|
+
// matches the published defaults instead of depending on the char heuristic.
|
|
3133
|
+
const keepTokens = keepFraction !== undefined
|
|
3134
|
+
// an explicit retry (overflow recovery): shrink toward a share of the budget
|
|
3135
|
+
? Math.max(1, Math.floor(this.opts.contextTokenBudget * keepFraction))
|
|
3136
|
+
// otherwise keep the newest stretch verbatim, but never more than a quarter
|
|
3137
|
+
// of the budget — a fixed floor GREATER than the budget would keep everything
|
|
3138
|
+
// and compaction would never fire at all (measured: a 500-token budget kept
|
|
3139
|
+
// a 7.5k-token history intact)
|
|
3140
|
+
: Math.min(KEEP_RECENT_TOKENS, Math.max(1, Math.floor(this.opts.contextTokenBudget / 4)));
|
|
3055
3141
|
let cut = -1;
|
|
3056
3142
|
for (let i = this.messages.length - 1; i >= 1; i--) {
|
|
3057
|
-
|
|
3058
|
-
|
|
3143
|
+
// token-aware boundary, using the SAME estimate the budget uses — a raw char
|
|
3144
|
+
// ratio would disagree with it for wide (CJK) text and per-message overhead
|
|
3145
|
+
if (this.estimateTokens(this.messages.slice(i)) > keepTokens)
|
|
3059
3146
|
break;
|
|
3060
3147
|
cut = i;
|
|
3061
3148
|
}
|
|
@@ -3064,10 +3151,62 @@ export class Agent {
|
|
|
3064
3151
|
return; // nothing safely compactable (phase never armed yet)
|
|
3065
3152
|
const old = this.messages.slice(0, cut);
|
|
3066
3153
|
const oldCount = old.length;
|
|
3154
|
+
// The prefix's share of the size the budget check ACTED on (which floors our
|
|
3155
|
+
// estimate against the provider's reported prompt_tokens). Scaling by character
|
|
3156
|
+
// share rather than message count: messages vary hugely in size, and the point
|
|
3157
|
+
// is to avoid treating an unmeasurably large context as small.
|
|
3158
|
+
const totalChars = this.messages.reduce((n, m) => n + (m.content?.length ?? 0), 0);
|
|
3159
|
+
const oldChars = old.reduce((n, m) => n + (m.content?.length ?? 0), 0);
|
|
3160
|
+
const oldShareOfBefore = totalChars > 0 ? Math.round((before * oldChars) / totalChars) : 0;
|
|
3067
3161
|
let summary = "";
|
|
3068
3162
|
let mode = "summarize";
|
|
3069
3163
|
let summarizedCount = oldCount;
|
|
3070
3164
|
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.
|
|
3170
|
+
//
|
|
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.
|
|
3177
|
+
//
|
|
3178
|
+
// Below the threshold the DROP alone reclaims the budget, for free.
|
|
3179
|
+
//
|
|
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
|
+
// Use BOTH sizes. `oldTokens` is our estimate of the prefix being summarized;
|
|
3186
|
+
// `before` is the size the budget check acted on, which already floors that
|
|
3187
|
+
// estimate against the provider's REPORTED prompt_tokens. Gating on the local
|
|
3188
|
+
// estimate alone would silently truncate a context the provider says is large
|
|
3189
|
+
// but we cannot measure (caught by a test: provider reports 5000 tokens for a
|
|
3190
|
+
// 300-token local estimate). The provider tokenizes for real; our count is a
|
|
3191
|
+
// guess, so the larger of the two is the honest figure.
|
|
3192
|
+
const oldTokens = Math.max(this.estimateTokens(old), oldShareOfBefore);
|
|
3193
|
+
// The bar is a SHARE of the budget with an absolute floor, so it scales with a
|
|
3194
|
+
// realistic window while never sitting above a small session's entire history
|
|
3195
|
+
// (measured: a budget of 200 made a pure-share bar 10 tokens, while the whole
|
|
3196
|
+
// summarizable prefix in that fixture was an 8-token prompt — summarization
|
|
3197
|
+
// would have been silently disabled).
|
|
3198
|
+
//
|
|
3199
|
+
// Below the bar the prefix is a turn or two, and dropping it costs the model
|
|
3200
|
+
// nothing it cannot recover by re-reading the goal or the files; a full-history
|
|
3201
|
+
// LLM call to preserve that is not worth its price.
|
|
3202
|
+
const summarizeBar = Math.max(SUMMARIZE_MIN_TOKENS, this.opts.contextTokenBudget * SUMMARIZE_MIN_SHARE);
|
|
3203
|
+
const worthSummarizing = force || oldTokens >= summarizeBar;
|
|
3204
|
+
// UPDATE mode: carry the previous notes forward instead of re-deriving them.
|
|
3205
|
+
// Every implementation surveyed (opencode, Kilo, Pi) updates rather than
|
|
3206
|
+
// re-summarizes, because a fresh summary each time loses whatever the previous
|
|
3207
|
+
// pass had already distilled. Recovered from the LAST compaction event, so it
|
|
3208
|
+
// survives a restart.
|
|
3209
|
+
const previousSummary = this.lastCompactSummary;
|
|
3071
3210
|
// progress is visible: announce the phase up front, then stream the
|
|
3072
3211
|
// summarizer's output as it generates (a long summarize used to look like
|
|
3073
3212
|
// the agent silently hung — hundreds of thousands of tokens take a while)
|
|
@@ -3101,18 +3240,32 @@ export class Agent {
|
|
|
3101
3240
|
tokensBefore: before,
|
|
3102
3241
|
messages: oldCount,
|
|
3103
3242
|
reason: force ? "manual" : "auto",
|
|
3243
|
+
worthSummarizing, // makes the cost decision auditable
|
|
3244
|
+
updating: Boolean(previousSummary),
|
|
3104
3245
|
});
|
|
3105
3246
|
try {
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3247
|
+
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);
|
|
3258
|
+
}
|
|
3259
|
+
else {
|
|
3260
|
+
// shallow saving: truncation reclaims the budget for free, so skip the
|
|
3261
|
+
// one full-history LLM call the summary would cost
|
|
3262
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
3263
|
+
event: "context-compaction-skipped-summarize",
|
|
3264
|
+
reason: "shallow: truncation reclaims the budget without a full-history LLM call",
|
|
3265
|
+
tokensBefore: before,
|
|
3266
|
+
budget: this.opts.contextTokenBudget,
|
|
3114
3267
|
});
|
|
3115
|
-
}
|
|
3268
|
+
}
|
|
3116
3269
|
this.compactPhase = "harvesting";
|
|
3117
3270
|
bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "harvesting" });
|
|
3118
3271
|
if (summary)
|
|
@@ -3145,7 +3298,23 @@ export class Agent {
|
|
|
3145
3298
|
return; // the finally clears the phase and tells the UI it ended
|
|
3146
3299
|
}
|
|
3147
3300
|
droppedCount = halfCut;
|
|
3148
|
-
|
|
3301
|
+
// Tell the model its history was cut. Dropping a prefix SILENTLY is the
|
|
3302
|
+
// one failure it cannot detect or work around: it would see a conversation
|
|
3303
|
+
// that simply starts mid-way and could not distinguish "I forgot this" from
|
|
3304
|
+
// "this never happened", then confidently act on a gap. Truncation is now
|
|
3305
|
+
// the COMMON path (the cost gate keeps the summarize call for deep cuts
|
|
3306
|
+
// only), which makes this the difference between a known gap and a wrong
|
|
3307
|
+
// belief. The dropped text stays in the log — nothing durable is lost.
|
|
3308
|
+
this.messages = [
|
|
3309
|
+
{
|
|
3310
|
+
role: "user",
|
|
3311
|
+
content: `[harness] ${droppedCount} earlier messages were DROPPED, not summarized (no room to ` +
|
|
3312
|
+
"summarize, or the saving would not have justified the call). Your earlier work is " +
|
|
3313
|
+
"in the session log but is NOT in your context any more: re-read files or the goal " +
|
|
3314
|
+
"before assuming what happened, and do not rely on memory of the dropped part.",
|
|
3315
|
+
},
|
|
3316
|
+
...this.messages.slice(halfCut),
|
|
3317
|
+
];
|
|
3149
3318
|
}
|
|
3150
3319
|
else {
|
|
3151
3320
|
this.messages = [
|
|
@@ -3226,6 +3395,9 @@ export class Agent {
|
|
|
3226
3395
|
mode,
|
|
3227
3396
|
...(summary ? { summary: String(summary).slice(0, 20_000) } : {}),
|
|
3228
3397
|
});
|
|
3398
|
+
// remember the notes for the NEXT compaction to update (and for a restore)
|
|
3399
|
+
if (summary)
|
|
3400
|
+
this.lastCompactSummary = summary;
|
|
3229
3401
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
3230
3402
|
event: "context-compacted",
|
|
3231
3403
|
tokensBefore: before,
|
|
@@ -3245,7 +3417,7 @@ export class Agent {
|
|
|
3245
3417
|
}
|
|
3246
3418
|
}
|
|
3247
3419
|
/** Ask the model for dense continuation notes over the compacted range. */
|
|
3248
|
-
async summarize(old, onDelta) {
|
|
3420
|
+
async summarize(old, onDelta, previousSummary) {
|
|
3249
3421
|
const full = old
|
|
3250
3422
|
.map((m) => {
|
|
3251
3423
|
const who = m.role === "tool" ? "tool" : m.role;
|
|
@@ -3286,10 +3458,25 @@ export class Agent {
|
|
|
3286
3458
|
// where "pending tasks" vs "user messages" live, so nothing silently
|
|
3287
3459
|
// merges away. The <analysis> scratchpad gives the model a thinking space;
|
|
3288
3460
|
// it is stripped from the stored summary (formatCompactSummary).
|
|
3289
|
-
const res = await fn(this.opts.llm,
|
|
3461
|
+
const res = await fn({ ...this.opts.llm, oneOff: true }, // one-off prompt: ask the provider not to retain it
|
|
3462
|
+
[
|
|
3290
3463
|
{
|
|
3291
3464
|
role: "system",
|
|
3292
|
-
content:
|
|
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
|
+
: "") +
|
|
3293
3480
|
"First think inside an <analysis> block: what mattered, what changed, what is unresolved.\n" +
|
|
3294
3481
|
"Then output ONLY the following sections, each starting with its exact heading:\n\n" +
|
|
3295
3482
|
"## Primary request and intent\n" +
|
|
@@ -3307,7 +3494,14 @@ export class Agent {
|
|
|
3307
3494
|
'Durable lessons lists reusable insights worth keeping forever (gotchas, preferences, what worked); omit the section if empty. ' +
|
|
3308
3495
|
"The <analysis> block is discarded before your notes are stored, so think freely there.",
|
|
3309
3496
|
},
|
|
3310
|
-
{
|
|
3497
|
+
{
|
|
3498
|
+
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."),
|
|
3504
|
+
},
|
|
3311
3505
|
], [], undefined, // never abortable by stop(): losing the summary would lose history
|
|
3312
3506
|
onDelta
|
|
3313
3507
|
? (s) => onDelta(s.text)
|
package/dist/agent/llm.js
CHANGED
|
@@ -8,13 +8,25 @@
|
|
|
8
8
|
import OpenAI from "openai";
|
|
9
9
|
const clients = new WeakMap();
|
|
10
10
|
/**
|
|
11
|
-
* Reasoning effort for a request body (#47).
|
|
11
|
+
* Reasoning effort for a request body (#47, #150).
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* MEASURED against the live endpoints in use (one probe per provider, `max_tokens:
|
|
14
|
+
* 3` with `reasoning_effort: "low"`):
|
|
15
|
+
*
|
|
16
|
+
* openrouter — documented, catalogue advertises the field
|
|
17
|
+
* commandcode-goat — 200 OK and the reply carried `reasoning_content`
|
|
18
|
+
* google — 400 "Thinking level is not supported for this model"
|
|
19
|
+
* orcarouter / opencode / experientiallabs — refused before reaching the field
|
|
20
|
+
*
|
|
21
|
+
* So "OpenRouter only" was too narrow (#150: `commandcode-goat` offers no options
|
|
22
|
+
* at all), but "send it everywhere" would break Google, which REJECTS the field
|
|
23
|
+
* with a 400 rather than ignoring it. The gate therefore stays opt-in:
|
|
24
|
+
*
|
|
25
|
+
* 1. the configured value must be a level the API documents, AND
|
|
26
|
+
* 2. EITHER the endpoint's catalogue advertises `reasoning_effort` (OpenRouter
|
|
27
|
+
* and compatible gateways), OR the provider is explicitly marked
|
|
28
|
+
* `reasoningEffort: true` in the config for an endpoint that accepts the field
|
|
29
|
+
* but publishes no catalogue capability list (#150).
|
|
18
30
|
*
|
|
19
31
|
* Implemented here rather than via model-meta to avoid an import cycle —
|
|
20
32
|
* model-meta already imports providerHeaders from this file.
|
|
@@ -23,11 +35,19 @@ export function effortForRequest(cfg, supported = cfg.supportedParameters) {
|
|
|
23
35
|
const e = cfg.reasoningEffort;
|
|
24
36
|
if (!e || !REASONING_EFFORTS.includes(e))
|
|
25
37
|
return {};
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
// the catalogue is a capability claim from a GATEWAY, so trust it only where the
|
|
39
|
+
// gateway format is the one that carries it. MEASURED: of the endpoints in use,
|
|
40
|
+
// only OpenRouter-family ones publish `supported_parameters` at all — google,
|
|
41
|
+
// commandcode, orcarouter and opencode all return 0 models with the field, so
|
|
42
|
+
// this cannot silently widen the blast radius.
|
|
43
|
+
if (supported?.includes("reasoning_effort") && isOpenRouterEndpoint(cfg.baseUrl)) {
|
|
44
|
+
return { reasoning_effort: e };
|
|
45
|
+
}
|
|
46
|
+
// #150: an endpoint that ACCEPTS the field but publishes no capability list needs
|
|
47
|
+
// the provider to say so explicitly (provider.reasoningEffort: true in config).
|
|
48
|
+
if (cfg.reasoningEffortOptIn === true)
|
|
49
|
+
return { reasoning_effort: e };
|
|
50
|
+
return {};
|
|
31
51
|
}
|
|
32
52
|
function isOpenRouterEndpoint(baseUrl) {
|
|
33
53
|
try {
|
|
@@ -37,6 +57,27 @@ function isOpenRouterEndpoint(baseUrl) {
|
|
|
37
57
|
return false;
|
|
38
58
|
}
|
|
39
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Ask the endpoint NOT to retain a one-off request (Pi does the same with
|
|
62
|
+
* `cacheRetention: "none"`; its docs say "summarization requests disable
|
|
63
|
+
* prompt-cache writes because these one-off prompts are unlikely to be reused").
|
|
64
|
+
*
|
|
65
|
+
* Sent ONLY to OpenRouter, which is the one endpoint in use here that both
|
|
66
|
+
* accepts the field and documents it. Every other OpenAI-compatible endpoint gets
|
|
67
|
+
* nothing: an unrecognised body field is a 400 on strict providers, and "send
|
|
68
|
+
* nothing" is exactly today's behaviour, so this can only ever add a saving.
|
|
69
|
+
*
|
|
70
|
+
* `24h` is the value that means "do not retain beyond this request's lifetime" for
|
|
71
|
+
* an organisation with zero-data-retention off; with ZDR on the default is already
|
|
72
|
+
* `in_memory` and this is a no-op.
|
|
73
|
+
*/
|
|
74
|
+
export function cacheControlForRequest(cfg) {
|
|
75
|
+
if (!cfg.oneOff)
|
|
76
|
+
return {};
|
|
77
|
+
if (!isOpenRouterEndpoint(cfg.baseUrl))
|
|
78
|
+
return {};
|
|
79
|
+
return { prompt_cache_retention: "24h" };
|
|
80
|
+
}
|
|
40
81
|
const REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
|
|
41
82
|
/**
|
|
42
83
|
* OpenRouter app attribution (https://openrouter.ai/docs/app-attribution):
|
|
@@ -194,6 +235,7 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
|
|
|
194
235
|
messages: sanitize(messages),
|
|
195
236
|
...(tools.length ? { tools } : {}),
|
|
196
237
|
...effortForRequest(cfg),
|
|
238
|
+
...cacheControlForRequest(cfg),
|
|
197
239
|
}, { signal });
|
|
198
240
|
// #50: a gateway may answer with `null` (or any non-object) on some proxy
|
|
199
241
|
// error paths, so the guard is on `rm`, not on `res` being truthy —
|
|
@@ -289,6 +331,7 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
|
|
|
289
331
|
messages: sanitize(messages),
|
|
290
332
|
...(tools.length ? { tools } : {}),
|
|
291
333
|
...effortForRequest(cfg),
|
|
334
|
+
...cacheControlForRequest(cfg),
|
|
292
335
|
stream: true,
|
|
293
336
|
// ask providers to include the final usage chunk in streaming mode
|
|
294
337
|
// (OpenAI-compatible; harmless where unsupported)
|
package/dist/config-schema.js
CHANGED
|
@@ -11,6 +11,15 @@ export const ProviderSchema = z.object({
|
|
|
11
11
|
baseUrl: z.union([z.string().url("baseUrl must be a valid URL"), z.literal("")]).optional(),
|
|
12
12
|
apiKey: z.string().optional(),
|
|
13
13
|
model: z.string().optional(),
|
|
14
|
+
/**
|
|
15
|
+
* Does this endpoint accept `reasoning_effort`? (#150)
|
|
16
|
+
*
|
|
17
|
+
* Needed for gateways that ACCEPT the field but publish no capability list in
|
|
18
|
+
* their /models response, so the gateway-style catalogue check cannot see it.
|
|
19
|
+
* Off by default because some endpoints (Google) reject the field with a 400
|
|
20
|
+
* rather than ignoring it — an unset field is safer than a wrong one.
|
|
21
|
+
*/
|
|
22
|
+
reasoningEffort: z.boolean().optional(),
|
|
14
23
|
});
|
|
15
24
|
export const AgentEntrySchema = z.object({
|
|
16
25
|
id: z
|
package/dist/master.js
CHANGED
|
@@ -804,6 +804,9 @@ export class Master {
|
|
|
804
804
|
...(modelMeta?.supportedParameters
|
|
805
805
|
? { supportedParameters: modelMeta.supportedParameters }
|
|
806
806
|
: {}),
|
|
807
|
+
// #150: the provider opts in explicitly when its catalogue does not carry
|
|
808
|
+
// capability metadata, so the gate above cannot see the support
|
|
809
|
+
...(prov?.reasoningEffort ? { reasoningEffortOptIn: true } : {}),
|
|
807
810
|
});
|
|
808
811
|
// pricing metadata (USD/token) for the runtime cost estimate — best
|
|
809
812
|
// effort: providers without pricing in /models simply show no cost
|
|
@@ -1662,6 +1665,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1662
1665
|
cfgEntry.reasoningEffort = opts.opts.reasoningEffort || undefined;
|
|
1663
1666
|
}
|
|
1664
1667
|
opts.opts.supportedParameters = meta?.supportedParameters ?? [];
|
|
1668
|
+
// #150: keep the explicit provider opt-in in step with the (possibly new)
|
|
1669
|
+
// provider, so switching to an opted-in gateway enables the dropdown at once
|
|
1670
|
+
const nowProv = this.config.providers?.[effectiveProvider];
|
|
1671
|
+
opts.opts.reasoningEffortOptIn = nowProv?.reasoningEffort === true;
|
|
1665
1672
|
if (!llm.model)
|
|
1666
1673
|
throw new Error("no model resolved (pass model or set one on the provider)");
|
|
1667
1674
|
if (!llm.baseUrl)
|
package/dist/server/api.js
CHANGED
|
@@ -749,6 +749,9 @@ export function buildApp(master) {
|
|
|
749
749
|
baseUrl: p.baseUrl ?? "",
|
|
750
750
|
apiKey: masked ? prev[name]?.apiKey : p.apiKey,
|
|
751
751
|
...(p.model ? { model: p.model } : {}),
|
|
752
|
+
// #150: the opt-in must survive a config PUT, or saving anything else
|
|
753
|
+
// would silently disable the effort dropdown for that provider
|
|
754
|
+
...(p.reasoningEffort ? { reasoningEffort: true } : {}),
|
|
752
755
|
};
|
|
753
756
|
}
|
|
754
757
|
master.updateConfig({
|