@wrongstack/core 0.306.3 → 0.306.4

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.
@@ -1,4 +1,5 @@
1
1
  // src/core/context.ts
2
+ import { realpathSync } from "node:fs";
2
3
  import * as path from "node:path";
3
4
 
4
5
  // src/types/blocks.ts
@@ -552,12 +553,15 @@ var ConversationState = class {
552
553
  * cap determines the starting index for the sum but does not gate it.
553
554
  */
554
555
  overflowCount(arr) {
555
- let drop = Context.MAX_MESSAGES > 0 ? Math.max(0, arr.length - Context.MAX_MESSAGES) : 0;
556
- if (Context.MAX_MESSAGE_TOKENS <= 0) return this.protocolSafeDropCount(arr, drop);
556
+ const contextClass = this.ctx.constructor;
557
+ const maxMessages = contextClass.MAX_MESSAGES;
558
+ const maxMessageTokens = contextClass.MAX_MESSAGE_TOKENS;
559
+ let drop = maxMessages > 0 ? Math.max(0, arr.length - maxMessages) : 0;
560
+ if (maxMessageTokens <= 0) return this.protocolSafeDropCount(arr, drop);
557
561
  let total = 0;
558
562
  for (let i = drop; i < arr.length; i++) total += arr[i]?._estTokens ?? 0;
559
- if (total <= Context.MAX_MESSAGE_TOKENS) return this.protocolSafeDropCount(arr, drop);
560
- while (drop < arr.length - 1 && total > Context.MAX_MESSAGE_TOKENS) {
563
+ if (total <= maxMessageTokens) return this.protocolSafeDropCount(arr, drop);
564
+ while (drop < arr.length - 1 && total > maxMessageTokens) {
561
565
  total -= arr[drop]?._estTokens ?? 0;
562
566
  drop++;
563
567
  }
@@ -1380,6 +1384,19 @@ var Context = class _Context {
1380
1384
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
1381
1385
  throw new Error(`Working directory "${resolved}" is outside project root "${root}"`);
1382
1386
  }
1387
+ let realTarget = resolved;
1388
+ let realRoot = root;
1389
+ try {
1390
+ realTarget = realpathSync.native(resolved);
1391
+ realRoot = realpathSync.native(root);
1392
+ } catch {
1393
+ }
1394
+ const realRel = path.relative(realRoot, realTarget);
1395
+ if (realRel.startsWith("..") || path.isAbsolute(realRel)) {
1396
+ throw new Error(
1397
+ `Working directory "${resolved}" resolves to "${realTarget}", outside project root "${realRoot}"`
1398
+ );
1399
+ }
1383
1400
  }
1384
1401
  const old = this.workingDir;
1385
1402
  this.workingDir = resolved;
@@ -2752,6 +2769,12 @@ function findExchangeStart(messages, userIndex) {
2752
2769
 
2753
2770
  // src/execution/auto-compaction-middleware.ts
2754
2771
  var LEVEL_RANK = { warn: 0, soft: 1, hard: 2 };
2772
+ function pressureLevelFor(load, thresholds) {
2773
+ if (load >= thresholds.hard) return "hard";
2774
+ if (load >= thresholds.soft) return "soft";
2775
+ if (load >= thresholds.warn) return "warn";
2776
+ return null;
2777
+ }
2755
2778
  var MAX_DIGEST_LOG_CHARS = 4e3;
2756
2779
  function truncateDigest(digest) {
2757
2780
  if (digest.length <= MAX_DIGEST_LOG_CHARS) return digest;
@@ -2797,8 +2820,19 @@ var AutoCompactionMiddleware = class _AutoCompactionMiddleware {
2797
2820
  * 1 / 2.5 = 0.4.
2798
2821
  */
2799
2822
  static GUARD_GATE_LOAD = 0.4;
2823
+ /**
2824
+ * How much the context must grow between two history-rewriting hygiene
2825
+ * passes, as a fraction of the available input window and as an absolute
2826
+ * floor. Every pass rewrites already-transmitted messages, which forces the
2827
+ * provider to re-cache the whole prompt; spacing the passes out is what lets
2828
+ * the conversation prefix stay cached for the turns in between.
2829
+ */
2830
+ static HYGIENE_GROWTH_RATIO = 0.15;
2831
+ static HYGIENE_MIN_GROWTH_TOKENS = 2e4;
2800
2832
  /** Tracks the most recent no-op attempt so we can avoid re-firing per turn. */
2801
2833
  lastNoopAttempt = null;
2834
+ /** Context size at the last hygiene pass; anchors the growth interval. */
2835
+ lastHygieneTokens = null;
2802
2836
  /**
2803
2837
  * Cached token estimate from the last handler() invocation. When the
2804
2838
  * message count and tool count haven't changed since the last estimate
@@ -2858,55 +2892,9 @@ var AutoCompactionMiddleware = class _AutoCompactionMiddleware {
2858
2892
  handler() {
2859
2893
  return async (ctx, next) => {
2860
2894
  if (!this._enabled) return next(ctx);
2861
- const rawHygiene = eliseAcknowledgedToolResults(ctx.messages, {
2862
- maxRetainedTokens: this.resolveToolResultRetention(ctx)
2863
- });
2864
- const receiptHygiene = collapseAcknowledgedToolReceipts(rawHygiene.messages, {
2865
- maxPairs: this.resolveToolReceiptRetention(ctx)
2866
- });
2867
- if (rawHygiene.changed || receiptHygiene.changed) {
2868
- ctx.state.replaceMessages(receiptHygiene.messages);
2869
- ctx.clearFileTracking();
2870
- this.invalidateTokenCaches(ctx);
2871
- }
2872
- const msgCount = ctx.messages.length;
2873
- const toolCount = (ctx.tools ?? []).length;
2874
- const revision = ctx.state?.revision ?? -1;
2875
- let tokens;
2876
- const anchorAt = typeof ctx.meta?.["realAnchorMsgCount"] === "number" ? ctx.meta["realAnchorMsgCount"] : void 0;
2877
- const anchored = realAnchoredInputTokens(ctx.messages, ctx.lastRealInputTokens, anchorAt);
2878
- if (anchored !== null) {
2879
- tokens = anchored;
2880
- } else if (this._estimator) {
2881
- tokens = this._estimator(ctx);
2882
- } else if (msgCount === this._cachedMsgCount && toolCount === this._cachedToolCount && revision === this._cachedRevision && ctx.systemPrompt === this._cachedSystemRef && ctx.tools === this._cachedToolsRef && this._cachedTokens >= 0) {
2883
- tokens = this._cachedTokens;
2884
- } else if (this.tryStashedTokens(ctx, msgCount, toolCount, revision) !== null) {
2885
- const stashed = this.tryStashedTokens(ctx, msgCount, toolCount, revision);
2886
- const cal = getCalibrationState(`${ctx.provider?.id ?? "unknown"}/${ctx.model}`);
2887
- tokens = cal.calibrated ? Math.round(stashed * Math.min(1.5, Math.max(0.5, cal.ratio))) : stashed;
2888
- this._cachedTokens = tokens;
2889
- this._cachedMsgCount = msgCount;
2890
- this._cachedToolCount = toolCount;
2891
- this._cachedRevision = revision;
2892
- this._cachedSystemRef = ctx.systemPrompt;
2893
- this._cachedToolsRef = ctx.tools;
2894
- } else {
2895
- tokens = estimateRequestTokensCalibrated(
2896
- ctx.messages,
2897
- ctx.systemPrompt,
2898
- ctx.tools ?? [],
2899
- `${ctx.provider?.id ?? "unknown"}/${ctx.model}`
2900
- ).total;
2901
- this._cachedTokens = tokens;
2902
- this._cachedMsgCount = msgCount;
2903
- this._cachedToolCount = toolCount;
2904
- this._cachedRevision = revision;
2905
- this._cachedSystemRef = ctx.systemPrompt;
2906
- this._cachedToolsRef = ctx.tools;
2907
- }
2895
+ let tokens = this.estimateContextTokens(ctx);
2908
2896
  const runtimeMaxContext = effectiveMaxContext(ctx, this._maxContext);
2909
- const budget = computeContextWindowBudget(ctx, tokens, runtimeMaxContext);
2897
+ let budget = computeContextWindowBudget(ctx, tokens, runtimeMaxContext);
2910
2898
  const calibratedLoad = budget.load;
2911
2899
  const policy = this.policyProvider?.(ctx);
2912
2900
  const thresholds = policy?.thresholds ?? {
@@ -2920,22 +2908,27 @@ var AutoCompactionMiddleware = class _AutoCompactionMiddleware {
2920
2908
  });
2921
2909
  const aggressiveOn = policy?.aggressiveOn ?? this.aggressiveOn;
2922
2910
  const targetLoad = normalizeTargetLoad(policy?.targetLoad, adaptiveThresholds);
2923
- let load = calibratedLoad;
2924
- if (calibratedLoad >= _AutoCompactionMiddleware.GUARD_GATE_LOAD) {
2925
- const guardTotal = estimateRequestTokensUpperBound(
2926
- ctx.messages,
2927
- ctx.systemPrompt,
2928
- ctx.tools ?? [],
2929
- `${ctx.provider?.id ?? "unknown"}/${ctx.model}`
2930
- ).total;
2931
- const guardLoad = guardTotal / budget.availableInputTokens;
2932
- if (guardLoad > load) load = guardLoad;
2933
- }
2934
- const level = load >= adaptiveThresholds.hard ? "hard" : load >= adaptiveThresholds.soft ? "soft" : load >= adaptiveThresholds.warn ? "warn" : null;
2911
+ let load = this.applySendGuard(ctx, calibratedLoad, budget.availableInputTokens);
2912
+ let level = pressureLevelFor(load, adaptiveThresholds);
2935
2913
  if (!level) {
2936
2914
  this.lastNoopAttempt = null;
2937
2915
  return next(ctx);
2938
2916
  }
2917
+ if (this.shouldRunHygiene(level, tokens, budget.availableInputTokens)) {
2918
+ const changed = this.runHistoryHygiene(ctx);
2919
+ tokens = changed ? this.estimateContextTokens(ctx) : tokens;
2920
+ this.lastHygieneTokens = tokens;
2921
+ if (changed) {
2922
+ budget = computeContextWindowBudget(ctx, tokens, runtimeMaxContext);
2923
+ load = this.applySendGuard(ctx, budget.load, budget.availableInputTokens);
2924
+ const relevelled = pressureLevelFor(load, adaptiveThresholds);
2925
+ if (!relevelled) {
2926
+ this.lastNoopAttempt = null;
2927
+ return next(ctx);
2928
+ }
2929
+ level = relevelled;
2930
+ }
2931
+ }
2939
2932
  if (this.shouldSkipNoopRetry(level, tokens)) {
2940
2933
  return next(ctx);
2941
2934
  }
@@ -2952,6 +2945,123 @@ var AutoCompactionMiddleware = class _AutoCompactionMiddleware {
2952
2945
  return next(ctx);
2953
2946
  };
2954
2947
  }
2948
+ /**
2949
+ * Full-request token total for the current context.
2950
+ *
2951
+ * Reuses the last estimate when the context hasn't grown since the previous
2952
+ * check — common in autonomous idle loops. The cached value is invalidated
2953
+ * whenever messages or tools change.
2954
+ *
2955
+ * IMPORTANT: the cache is only valid for the deterministic
2956
+ * `estimateRequestTokensCalibrated` path (messages+system+tools → fixed
2957
+ * output). When a custom `_estimator` is provided (e.g. in tests with a
2958
+ * mutable closure, or a dynamic policy provider), always call it fresh — the
2959
+ * estimator owns its own semantics and the middleware cannot safely cache its
2960
+ * result across calls.
2961
+ */
2962
+ estimateContextTokens(ctx) {
2963
+ const msgCount = ctx.messages.length;
2964
+ const toolCount = (ctx.tools ?? []).length;
2965
+ const revision = ctx.state?.revision ?? -1;
2966
+ const anchorAt = typeof ctx.meta?.["realAnchorMsgCount"] === "number" ? ctx.meta["realAnchorMsgCount"] : void 0;
2967
+ const anchored = realAnchoredInputTokens(ctx.messages, ctx.lastRealInputTokens, anchorAt);
2968
+ if (anchored !== null) return anchored;
2969
+ if (this._estimator) return this._estimator(ctx);
2970
+ if (msgCount === this._cachedMsgCount && toolCount === this._cachedToolCount && revision === this._cachedRevision && ctx.systemPrompt === this._cachedSystemRef && ctx.tools === this._cachedToolsRef && this._cachedTokens >= 0) {
2971
+ return this._cachedTokens;
2972
+ }
2973
+ const stashed = this.tryStashedTokens(ctx, msgCount, toolCount, revision);
2974
+ let tokens;
2975
+ if (stashed !== null) {
2976
+ const cal = getCalibrationState(`${ctx.provider?.id ?? "unknown"}/${ctx.model}`);
2977
+ tokens = cal.calibrated ? Math.round(stashed * Math.min(1.5, Math.max(0.5, cal.ratio))) : stashed;
2978
+ } else {
2979
+ tokens = estimateRequestTokensCalibrated(
2980
+ ctx.messages,
2981
+ ctx.systemPrompt,
2982
+ ctx.tools ?? [],
2983
+ `${ctx.provider?.id ?? "unknown"}/${ctx.model}`
2984
+ ).total;
2985
+ }
2986
+ this._cachedTokens = tokens;
2987
+ this._cachedMsgCount = msgCount;
2988
+ this._cachedToolCount = toolCount;
2989
+ this._cachedRevision = revision;
2990
+ this._cachedSystemRef = ctx.systemPrompt;
2991
+ this._cachedToolsRef = ctx.tools;
2992
+ return tokens;
2993
+ }
2994
+ /**
2995
+ * Never-undercount send guard.
2996
+ *
2997
+ * The calibrated estimate can under-count dense content (CJK, base64,
2998
+ * minified) by >1.5×, which would let an over-limit request slip past the
2999
+ * thresholds and reach the provider. Once the calibrated load is high enough
3000
+ * that even the max density factor (2.5×) *could* overflow (load ≥ 1/2.5 =
3001
+ * 0.4), re-check with the upper-bound estimator and escalate to whichever
3002
+ * load is larger. Below 0.4 an overflow is arithmetically impossible, so the
3003
+ * extra scan is skipped.
3004
+ */
3005
+ applySendGuard(ctx, calibratedLoad, availableInputTokens) {
3006
+ if (calibratedLoad < _AutoCompactionMiddleware.GUARD_GATE_LOAD) return calibratedLoad;
3007
+ const guardTotal = estimateRequestTokensUpperBound(
3008
+ ctx.messages,
3009
+ ctx.systemPrompt,
3010
+ ctx.tools ?? [],
3011
+ `${ctx.provider?.id ?? "unknown"}/${ctx.model}`
3012
+ ).total;
3013
+ const guardLoad = guardTotal / availableInputTokens;
3014
+ return guardLoad > calibratedLoad ? guardLoad : calibratedLoad;
3015
+ }
3016
+ /**
3017
+ * Rewrite acknowledged tool protocol in place.
3018
+ *
3019
+ * Tool results are protocol inputs for the immediately following model
3020
+ * response, not unlimited durable prompt memory. Once a later assistant
3021
+ * message proves the provider consumed them, keep a mode-sized raw window and
3022
+ * replace the rest with semantic head/tail receipts, then fold fully-aged
3023
+ * pairs into the history digest.
3024
+ *
3025
+ * Every one of those edits touches a message the provider has already seen,
3026
+ * so each call costs a full prompt re-cache — it belongs behind
3027
+ * {@link shouldRunHygiene}, never on the per-turn path. Content staleness is
3028
+ * not a reason to bypass that gate: the edit/replace tools guard against
3029
+ * acting on an out-of-date read with their own mtime + sha-256 check.
3030
+ *
3031
+ * @returns whether the conversation was rewritten.
3032
+ */
3033
+ runHistoryHygiene(ctx) {
3034
+ const raw = eliseAcknowledgedToolResults(ctx.messages, {
3035
+ maxRetainedTokens: this.resolveToolResultRetention(ctx)
3036
+ });
3037
+ const receipts = collapseAcknowledgedToolReceipts(raw.messages, {
3038
+ maxPairs: this.resolveToolReceiptRetention(ctx)
3039
+ });
3040
+ if (!raw.changed && !receipts.changed) return false;
3041
+ ctx.state.replaceMessages(receipts.messages);
3042
+ ctx.clearFileTracking();
3043
+ this.invalidateTokenCaches(ctx);
3044
+ return true;
3045
+ }
3046
+ /**
3047
+ * Whether the history-rewriting hygiene pass may run this turn.
3048
+ *
3049
+ * Hard pressure always runs it — staying under the window outranks caching.
3050
+ * Otherwise it runs at most once per growth interval, so the conversation
3051
+ * stays append-only (and therefore cacheable by the provider) in between.
3052
+ */
3053
+ shouldRunHygiene(level, tokens, availableInputTokens) {
3054
+ if (level === "hard") return true;
3055
+ const last = this.lastHygieneTokens;
3056
+ if (last === null) return true;
3057
+ const anchor = Math.min(last, tokens);
3058
+ this.lastHygieneTokens = anchor;
3059
+ const interval = Math.max(
3060
+ _AutoCompactionMiddleware.HYGIENE_MIN_GROWTH_TOKENS,
3061
+ Math.floor(availableInputTokens * _AutoCompactionMiddleware.HYGIENE_GROWTH_RATIO)
3062
+ );
3063
+ return tokens - anchor >= interval;
3064
+ }
2955
3065
  /**
2956
3066
  * H1: try to read a pre-computed token total from `ctx.lastRequestTokens`
2957
3067
  * (set by the agent loop's pre-flight or its restash in emitContextPct).
@@ -4964,6 +5074,12 @@ function normalizeModelRef(ref, defaultProvider) {
4964
5074
  function hasText(value) {
4965
5075
  return typeof value === "string" && value.trim().length > 0;
4966
5076
  }
5077
+ function asRefList(value) {
5078
+ return Array.isArray(value) ? value : void 0;
5079
+ }
5080
+ function asProfileName(value) {
5081
+ return hasText(value) ? value : void 0;
5082
+ }
4967
5083
  function providerHasKey(entry) {
4968
5084
  if (!entry) return false;
4969
5085
  if (hasText(entry.apiKey)) return true;
@@ -4974,7 +5090,7 @@ function providerHasKey(entry) {
4974
5090
  }
4975
5091
  function visibleProviderModels(config, providerId, providerModels) {
4976
5092
  const entry = config.providers?.[providerId];
4977
- return entry?.models !== void 0 ? [...entry.models] : providerModels;
5093
+ return Array.isArray(entry?.models) ? [...entry.models] : providerModels;
4978
5094
  }
4979
5095
  function buildProfiles(config) {
4980
5096
  const entries = /* @__PURE__ */ new Map();
@@ -5011,13 +5127,34 @@ var FallbackProfileManager = class {
5011
5127
  listProfiles() {
5012
5128
  return Object.freeze([...this.profiles.keys()]);
5013
5129
  }
5130
+ /**
5131
+ * The profile the session has selected (`config.fallbackProfile`, set by
5132
+ * `/fallback profile use <name>`), or undefined when none is selected or the
5133
+ * name no longer resolves to a defined profile.
5134
+ *
5135
+ * Consulted by every resolution entry point when the caller does not name a
5136
+ * profile itself. Without this the leader — which passes no profile — could
5137
+ * never use a named profile at all: `config.fallbackProfiles` was reachable
5138
+ * only by copying a chain into `fallbackModels`.
5139
+ */
5140
+ activeProfileName() {
5141
+ const name = asProfileName(this.config.fallbackProfile);
5142
+ return name && this.profiles.has(name) ? name : void 0;
5143
+ }
5014
5144
  // ── Resolution ─────────────────────────────────────────────────────────
5015
5145
  /**
5016
5146
  * Resolve a named fallback profile to a validated, provider-filtered chain.
5017
5147
  *
5018
- * Returns an empty chain when:
5019
- * - The profile doesn't exist.
5020
- * - Every entry's provider is missing, has no key, or has no matching model.
5148
+ * Returns an empty chain when the profile doesn't exist, or when every entry
5149
+ * is excluded, quarantined, or blacked out.
5150
+ *
5151
+ * Filtering is intentionally identical to {@link resolveRefs} (the explicit
5152
+ * `fallbackModels` path): the self-exclusion, the runtime status tracker,
5153
+ * and the availability calendar — nothing else. Anything a named profile
5154
+ * drops, an explicit chain drops too, and vice versa. Profiles used to apply
5155
+ * two extra filters (provider "usability" and the `providers[].models`
5156
+ * snapshot) that the explicit path did not, which silently rerouted roles
5157
+ * pinned to a profile onto a different model than the one configured.
5021
5158
  *
5022
5159
  * @param name - Profile name from config.fallbackProfiles.
5023
5160
  * @param defaultProvider - Used when an entry has no explicit provider.
@@ -5038,13 +5175,9 @@ var FallbackProfileManager = class {
5038
5175
  if (seen.has(key)) continue;
5039
5176
  seen.add(key);
5040
5177
  if (excludeKey && key === excludeKey) continue;
5041
- const health = this.checkProvider(providerId);
5042
- if (!health.usable) continue;
5043
5178
  if (this.statusTracker && !this.statusTracker.isAvailable(providerId, parsed.model)) continue;
5044
5179
  if (!evaluateModelCalendar(this.config.modelAvailabilitySchedule, providerId, parsed.model).allowed)
5045
5180
  continue;
5046
- const allowedModels = this.config.providers?.[providerId]?.models;
5047
- if (allowedModels && !allowedModels.includes(parsed.model)) continue;
5048
5181
  resolved.push({
5049
5182
  providerId,
5050
5183
  model: parsed.model,
@@ -5062,12 +5195,14 @@ var FallbackProfileManager = class {
5062
5195
  resolveEffective(opts = {}) {
5063
5196
  const bridge = this.resolveBridge(opts.exclude);
5064
5197
  let selected = FREEZER_EMPTY;
5065
- if (opts.fallbackModels && opts.fallbackModels.length > 0) {
5066
- const resolved = this.resolveRefs(opts.fallbackModels, opts.exclude);
5198
+ const explicitRefs = asRefList(opts.fallbackModels);
5199
+ const profileName = asProfileName(opts.fallbackProfile) ?? this.activeProfileName();
5200
+ if (explicitRefs && explicitRefs.length > 0) {
5201
+ const resolved = this.resolveRefs(explicitRefs, opts.exclude);
5067
5202
  if (resolved.length > 0) selected = resolved;
5068
5203
  }
5069
- if (selected.length === 0 && opts.fallbackProfile) {
5070
- const resolved = this.resolve(opts.fallbackProfile, { exclude: opts.exclude });
5204
+ if (selected.length === 0 && profileName) {
5205
+ const resolved = this.resolve(profileName, { exclude: opts.exclude });
5071
5206
  if (resolved.length > 0) selected = resolved;
5072
5207
  }
5073
5208
  if (selected.length === 0 && opts.fallbackAuto !== false) {
@@ -5143,13 +5278,14 @@ var FallbackProfileManager = class {
5143
5278
  };
5144
5279
  const configFallbackAuto = this.config.fallbackAuto;
5145
5280
  const effectiveFallbackAuto = configFallbackAuto !== void 0 && configFallbackAuto !== null ? configFallbackAuto : !opts.closedWorld;
5146
- const explicitRefs = opts.fallbackModels ?? this.config.fallbackModels;
5281
+ const explicitRefs = asRefList(opts.fallbackModels) ?? asRefList(this.config.fallbackModels);
5282
+ const profileName = asProfileName(opts.fallbackProfile) ?? this.activeProfileName();
5147
5283
  const explicitUsable = explicitRefs !== void 0 && explicitRefs.length > 0 && this.resolveRefs(explicitRefs, current).length > 0;
5148
- const profileUsable = opts.fallbackProfile !== void 0 && this.hasProfile(opts.fallbackProfile) && this.resolve(opts.fallbackProfile, { exclude: current }).length > 0;
5284
+ const profileUsable = profileName !== void 0 && this.hasProfile(profileName) && this.resolve(profileName, { exclude: current }).length > 0;
5149
5285
  const fromExplicitSource = explicitUsable || profileUsable;
5150
- const selectedChain = opts.closedWorld ? explicitRefs && explicitRefs.length > 0 ? this.resolveRefs(explicitRefs, current) : opts.fallbackProfile ? this.resolve(opts.fallbackProfile, { exclude: current }) : FREEZER_EMPTY : this.resolveEffective({
5286
+ const selectedChain = opts.closedWorld ? explicitRefs && explicitRefs.length > 0 ? this.resolveRefs(explicitRefs, current) : profileName ? this.resolve(profileName, { exclude: current }) : FREEZER_EMPTY : this.resolveEffective({
5151
5287
  fallbackModels: explicitRefs,
5152
- fallbackProfile: opts.fallbackProfile,
5288
+ fallbackProfile: profileName,
5153
5289
  fallbackAuto: effectiveFallbackAuto,
5154
5290
  exclude: current
5155
5291
  });
@@ -5166,7 +5302,7 @@ var FallbackProfileManager = class {
5166
5302
  });
5167
5303
  }
5168
5304
  candidates.push(...selectedChain);
5169
- if (!fromExplicitSource && effectiveFallbackAuto && opts.fallbackProfile !== "default") {
5305
+ if (!fromExplicitSource && effectiveFallbackAuto && profileName !== "default") {
5170
5306
  candidates.push(...this.resolve("default", { exclude: current }));
5171
5307
  }
5172
5308
  if (!fromExplicitSource && effectiveFallbackAuto) {
@@ -5244,7 +5380,7 @@ var FallbackProfileManager = class {
5244
5380
  const leaderModel = this.config.model;
5245
5381
  const providers = this.config.providers ?? {};
5246
5382
  const favoriteSet = new Set(
5247
- (this.config.favoriteModels ?? []).map((ref) => {
5383
+ (asRefList(this.config.favoriteModels) ?? []).map((ref) => {
5248
5384
  const p = parseModelRef(ref);
5249
5385
  return `${p.provider ?? leaderProvider}/${p.model}`;
5250
5386
  })
@@ -5324,6 +5460,7 @@ function effectiveFallbackChain(config) {
5324
5460
  const mgr = new FallbackProfileManager(config);
5325
5461
  return mgr.resolveEffective({
5326
5462
  fallbackModels: config.fallbackModels,
5463
+ fallbackProfile: config.fallbackProfile,
5327
5464
  fallbackAuto: config.fallbackAuto
5328
5465
  }).map((e) => `${e.providerId}/${e.model}`);
5329
5466
  }
@@ -19164,26 +19301,29 @@ function escapeRegExp(s) {
19164
19301
  // src/execution/retry-policy.ts
19165
19302
  import { randomInt } from "node:crypto";
19166
19303
  var MAX_RETRY_AFTER_MS = 6e4;
19304
+ var MODEL_RETRIES = 3;
19167
19305
  var MAX_ATTEMPTS_BY_KIND = {
19168
- rate_limit: 5,
19306
+ rate_limit: MODEL_RETRIES,
19307
+ overloaded: MODEL_RETRIES,
19308
+ server: MODEL_RETRIES,
19309
+ timeout: MODEL_RETRIES,
19310
+ network: MODEL_RETRIES,
19311
+ stream_hang: MODEL_RETRIES,
19169
19312
  quota_exhausted: 0,
19170
- stream_hang: 2,
19171
- // proxy-level timeout — retrying 5x wastes ~40s before fallback kicks in
19172
- overloaded: 3,
19173
- server: 3,
19174
- timeout: 2,
19175
- network: 2,
19176
19313
  auth: 0,
19177
19314
  invalid_request: 0,
19178
19315
  context_overflow: 0,
19179
19316
  content_filter: 0,
19180
19317
  unknown: 0
19181
19318
  };
19319
+ var FAILOVER_RETRY_AFTER_MS = 15e3;
19182
19320
  var DefaultRetryPolicy = class {
19183
19321
  shouldRetry(err, attempt) {
19184
19322
  const isProviderErr = err instanceof ProviderError || ProviderError.isProviderError(err);
19185
19323
  if (isProviderErr) {
19186
19324
  if (!err.retryable) return false;
19325
+ const hint = retryAfterMsFromError(err);
19326
+ if (hint !== void 0 && hint >= FAILOVER_RETRY_AFTER_MS) return false;
19187
19327
  return attempt < this.maxAttempts(err);
19188
19328
  }
19189
19329
  const msg = err.message ?? "";
@@ -22992,6 +23132,7 @@ export {
22992
23132
  HybridCompactor,
22993
23133
  IntelligentCompactor,
22994
23134
  MAX_COUNCIL_CONCURRENCY,
23135
+ MODEL_RETRIES,
22995
23136
  OneShotOrchestrator,
22996
23137
  ParallelEternalEngine,
22997
23138
  SelectiveCompactor,
@@ -1,5 +1,32 @@
1
1
  import { ProviderError } from '../types/provider.js';
2
2
  import type { RetryPolicy } from '../types/retry-policy.js';
3
+ /**
4
+ * Retries a single model gets before the turn moves on to the next entry in
5
+ * the fallback chain.
6
+ *
7
+ * One number for every recoverable failure kind, on purpose. The per-kind
8
+ * budgets used to range from 2 (timeout, network, stream_hang) to 5
9
+ * (rate_limit), which made "how long until we try another model?" depend on
10
+ * which error came back: a rate-limited model could spend five backoff waits —
11
+ * each honouring a `Retry-After` up to 60s — before the cross-model fallback
12
+ * engine, which only engages once in-place retries are exhausted, got its
13
+ * turn, while a timeout gave up after two. The contract is now uniform and
14
+ * predictable across every surface that runs the agent loop (leader, host
15
+ * subagents, Chimera reviewers, SDD workers — they all resolve this one
16
+ * policy from the container): try the model {@link MODEL_RETRIES} times, then
17
+ * fail over.
18
+ *
19
+ * Exhaustive by construction (`Record<ProviderErrorKind, …>`) — adding a new
20
+ * kind refuses to compile until it gets an attempt budget.
21
+ *
22
+ * Zero means "do not replay this request against this model":
23
+ * - request-shaped failures (auth / invalid_request / context_overflow /
24
+ * content_filter) would fail identically on every attempt;
25
+ * - `quota_exhausted` is a depleted account or plan, not a transient burst, so
26
+ * the route is done until it resets — retrying only delays the hop, and the
27
+ * kind IS fallback-worthy, so another provider gets tried immediately.
28
+ */
29
+ export declare const MODEL_RETRIES = 3;
3
30
  export declare class DefaultRetryPolicy implements RetryPolicy {
4
31
  shouldRetry(err: Error | ProviderError, attempt: number): boolean;
5
32
  maxAttempts(err: Error | ProviderError): number;
package/dist/hq/index.js CHANGED
@@ -675,6 +675,24 @@ import * as v8 from "node:v8";
675
675
  var SESSION_RECIPIENT_PREFIX = "@session:";
676
676
 
677
677
  // src/security/secret-scrubber.ts
678
+ var JSON_CREDENTIAL_KEY_ANCHORS = [
679
+ 'Key"',
680
+ 'key"',
681
+ 'KEY"',
682
+ 'token"',
683
+ 'Token"',
684
+ 'TOKEN"',
685
+ 'secret"',
686
+ 'Secret"',
687
+ 'SECRET"',
688
+ 'password"',
689
+ 'Password"',
690
+ 'PASSWORD"',
691
+ 'authorization"',
692
+ 'Authorization"',
693
+ 'bearer"',
694
+ 'Bearer"'
695
+ ];
678
696
  var PATTERNS = [
679
697
  // Anchored at the start where possible so partial matches inside larger
680
698
  // strings don't trigger false positives.
@@ -777,6 +795,30 @@ var PATTERNS = [
777
795
  regex: /(^|\s)([A-Z_]{4,}(?:KEY|TOKEN|SECRET|PASSWORD|PWD))\s*[:=]\s*['"]?([A-Za-z0-9_/+=-]{20,512})['"]?(?=\s|$)/g,
778
796
  anchor: ["KEY", "TOKEN", "SECRET", "PASSWORD", "PWD"]
779
797
  },
798
+ {
799
+ type: "json_credential_key",
800
+ // The JSON counterpart to `high_entropy_env`, and the pattern that the
801
+ // now-deleted `JSON_KEY_ANCHORS` list was written for. Without it those
802
+ // anchors only widened the cheap pre-scan — `hasCredentialAnchors` said
803
+ // "this text may hold a secret", every pattern then declined to match, and
804
+ // the value went out verbatim. `high_entropy_env` cannot cover these: it
805
+ // requires an UPPERCASE unquoted key (`API_KEY=…`), so `{"apiKey":"…"}`
806
+ // never matched.
807
+ //
808
+ // Tool results are routinely serialised as JSON, and a credential with no
809
+ // recognisable prefix (Azure, self-hosted gateways, Anthropic/Codex OAuth)
810
+ // has no other pattern that can catch it — this is the only thing standing
811
+ // between such a value and the session JSONL, chronicle, HQ broadcast and
812
+ // the model's own context.
813
+ //
814
+ // The key may carry a prefix (`"anthropicApiKey"`), but the credential word
815
+ // must END the key: `"tokenCount"` and `"maxTokens"` do not match, because
816
+ // the closing quote has to follow the word immediately.
817
+ // Value floor of 8 chars keeps enum-ish values (`"authorization":"none"`)
818
+ // out. Capture groups: 1=key + punctuation, 2=value, 3=closing quote.
819
+ regex: /("[A-Za-z0-9_]*(?:apiKey|api_key|token|secret|password|authorization|bearer|private_key|access_token|refresh_token|client_secret)"\s*:\s*")([^"\\]{8,512})(")/gi,
820
+ anchor: JSON_CREDENTIAL_KEY_ANCHORS
821
+ },
780
822
  // ── Ported from packages/plugins credential-patterns.ts (WS-034) ─────────
781
823
  // The plugin runtime carried 37 patterns while this scrubber — the one that
782
824
  // guards session JSONL, chronicle, HQ broadcast, WebUI events and the auth
@@ -859,9 +901,12 @@ var PATTERNS = [
859
901
  anchor: "GOCSPX-"
860
902
  }
861
903
  ];
862
- var SIMPLE_PATTERNS = PATTERNS.filter((p) => p.type !== "high_entropy_env");
904
+ var SIMPLE_PATTERNS = PATTERNS.filter(
905
+ (p) => p.type !== "high_entropy_env" && p.type !== "json_credential_key"
906
+ );
863
907
  var COMBINED_REGEX = new RegExp(SIMPLE_PATTERNS.map((p) => `(${p.regex.source})`).join("|"), "g");
864
908
  var HIGH_ENTROPY_REGEX = PATTERNS.find((p) => p.type === "high_entropy_env").regex;
909
+ var JSON_CREDENTIAL_REGEX = PATTERNS.find((p) => p.type === "json_credential_key").regex;
865
910
  var COMBINED_REPLACEMENTS = SIMPLE_PATTERNS.map((p) => `[REDACTED:${p.type}]`);
866
911
  var SCRUB_CHUNK_BYTES = 64 * 1024;
867
912
  var SCRUB_OVERLAP_BYTES = 1024;
@@ -872,20 +917,7 @@ var PATTERN_ANCHORS = [
872
917
  )
873
918
  )
874
919
  ];
875
- var JSON_KEY_ANCHORS = [
876
- '"apiKey"',
877
- '"api_key"',
878
- '"token"',
879
- '"secret"',
880
- '"password"',
881
- '"authorization"',
882
- '"bearer"',
883
- '"private_key"',
884
- '"access_token"',
885
- '"refresh_token"',
886
- '"client_secret"'
887
- ];
888
- var ALL_ANCHORS = [...PATTERN_ANCHORS, ...JSON_KEY_ANCHORS];
920
+ var ALL_ANCHORS = PATTERN_ANCHORS;
889
921
  function hasCredentialAnchors(text) {
890
922
  for (const anchor of ALL_ANCHORS) {
891
923
  if (text.includes(anchor)) return true;
@@ -934,6 +966,9 @@ var DefaultSecretScrubber = class {
934
966
  out = out.replace(HIGH_ENTROPY_REGEX, (_match, lead, key, _value) => {
935
967
  return `${lead}${key}=[REDACTED:high_entropy_env]`;
936
968
  });
969
+ out = out.replace(JSON_CREDENTIAL_REGEX, (_match, keyPrefix, _value, closingQuote) => {
970
+ return `${keyPrefix}[REDACTED:json_credential_key]${closingQuote}`;
971
+ });
937
972
  return out;
938
973
  }
939
974
  /**
package/dist/index.d.ts CHANGED
@@ -48,7 +48,7 @@ export { Context, type ContextInit, type ProviderMemoryEvidence, type RunOptions
48
48
  export { type ContinuationInput, type ContinuationSource, detectContinueIntent, type ResolvedContinuation, resolveContinuation, } from './core/continue-intent.js';
49
49
  export { type ContinueDirective, makeContinueToNextIterationTool, parseContinueDirective, } from './core/continue-to-next-iteration.js';
50
50
  export { ConversationState, type ReadonlyConversationState, type StateChange, type StateChangeHandler, wrapAsState, } from './core/conversation-state.js';
51
- export { createFallbackModelExtension, effectiveFallbackChain, type FallbackModelDeps, fallbackProfileChain, formatModelRef, normalizeModelRef, parseModelRef, smartDefaultFallbackChain, } from './core/fallback-model.js';
51
+ export { createFallbackModelExtension, effectiveFallbackChain, type FallbackModelDeps, fallbackProfileChain, formatModelRef, normalizeModelRef, parseModelRef, runtimeFallbackChain, smartDefaultFallbackChain, } from './core/fallback-model.js';
52
52
  export type { FallbackChain, FallbackChainEntry, ProviderHealth, } from './core/fallback-profile-manager.js';
53
53
  export { FallbackProfileManager } from './core/fallback-profile-manager.js';
54
54
  export { InputBuilder, type InputBuilderEvent, type InputBuilderOptions, } from './core/input-builder.js';