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.
@@ -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 ONCE and retry the turn
1922
- // (OpenCode-style overflow recovery). even with autoCompact off, an
1923
- // actual overflow is proof the estimate missed; a second overflow is
1924
- // returned as a real error instead of looping.
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
- await this.maybeCompact(true);
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 this.messages) {
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 + this.messages.length * 8);
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
- /** Compact history when the real prompt size exceeds the budget. */
3036
- async maybeCompact(force = false) {
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
- const keepCharBudget = (this.opts.contextTokenBudget / 4) * 4;
3054
- let keepChars = 0;
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
- keepChars += (this.messages[i].content?.length ?? 0) + JSON.stringify(this.messages[i].tool_calls ?? "").length;
3058
- if (keepChars > keepCharBudget)
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
- summary = await this.summarize(old, (text) => {
3107
- bus.emit("update", {
3108
- kind: "llm-delta",
3109
- agentId: this.opts.id,
3110
- // #146: the bubble is session-scoped, so the stream must say which session
3111
- sessionId: this.currentSession,
3112
- text: `[compact] ${text}`,
3113
- reasoning: "",
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
- this.messages = this.messages.slice(halfCut);
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: "You compress a coding agent's conversation into dense notes for it to continue working.\n\n" +
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
- { role: "user", content: `Conversation:\n\n${transcript}\n\nWrite the continuation notes now.` },
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
- * Kept local and deliberately conservative: the setting is only honoured when
14
- * the endpoint is OpenRouter, the value is one OpenRouter documents, and the
15
- * catalogue says this model advertises `reasoning_effort`. Support is far from
16
- * universal (verified against the live /models endpoint: 196 of 462 models),
17
- * so an unsent field is much safer than a wrong one.
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
- if (!isOpenRouterEndpoint(cfg.baseUrl))
27
- return {};
28
- if (!supported?.includes("reasoning_effort"))
29
- return {};
30
- return { reasoning_effort: e };
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)
@@ -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)
@@ -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({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.29.9",
3
+ "version": "0.29.11",
4
4
  "description": "A lightweight, always-on multi-agent harness for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",