opencode-acp 1.16.0-pr.297.127 → 1.16.0-pr.350.123

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -890,6 +890,7 @@ var VALID_CONFIG_KEYS = /* @__PURE__ */ new Set([
890
890
  "compress.modelMaxLimits",
891
891
  "compress.modelMinLimits",
892
892
  "compress.providers",
893
+ "compress.contextLimitFallback",
893
894
  "compress.nudgeFrequency",
894
895
  "compress.minNudgeContextPercent",
895
896
  "compress.nudgeGrowthTokens",
@@ -913,6 +914,7 @@ var VALID_CONFIG_KEYS = /* @__PURE__ */ new Set([
913
914
  "compress.reasoning",
914
915
  "compress.reasoning.drop",
915
916
  "compress.reasoning.threshold",
917
+ "compress.completionReserveTokens",
916
918
  "gc",
917
919
  "gc.algorithm",
918
920
  "gc.promotionThreshold",
@@ -964,7 +966,11 @@ function validateConfigTypes(config) {
964
966
  errors.push({ key: "storagePath", expected: "string", actual: typeof config.storagePath });
965
967
  }
966
968
  if (config.allowSubAgents !== void 0 && typeof config.allowSubAgents !== "boolean") {
967
- errors.push({ key: "allowSubAgents", expected: "boolean", actual: typeof config.allowSubAgents });
969
+ errors.push({
970
+ key: "allowSubAgents",
971
+ expected: "boolean",
972
+ actual: typeof config.allowSubAgents
973
+ });
968
974
  }
969
975
  if (config.pruneNotification !== void 0) {
970
976
  const validValues = ["off", "minimal", "detailed"];
@@ -1293,6 +1299,20 @@ function validateConfigTypes(config) {
1293
1299
  }
1294
1300
  }
1295
1301
  }
1302
+ if (compress.completionReserveTokens !== void 0 && typeof compress.completionReserveTokens !== "number") {
1303
+ errors.push({
1304
+ key: "compress.completionReserveTokens",
1305
+ expected: "number",
1306
+ actual: typeof compress.completionReserveTokens
1307
+ });
1308
+ }
1309
+ if (typeof compress.completionReserveTokens === "number" && compress.completionReserveTokens < 0) {
1310
+ errors.push({
1311
+ key: "compress.completionReserveTokens",
1312
+ expected: "non-negative number (>= 0)",
1313
+ actual: `${compress.completionReserveTokens}`
1314
+ });
1315
+ }
1296
1316
  if (typeof compress.iterationNudgeThreshold === "number" && compress.iterationNudgeThreshold < 1) {
1297
1317
  errors.push({
1298
1318
  key: "compress.iterationNudgeThreshold",
@@ -1411,12 +1431,20 @@ function validateConfigTypes(config) {
1411
1431
  break;
1412
1432
  case "nudgeForce":
1413
1433
  if (value !== "strong" && value !== "soft") {
1414
- errors.push({ key, expected: "'strong' | 'soft'", actual: JSON.stringify(value) });
1434
+ errors.push({
1435
+ key,
1436
+ expected: "'strong' | 'soft'",
1437
+ actual: JSON.stringify(value)
1438
+ });
1415
1439
  }
1416
1440
  break;
1417
1441
  case "stringArray":
1418
1442
  if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
1419
- errors.push({ key, expected: "string[]", actual: JSON.stringify(value) });
1443
+ errors.push({
1444
+ key,
1445
+ expected: "string[]",
1446
+ actual: JSON.stringify(value)
1447
+ });
1420
1448
  }
1421
1449
  break;
1422
1450
  case "reasoningConfig":
@@ -1451,7 +1479,11 @@ function validateConfigTypes(config) {
1451
1479
  return;
1452
1480
  }
1453
1481
  if (typeof overrides !== "object" || overrides === null || Array.isArray(overrides)) {
1454
- errors.push({ key: prefix, expected: "CompressModelOverrides", actual: typeof overrides });
1482
+ errors.push({
1483
+ key: prefix,
1484
+ expected: "CompressModelOverrides",
1485
+ actual: typeof overrides
1486
+ });
1455
1487
  return;
1456
1488
  }
1457
1489
  const model = overrides;
@@ -1484,7 +1516,11 @@ function validateConfigTypes(config) {
1484
1516
  for (const [providerId, providerValue] of Object.entries(providers)) {
1485
1517
  const prefix = `compress.providers.${providerId}`;
1486
1518
  if (typeof providerValue !== "object" || providerValue === null || Array.isArray(providerValue)) {
1487
- errors.push({ key: prefix, expected: "ProviderOverrides", actual: typeof providerValue });
1519
+ errors.push({
1520
+ key: prefix,
1521
+ expected: "ProviderOverrides",
1522
+ actual: typeof providerValue
1523
+ });
1488
1524
  continue;
1489
1525
  }
1490
1526
  const provider = providerValue;
@@ -1521,6 +1557,20 @@ function validateConfigTypes(config) {
1521
1557
  }
1522
1558
  };
1523
1559
  validateProviderOverrides(compress.providers);
1560
+ if (compress.contextLimitFallback !== void 0 && typeof compress.contextLimitFallback !== "number") {
1561
+ errors.push({
1562
+ key: "compress.contextLimitFallback",
1563
+ expected: "number",
1564
+ actual: typeof compress.contextLimitFallback
1565
+ });
1566
+ }
1567
+ if (typeof compress.contextLimitFallback === "number" && compress.contextLimitFallback < 0) {
1568
+ errors.push({
1569
+ key: "compress.contextLimitFallback",
1570
+ expected: "non-negative number (0 disables the fallback)",
1571
+ actual: `${compress.contextLimitFallback}`
1572
+ });
1573
+ }
1524
1574
  const validValues = ["ask", "allow", "deny"];
1525
1575
  if (compress.permission !== void 0 && !validValues.includes(compress.permission)) {
1526
1576
  errors.push({
@@ -1606,13 +1656,22 @@ function validateConfigTypes(config) {
1606
1656
  });
1607
1657
  } else {
1608
1658
  if (gc.batchCleanup.lowThreshold !== void 0) {
1609
- validateBatchThreshold("gc.batchCleanup.lowThreshold", gc.batchCleanup.lowThreshold);
1659
+ validateBatchThreshold(
1660
+ "gc.batchCleanup.lowThreshold",
1661
+ gc.batchCleanup.lowThreshold
1662
+ );
1610
1663
  }
1611
1664
  if (gc.batchCleanup.highThreshold !== void 0) {
1612
- validateBatchThreshold("gc.batchCleanup.highThreshold", gc.batchCleanup.highThreshold);
1665
+ validateBatchThreshold(
1666
+ "gc.batchCleanup.highThreshold",
1667
+ gc.batchCleanup.highThreshold
1668
+ );
1613
1669
  }
1614
1670
  if (gc.batchCleanup.forceThreshold !== void 0) {
1615
- validateBatchThreshold("gc.batchCleanup.forceThreshold", gc.batchCleanup.forceThreshold);
1671
+ validateBatchThreshold(
1672
+ "gc.batchCleanup.forceThreshold",
1673
+ gc.batchCleanup.forceThreshold
1674
+ );
1616
1675
  }
1617
1676
  }
1618
1677
  }
@@ -1702,6 +1761,7 @@ var defaultConfig = {
1702
1761
  summaryBuffer: true,
1703
1762
  maxContextLimit: "80%",
1704
1763
  minContextLimit: "80%",
1764
+ contextLimitFallback: 128e3,
1705
1765
  nudgeFrequency: 5,
1706
1766
  minNudgeContextPercent: 5,
1707
1767
  iterationNudgeThreshold: 15,
@@ -1872,6 +1932,7 @@ function mergeCompress(base, override) {
1872
1932
  modelMaxLimits: override.modelMaxLimits ?? base.modelMaxLimits,
1873
1933
  modelMinLimits: override.modelMinLimits ?? base.modelMinLimits,
1874
1934
  providers: mergeProviderOverrides(base.providers, override.providers),
1935
+ contextLimitFallback: override.contextLimitFallback ?? base.contextLimitFallback,
1875
1936
  nudgeFrequency: override.nudgeFrequency ?? base.nudgeFrequency,
1876
1937
  minNudgeContextPercent: override.minNudgeContextPercent ?? base.minNudgeContextPercent,
1877
1938
  nudgeGrowthTokens: override.nudgeGrowthTokens,
@@ -1895,7 +1956,8 @@ function mergeCompress(base, override) {
1895
1956
  reasoning: {
1896
1957
  drop: override.reasoning?.drop ?? base.reasoning?.drop ?? DEFAULT_COMPRESS_REASONING.drop,
1897
1958
  threshold: override.reasoning?.threshold ?? base.reasoning?.threshold ?? DEFAULT_COMPRESS_REASONING.threshold
1898
- }
1959
+ },
1960
+ completionReserveTokens: override.completionReserveTokens ?? base.completionReserveTokens
1899
1961
  };
1900
1962
  }
1901
1963
  function mergeCommands(base, override) {
@@ -1933,10 +1995,9 @@ function deepCloneConfig(config) {
1933
1995
  ...provider,
1934
1996
  ...provider.models ? {
1935
1997
  models: Object.fromEntries(
1936
- Object.entries(provider.models).map(([modelId, model]) => [
1937
- modelId,
1938
- { ...model }
1939
- ])
1998
+ Object.entries(provider.models).map(
1999
+ ([modelId, model]) => [modelId, { ...model }]
2000
+ )
1940
2001
  )
1941
2002
  } : {}
1942
2003
  }
@@ -2010,8 +2071,14 @@ function mergeLayer(config, data) {
2010
2071
  ],
2011
2072
  compress: mergeCompress(config.compress, data.compress),
2012
2073
  gc: mergeGC(config.gc, data.gc),
2013
- qualityGate: mergeQualityGate(config.qualityGate, data.qualityGate),
2014
- messageFilters: mergeMessageFilters(config.messageFilters, data.messageFilters)
2074
+ qualityGate: mergeQualityGate(
2075
+ config.qualityGate,
2076
+ data.qualityGate
2077
+ ),
2078
+ messageFilters: mergeMessageFilters(
2079
+ config.messageFilters,
2080
+ data.messageFilters
2081
+ )
2015
2082
  };
2016
2083
  }
2017
2084
  function scheduleParseWarning(ctx, title, message) {
@@ -2635,6 +2702,16 @@ function resetOnCompaction(state) {
2635
2702
  nextRef: 1
2636
2703
  };
2637
2704
  }
2705
+ function resolveEffectiveContextLimit(state, config) {
2706
+ if (typeof state.modelContextLimit === "number" && state.modelContextLimit > 0) {
2707
+ return { limit: state.modelContextLimit, source: "model" };
2708
+ }
2709
+ const fallback = config.compress.contextLimitFallback;
2710
+ if (typeof fallback === "number" && fallback > 0) {
2711
+ return { limit: fallback, source: "fallback" };
2712
+ }
2713
+ return void 0;
2714
+ }
2638
2715
 
2639
2716
  // lib/state/persistence.ts
2640
2717
  function getDefaultStorageDir() {
@@ -4506,6 +4583,24 @@ var SessionStateRegistry = class {
4506
4583
  hydrateModelLimitsFromClient(client) {
4507
4584
  return this.catalog.hydrateFromClient(client);
4508
4585
  }
4586
+ // [FIX #346] The init-time seed (above) is fire-and-forget and races
4587
+ // server readiness: in headless spawn+resume mode the provider-config
4588
+ // call can fail before the server is up, leaving the catalog empty for
4589
+ // the process's lifetime. During a request the server is guaranteed up
4590
+ // (we are inside its pipeline), so on a catalog miss we retry hydration
4591
+ // once per process before giving up (the fallback limit then applies).
4592
+ // The in-flight promise (not a boolean) lets concurrent callers await the
4593
+ // same hydration instead of skipping it.
4594
+ lazyHydration;
4595
+ async hydrateAndResolve(client, providerId, modelId) {
4596
+ const existing = this.catalog.resolve(providerId, modelId);
4597
+ if (existing !== void 0) {
4598
+ return existing;
4599
+ }
4600
+ this.lazyHydration ??= this.catalog.hydrateFromClient(client);
4601
+ await this.lazyHydration;
4602
+ return this.catalog.resolve(providerId, modelId);
4603
+ }
4509
4604
  get(sessionId) {
4510
4605
  return this.states.get(sessionId);
4511
4606
  }
@@ -4599,7 +4694,8 @@ function createSessionState() {
4599
4694
  modelID: void 0,
4600
4695
  systemPromptTokens: void 0,
4601
4696
  storageDir: void 0,
4602
- qualityGateRetryPending: false
4697
+ qualityGateRetryPending: false,
4698
+ noContextLimitWarned: false
4603
4699
  };
4604
4700
  }
4605
4701
  function resetSessionState(state) {
@@ -4642,6 +4738,7 @@ function resetSessionState(state) {
4642
4738
  state.systemPromptTokens = void 0;
4643
4739
  state.storageDir = void 0;
4644
4740
  state.qualityGateRetryPending = false;
4741
+ state.noContextLimitWarned = false;
4645
4742
  }
4646
4743
  async function ensureSessionInitialized(client, state, sessionId, logger, messages, config, projectDir) {
4647
4744
  if (state.sessionId === sessionId) {
@@ -6657,6 +6754,7 @@ function getModelInfo(messages) {
6657
6754
  };
6658
6755
  }
6659
6756
  function resolveContextTokenLimit(config, state, providerId, modelId, threshold) {
6757
+ const effectiveLimit = resolveEffectiveContextLimit(state, config);
6660
6758
  const parseLimitValue = (limit) => {
6661
6759
  if (limit === void 0) {
6662
6760
  return void 0;
@@ -6664,7 +6762,7 @@ function resolveContextTokenLimit(config, state, providerId, modelId, threshold)
6664
6762
  if (typeof limit === "number") {
6665
6763
  return limit;
6666
6764
  }
6667
- if (!limit.endsWith("%") || state.modelContextLimit === void 0) {
6765
+ if (!limit.endsWith("%") || effectiveLimit === void 0) {
6668
6766
  return void 0;
6669
6767
  }
6670
6768
  const parsedPercent = parseFloat(limit.slice(0, -1));
@@ -6673,7 +6771,7 @@ function resolveContextTokenLimit(config, state, providerId, modelId, threshold)
6673
6771
  }
6674
6772
  const roundedPercent = Math.round(parsedPercent);
6675
6773
  const clampedPercent = Math.max(0, Math.min(100, roundedPercent));
6676
- return Math.round(clampedPercent / 100 * state.modelContextLimit);
6774
+ return Math.round(clampedPercent / 100 * effectiveLimit.limit);
6677
6775
  };
6678
6776
  if (threshold === "max") {
6679
6777
  const nestedLimit = resolveCompressOverrides(config, providerId, modelId).maxContextLimit;
@@ -6724,11 +6822,12 @@ function isContextOverLimits(config, state, providerId, modelId, messages) {
6724
6822
  if (!overMaxLimit) break;
6725
6823
  }
6726
6824
  }
6825
+ const effectiveLimit = resolveEffectiveContextLimit(state, config);
6727
6826
  return {
6728
6827
  overMaxLimit,
6729
6828
  overMinLimit,
6730
6829
  currentTokens,
6731
- modelContextLimit: state.modelContextLimit
6830
+ modelContextLimit: effectiveLimit?.limit
6732
6831
  };
6733
6832
  }
6734
6833
  ensureBuiltinTriggerPolicyRegistered();
@@ -8807,8 +8906,9 @@ function createDecompressTool(factoryCtx) {
8807
8906
  async execute(args, toolCtx) {
8808
8907
  const ctx = resolveToolContext(factoryCtx, toolCtx.sessionID);
8809
8908
  const { rawMessages } = await prepareDecompressSession(ctx, toolCtx);
8810
- const contextUsageBefore = ctx.state.modelContextLimit ? Math.round(
8811
- getCurrentTokenUsage(ctx.state, rawMessages) / ctx.state.modelContextLimit * 100
8909
+ const effectiveLimitBefore = resolveEffectiveContextLimit(ctx.state, ctx.config);
8910
+ const contextUsageBefore = effectiveLimitBefore ? Math.round(
8911
+ getCurrentTokenUsage(ctx.state, rawMessages) / effectiveLimitBefore.limit * 100
8812
8912
  ) : void 0;
8813
8913
  const resolved = resolveTargets(args, ctx.state, rawMessages, ctx.logger);
8814
8914
  if (!resolved.ok) {
@@ -8872,8 +8972,9 @@ function createDecompressTool(factoryCtx) {
8872
8972
  0,
8873
8973
  ctx.state.stats.totalPruneTokens - restoredTokens
8874
8974
  );
8875
- const contextUsageAfter = ctx.state.modelContextLimit ? Math.round(
8876
- getCurrentTokenUsage(ctx.state, rawMessages) / ctx.state.modelContextLimit * 100
8975
+ const effectiveLimitAfter = resolveEffectiveContextLimit(ctx.state, ctx.config);
8976
+ const contextUsageAfter = effectiveLimitAfter ? Math.round(
8977
+ getCurrentTokenUsage(ctx.state, rawMessages) / effectiveLimitAfter.limit * 100
8877
8978
  ) : void 0;
8878
8979
  await finalizeDecompressSession(ctx);
8879
8980
  const restoredContentPreview = buildRestoredContentPreview(
@@ -9530,7 +9631,7 @@ import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
9530
9631
  import { join as join4 } from "path";
9531
9632
  import { existsSync as existsSync4 } from "fs";
9532
9633
  import { homedir as homedir3 } from "os";
9533
- var LOG_VERSION = true ? "1.16.0-pr.297.127" : "dev";
9634
+ var LOG_VERSION = true ? "1.16.0-pr.350.123" : "dev";
9534
9635
  var LEVEL_RANK = {
9535
9636
  debug: 10,
9536
9637
  info: 20,
@@ -10354,6 +10455,8 @@ var MIN_OUTPUT_TOKENS = 1e3;
10354
10455
  var KEEP_PREFIX_CHARS = 2e3;
10355
10456
  var KEEP_SUFFIX_CHARS = 2e3;
10356
10457
  var PROTECT_RECENT_MESSAGES = 3;
10458
+ var OUTPUT_RESERVE_TOKENS = 16384;
10459
+ var overheadErrorLogged = /* @__PURE__ */ new Set();
10357
10460
  function parseGcThreshold(threshold, modelContextLimit) {
10358
10461
  if (typeof threshold === "number") return threshold;
10359
10462
  const str = threshold ?? "100%";
@@ -10362,10 +10465,26 @@ function parseGcThreshold(threshold, modelContextLimit) {
10362
10465
  return modelContextLimit;
10363
10466
  }
10364
10467
  function truncateLargeToolOutputs(state, config, logger, messages) {
10365
- if (!state.modelContextLimit) return;
10468
+ const effective = resolveEffectiveContextLimit(state, config);
10469
+ if (!effective) return;
10366
10470
  const currentTokens = getCurrentTokenUsage(state, messages);
10367
10471
  if (currentTokens === 0) return;
10368
- const threshold = parseGcThreshold(config.gc?.majorGcThresholdPercent, state.modelContextLimit);
10472
+ const configuredThreshold = parseGcThreshold(config.gc?.majorGcThresholdPercent, effective.limit);
10473
+ const overhead = (state.systemPromptTokens ?? 0) + OUTPUT_RESERVE_TOKENS;
10474
+ const threshold = Math.min(configuredThreshold, effective.limit - overhead);
10475
+ if (threshold <= 0) {
10476
+ const sessionKey = state.sessionId ?? "unknown";
10477
+ if (!overheadErrorLogged.has(sessionKey)) {
10478
+ overheadErrorLogged.add(sessionKey);
10479
+ logger.error("ACP: model context window too small to fit overhead", {
10480
+ session: state.sessionId,
10481
+ limit: effective.limit,
10482
+ contextLimitSource: effective.source,
10483
+ overhead
10484
+ });
10485
+ }
10486
+ return;
10487
+ }
10369
10488
  if (currentTokens < threshold) return;
10370
10489
  const protectedIndex = messages.length - PROTECT_RECENT_MESSAGES;
10371
10490
  const candidates = [];
@@ -10410,9 +10529,158 @@ function truncateLargeToolOutputs(state, config, logger, messages) {
10410
10529
  truncatedCount,
10411
10530
  estimatedSavedTokens: Math.round(savedTokens),
10412
10531
  currentTokens,
10413
- threshold
10532
+ threshold,
10533
+ contextLimit: effective.limit,
10534
+ contextLimitSource: effective.source
10535
+ });
10536
+ }
10537
+ }
10538
+
10539
+ // lib/messages/enforce-budget.ts
10540
+ var DEFAULT_COMPLETION_RESERVE_TOKENS = 32768;
10541
+ var TRUNCATION_MARKER2 = "[truncated for context space";
10542
+ var KEEP_PREFIX_CHARS2 = 2e3;
10543
+ var KEEP_SUFFIX_CHARS2 = 2e3;
10544
+ var PROTECT_RECENT_MESSAGES2 = 3;
10545
+ var MIN_CLEAR_TOKENS = 200;
10546
+ function resolveContextWindow(state) {
10547
+ if (typeof state.modelContextLimit === "number" && state.modelContextLimit > 0) {
10548
+ return state.modelContextLimit;
10549
+ }
10550
+ return void 0;
10551
+ }
10552
+ function estimateWireTokens(state, messages) {
10553
+ const base = getCurrentTokenUsage(state, messages);
10554
+ if (base > 0) {
10555
+ let baseAssistant = -1;
10556
+ for (let i = messages.length - 1; i >= 0; i--) {
10557
+ if (messages[i].info.role !== "assistant") continue;
10558
+ const tokens = messages[i].info.tokens;
10559
+ if ((tokens?.input || 0) <= 0 && (tokens?.output || 0) <= 0) continue;
10560
+ baseAssistant = i;
10561
+ break;
10562
+ }
10563
+ if (baseAssistant >= 0) {
10564
+ let additions = 0;
10565
+ for (let i = baseAssistant + 1; i < messages.length; i++) {
10566
+ additions += countAllMessageTokens(messages[i]);
10567
+ }
10568
+ return base + additions;
10569
+ }
10570
+ }
10571
+ let total = 0;
10572
+ for (const m of messages) total += countAllMessageTokens(m);
10573
+ return total + (state.systemPromptTokens ?? 0);
10574
+ }
10575
+ function enforceContextBudget(state, config, logger, messages) {
10576
+ const window = resolveContextWindow(state);
10577
+ if (window === void 0) return void 0;
10578
+ const configuredReserve = config.compress?.completionReserveTokens;
10579
+ const reserve = typeof configuredReserve === "number" && Number.isFinite(configuredReserve) && configuredReserve >= 0 ? configuredReserve : DEFAULT_COMPLETION_RESERVE_TOKENS;
10580
+ const budget = window - reserve;
10581
+ if (budget <= 0) return void 0;
10582
+ const estimatedTokens = estimateWireTokens(state, messages);
10583
+ if (estimatedTokens <= budget) {
10584
+ return {
10585
+ applied: false,
10586
+ window,
10587
+ reserve,
10588
+ budget,
10589
+ estimatedTokens,
10590
+ finalEstimate: estimatedTokens,
10591
+ truncatedCount: 0,
10592
+ clearedCount: 0
10593
+ };
10594
+ }
10595
+ const protectedIndex = messages.length - PROTECT_RECENT_MESSAGES2;
10596
+ const protectedTools = new Set(config.compress?.protectedTools ?? []);
10597
+ const candidates = [];
10598
+ for (let mi = 0; mi < protectedIndex; mi++) {
10599
+ if (mi === 0 && messages[mi].info.role === "user") continue;
10600
+ const msg = messages[mi];
10601
+ const parts = Array.isArray(msg.parts) ? msg.parts : [];
10602
+ for (const part of parts) {
10603
+ if (part?.type !== "tool") continue;
10604
+ if (part.state?.status !== "completed") continue;
10605
+ if (part.tool === "compress") continue;
10606
+ if (protectedTools.has(part.tool)) continue;
10607
+ const content = extractCompletedToolOutput(part);
10608
+ if (content === void 0) continue;
10609
+ if (content === COMPACTED_TOOL_OUTPUT_PLACEHOLDER) continue;
10610
+ const tokens = countTokens2(content);
10611
+ if (tokens <= 0) continue;
10612
+ candidates.push({ part, content, tokens, index: mi });
10613
+ }
10614
+ }
10615
+ let saved = 0;
10616
+ let truncatedCount = 0;
10617
+ let clearedCount = 0;
10618
+ const truncatable = candidates.filter(
10619
+ (c) => !c.content.includes(TRUNCATION_MARKER2) && c.content.length > KEEP_PREFIX_CHARS2 + KEEP_SUFFIX_CHARS2
10620
+ ).sort((a, b) => b.tokens - a.tokens);
10621
+ for (const c of truncatable) {
10622
+ if (estimatedTokens - saved <= budget) break;
10623
+ const prefix = c.content.slice(0, KEEP_PREFIX_CHARS2);
10624
+ const suffix = c.content.slice(-KEEP_SUFFIX_CHARS2);
10625
+ const truncated = prefix + `
10626
+
10627
+ ...${TRUNCATION_MARKER2} \u2014 original ~${c.tokens} tokens]...
10628
+
10629
+ ` + suffix;
10630
+ if (truncated.length >= c.content.length) continue;
10631
+ c.part.state.output = truncated;
10632
+ saved += c.tokens - countTokens2(truncated);
10633
+ truncatedCount++;
10634
+ }
10635
+ if (estimatedTokens - saved > budget) {
10636
+ const clearable = candidates.filter((c) => {
10637
+ const out = extractCompletedToolOutput(c.part);
10638
+ return out !== void 0 && out !== COMPACTED_TOOL_OUTPUT_PLACEHOLDER && countTokens2(out) > MIN_CLEAR_TOKENS;
10639
+ }).sort((a, b) => a.index - b.index);
10640
+ for (const c of clearable) {
10641
+ if (estimatedTokens - saved <= budget) break;
10642
+ const current = extractCompletedToolOutput(c.part);
10643
+ if (current === void 0 || current === COMPACTED_TOOL_OUTPUT_PLACEHOLDER) continue;
10644
+ c.part.state.output = COMPACTED_TOOL_OUTPUT_PLACEHOLDER;
10645
+ saved += countTokens2(current) - countTokens2(COMPACTED_TOOL_OUTPUT_PLACEHOLDER);
10646
+ clearedCount++;
10647
+ }
10648
+ }
10649
+ const finalEstimate = Math.max(0, estimatedTokens - saved);
10650
+ if (truncatedCount > 0 || clearedCount > 0) {
10651
+ logger.warn("Context budget guard: pruned tool outputs to fit the request", {
10652
+ session: state.sessionId,
10653
+ estimatedTokens: Math.round(estimatedTokens),
10654
+ budget,
10655
+ window,
10656
+ reserve,
10657
+ truncatedCount,
10658
+ clearedCount,
10659
+ estimatedSavedTokens: Math.round(saved),
10660
+ finalEstimate: Math.round(finalEstimate)
10414
10661
  });
10415
10662
  }
10663
+ if (finalEstimate > budget) {
10664
+ logger.warn(
10665
+ "Context budget guard: still over budget after pruning all compressible tool outputs; the request may be rejected by the model",
10666
+ {
10667
+ session: state.sessionId,
10668
+ finalEstimate: Math.round(finalEstimate),
10669
+ budget,
10670
+ window
10671
+ }
10672
+ );
10673
+ }
10674
+ return {
10675
+ applied: truncatedCount > 0 || clearedCount > 0,
10676
+ window,
10677
+ reserve,
10678
+ budget,
10679
+ estimatedTokens,
10680
+ finalEstimate,
10681
+ truncatedCount,
10682
+ clearedCount
10683
+ };
10416
10684
  }
10417
10685
 
10418
10686
  // lib/commands/context.ts
@@ -11371,11 +11639,12 @@ function runBatchCleanup(state, config, logger, messages) {
11371
11639
  mergedCount: 0,
11372
11640
  savedTokens: 0
11373
11641
  };
11374
- if (!state.modelContextLimit || state.modelContextLimit <= 0) {
11642
+ const effective = resolveEffectiveContextLimit(state, config);
11643
+ if (!effective) {
11375
11644
  return noop;
11376
11645
  }
11377
11646
  const currentTokens = getCurrentTokenUsage(state, messages);
11378
- if (currentTokens < state.modelContextLimit) {
11647
+ if (currentTokens < effective.limit) {
11379
11648
  return noop;
11380
11649
  }
11381
11650
  const maxMergedLength = config.gc.maxOldGenSummaryLength;
@@ -11392,7 +11661,8 @@ function runBatchCleanup(state, config, logger, messages) {
11392
11661
  mergedCount: result.mergedCount,
11393
11662
  savedTokens: result.savedTokens,
11394
11663
  currentTokens,
11395
- contextLimit: state.modelContextLimit
11664
+ contextLimit: effective.limit,
11665
+ contextLimitSource: effective.source
11396
11666
  });
11397
11667
  return {
11398
11668
  tier: 3,
@@ -11426,11 +11696,6 @@ function createSystemPromptHandler(registry4, logger, config, prompts) {
11426
11696
  input.model?.limit?.context
11427
11697
  );
11428
11698
  const state = input.sessionID ? registry4.get(input.sessionID) : void 0;
11429
- if (state && input.model?.limit?.context) {
11430
- state.modelContextLimit = input.model.limit.context;
11431
- state.modelProviderID = input.model?.providerID;
11432
- state.modelID = input.model?.id;
11433
- }
11434
11699
  if (!state || state.isSubAgent && !config.allowSubAgents) {
11435
11700
  return;
11436
11701
  }
@@ -11439,6 +11704,23 @@ function createSystemPromptHandler(registry4, logger, config, prompts) {
11439
11704
  logger.info("Skipping DCP system prompt injection for internal agent");
11440
11705
  return;
11441
11706
  }
11707
+ if (input.model?.limit?.context) {
11708
+ const limit = input.model.limit.context;
11709
+ const providerID = input.model?.providerID;
11710
+ const modelID = input.model?.id;
11711
+ const changed = state.modelContextLimit !== limit || providerID !== void 0 && state.modelProviderID !== providerID || modelID !== void 0 && state.modelID !== modelID;
11712
+ state.modelContextLimit = limit;
11713
+ if (providerID !== void 0) {
11714
+ state.modelProviderID = providerID;
11715
+ }
11716
+ if (modelID !== void 0) {
11717
+ state.modelID = modelID;
11718
+ }
11719
+ if (changed) {
11720
+ saveSessionState(state, logger).catch(() => {
11721
+ });
11722
+ }
11723
+ }
11442
11724
  const effectivePermission = compressPermission(state, config);
11443
11725
  if (effectivePermission === "deny") {
11444
11726
  return;
@@ -11483,10 +11765,17 @@ function createChatMessageTransformHandler(client, registry4, logger, config, pr
11483
11765
  config
11484
11766
  );
11485
11767
  const requestModel = lastUserMessage.info.model;
11486
- const requestModelLimit = registry4.resolveModelLimit(
11768
+ let requestModelLimit = registry4.resolveModelLimit(
11487
11769
  requestModel?.providerID,
11488
11770
  requestModel?.modelID
11489
11771
  );
11772
+ if (requestModelLimit === void 0 && requestModel?.providerID && requestModel?.modelID) {
11773
+ requestModelLimit = await registry4.hydrateAndResolve(
11774
+ client,
11775
+ requestModel.providerID,
11776
+ requestModel.modelID
11777
+ );
11778
+ }
11490
11779
  const prevModelID = state.modelID;
11491
11780
  if (requestModelLimit !== void 0) {
11492
11781
  state.modelContextLimit = requestModelLimit;
@@ -11516,6 +11805,16 @@ function createChatMessageTransformHandler(client, registry4, logger, config, pr
11516
11805
  });
11517
11806
  }
11518
11807
  await updatePerTurnState(state, logger, messages);
11808
+ if (state.modelContextLimit === void 0 && !state.noContextLimitWarned && requestModel?.providerID && requestModel?.modelID && registry4.resolveModelLimit(requestModel.providerID, requestModel.modelID) === void 0) {
11809
+ state.noContextLimitWarned = true;
11810
+ logger.warn(
11811
+ 'Model reports no context window and the catalog has no entry for it; all percentage thresholds (min/max/emergency, GC) and the context-budget guard are disabled. Set the model limit in opencode.json (e.g. "limit": {"context": 262144, "output": 16384}) to enable them (also fixes the 32000 max_tokens fallback); an absolute compress.maxContextLimit in acp.jsonc only enables proactive nudges, not the guard.',
11812
+ {
11813
+ session: state.sessionId,
11814
+ model: `${requestModel.providerID}/${requestModel.modelID}`
11815
+ }
11816
+ );
11817
+ }
11519
11818
  }
11520
11819
  syncCompressPermissionState(state, config, hostPermissions, output.messages);
11521
11820
  if (state.isSubAgent && !config.allowSubAgents) {
@@ -11541,10 +11840,11 @@ function createChatMessageTransformHandler(client, registry4, logger, config, pr
11541
11840
  }
11542
11841
  }
11543
11842
  ensureBuiltinFiltersRegistered();
11843
+ const effectiveLimit = resolveEffectiveContextLimit(state, config);
11544
11844
  applyMessageFilters(output.messages, config.messageFilters, logger, {
11545
11845
  sessionId: state.sessionId ?? "",
11546
11846
  isSubAgent: state.isSubAgent,
11547
- modelContextLimit: state.modelContextLimit
11847
+ modelContextLimit: effectiveLimit?.limit
11548
11848
  });
11549
11849
  cacheSystemPromptTokens(state, output.messages);
11550
11850
  assignMessageRefs(state, output.messages);
@@ -11564,6 +11864,7 @@ function createChatMessageTransformHandler(client, registry4, logger, config, pr
11564
11864
  const prePruneTokens = getCurrentTokenUsage(state, output.messages);
11565
11865
  prune(state, logger, config, output.messages);
11566
11866
  truncateLargeToolOutputs(state, config, logger, output.messages);
11867
+ enforceContextBudget(state, config, logger, output.messages);
11567
11868
  hideConsumedCompressCalls(state, output.messages);
11568
11869
  assignMessageRefs(state, output.messages);
11569
11870
  const compressionPriorities = buildPriorityMap(config, state, output.messages);
@@ -11595,14 +11896,31 @@ ${text}`);
11595
11896
  stripStaleMetadata(output.messages);
11596
11897
  dropEmptyMessages(output.messages);
11597
11898
  const postTokens = getCurrentTokenUsage(state, output.messages);
11899
+ if (postTokens !== void 0 && effectiveLimit) {
11900
+ const budget = effectiveLimit.limit - (state.systemPromptTokens ?? 0) - OUTPUT_RESERVE_TOKENS;
11901
+ if (postTokens > budget) {
11902
+ logger.error(
11903
+ "ACP hard guard: context exceeds model budget after in-flight reduction",
11904
+ {
11905
+ session: state.sessionId,
11906
+ postTokens,
11907
+ budget,
11908
+ contextLimit: effectiveLimit.limit,
11909
+ contextLimitSource: effectiveLimit.source,
11910
+ hint: "request will likely be rejected; run /compact or start a new session"
11911
+ }
11912
+ );
11913
+ }
11914
+ }
11598
11915
  logger.info("Chat transform complete", {
11599
11916
  session: state.sessionId,
11600
11917
  model: state.modelID,
11601
11918
  messages: output.messages.length,
11602
11919
  prePruneTokens,
11603
11920
  postTokens,
11604
- contextLimit: state.modelContextLimit,
11605
- usagePct: postTokens !== void 0 && state.modelContextLimit ? `${(postTokens / state.modelContextLimit * 100).toFixed(1)}%` : void 0,
11921
+ contextLimit: effectiveLimit?.limit,
11922
+ contextLimitSource: effectiveLimit?.source,
11923
+ usagePct: postTokens !== void 0 && effectiveLimit ? `${(postTokens / effectiveLimit.limit * 100).toFixed(1)}%` : void 0,
11606
11924
  nudged: state.nudges.shouldInjectThisTurn
11607
11925
  });
11608
11926
  if (state.sessionId) {
@@ -11634,12 +11952,7 @@ function createCommandExecuteHandler(client, registry4, logger, config, workingD
11634
11952
  path: { id: input.sessionID }
11635
11953
  });
11636
11954
  const messages = filterMessages(messagesResponse.data || messagesResponse);
11637
- const state = await registry4.getOrCreate(
11638
- client,
11639
- input.sessionID,
11640
- messages,
11641
- config
11642
- );
11955
+ const state = await registry4.getOrCreate(client, input.sessionID, messages, config);
11643
11956
  syncCompressPermissionState(state, config, hostPermissions, messages);
11644
11957
  const commandCtx = {
11645
11958
  client,
@@ -11653,7 +11966,7 @@ function createCommandExecuteHandler(client, registry4, logger, config, workingD
11653
11966
  const sub = input.arguments?.trim().toLowerCase();
11654
11967
  if (sub === "stats" || sub === "status" || sub === "") {
11655
11968
  await handleStatsCommand(commandCtx);
11656
- return;
11969
+ throw new Error("__DCP_CONTEXT_HANDLED__");
11657
11970
  }
11658
11971
  if (sub === "export" || sub.startsWith("export ")) {
11659
11972
  const exportArgs = input.arguments?.trim().slice("export".length).trim() || "";
@@ -11661,16 +11974,11 @@ function createCommandExecuteHandler(client, registry4, logger, config, workingD
11661
11974
  throw new Error("__DCP_CONTEXT_HANDLED__");
11662
11975
  }
11663
11976
  if (sub === "help") {
11664
- await sendIgnoredMessage(
11665
- client,
11666
- input.sessionID,
11667
- buildHelpText(),
11668
- {},
11669
- logger
11670
- );
11977
+ await sendIgnoredMessage(client, input.sessionID, buildHelpText(), {}, logger);
11671
11978
  throw new Error("__DCP_CONTEXT_HANDLED__");
11672
11979
  }
11673
11980
  await handleContextCommand(commandCtx);
11981
+ throw new Error("__DCP_CONTEXT_HANDLED__");
11674
11982
  }
11675
11983
  };
11676
11984
  }
@@ -11741,9 +12049,7 @@ function createEventHandler(registry4, logger) {
11741
12049
  return;
11742
12050
  }
11743
12051
  if (typeof part.callID === "string" && typeof part.messageID === "string") {
11744
- timing.startsByCallId.delete(
11745
- buildCompressionTimingKey(part.messageID, part.callID)
11746
- );
12052
+ timing.startsByCallId.delete(buildCompressionTimingKey(part.messageID, part.callID));
11747
12053
  }
11748
12054
  };
11749
12055
  }
@@ -12020,7 +12326,7 @@ var server = (async (ctx) => {
12020
12326
  }
12021
12327
  const logger = new Logger(config.debug, config.debug ? "debug" : config.logLevel);
12022
12328
  logger.info("ACP plugin initialized", {
12023
- version: true ? "1.16.0-pr.297.127" : "dev",
12329
+ version: true ? "1.16.0-pr.350.123" : "dev",
12024
12330
  workspace: ctx.directory,
12025
12331
  logLevel: logger.level,
12026
12332
  debug: config.debug,