@wrongstack/core 0.306.2 → 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.
Files changed (41) hide show
  1. package/dist/coordination/index.js +184 -86
  2. package/dist/coordination/mail-tools.d.ts +1 -1
  3. package/dist/coordination/mailbox-project-server.js +38 -16
  4. package/dist/coordination/sqlite-mailbox.d.ts +1 -0
  5. package/dist/core/conversation-state.d.ts +1 -2
  6. package/dist/core/fallback-model.d.ts +19 -1
  7. package/dist/core/fallback-profile-manager.d.ts +21 -3
  8. package/dist/core/index.d.ts +1 -1
  9. package/dist/core/index.js +152 -46
  10. package/dist/defaults/index.js +347 -139
  11. package/dist/execution/auto-compaction-middleware.d.ts +64 -0
  12. package/dist/execution/compaction-core.d.ts +4 -0
  13. package/dist/execution/index.d.ts +1 -1
  14. package/dist/execution/index.js +234 -91
  15. package/dist/execution/retry-policy.d.ts +27 -0
  16. package/dist/hq/index.js +50 -15
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +642 -184
  19. package/dist/infrastructure/index.js +21 -0
  20. package/dist/plugin/index.js +195 -19
  21. package/dist/security/index.js +52 -16
  22. package/dist/security/kanban-boundary.d.ts +3 -1
  23. package/dist/session-catalog/index.js +50 -15
  24. package/dist/session-catalog/project-server.js +50 -15
  25. package/dist/storage/cloud-config-sync/sanitize.d.ts +18 -0
  26. package/dist/storage/cloud-config-sync.d.ts +1 -1
  27. package/dist/storage/index.js +326 -71
  28. package/dist/storage/provider-config-watcher.d.ts +9 -0
  29. package/dist/storage/session-store/strict-empty-check.d.ts +7 -0
  30. package/dist/storage/session-store.d.ts +1 -0
  31. package/dist/tools/index.d.ts +1 -1
  32. package/dist/tools/index.js +83 -22
  33. package/dist/types/blocks.d.ts +9 -0
  34. package/dist/types/config/root.d.ts +14 -0
  35. package/dist/types/session.d.ts +7 -0
  36. package/instructions/agents/code-reviewer.md +3 -0
  37. package/instructions/coordination/subagent-baseline.md +10 -1
  38. package/instructions/system-lite.md +8 -5
  39. package/instructions/system-pro.md +26 -0
  40. package/instructions/system.md +21 -0
  41. package/package.json +3 -3
@@ -78,8 +78,19 @@ export declare class AutoCompactionMiddleware {
78
78
  * 1 / 2.5 = 0.4.
79
79
  */
80
80
  private static readonly GUARD_GATE_LOAD;
81
+ /**
82
+ * How much the context must grow between two history-rewriting hygiene
83
+ * passes, as a fraction of the available input window and as an absolute
84
+ * floor. Every pass rewrites already-transmitted messages, which forces the
85
+ * provider to re-cache the whole prompt; spacing the passes out is what lets
86
+ * the conversation prefix stay cached for the turns in between.
87
+ */
88
+ private static readonly HYGIENE_GROWTH_RATIO;
89
+ private static readonly HYGIENE_MIN_GROWTH_TOKENS;
81
90
  /** Tracks the most recent no-op attempt so we can avoid re-firing per turn. */
82
91
  private lastNoopAttempt;
92
+ /** Context size at the last hygiene pass; anchors the growth interval. */
93
+ private lastHygieneTokens;
83
94
  /**
84
95
  * Cached token estimate from the last handler() invocation. When the
85
96
  * message count and tool count haven't changed since the last estimate
@@ -128,6 +139,59 @@ export declare class AutoCompactionMiddleware {
128
139
  * tokens or compacting. */
129
140
  setEnabled(enabled: boolean): void;
130
141
  handler(): MiddlewareHandler<Context>;
142
+ /**
143
+ * Full-request token total for the current context.
144
+ *
145
+ * Reuses the last estimate when the context hasn't grown since the previous
146
+ * check — common in autonomous idle loops. The cached value is invalidated
147
+ * whenever messages or tools change.
148
+ *
149
+ * IMPORTANT: the cache is only valid for the deterministic
150
+ * `estimateRequestTokensCalibrated` path (messages+system+tools → fixed
151
+ * output). When a custom `_estimator` is provided (e.g. in tests with a
152
+ * mutable closure, or a dynamic policy provider), always call it fresh — the
153
+ * estimator owns its own semantics and the middleware cannot safely cache its
154
+ * result across calls.
155
+ */
156
+ private estimateContextTokens;
157
+ /**
158
+ * Never-undercount send guard.
159
+ *
160
+ * The calibrated estimate can under-count dense content (CJK, base64,
161
+ * minified) by >1.5×, which would let an over-limit request slip past the
162
+ * thresholds and reach the provider. Once the calibrated load is high enough
163
+ * that even the max density factor (2.5×) *could* overflow (load ≥ 1/2.5 =
164
+ * 0.4), re-check with the upper-bound estimator and escalate to whichever
165
+ * load is larger. Below 0.4 an overflow is arithmetically impossible, so the
166
+ * extra scan is skipped.
167
+ */
168
+ private applySendGuard;
169
+ /**
170
+ * Rewrite acknowledged tool protocol in place.
171
+ *
172
+ * Tool results are protocol inputs for the immediately following model
173
+ * response, not unlimited durable prompt memory. Once a later assistant
174
+ * message proves the provider consumed them, keep a mode-sized raw window and
175
+ * replace the rest with semantic head/tail receipts, then fold fully-aged
176
+ * pairs into the history digest.
177
+ *
178
+ * Every one of those edits touches a message the provider has already seen,
179
+ * so each call costs a full prompt re-cache — it belongs behind
180
+ * {@link shouldRunHygiene}, never on the per-turn path. Content staleness is
181
+ * not a reason to bypass that gate: the edit/replace tools guard against
182
+ * acting on an out-of-date read with their own mtime + sha-256 check.
183
+ *
184
+ * @returns whether the conversation was rewritten.
185
+ */
186
+ private runHistoryHygiene;
187
+ /**
188
+ * Whether the history-rewriting hygiene pass may run this turn.
189
+ *
190
+ * Hard pressure always runs it — staying under the window outranks caching.
191
+ * Otherwise it runs at most once per growth interval, so the conversation
192
+ * stays append-only (and therefore cacheable by the provider) in between.
193
+ */
194
+ private shouldRunHygiene;
131
195
  /**
132
196
  * H1: try to read a pre-computed token total from `ctx.lastRequestTokens`
133
197
  * (set by the agent loop's pre-flight or its restash in emitContextPct).
@@ -63,6 +63,10 @@ export interface AcknowledgedToolReceiptCollapse extends EliseResult {
63
63
  * become semantic receipts while their exact payload remains in the session
64
64
  * log. Tool ids and provider metadata stay intact so strict provider replay
65
65
  * adjacency remains valid.
66
+ *
67
+ * Every edit here rewrites a message the provider has already seen, so callers
68
+ * must run this on a pressure-gated interval rather than per turn — see
69
+ * `AutoCompactionMiddleware.shouldRunHygiene`.
66
70
  */
67
71
  export declare function eliseAcknowledgedToolResults(messages: readonly Message[], opts: {
68
72
  maxRetainedTokens: number;
@@ -25,7 +25,7 @@ export { OneShotOrchestrator } from './one-shot-llm.js';
25
25
  export { type ParallelEngineState, ParallelEternalEngine, type ParallelEternalOptions, type ParallelIterationStage, } from './parallel-eternal-engine.js';
26
26
  export { buildRefinerContextSections, DEFAULT_REFINER_RETRY_FEEDBACK, type EnhanceFailureKind, enhanceUserPrompt, gatedEnhancerReasoning, isValidEnglishRefinement, normalizedEqual, parseBilingualEnhancement, recentTextTurns, shouldEnhance, } from './prompt-enhancer.js';
27
27
  export { DefaultPromptLoader, type PromptLoaderOptions, renderPrompt } from './prompt-loader.js';
28
- export { DefaultRetryPolicy } from './retry-policy.js';
28
+ export { DefaultRetryPolicy, MODEL_RETRIES } from './retry-policy.js';
29
29
  export { SelectiveCompactor, type SelectiveCompactorOptions } from './selective-compactor.js';
30
30
  export { DefaultSkillLoader, type SkillLoaderOptions } from './skill-loader.js';
31
31
  export { type CompactorStrategy, createStrategyCompactor, type StrategyCompactorOptions, } from './strategy-compactor.js';
@@ -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 ?? "";
@@ -21406,7 +21546,8 @@ async function evaluateToolKanbanBoundary(tool, input, ctx, options = {}) {
21406
21546
  decision: "block",
21407
21547
  reason: "Active card is not implementation-ready: " + readiness.issues.map((issue) => issue.message).join(" | "),
21408
21548
  boardId: board.id,
21409
- taskId: task.id
21549
+ taskId: task.id,
21550
+ readinessIssues: readiness.issues
21410
21551
  };
21411
21552
  }
21412
21553
  if (task.lifecycle?.currentStage !== "running" || task.assignment?.status !== "running") {
@@ -22197,7 +22338,8 @@ ${errorDetails}`,
22197
22338
  type: "tool_result",
22198
22339
  tool_use_id: use.id,
22199
22340
  content: `Tool "${tool.name}" blocked by Kanban boundary. ${boundary.reason ?? ""}`.trim(),
22200
- is_error: true
22341
+ is_error: true,
22342
+ _kanbanBoundary: boundary
22201
22343
  };
22202
22344
  budget = this.budgetForString(result.content, budget);
22203
22345
  return { result, tool, durationMs: Date.now() - start };
@@ -22990,6 +23132,7 @@ export {
22990
23132
  HybridCompactor,
22991
23133
  IntelligentCompactor,
22992
23134
  MAX_COUNCIL_CONCURRENCY,
23135
+ MODEL_RETRIES,
22993
23136
  OneShotOrchestrator,
22994
23137
  ParallelEternalEngine,
22995
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;