@oh-my-pi/pi-coding-agent 17.0.8 → 17.1.0
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/CHANGELOG.md +87 -1
- package/README.md +1 -1
- package/dist/cli.js +5495 -5021
- package/dist/types/async/job-manager.d.ts +37 -1
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/bench-cli.d.ts +41 -0
- package/dist/types/commands/bench.d.ts +15 -3
- package/dist/types/commands/launch.d.ts +4 -0
- package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +30 -1
- package/dist/types/config/models-config-schema.d.ts +18 -6
- package/dist/types/config/models-config.d.ts +14 -5
- package/dist/types/config/provider-globals.d.ts +2 -2
- package/dist/types/config/service-tier.d.ts +5 -1
- package/dist/types/config/settings-schema.d.ts +350 -50
- package/dist/types/config/settings.d.ts +7 -0
- package/dist/types/dap/client.d.ts +20 -0
- package/dist/types/exec/bash-executor.d.ts +38 -0
- package/dist/types/exec/direnv.d.ts +33 -0
- package/dist/types/export/html/args.d.ts +15 -0
- package/dist/types/export/html/index.d.ts +11 -26
- package/dist/types/export/html/web-palette.d.ts +55 -116
- package/dist/types/extensibility/extensions/loader.d.ts +3 -0
- package/dist/types/extensibility/extensions/types.d.ts +17 -2
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
- package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +14 -1
- package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
- package/dist/types/live/controller.d.ts +40 -0
- package/dist/types/live/protocol.d.ts +85 -0
- package/dist/types/live/protocol.test.d.ts +1 -0
- package/dist/types/live/transport.d.ts +48 -0
- package/dist/types/live/visualizer.d.ts +33 -0
- package/dist/types/lsp/client.d.ts +18 -1
- package/dist/types/mcp/config-writer.test.d.ts +1 -0
- package/dist/types/mcp/manager.d.ts +6 -2
- package/dist/types/mcp/oauth-discovery.d.ts +2 -0
- package/dist/types/mcp/render.d.ts +2 -2
- package/dist/types/mcp/smithery-auth.d.ts +1 -1
- package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
- package/dist/types/mcp/tool-bridge.d.ts +7 -3
- package/dist/types/mcp/types.d.ts +6 -0
- package/dist/types/memory-backend/tool-names.d.ts +2 -0
- package/dist/types/modes/components/login-dialog.d.ts +2 -2
- package/dist/types/modes/components/oauth-selector.d.ts +8 -0
- package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
- package/dist/types/modes/components/settings-defs.d.ts +8 -1
- package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
- package/dist/types/modes/components/tool-execution.d.ts +9 -1
- package/dist/types/modes/controllers/command-controller.d.ts +1 -1
- package/dist/types/modes/controllers/event-controller.d.ts +6 -1
- package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
- package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
- package/dist/types/modes/interactive-mode.d.ts +5 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
- package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
- package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
- package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
- package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
- package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/modes/utils/context-usage.d.ts +12 -2
- package/dist/types/plan-mode/plan-files.d.ts +10 -0
- package/dist/types/registry/agent-lifecycle.d.ts +21 -9
- package/dist/types/registry/agent-registry.d.ts +11 -4
- package/dist/types/sdk.d.ts +15 -1
- package/dist/types/secrets/index.d.ts +15 -2
- package/dist/types/secrets/obfuscator.d.ts +113 -15
- package/dist/types/session/acp-permission-gate.d.ts +19 -0
- package/dist/types/session/agent-session-events.d.ts +84 -0
- package/dist/types/session/agent-session-types.d.ts +314 -0
- package/dist/types/session/agent-session.d.ts +120 -750
- package/dist/types/session/async-job-delivery.d.ts +28 -0
- package/dist/types/session/bash-runner.d.ts +75 -0
- package/dist/types/session/blob-store.d.ts +11 -1
- package/dist/types/session/blob-store.test.d.ts +1 -0
- package/dist/types/session/checkpoint-entries.d.ts +28 -0
- package/dist/types/session/eval-runner.d.ts +49 -0
- package/dist/types/session/irc-bridge.d.ts +46 -0
- package/dist/types/session/messages.d.ts +22 -0
- package/dist/types/session/model-controls.d.ts +180 -0
- package/dist/types/session/prewalk.d.ts +46 -0
- package/dist/types/session/queued-messages.d.ts +22 -0
- package/dist/types/session/redis-session-storage.d.ts +1 -0
- package/dist/types/session/retry-fallback-chains.d.ts +74 -0
- package/dist/types/session/role-models.d.ts +15 -0
- package/dist/types/session/session-advisors.d.ts +228 -0
- package/dist/types/session/session-entries.d.ts +8 -0
- package/dist/types/session/session-handoff.d.ts +70 -0
- package/dist/types/session/session-history-format.d.ts +6 -1
- package/dist/types/session/session-loader.d.ts +6 -4
- package/dist/types/session/session-maintenance.d.ts +246 -0
- package/dist/types/session/session-manager.d.ts +41 -0
- package/dist/types/session/session-memory.d.ts +56 -0
- package/dist/types/session/session-provider-boundary.d.ts +62 -0
- package/dist/types/session/session-stats.d.ts +48 -0
- package/dist/types/session/session-tools.d.ts +143 -0
- package/dist/types/session/session-workspace.d.ts +32 -0
- package/dist/types/session/stream-guards.d.ts +44 -0
- package/dist/types/session/todo-tracker.d.ts +56 -0
- package/dist/types/session/ttsr-coordinator.d.ts +51 -0
- package/dist/types/session/turn-recovery.d.ts +225 -0
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/task/executor.d.ts +4 -1
- package/dist/types/task/output-manager.d.ts +3 -1
- package/dist/types/task/types.d.ts +14 -0
- package/dist/types/tools/approval.d.ts +1 -0
- package/dist/types/tools/browser/launch.d.ts +5 -0
- package/dist/types/tools/hub/jobs.d.ts +1 -1
- package/dist/types/tools/image-gen.d.ts +4 -4
- package/dist/types/tools/image-providers.d.ts +38 -0
- package/dist/types/tools/index.d.ts +8 -0
- package/dist/types/tools/path-utils.d.ts +8 -0
- package/dist/types/tools/report-tool-issue.d.ts +24 -9
- package/dist/types/tools/todo.d.ts +10 -4
- package/dist/types/tools/xdev.d.ts +5 -1
- package/dist/types/tts/streaming-player.d.ts +14 -0
- package/dist/types/tts/vocalizer.d.ts +5 -0
- package/dist/types/utils/jj.d.ts +29 -0
- package/dist/types/utils/lang-from-path.d.ts +1 -0
- package/dist/types/utils/title-generator.d.ts +30 -0
- package/dist/types/vibe/runtime.d.ts +36 -8
- package/dist/types/web/search/index.d.ts +1 -1
- package/dist/types/web/search/provider.d.ts +15 -5
- package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
- package/dist/types/web/search/types.d.ts +95 -1
- package/package.json +13 -12
- package/scripts/generate-share-viewer.ts +4 -6
- package/scripts/legacy-pi-virtual-module.ts +1 -1
- package/src/advisor/__tests__/advisor.test.ts +643 -0
- package/src/advisor/runtime.ts +144 -43
- package/src/async/job-manager.ts +90 -2
- package/src/cli/args.ts +2 -0
- package/src/cli/auth-gateway-cli.ts +18 -3
- package/src/cli/bench-cli.ts +395 -29
- package/src/cli/flag-tables.ts +3 -0
- package/src/cli/gc-cli.ts +2 -2
- package/src/cli/grievances-cli.ts +2 -2
- package/src/cli/usage-cli.ts +11 -2
- package/src/collab/host.ts +3 -2
- package/src/commands/bench.ts +18 -4
- package/src/commands/launch.ts +4 -0
- package/src/config/__tests__/model-registry.test.ts +147 -0
- package/src/config/model-registry.ts +50 -2
- package/src/config/model-resolver.ts +0 -1
- package/src/config/models-config-schema.ts +15 -4
- package/src/config/provider-globals.ts +9 -9
- package/src/config/service-tier.ts +26 -1
- package/src/config/settings-schema.ts +250 -54
- package/src/config/settings.ts +81 -2
- package/src/dap/client.ts +37 -4
- package/src/edit/index.ts +49 -12
- package/src/eval/py/executor.ts +103 -33
- package/src/exec/bash-executor.ts +89 -6
- package/src/exec/direnv.ts +145 -0
- package/src/export/html/args.ts +20 -0
- package/src/export/html/index.ts +45 -47
- package/src/export/html/template.css +19 -1
- package/src/export/html/template.html +6 -0
- package/src/export/html/template.js +21 -0
- package/src/export/html/tool-views.generated.js +31 -31
- package/src/export/html/web-palette.ts +116 -132
- package/src/export/share.ts +249 -49
- package/src/extensibility/extensions/loader.ts +29 -1
- package/src/extensibility/extensions/runner.ts +6 -0
- package/src/extensibility/extensions/types.ts +33 -2
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
- package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +870 -311
- package/src/hindsight/state.ts +64 -6
- package/src/internal-urls/local-protocol.ts +2 -1
- package/src/internal-urls/memory-protocol.ts +2 -2
- package/src/internal-urls/registry-helpers.ts +40 -0
- package/src/internal-urls/skill-protocol.ts +2 -2
- package/src/internal-urls/ssh-protocol.ts +2 -1
- package/src/internal-urls/vault-protocol.ts +2 -1
- package/src/live/audio-worklet.txt +59 -0
- package/src/live/browser-runtime.txt +221 -0
- package/src/live/controller.ts +464 -0
- package/src/live/prompts/agent-final-message.md +3 -0
- package/src/live/prompts/live-instructions.md +23 -0
- package/src/live/protocol.test.ts +140 -0
- package/src/live/protocol.ts +233 -0
- package/src/live/transport.ts +490 -0
- package/src/live/visualizer.ts +234 -0
- package/src/lsp/client.ts +52 -9
- package/src/lsp/clients/biome-client.ts +3 -4
- package/src/lsp/index.ts +44 -11
- package/src/main.ts +9 -1
- package/src/mcp/config-writer.test.ts +43 -0
- package/src/mcp/config-writer.ts +109 -82
- package/src/mcp/manager.ts +39 -8
- package/src/mcp/oauth-discovery.ts +10 -2
- package/src/mcp/render.ts +94 -35
- package/src/mcp/smithery-auth.test.ts +29 -0
- package/src/mcp/smithery-auth.ts +3 -2
- package/src/mcp/tool-bridge.ts +107 -11
- package/src/mcp/types.ts +7 -0
- package/src/memories/index.ts +40 -20
- package/src/memory-backend/tool-names.ts +2 -0
- package/src/mnemopi/backend.ts +24 -7
- package/src/modes/acp/acp-agent.ts +56 -10
- package/src/modes/acp/acp-event-mapper.ts +8 -1
- package/src/modes/components/agent-hub.ts +27 -10
- package/src/modes/components/login-dialog.ts +14 -4
- package/src/modes/components/oauth-selector.ts +24 -7
- package/src/modes/components/plan-review-overlay.ts +350 -35
- package/src/modes/components/settings-defs.ts +28 -3
- package/src/modes/components/settings-selector.ts +178 -6
- package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
- package/src/modes/components/status-line/component.ts +123 -2
- package/src/modes/components/tool-execution.test.ts +63 -2
- package/src/modes/components/tool-execution.ts +11 -2
- package/src/modes/controllers/command-controller.ts +70 -22
- package/src/modes/controllers/event-controller.ts +111 -16
- package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
- package/src/modes/controllers/extension-ui-controller.ts +20 -4
- package/src/modes/controllers/live-command-controller.ts +178 -0
- package/src/modes/controllers/mcp-command-controller.ts +80 -41
- package/src/modes/controllers/omfg-controller.ts +44 -40
- package/src/modes/controllers/selector-controller.ts +18 -10
- package/src/modes/controllers/session-focus-controller.ts +6 -1
- package/src/modes/interactive-mode.ts +160 -38
- package/src/modes/rpc/rpc-client.ts +94 -3
- package/src/modes/rpc/rpc-frame.ts +164 -4
- package/src/modes/rpc/rpc-messages.ts +127 -0
- package/src/modes/rpc/rpc-mode.ts +79 -7
- package/src/modes/rpc/rpc-types.ts +34 -2
- package/src/modes/runtime-init.ts +2 -0
- package/src/modes/setup-wizard/scenes/model.ts +5 -7
- package/src/modes/setup-wizard/scenes/providers.ts +3 -2
- package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
- package/src/modes/setup-wizard/scenes/theme.ts +13 -3
- package/src/modes/setup-wizard/scenes/types.ts +9 -1
- package/src/modes/setup-wizard/scenes/web-search.ts +23 -10
- package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
- package/src/modes/theme/theme.ts +1 -1
- package/src/modes/types.ts +3 -1
- package/src/modes/utils/context-usage.ts +23 -7
- package/src/modes/utils/ui-helpers.ts +10 -5
- package/src/plan-mode/plan-files.ts +40 -0
- package/src/prompts/bench/cache-prefix-chunk.md +1 -0
- package/src/prompts/bench/cache-prefix.md +3 -0
- package/src/prompts/bench/cache-suffix.md +1 -0
- package/src/prompts/goals/guided-goal-system.md +24 -3
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/project-prompt.md +8 -1
- package/src/prompts/system/subagent-async-pending.md +6 -0
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/prompts/system/workflow-notice.md +48 -40
- package/src/prompts/system/xdev-mount-notice.md +4 -0
- package/src/prompts/tools/task.md +12 -2
- package/src/prompts/tools/todo.md +3 -1
- package/src/registry/agent-lifecycle.ts +116 -46
- package/src/registry/agent-registry.ts +35 -8
- package/src/registry/persisted-agents.ts +26 -2
- package/src/sdk.ts +255 -139
- package/src/secrets/index.ts +127 -4
- package/src/secrets/obfuscator.ts +2328 -105
- package/src/session/acp-permission-gate.ts +165 -0
- package/src/session/agent-session-error-log.test.ts +1 -1
- package/src/session/agent-session-events.ts +66 -0
- package/src/session/agent-session-types.ts +334 -0
- package/src/session/agent-session.ts +1910 -12213
- package/src/session/artifacts.ts +6 -5
- package/src/session/async-job-delivery.ts +74 -0
- package/src/session/bash-runner.ts +326 -0
- package/src/session/blob-store.test.ts +56 -0
- package/src/session/blob-store.ts +18 -2
- package/src/session/checkpoint-entries.ts +81 -0
- package/src/session/eval-runner.ts +212 -0
- package/src/session/indexed-session-storage.ts +9 -2
- package/src/session/irc-bridge.ts +203 -0
- package/src/session/messages.ts +263 -1
- package/src/session/model-controls.ts +714 -0
- package/src/session/prewalk.ts +252 -0
- package/src/session/queued-messages.ts +93 -0
- package/src/session/redis-session-storage.ts +51 -11
- package/src/session/retry-fallback-chains.ts +455 -0
- package/src/session/role-models.ts +85 -0
- package/src/session/session-advisors.ts +1679 -0
- package/src/session/session-context.test.ts +224 -1
- package/src/session/session-context.ts +47 -5
- package/src/session/session-entries.ts +8 -0
- package/src/session/session-handoff.ts +308 -0
- package/src/session/session-history-format.ts +20 -9
- package/src/session/session-loader.ts +10 -51
- package/src/session/session-maintenance.ts +2983 -0
- package/src/session/session-manager.ts +390 -30
- package/src/session/session-memory.ts +222 -0
- package/src/session/session-provider-boundary.ts +307 -0
- package/src/session/session-stats.ts +293 -0
- package/src/session/session-tools.ts +943 -0
- package/src/session/session-workspace.ts +53 -0
- package/src/session/stream-guards.ts +417 -0
- package/src/session/todo-tracker.ts +380 -0
- package/src/session/ttsr-coordinator.ts +496 -0
- package/src/session/turn-recovery.ts +1629 -0
- package/src/slash-commands/builtin-registry.ts +92 -11
- package/src/slash-commands/helpers/usage-report.ts +25 -4
- package/src/system-prompt.ts +41 -17
- package/src/task/executor.ts +288 -70
- package/src/task/index.ts +148 -111
- package/src/task/output-manager.ts +25 -3
- package/src/task/persisted-revive.ts +4 -3
- package/src/task/structured-subagent.ts +4 -1
- package/src/task/types.ts +16 -0
- package/src/tools/approval.ts +55 -10
- package/src/tools/bash-interactive.ts +26 -0
- package/src/tools/bash-skill-urls.ts +28 -2
- package/src/tools/bash.ts +339 -123
- package/src/tools/browser/launch.ts +9 -1
- package/src/tools/browser/tab-supervisor.ts +8 -5
- package/src/tools/browser.ts +94 -47
- package/src/tools/hub/index.ts +1 -1
- package/src/tools/hub/jobs.ts +67 -8
- package/src/tools/image-gen.ts +20 -28
- package/src/tools/image-providers.ts +50 -0
- package/src/tools/index.ts +12 -0
- package/src/tools/path-utils.ts +49 -2
- package/src/tools/read.ts +156 -88
- package/src/tools/renderers.ts +7 -1
- package/src/tools/report-tool-issue.ts +79 -28
- package/src/tools/todo.ts +101 -11
- package/src/tools/vibe.ts +1 -2
- package/src/tools/write.ts +51 -1
- package/src/tools/xdev.ts +67 -3
- package/src/tts/streaming-player.ts +62 -4
- package/src/tts/vocalizer.ts +18 -1
- package/src/utils/jj.ts +125 -4
- package/src/utils/lang-from-path.ts +7 -0
- package/src/utils/title-generator.ts +137 -1
- package/src/vibe/runtime.ts +852 -72
- package/src/web/search/index.ts +3 -6
- package/src/web/search/provider.ts +31 -16
- package/src/web/search/providers/firecrawl.ts +46 -13
- package/src/web/search/providers/xai.ts +7 -1
- package/src/web/search/types.ts +8 -1
|
@@ -0,0 +1,2983 @@
|
|
|
1
|
+
/** Context maintenance for an active coding-agent session. */
|
|
2
|
+
|
|
3
|
+
import { scheduler } from "node:timers/promises";
|
|
4
|
+
import {
|
|
5
|
+
type Agent,
|
|
6
|
+
type AgentMessage,
|
|
7
|
+
type AgentTurnEndContext,
|
|
8
|
+
countTokens,
|
|
9
|
+
resolveTelemetry,
|
|
10
|
+
type StreamFn,
|
|
11
|
+
type ThinkingLevel,
|
|
12
|
+
} from "@oh-my-pi/pi-agent-core";
|
|
13
|
+
import {
|
|
14
|
+
AGGRESSIVE_SHAKE_CONFIG,
|
|
15
|
+
AUTO_HANDOFF_THRESHOLD_FOCUS,
|
|
16
|
+
applyShakeRegions,
|
|
17
|
+
CompactionCancelledError,
|
|
18
|
+
type CompactionPreparation,
|
|
19
|
+
type CompactionResult,
|
|
20
|
+
type CompactionSettings,
|
|
21
|
+
calculateContextTokens,
|
|
22
|
+
collectShakeRegions,
|
|
23
|
+
compact,
|
|
24
|
+
compactionContextTokens,
|
|
25
|
+
createCompactionSummaryMessage,
|
|
26
|
+
DEFAULT_SHAKE_CONFIG,
|
|
27
|
+
effectiveReserveTokens,
|
|
28
|
+
estimateTokens,
|
|
29
|
+
prepareCompaction,
|
|
30
|
+
resolveBudgetReserveTokens,
|
|
31
|
+
resolveThresholdTokens,
|
|
32
|
+
type ShakeConfig,
|
|
33
|
+
type ShakeRegion,
|
|
34
|
+
type SummaryOptions,
|
|
35
|
+
shouldCompact,
|
|
36
|
+
shouldUseOpenAiRemoteCompaction,
|
|
37
|
+
} from "@oh-my-pi/pi-agent-core/compaction";
|
|
38
|
+
import {
|
|
39
|
+
DEFAULT_PRUNE_CONFIG,
|
|
40
|
+
pruneSupersededToolResults,
|
|
41
|
+
pruneToolOutputs,
|
|
42
|
+
readToolSupersedeKey,
|
|
43
|
+
} from "@oh-my-pi/pi-agent-core/compaction/pruning";
|
|
44
|
+
import type { ProtectedToolMatcher } from "@oh-my-pi/pi-agent-core/compaction/tool-protection";
|
|
45
|
+
import type { AssistantMessage, CodexCompactionContext, Message, Model, ProviderSessionState } from "@oh-my-pi/pi-ai";
|
|
46
|
+
import * as AIError from "@oh-my-pi/pi-ai/error";
|
|
47
|
+
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
|
48
|
+
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
|
|
49
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
50
|
+
import * as snapcompact from "@oh-my-pi/snapcompact";
|
|
51
|
+
import type { ModelRegistry } from "../config/model-registry";
|
|
52
|
+
import { MODEL_ROLE_IDS } from "../config/model-roles";
|
|
53
|
+
import type { Settings } from "../config/settings";
|
|
54
|
+
import { getDefault } from "../config/settings";
|
|
55
|
+
import type { ExtensionRunner, SessionBeforeCompactResult } from "../extensibility/extensions";
|
|
56
|
+
import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
|
|
57
|
+
import type { GoalModeState } from "../goals/state";
|
|
58
|
+
import { resolveMemoryBackend } from "../memory-backend/resolve";
|
|
59
|
+
import type { MemoryBackendOperationContext } from "../memory-backend/types";
|
|
60
|
+
import type { NonMessageTokenSource } from "../modes/utils/context-usage";
|
|
61
|
+
import { computeNonMessageTokens } from "../modes/utils/context-usage";
|
|
62
|
+
import { createPlanReadMatcher } from "../plan-mode/plan-protection";
|
|
63
|
+
import type { ConfiguredThinkingLevel } from "../thinking";
|
|
64
|
+
import type { AgentSessionEvent } from "./agent-session-events";
|
|
65
|
+
import type { ContextUsageBreakdown, HandoffResult, SessionHandoffOptions } from "./agent-session-types";
|
|
66
|
+
import { findCompactMode } from "./compact-modes";
|
|
67
|
+
import { convertToLlm, stripImagesFromMessage } from "./messages";
|
|
68
|
+
import { isTerminalTextAssistantAnswer } from "./queued-messages";
|
|
69
|
+
import {
|
|
70
|
+
resolveCompactionConfiguredTarget,
|
|
71
|
+
resolveContextPromotionConfiguredTarget,
|
|
72
|
+
resolveRoleModelFull,
|
|
73
|
+
} from "./role-models";
|
|
74
|
+
import type { SessionContext } from "./session-context";
|
|
75
|
+
import { getLatestCompactionEntry } from "./session-context";
|
|
76
|
+
import type { CompactionEntry, SessionEntry } from "./session-entries";
|
|
77
|
+
import type { SessionManager } from "./session-manager";
|
|
78
|
+
import type { ShakeMode, ShakeResult } from "./shake-types";
|
|
79
|
+
|
|
80
|
+
export type CompactionCheckResult = Readonly<{
|
|
81
|
+
deferredHandoff: boolean;
|
|
82
|
+
continuationScheduled: boolean;
|
|
83
|
+
automaticContinuationBlocked?: boolean;
|
|
84
|
+
historyRewritten?: boolean;
|
|
85
|
+
}>;
|
|
86
|
+
|
|
87
|
+
/** Shared no-op result for dispatcher paths that perform no maintenance. */
|
|
88
|
+
export const COMPACTION_CHECK_NONE: CompactionCheckResult = {
|
|
89
|
+
deferredHandoff: false,
|
|
90
|
+
continuationScheduled: false,
|
|
91
|
+
};
|
|
92
|
+
const COMPACTION_CHECK_DEFERRED_HANDOFF: CompactionCheckResult = {
|
|
93
|
+
deferredHandoff: true,
|
|
94
|
+
continuationScheduled: false,
|
|
95
|
+
};
|
|
96
|
+
const COMPACTION_CHECK_CONTINUATION: CompactionCheckResult = {
|
|
97
|
+
deferredHandoff: false,
|
|
98
|
+
continuationScheduled: true,
|
|
99
|
+
};
|
|
100
|
+
const COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION: CompactionCheckResult = {
|
|
101
|
+
deferredHandoff: false,
|
|
102
|
+
continuationScheduled: false,
|
|
103
|
+
automaticContinuationBlocked: true,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* User-facing notice for a compaction dead end: maintenance freed too little
|
|
108
|
+
* to retry safely. `remedies` names the recovery actions left on the emitting
|
|
109
|
+
* path — by the time the post-pass dead end fires, the tiered rescue has
|
|
110
|
+
* already attempted both elide and image-drop automatically.
|
|
111
|
+
*/
|
|
112
|
+
function compactionDeadEndWarning(remedies: string): string {
|
|
113
|
+
return (
|
|
114
|
+
"Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. " +
|
|
115
|
+
`The most recent turn alone is too large to reduce further; ${remedies} or switch to a larger-context model.`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Creates one provider-scoped compaction lifecycle descriptor. */
|
|
120
|
+
export function createCodexCompactionContext(options: {
|
|
121
|
+
trigger: CodexCompactionContext["trigger"];
|
|
122
|
+
reason: CodexCompactionContext["reason"];
|
|
123
|
+
phase: CodexCompactionContext["phase"];
|
|
124
|
+
}): CodexCompactionContext {
|
|
125
|
+
return {
|
|
126
|
+
operationId: crypto.randomUUID(),
|
|
127
|
+
trigger: options.trigger,
|
|
128
|
+
reason: options.reason,
|
|
129
|
+
phase: options.phase,
|
|
130
|
+
strategy: "memento",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Per-turn prune cache window. A tool result whose all-message suffix exceeds
|
|
136
|
+
* this is in the warm, already-sent prompt-cache prefix: re-writing it costs the
|
|
137
|
+
* cacheWrite premium on the whole suffix. Per-turn passes only reclaim inside
|
|
138
|
+
* this tail (matches the supersede pass's default `suffixTokenLimit`); deeper
|
|
139
|
+
* stale/age victims are left to compaction/shake, which rebuild the cache anyway.
|
|
140
|
+
*/
|
|
141
|
+
const PRUNE_CACHE_WARM_SUFFIX_TOKENS = 8_000;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Idle gap after which the supersede pass may flush the whole sent region (the
|
|
145
|
+
* provider cache is cold, so re-writing it is free). MUST exceed the maximum
|
|
146
|
+
* Anthropic prompt-cache TTL — "long" retention (the OAuth default) is 1h — or a
|
|
147
|
+
* still-warm prefix is busted by the flush. 90 min leaves margin over the 1h TTL.
|
|
148
|
+
*/
|
|
149
|
+
const PRUNE_IDLE_FLUSH_MS = 90 * 60_000;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Hysteresis band for the post-maintenance "did we actually create headroom?"
|
|
153
|
+
* check shared by the shake tail and the context-full / snapcompact tail. A
|
|
154
|
+
* pass counts as having resolved threshold pressure only when residual context
|
|
155
|
+
* lands at or below `COMPACTION_RECOVERY_BAND × threshold`. Re-checking against
|
|
156
|
+
* the raw threshold lets a pass keep reclaiming a trickle of the previous
|
|
157
|
+
* turn's output and land just under the line every turn, sustaining the
|
|
158
|
+
* auto-continue dead loop reported in #2275; the same band stops the
|
|
159
|
+
* context-full / snapcompact tail from re-firing on a history whose single
|
|
160
|
+
* most-recent kept turn already exceeds the threshold (the snapcompact thrash).
|
|
161
|
+
*/
|
|
162
|
+
const COMPACTION_RECOVERY_BAND = 0.8;
|
|
163
|
+
|
|
164
|
+
function mergeLlmCompactionPreserveData(
|
|
165
|
+
hookPreserveData: Record<string, unknown> | undefined,
|
|
166
|
+
resultPreserveData: Record<string, unknown> | undefined,
|
|
167
|
+
): Record<string, unknown> | undefined {
|
|
168
|
+
const preserveData = { ...(hookPreserveData ?? {}), ...(resultPreserveData ?? {}) };
|
|
169
|
+
return snapcompact.stripPreservedArchive(Object.keys(preserveData).length > 0 ? preserveData : undefined);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Capabilities borrowed from the owning AgentSession. */
|
|
173
|
+
export interface SessionMaintenanceHost {
|
|
174
|
+
agent: Agent;
|
|
175
|
+
sessionManager: SessionManager;
|
|
176
|
+
settings: Settings;
|
|
177
|
+
modelRegistry: ModelRegistry;
|
|
178
|
+
extensionRunner: ExtensionRunner | undefined;
|
|
179
|
+
sideStreamFn: StreamFn;
|
|
180
|
+
providerSessionState: Map<string, ProviderSessionState>;
|
|
181
|
+
model(): Model | undefined;
|
|
182
|
+
thinkingLevel(): ThinkingLevel | undefined;
|
|
183
|
+
isDisposed(): boolean;
|
|
184
|
+
isStreaming(): boolean;
|
|
185
|
+
isGeneratingHandoff(): boolean;
|
|
186
|
+
promptGeneration(): number;
|
|
187
|
+
sessionId(): string;
|
|
188
|
+
messages(): AgentMessage[];
|
|
189
|
+
baseSystemPrompt(): string[];
|
|
190
|
+
goalModeState(): GoalModeState | undefined;
|
|
191
|
+
planReferencePath(): string;
|
|
192
|
+
nonMessageTokenSource(): NonMessageTokenSource;
|
|
193
|
+
memoryBackendSession(): MemoryBackendOperationContext["session"];
|
|
194
|
+
emitSessionEvent(event: AgentSessionEvent): Promise<void>;
|
|
195
|
+
emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void;
|
|
196
|
+
schedulePostPromptTask(
|
|
197
|
+
task: (signal: AbortSignal) => Promise<void>,
|
|
198
|
+
options?: { delayMs?: number; generation?: number; onSkip?: (reason: "aborted" | "stale-generation") => void },
|
|
199
|
+
): void;
|
|
200
|
+
scheduleAgentContinue(options?: {
|
|
201
|
+
delayMs?: number;
|
|
202
|
+
generation?: number;
|
|
203
|
+
shouldContinue?: () => boolean;
|
|
204
|
+
onSkip?: (
|
|
205
|
+
reason:
|
|
206
|
+
| "aborted"
|
|
207
|
+
| "stale-generation"
|
|
208
|
+
| "session-unavailable"
|
|
209
|
+
| "should-continue-false"
|
|
210
|
+
| "post-restore-unavailable",
|
|
211
|
+
) => void;
|
|
212
|
+
onError?: () => void;
|
|
213
|
+
}): void;
|
|
214
|
+
scheduleCompactionContinuation(options: {
|
|
215
|
+
generation: number;
|
|
216
|
+
autoContinue: boolean;
|
|
217
|
+
terminalTextAnswer: boolean;
|
|
218
|
+
suppressContinuation: boolean;
|
|
219
|
+
}): boolean;
|
|
220
|
+
persistTurnMessagesForMidRunCompaction(context: AgentTurnEndContext | undefined): Promise<boolean>;
|
|
221
|
+
findLastAssistantMessage(): AssistantMessage | undefined;
|
|
222
|
+
disconnectFromAgent(): void;
|
|
223
|
+
reconnectToAgent(): void;
|
|
224
|
+
drainStrandedQueuedMessages(): void;
|
|
225
|
+
buildDisplaySessionContext(): SessionContext;
|
|
226
|
+
convertToLlmForSideRequest(messages: AgentMessage[]): Message[];
|
|
227
|
+
obfuscateTextForProvider(text: string | undefined): string | undefined;
|
|
228
|
+
obfuscatePreparationForProvider(preparation: CompactionPreparation): CompactionPreparation;
|
|
229
|
+
closeCodexProviderSessionsForHistoryRewrite(): void;
|
|
230
|
+
resetCodexProviderAfterCompaction(compaction: CodexCompactionContext): void;
|
|
231
|
+
resetPlanReference(): void;
|
|
232
|
+
syncTodoPhasesFromBranch(): void;
|
|
233
|
+
resetAdvisorRuntimes(): void;
|
|
234
|
+
rebaseAfterCompaction(): void;
|
|
235
|
+
getContextBreakdown(options?: {
|
|
236
|
+
contextWindow?: number;
|
|
237
|
+
pendingMessages?: AgentMessage[];
|
|
238
|
+
}): ContextUsageBreakdown | undefined;
|
|
239
|
+
getContextUsage(options?: { contextWindow?: number }): ContextUsage | undefined;
|
|
240
|
+
shake(mode: ShakeMode, options?: { config?: ShakeConfig; signal?: AbortSignal }): Promise<ShakeResult>;
|
|
241
|
+
dropImages(): Promise<{ removed: number }>;
|
|
242
|
+
runHandoff(customInstructions?: string, options?: SessionHandoffOptions): Promise<HandoffResult | undefined>;
|
|
243
|
+
removeAssistantMessageFromActiveContext(message: AssistantMessage): void;
|
|
244
|
+
dropPersistedAssistantTurn(message: AssistantMessage): Promise<void>;
|
|
245
|
+
runRecoveryCompactionWithRollback(
|
|
246
|
+
reason: "overflow" | "incomplete",
|
|
247
|
+
message: AssistantMessage,
|
|
248
|
+
allowDefer: boolean,
|
|
249
|
+
options: { autoContinue: boolean; triggerContextTokens?: number },
|
|
250
|
+
): Promise<CompactionCheckResult>;
|
|
251
|
+
parseRetryAfterMsFromError(errorMessage: string): number | undefined;
|
|
252
|
+
setModelTemporary(
|
|
253
|
+
model: Model,
|
|
254
|
+
thinkingLevel?: ConfiguredThinkingLevel,
|
|
255
|
+
options?: { ephemeral?: boolean },
|
|
256
|
+
): Promise<void>;
|
|
257
|
+
abort(options?: {
|
|
258
|
+
goalReason?: "interrupted" | "internal";
|
|
259
|
+
reason?: string;
|
|
260
|
+
preserveCompaction?: boolean;
|
|
261
|
+
}): Promise<void>;
|
|
262
|
+
abortHandoff(): void;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Owns compaction, pruning, shake, promotion, and automatic context maintenance. */
|
|
266
|
+
export class SessionMaintenance {
|
|
267
|
+
#compactionAbortController: AbortController | undefined;
|
|
268
|
+
#autoCompactionAbortController: AbortController | undefined;
|
|
269
|
+
#skipPostTurnMaintenanceAssistantTimestamp: number | undefined;
|
|
270
|
+
readonly #host: SessionMaintenanceHost;
|
|
271
|
+
|
|
272
|
+
get #model(): Model | undefined {
|
|
273
|
+
return this.#host.model();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
get #goalModeState(): GoalModeState | undefined {
|
|
277
|
+
return this.#host.goalModeState();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
constructor(host: SessionMaintenanceHost) {
|
|
281
|
+
this.#host = host;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Whether manual or automatic context maintenance is active. */
|
|
285
|
+
get isCompacting(): boolean {
|
|
286
|
+
return this.#autoCompactionAbortController !== undefined || this.#compactionAbortController !== undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Assistant timestamp whose post-turn maintenance must be skipped once. */
|
|
290
|
+
get skipPostTurnMaintenanceAssistantTimestamp(): number | undefined {
|
|
291
|
+
return this.#skipPostTurnMaintenanceAssistantTimestamp;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
set skipPostTurnMaintenanceAssistantTimestamp(timestamp: number | undefined) {
|
|
295
|
+
this.#skipPostTurnMaintenanceAssistantTimestamp = timestamp;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Append plan-read protection to a prune/shake config so the active plan
|
|
299
|
+
* file survives compaction alongside skill reads (the config defaults
|
|
300
|
+
* already carry skill protection). The matcher reads the current plan
|
|
301
|
+
* reference path at match time, so retitled plans are covered.
|
|
302
|
+
*/
|
|
303
|
+
#withPlanProtection<T extends { protectedTools: ProtectedToolMatcher[] }>(config: T): T {
|
|
304
|
+
const planMatcher = createPlanReadMatcher(() => this.#host.planReferencePath());
|
|
305
|
+
return { ...config, protectedTools: [...config.protectedTools, planMatcher] };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
|
|
309
|
+
const branchEntries = this.#host.sessionManager.getBranch();
|
|
310
|
+
const keepBoundaryId = getLatestCompactionEntry(branchEntries)?.firstKeptEntryId;
|
|
311
|
+
const result = pruneToolOutputs(
|
|
312
|
+
branchEntries,
|
|
313
|
+
this.#withPlanProtection({
|
|
314
|
+
...DEFAULT_PRUNE_CONFIG,
|
|
315
|
+
pruneUseless: this.#host.settings.getGroup("compaction").dropUseless,
|
|
316
|
+
// Cache-stable boundary: never re-write the warm, already-sent prefix
|
|
317
|
+
// (deep stale/age victims) or summarized-away entries every turn.
|
|
318
|
+
keepBoundaryId,
|
|
319
|
+
cacheWarmSuffixTokens: PRUNE_CACHE_WARM_SUFFIX_TOKENS,
|
|
320
|
+
}),
|
|
321
|
+
);
|
|
322
|
+
if (result.prunedCount === 0) {
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
await this.#host.sessionManager.rewriteEntries();
|
|
327
|
+
const sessionContext = this.#host.buildDisplaySessionContext();
|
|
328
|
+
this.#host.agent.replaceMessages(sessionContext.messages);
|
|
329
|
+
this.#host.resetAdvisorRuntimes();
|
|
330
|
+
this.#host.syncTodoPhasesFromBranch();
|
|
331
|
+
this.#host.closeCodexProviderSessionsForHistoryRewrite();
|
|
332
|
+
return result;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Per-turn stale-result pass: prune older `read` results that a newer read
|
|
337
|
+
* of the same file has made stale, plus results their tool flagged
|
|
338
|
+
* contextually useless. Cache-aware (only fires when the suffix after a
|
|
339
|
+
* candidate is small or the session has been idle long enough that the
|
|
340
|
+
* provider prompt cache is cold), so it is cheap to run every turn. Gated
|
|
341
|
+
* on the `compaction.supersedeReads` and `compaction.dropUseless` settings.
|
|
342
|
+
*
|
|
343
|
+
* Persists via `rewriteEntries` like every other history rewrite — the
|
|
344
|
+
* session file must match the live (pruned) context or file-based forks
|
|
345
|
+
* (`/fork`, `/tan`) and resume rebuild a divergent prefix and cold-miss the
|
|
346
|
+
* provider prompt cache.
|
|
347
|
+
*/
|
|
348
|
+
async #pruneStaleToolResults(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
|
|
349
|
+
const { supersedeReads, dropUseless } = this.#host.settings.getGroup("compaction");
|
|
350
|
+
if (!supersedeReads && !dropUseless) return undefined;
|
|
351
|
+
const branchEntries = this.#host.sessionManager.getBranch();
|
|
352
|
+
const keepBoundaryId = getLatestCompactionEntry(branchEntries)?.firstKeptEntryId;
|
|
353
|
+
const result = pruneSupersededToolResults(
|
|
354
|
+
branchEntries,
|
|
355
|
+
this.#withPlanProtection({
|
|
356
|
+
supersedeKey: supersedeReads ? readToolSupersedeKey : undefined,
|
|
357
|
+
pruneUseless: dropUseless,
|
|
358
|
+
protectedTools: [...DEFAULT_PRUNE_CONFIG.protectedTools],
|
|
359
|
+
// Never re-write summarized-away entries; only flush the whole sent
|
|
360
|
+
// region once the cache is genuinely cold (idle exceeds the 1h TTL).
|
|
361
|
+
keepBoundaryId,
|
|
362
|
+
idleFlushMs: PRUNE_IDLE_FLUSH_MS,
|
|
363
|
+
}),
|
|
364
|
+
);
|
|
365
|
+
if (result.prunedCount === 0) {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
await this.#host.sessionManager.rewriteEntries();
|
|
370
|
+
const sessionContext = this.#host.buildDisplaySessionContext();
|
|
371
|
+
this.#host.agent.replaceMessages(sessionContext.messages);
|
|
372
|
+
this.#host.resetAdvisorRuntimes();
|
|
373
|
+
this.#host.syncTodoPhasesFromBranch();
|
|
374
|
+
this.#host.closeCodexProviderSessionsForHistoryRewrite();
|
|
375
|
+
return result;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Strip image content blocks from every message on the current branch and
|
|
380
|
+
* persist the rewrite. Walks `SessionManager.getBranch()` in place — both
|
|
381
|
+
* `SessionMessageEntry.message` and `CustomMessageEntry.content` arrays
|
|
382
|
+
* are mutated, then `rewriteEntries` durably commits the new shape. The
|
|
383
|
+
* agent's runtime view is rebuilt from the freshly-mutated entries so any
|
|
384
|
+
* provider sessions caching message identity (Codex Responses) are torn
|
|
385
|
+
* down to force a clean replay on the next turn.
|
|
386
|
+
*
|
|
387
|
+
* No-op when the branch carries no images; returns `{ removed: 0 }` and
|
|
388
|
+
* skips the disk rewrite.
|
|
389
|
+
*/
|
|
390
|
+
async dropImages(): Promise<{ removed: number }> {
|
|
391
|
+
const branchEntries = this.#host.sessionManager.getBranch();
|
|
392
|
+
let removed = 0;
|
|
393
|
+
for (const entry of branchEntries) {
|
|
394
|
+
if (entry.type === "message") {
|
|
395
|
+
removed += stripImagesFromMessage(entry.message);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (entry.type === "custom_message" && typeof entry.content !== "string") {
|
|
399
|
+
const kept: typeof entry.content = [];
|
|
400
|
+
let dropped = 0;
|
|
401
|
+
for (const part of entry.content) {
|
|
402
|
+
if (part.type === "image") {
|
|
403
|
+
dropped++;
|
|
404
|
+
} else {
|
|
405
|
+
kept.push(part);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (dropped > 0) {
|
|
409
|
+
if (kept.length === 0) {
|
|
410
|
+
kept.push({ type: "text", text: "[image removed]" });
|
|
411
|
+
}
|
|
412
|
+
entry.content = kept;
|
|
413
|
+
removed += dropped;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (removed === 0) {
|
|
418
|
+
return { removed: 0 };
|
|
419
|
+
}
|
|
420
|
+
await this.#host.sessionManager.rewriteEntries();
|
|
421
|
+
const sessionContext = this.#host.buildDisplaySessionContext();
|
|
422
|
+
this.#host.agent.replaceMessages(sessionContext.messages);
|
|
423
|
+
this.#host.resetAdvisorRuntimes();
|
|
424
|
+
this.#host.closeCodexProviderSessionsForHistoryRewrite();
|
|
425
|
+
return { removed };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Surgically reduce context by dropping heavy content ("shake").
|
|
430
|
+
*
|
|
431
|
+
* - `images` delegates to {@link dropImages}.
|
|
432
|
+
* - `elide` replaces whole tool-call results and large fenced/XML blocks
|
|
433
|
+
* with short placeholders that embed an `artifact://` recovery link.
|
|
434
|
+
*
|
|
435
|
+
* Mutates the branch in place, persists via `rewriteEntries`, replays the
|
|
436
|
+
* rebuilt context through the agent, and tears down provider sessions that
|
|
437
|
+
* cache message identity — same rewrite contract as {@link dropImages}.
|
|
438
|
+
*
|
|
439
|
+
* No-op (zero counts) when nothing is eligible.
|
|
440
|
+
*/
|
|
441
|
+
async shake(mode: ShakeMode, opts: { config?: ShakeConfig; signal?: AbortSignal } = {}): Promise<ShakeResult> {
|
|
442
|
+
if (mode === "images") {
|
|
443
|
+
const { removed } = await this.#host.dropImages();
|
|
444
|
+
return { mode, toolResultsDropped: 0, blocksDropped: 0, imagesDropped: removed, tokensFreed: 0 };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const branchEntries = this.#host.sessionManager.getBranch();
|
|
448
|
+
const config = this.#withPlanProtection({
|
|
449
|
+
...(opts.config ?? AGGRESSIVE_SHAKE_CONFIG),
|
|
450
|
+
// Skip entries summarized away by the latest compaction — shaking them
|
|
451
|
+
// only churns persisted history with no prompt/cache effect.
|
|
452
|
+
keepBoundaryId: getLatestCompactionEntry(branchEntries)?.firstKeptEntryId,
|
|
453
|
+
});
|
|
454
|
+
const regions = collectShakeRegions(branchEntries, config);
|
|
455
|
+
if (regions.length === 0) {
|
|
456
|
+
return { mode, toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const artifactId = await this.#saveShakeArtifact(regions);
|
|
460
|
+
const replacements = regions.map((region, index) => this.#shakeElidePlaceholder(region, index, artifactId));
|
|
461
|
+
|
|
462
|
+
let toolResultsDropped = 0;
|
|
463
|
+
let blocksDropped = 0;
|
|
464
|
+
let originalTokens = 0;
|
|
465
|
+
let replacementTokens = 0;
|
|
466
|
+
const items = regions.map((region, index) => {
|
|
467
|
+
if (region.kind === "toolResult") toolResultsDropped++;
|
|
468
|
+
else blocksDropped++;
|
|
469
|
+
originalTokens += region.tokens;
|
|
470
|
+
const replacement = replacements[index];
|
|
471
|
+
if (replacement.length > 0) replacementTokens += countTokens(replacement);
|
|
472
|
+
return { region, replacement };
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
applyShakeRegions(items);
|
|
476
|
+
|
|
477
|
+
await this.#host.sessionManager.rewriteEntries();
|
|
478
|
+
const sessionContext = this.#host.buildDisplaySessionContext();
|
|
479
|
+
this.#host.agent.replaceMessages(sessionContext.messages);
|
|
480
|
+
this.#host.resetAdvisorRuntimes();
|
|
481
|
+
this.#host.closeCodexProviderSessionsForHistoryRewrite();
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
mode,
|
|
485
|
+
toolResultsDropped,
|
|
486
|
+
blocksDropped,
|
|
487
|
+
tokensFreed: Math.max(0, originalTokens - replacementTokens),
|
|
488
|
+
artifactId,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
#shakeElidePlaceholder(region: ShakeRegion, index: number, artifactId: string | undefined): string {
|
|
493
|
+
if (artifactId) {
|
|
494
|
+
return `[shaken ~${region.tokens} tokens — recover: artifact://${artifactId} (region ${index + 1})]`;
|
|
495
|
+
}
|
|
496
|
+
return `[shaken ~${region.tokens} tokens]`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Concatenate the original region contents into one session artifact so the
|
|
501
|
+
* agent can read them back via `artifact://<id>`. Returns `undefined` when
|
|
502
|
+
* the session is not persisted or the write fails — callers degrade to a
|
|
503
|
+
* bare placeholder.
|
|
504
|
+
*/
|
|
505
|
+
async #saveShakeArtifact(regions: ShakeRegion[]): Promise<string | undefined> {
|
|
506
|
+
const parts: string[] = [];
|
|
507
|
+
for (let i = 0; i < regions.length; i++) {
|
|
508
|
+
const region = regions[i];
|
|
509
|
+
parts.push(`### region ${i + 1} (${region.label}, ~${region.tokens} tok)`, "", region.originalText, "");
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
return await this.#host.sessionManager.saveArtifact(parts.join("\n"), "shake");
|
|
513
|
+
} catch {
|
|
514
|
+
return undefined;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Manually compact the session context.
|
|
520
|
+
* Aborts current agent operation first.
|
|
521
|
+
* @param customInstructions Optional instructions for the compaction summary
|
|
522
|
+
* @param options Optional callbacks for completion/error handling
|
|
523
|
+
*/
|
|
524
|
+
async compact(customInstructions?: string, options?: CompactOptions): Promise<CompactionResult> {
|
|
525
|
+
if (this.#compactionAbortController) {
|
|
526
|
+
throw new Error("Compaction already in progress");
|
|
527
|
+
}
|
|
528
|
+
// Resolve the `/compact <mode>` subcommand up front so input validation
|
|
529
|
+
// runs before we disconnect/abort the active agent operation below.
|
|
530
|
+
const compactMode = options?.mode ? findCompactMode(options.mode) : undefined;
|
|
531
|
+
// Modes that produce no LLM summary (snapcompact) have nothing to focus.
|
|
532
|
+
// Reject focus text loudly so programmatic callers don't silently lose
|
|
533
|
+
// instructions (the slash path pre-validates via parseCompactArgs).
|
|
534
|
+
// `internalGuidance` counts the same way — plan-mode approval never
|
|
535
|
+
// combines with a rejects-focus mode, but reject early if a caller ever
|
|
536
|
+
// wires it up so we don't silently drop the directive on the snapcompact
|
|
537
|
+
// fallback (issue #4359).
|
|
538
|
+
if (compactMode?.rejectsFocus && (customInstructions || options?.internalGuidance)) {
|
|
539
|
+
throw new Error(`/compact ${compactMode.name} does not take focus instructions.`);
|
|
540
|
+
}
|
|
541
|
+
const compactionAbortController = new AbortController();
|
|
542
|
+
this.#compactionAbortController = compactionAbortController;
|
|
543
|
+
|
|
544
|
+
try {
|
|
545
|
+
this.#host.disconnectFromAgent();
|
|
546
|
+
await this.#host.abort({ goalReason: "internal", preserveCompaction: true });
|
|
547
|
+
if (!this.#model) {
|
|
548
|
+
throw new Error("No model selected");
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const compactionSettings = this.#host.settings.getGroup("compaction");
|
|
552
|
+
// The `/compact <mode>` override (resolved above) replaces the configured
|
|
553
|
+
// strategy/remote flags for this one invocation. Merged before
|
|
554
|
+
// prepareCompaction so the remote gating (preparation.settings.
|
|
555
|
+
// remoteEnabled/endpoint) and the snapcompact decision below both see it.
|
|
556
|
+
const effectiveSettings = compactMode
|
|
557
|
+
? { ...compactionSettings, ...compactMode.overrides }
|
|
558
|
+
: compactionSettings;
|
|
559
|
+
// /compact remote demands provider-native compaction. When no remote
|
|
560
|
+
// endpoint is configured (one would override per-model gating in
|
|
561
|
+
// compact()), drop fallback candidates that aren't remote-capable so the
|
|
562
|
+
// engine never silently runs a local summary on a configured-but-non-
|
|
563
|
+
// remote compactionModel. If filtering empties the chain, warn and fall
|
|
564
|
+
// back to the full chain so the operation still completes.
|
|
565
|
+
const availableModels = this.#host.modelRegistry.getAvailable();
|
|
566
|
+
const requireProviderRemote = Boolean(compactMode?.requiresRemote && !effectiveSettings.remoteEndpoint);
|
|
567
|
+
let compactionCandidates = this.#getCompactionModelCandidates(
|
|
568
|
+
availableModels,
|
|
569
|
+
requireProviderRemote ? shouldUseOpenAiRemoteCompaction : undefined,
|
|
570
|
+
);
|
|
571
|
+
if (requireProviderRemote && compactionCandidates.length === 0) {
|
|
572
|
+
this.#host.emitNotice(
|
|
573
|
+
"warning",
|
|
574
|
+
`remote compaction is unavailable for ${this.#model.id} (no remote endpoint configured and no provider-native remote-capable model in the fallback chain) — using a local summary instead`,
|
|
575
|
+
"compaction",
|
|
576
|
+
);
|
|
577
|
+
compactionCandidates = this.#getCompactionModelCandidates(availableModels);
|
|
578
|
+
}
|
|
579
|
+
const pathEntries = this.#host.sessionManager.getBranch();
|
|
580
|
+
const preparation = prepareCompaction(pathEntries, effectiveSettings, this.#model);
|
|
581
|
+
if (!preparation) {
|
|
582
|
+
// Check why we can't compact
|
|
583
|
+
const lastEntry = pathEntries[pathEntries.length - 1];
|
|
584
|
+
if (lastEntry?.type === "compaction") {
|
|
585
|
+
throw new Error("Already compacted");
|
|
586
|
+
}
|
|
587
|
+
throw new Error("Nothing to compact (session too small)");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
let hookCompaction: CompactionResult | undefined;
|
|
591
|
+
let fromExtension = false;
|
|
592
|
+
let preserveData: Record<string, unknown> | undefined;
|
|
593
|
+
|
|
594
|
+
if (this.#host.extensionRunner?.hasHandlers("session_before_compact")) {
|
|
595
|
+
const result = (await this.#host.extensionRunner.emit({
|
|
596
|
+
type: "session_before_compact",
|
|
597
|
+
preparation,
|
|
598
|
+
branchEntries: pathEntries,
|
|
599
|
+
customInstructions,
|
|
600
|
+
signal: compactionAbortController.signal,
|
|
601
|
+
})) as SessionBeforeCompactResult | undefined;
|
|
602
|
+
|
|
603
|
+
if (result?.cancel) {
|
|
604
|
+
throw new CompactionCancelledError();
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (result?.compaction) {
|
|
608
|
+
hookCompaction = result.compaction;
|
|
609
|
+
fromExtension = true;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
|
|
614
|
+
|
|
615
|
+
// Strategy honored on manual /compact too. Custom instructions (public
|
|
616
|
+
// user focus OR internal plan-mode guidance) imply a directed LLM
|
|
617
|
+
// summary; a text-only model cannot read snapcompact frames.
|
|
618
|
+
const wantsSnapcompact =
|
|
619
|
+
compactionPrep.kind !== "fromHook" &&
|
|
620
|
+
effectiveSettings.strategy === "snapcompact" &&
|
|
621
|
+
!customInstructions &&
|
|
622
|
+
!options?.internalGuidance;
|
|
623
|
+
// `/compact snapcompact` is an explicit no-LLM archive request: honor
|
|
624
|
+
// its contract by failing locally rather than silently shipping the
|
|
625
|
+
// transcript to a provider. The default-configured snapcompact
|
|
626
|
+
// strategy, in contrast, falls back to LLM compaction (mirroring the
|
|
627
|
+
// auto-compaction path) so a routine /compact still completes on a
|
|
628
|
+
// text-only model (issue #5064).
|
|
629
|
+
const explicitSnapcompact = compactMode?.name === "snapcompact";
|
|
630
|
+
let snapcompactReady = wantsSnapcompact;
|
|
631
|
+
const snapcompactShapeSetting = this.#host.settings.get("snapcompact.shape");
|
|
632
|
+
let snapcompactShape: snapcompact.Shape | undefined;
|
|
633
|
+
// Claude refuses inputs that reproduce its own reasoning as text
|
|
634
|
+
// ("reasoning_extraction"), and the snapcompact archive is replayed as
|
|
635
|
+
// text into every later request; drop `¶think:` sections for
|
|
636
|
+
// Anthropic-dialect targets (issue #6093).
|
|
637
|
+
const snapcompactIncludeThinking = preferredDialect(this.#model.id) !== "anthropic";
|
|
638
|
+
if (wantsSnapcompact && !this.#model.input.includes("image")) {
|
|
639
|
+
if (explicitSnapcompact) {
|
|
640
|
+
this.#host.emitNotice(
|
|
641
|
+
"warning",
|
|
642
|
+
`snapcompact needs a vision-capable model (${this.#model.id} is text-only)`,
|
|
643
|
+
"compaction",
|
|
644
|
+
);
|
|
645
|
+
throw new Error(`snapcompact cannot run locally: ${this.#model.id} is text-only.`);
|
|
646
|
+
}
|
|
647
|
+
this.#host.emitNotice(
|
|
648
|
+
"warning",
|
|
649
|
+
`snapcompact needs a vision-capable model (${this.#model.id} is text-only); falling back to LLM compaction`,
|
|
650
|
+
"compaction",
|
|
651
|
+
);
|
|
652
|
+
snapcompactReady = false;
|
|
653
|
+
} else if (snapcompactReady) {
|
|
654
|
+
const text = snapcompact.serializeConversation(
|
|
655
|
+
convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
|
|
656
|
+
{ includeThinking: snapcompactIncludeThinking },
|
|
657
|
+
);
|
|
658
|
+
const probeText = snapcompact.renderabilityProbeText(
|
|
659
|
+
text,
|
|
660
|
+
preparation.previousPreserveData,
|
|
661
|
+
preparation.previousSummary,
|
|
662
|
+
);
|
|
663
|
+
snapcompactShape = snapcompact.resolveShapeForText(probeText, this.#model, snapcompactShapeSetting);
|
|
664
|
+
const renderScan = snapcompact.scanRenderability(probeText, { shape: snapcompactShape });
|
|
665
|
+
if (!renderScan.isSafe) {
|
|
666
|
+
const percent = (renderScan.unrenderableRatio * 100).toFixed(1);
|
|
667
|
+
this.#host.emitNotice(
|
|
668
|
+
"warning",
|
|
669
|
+
`snapcompact disabled: unsupported characters for selected snapcompact font (${percent}%). No LLM fallback was attempted.`,
|
|
670
|
+
"compaction",
|
|
671
|
+
);
|
|
672
|
+
throw new Error(
|
|
673
|
+
`snapcompact cannot render this conversation locally: unsupported characters for selected snapcompact font (${percent}%).`,
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
let summary: string;
|
|
679
|
+
let shortSummary: string | undefined;
|
|
680
|
+
let firstKeptEntryId: string;
|
|
681
|
+
let tokensBefore: number;
|
|
682
|
+
let details: unknown;
|
|
683
|
+
let codexCompaction: CodexCompactionContext | undefined;
|
|
684
|
+
|
|
685
|
+
// Snapcompact runs locally first. The frame cap is sized from the live
|
|
686
|
+
// model window via #computeSnapcompactMaxFrames so the post-render context
|
|
687
|
+
// fits without the warning loop (issue #3247). Zero-frame budget now fails
|
|
688
|
+
// the snapcompact request locally rather than falling back to an LLM call.
|
|
689
|
+
let snapcompactResult: snapcompact.CompactionResult | undefined;
|
|
690
|
+
if (snapcompactReady) {
|
|
691
|
+
const maxFrames = this.#computeSnapcompactMaxFrames(preparation, effectiveSettings);
|
|
692
|
+
if (maxFrames < 1) {
|
|
693
|
+
logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
|
|
694
|
+
model: this.#model?.id,
|
|
695
|
+
});
|
|
696
|
+
this.#host.emitNotice(
|
|
697
|
+
"warning",
|
|
698
|
+
"snapcompact: kept history alone exceeds the context budget. No LLM fallback was attempted.",
|
|
699
|
+
"compaction",
|
|
700
|
+
);
|
|
701
|
+
throw new Error("snapcompact cannot run locally: kept history alone exceeds the context budget.");
|
|
702
|
+
} else {
|
|
703
|
+
const shape = snapcompactShape;
|
|
704
|
+
if (!shape) {
|
|
705
|
+
throw new Error("snapcompact shape was not resolved before rendering.");
|
|
706
|
+
}
|
|
707
|
+
snapcompactResult = await snapcompact.compact(preparation, {
|
|
708
|
+
convertToLlm,
|
|
709
|
+
model: this.#model,
|
|
710
|
+
...(snapcompactShapeSetting === "auto" ? {} : { shape }),
|
|
711
|
+
maxFrames,
|
|
712
|
+
includeThinking: snapcompactIncludeThinking,
|
|
713
|
+
});
|
|
714
|
+
const framePayloadBytes = this.#snapcompactFramePayloadBytes(snapcompactResult);
|
|
715
|
+
if (framePayloadBytes > snapcompact.FRAME_DATA_BYTES_BUDGET) {
|
|
716
|
+
logger.warn("Snapcompact exceeded the per-request frame payload budget", {
|
|
717
|
+
model: this.#model?.id,
|
|
718
|
+
framePayloadBytes,
|
|
719
|
+
budget: snapcompact.FRAME_DATA_BYTES_BUDGET,
|
|
720
|
+
});
|
|
721
|
+
this.#host.emitNotice(
|
|
722
|
+
"warning",
|
|
723
|
+
"snapcompact produced too much standing image payload. No LLM fallback was attempted.",
|
|
724
|
+
"compaction",
|
|
725
|
+
);
|
|
726
|
+
throw new Error(
|
|
727
|
+
"snapcompact cannot run locally: standing image payload exceeds the per-request budget.",
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
const ctxWindow = this.#model?.contextWindow ?? 0;
|
|
731
|
+
const budget =
|
|
732
|
+
ctxWindow > 0
|
|
733
|
+
? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings)
|
|
734
|
+
: Number.POSITIVE_INFINITY;
|
|
735
|
+
if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) {
|
|
736
|
+
logger.warn("Snapcompact still overflows the window after frame-budget sizing", {
|
|
737
|
+
model: this.#model?.id,
|
|
738
|
+
});
|
|
739
|
+
this.#host.emitNotice(
|
|
740
|
+
"warning",
|
|
741
|
+
"snapcompact could not bring the context under the limit. No LLM fallback was attempted.",
|
|
742
|
+
"compaction",
|
|
743
|
+
);
|
|
744
|
+
throw new Error("snapcompact could not bring the context under the limit locally.");
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (compactionPrep.kind === "fromHook") {
|
|
750
|
+
summary = compactionPrep.summary;
|
|
751
|
+
shortSummary = compactionPrep.shortSummary;
|
|
752
|
+
firstKeptEntryId = compactionPrep.firstKeptEntryId;
|
|
753
|
+
tokensBefore = compactionPrep.tokensBefore;
|
|
754
|
+
details = compactionPrep.details;
|
|
755
|
+
preserveData = compactionPrep.preserveData;
|
|
756
|
+
} else if (snapcompactResult) {
|
|
757
|
+
summary = snapcompactResult.summary;
|
|
758
|
+
shortSummary = snapcompactResult.shortSummary;
|
|
759
|
+
firstKeptEntryId = snapcompactResult.firstKeptEntryId;
|
|
760
|
+
tokensBefore = snapcompactResult.tokensBefore;
|
|
761
|
+
details = snapcompactResult.details;
|
|
762
|
+
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) };
|
|
763
|
+
} else {
|
|
764
|
+
codexCompaction = createCodexCompactionContext({
|
|
765
|
+
trigger: "manual",
|
|
766
|
+
reason: "user_requested",
|
|
767
|
+
phase: "standalone_turn",
|
|
768
|
+
});
|
|
769
|
+
// Generate compaction result. Only convert known abort-shaped
|
|
770
|
+
// rejections (AbortError raised while the abort signal is set,
|
|
771
|
+
// or an already-typed sentinel) into `CompactionCancelledError`
|
|
772
|
+
// so downstream callers can discriminate cancel from generic
|
|
773
|
+
// failure via `instanceof` without inspecting message strings.
|
|
774
|
+
// Real compaction bugs (network, server, parsing, etc.) keep
|
|
775
|
+
// their original shape — they must not be silently relabeled
|
|
776
|
+
// as cancellations even if the signal happens to be aborted
|
|
777
|
+
// for an unrelated reason. Assignments live inside the try
|
|
778
|
+
// block because every catch path throws — the post-try reads
|
|
779
|
+
// of the result-derived locals are reachable only on success.
|
|
780
|
+
try {
|
|
781
|
+
const result = await this.#compactWithFallbackModel(
|
|
782
|
+
preparation,
|
|
783
|
+
options?.internalGuidance ?? customInstructions,
|
|
784
|
+
compactionAbortController.signal,
|
|
785
|
+
{
|
|
786
|
+
promptOverride: this.#host.obfuscateTextForProvider(compactionPrep.hookPrompt),
|
|
787
|
+
extraContext: compactionPrep.hookContext,
|
|
788
|
+
remoteInstructions: this.#host.baseSystemPrompt().join("\n\n"),
|
|
789
|
+
convertToLlm: messages => this.#host.convertToLlmForSideRequest(messages),
|
|
790
|
+
codexCompaction,
|
|
791
|
+
},
|
|
792
|
+
compactionCandidates,
|
|
793
|
+
);
|
|
794
|
+
summary = result.summary;
|
|
795
|
+
shortSummary = result.shortSummary;
|
|
796
|
+
firstKeptEntryId = result.firstKeptEntryId;
|
|
797
|
+
tokensBefore = result.tokensBefore;
|
|
798
|
+
details = result.details;
|
|
799
|
+
preserveData = mergeLlmCompactionPreserveData(compactionPrep.preserveData, result.preserveData);
|
|
800
|
+
} catch (err) {
|
|
801
|
+
if (err instanceof CompactionCancelledError) {
|
|
802
|
+
throw err;
|
|
803
|
+
}
|
|
804
|
+
if (compactionAbortController.signal.aborted && err instanceof Error && err.name === "AbortError") {
|
|
805
|
+
throw new CompactionCancelledError();
|
|
806
|
+
}
|
|
807
|
+
throw err;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (compactionAbortController.signal.aborted) {
|
|
812
|
+
throw new CompactionCancelledError();
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
this.#host.sessionManager.appendCompaction(
|
|
816
|
+
summary,
|
|
817
|
+
shortSummary,
|
|
818
|
+
firstKeptEntryId,
|
|
819
|
+
tokensBefore,
|
|
820
|
+
details,
|
|
821
|
+
fromExtension,
|
|
822
|
+
preserveData,
|
|
823
|
+
);
|
|
824
|
+
const newEntries = this.#host.sessionManager.getEntries();
|
|
825
|
+
const sessionContext = this.#host.buildDisplaySessionContext();
|
|
826
|
+
this.#host.agent.replaceMessages(sessionContext.messages);
|
|
827
|
+
this.#host.rebaseAfterCompaction();
|
|
828
|
+
// Compaction discarded the conversation history that carried the approved
|
|
829
|
+
// plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads
|
|
830
|
+
// the plan from disk and re-injects it on the next turn (issue #1246).
|
|
831
|
+
this.#host.resetPlanReference();
|
|
832
|
+
this.#host.resetAdvisorRuntimes();
|
|
833
|
+
this.#host.syncTodoPhasesFromBranch();
|
|
834
|
+
if (codexCompaction) {
|
|
835
|
+
this.#host.resetCodexProviderAfterCompaction(codexCompaction);
|
|
836
|
+
} else {
|
|
837
|
+
this.#host.closeCodexProviderSessionsForHistoryRewrite();
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Get the saved compaction entry for the hook
|
|
841
|
+
const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
|
|
842
|
+
| CompactionEntry
|
|
843
|
+
| undefined;
|
|
844
|
+
|
|
845
|
+
if (this.#host.extensionRunner && savedCompactionEntry) {
|
|
846
|
+
await this.#host.extensionRunner.emit({
|
|
847
|
+
type: "session_compact",
|
|
848
|
+
compactionEntry: savedCompactionEntry,
|
|
849
|
+
fromExtension,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const compactionResult: CompactionResult = {
|
|
854
|
+
summary,
|
|
855
|
+
shortSummary,
|
|
856
|
+
firstKeptEntryId,
|
|
857
|
+
tokensBefore,
|
|
858
|
+
details,
|
|
859
|
+
preserveData,
|
|
860
|
+
};
|
|
861
|
+
options?.onComplete?.(compactionResult);
|
|
862
|
+
return compactionResult;
|
|
863
|
+
} catch (error) {
|
|
864
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
865
|
+
options?.onError?.(err);
|
|
866
|
+
throw error;
|
|
867
|
+
} finally {
|
|
868
|
+
if (this.#compactionAbortController === compactionAbortController) {
|
|
869
|
+
this.#compactionAbortController = undefined;
|
|
870
|
+
}
|
|
871
|
+
this.#host.reconnectToAgent();
|
|
872
|
+
// Compaction disconnected before `await abort()`, so abort's finally drain
|
|
873
|
+
|
|
874
|
+
// (and any steer/follow-up that arrived mid-compaction — async IRC, an
|
|
875
|
+
// `xd://` mount notice, an SDK/RPC steer) was suppressed while disconnected
|
|
876
|
+
// (issue #5800). Unlike `/new`/switchSession, compaction preserves the agent
|
|
877
|
+
// queues, so nothing else resumes them: re-drain now that the listener is back
|
|
878
|
+
// and `isCompacting` is false, or the queued turn hangs until the next prompt.
|
|
879
|
+
this.#host.drainStrandedQueuedMessages();
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Ask the active memory backend for an extra-context block to splice into
|
|
885
|
+
* the compaction summary prompt. Both the manual and auto compaction paths
|
|
886
|
+
* funnel through this helper so the behaviour stays identical.
|
|
887
|
+
*
|
|
888
|
+
* Failures are swallowed: a memory backend going sideways MUST NOT block
|
|
889
|
+
* compaction (which is itself the recovery path for context overflow).
|
|
890
|
+
*/
|
|
891
|
+
async #collectMemoryBackendContext(preparation: {
|
|
892
|
+
messagesToSummarize: AgentMessage[];
|
|
893
|
+
turnPrefixMessages: AgentMessage[];
|
|
894
|
+
}): Promise<string | undefined> {
|
|
895
|
+
const backend = await resolveMemoryBackend(this.#host.settings);
|
|
896
|
+
if (!backend.preCompactionContext) return undefined;
|
|
897
|
+
const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
|
|
898
|
+
try {
|
|
899
|
+
return await backend.preCompactionContext(messages, this.#host.settings, this.#host.memoryBackendSession());
|
|
900
|
+
} catch (err) {
|
|
901
|
+
logger.debug("Memory backend preCompactionContext failed", {
|
|
902
|
+
backend: backend.id,
|
|
903
|
+
error: String(err),
|
|
904
|
+
});
|
|
905
|
+
return undefined;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Cancel in-progress context maintenance (manual compaction, auto-compaction, or auto-handoff).
|
|
911
|
+
*/
|
|
912
|
+
abortCompaction(): void {
|
|
913
|
+
this.#compactionAbortController?.abort();
|
|
914
|
+
this.#autoCompactionAbortController?.abort();
|
|
915
|
+
this.#host.abortHandoff();
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** Cancel only automatic maintenance while preserving a manual compaction. */
|
|
919
|
+
abortAutomaticCompaction(): void {
|
|
920
|
+
this.#autoCompactionAbortController?.abort();
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** Trigger idle compaction through the auto-compaction flow (with UI events). */
|
|
924
|
+
async runIdleCompaction(): Promise<void> {
|
|
925
|
+
if (this.#host.isStreaming() || this.isCompacting) return;
|
|
926
|
+
await this.runAutoCompaction("idle", false, true);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Local token estimate of the stored conversation (plus any pending messages),
|
|
931
|
+
* independent of provider-reported usage. A `before_provider_request` hook
|
|
932
|
+
* (e.g. a compression extension such as Headroom) or other on-wire payload
|
|
933
|
+
* transform can shrink the request below the real stored conversation; the
|
|
934
|
+
* provider then reports deflated prompt tokens, so anchoring the compaction
|
|
935
|
+
* decision purely on that usage lets the real history grow unbounded until it
|
|
936
|
+
* overflows and native compaction can no longer run. This estimate is the
|
|
937
|
+
* floor the compaction decision respects so on-wire compression can never
|
|
938
|
+
* suppress it.
|
|
939
|
+
*/
|
|
940
|
+
#estimateStoredContextTokens(pendingMessages: AgentMessage[] = []): number {
|
|
941
|
+
// Exclude encrypted reasoning (thinkingSignature / redactedThinking): its
|
|
942
|
+
// local byte size diverges from what the provider bills, so counting it here
|
|
943
|
+
// would let a thinking-heavy turn falsely trip the floor. The provider usage
|
|
944
|
+
// (the other arm of compactionContextTokens) already accounts for it.
|
|
945
|
+
const opts = { excludeEncryptedReasoning: true } as const;
|
|
946
|
+
return (
|
|
947
|
+
computeNonMessageTokens(this.#host.nonMessageTokenSource()) +
|
|
948
|
+
this.#host.messages().reduce((sum, msg) => sum + estimateTokens(msg, opts), 0) +
|
|
949
|
+
pendingMessages.reduce((sum, msg) => sum + estimateTokens(msg, opts), 0)
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
#estimatePrePromptContextTokens(messages: AgentMessage[], contextWindow: number): number {
|
|
954
|
+
const breakdown = this.#host.getContextBreakdown({ contextWindow, pendingMessages: messages });
|
|
955
|
+
const localEstimate = this.#estimateStoredContextTokens(messages);
|
|
956
|
+
// Floor by the local estimate: a payload-shrinking before_provider_request
|
|
957
|
+
// hook deflates the provider-anchored breakdown, which must not suppress
|
|
958
|
+
// pre-prompt compaction (see #estimateStoredContextTokens).
|
|
959
|
+
return compactionContextTokens(breakdown?.usedTokens ?? 0, localEstimate);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
async runPrePromptCompactionIfNeeded(messages: AgentMessage[]): Promise<void> {
|
|
963
|
+
const model = this.#model;
|
|
964
|
+
if (!model) return;
|
|
965
|
+
const contextWindow = model.contextWindow ?? 0;
|
|
966
|
+
if (contextWindow <= 0) return;
|
|
967
|
+
const compactionSettings = this.#host.settings.getGroup("compaction");
|
|
968
|
+
const contextTokens = this.#estimatePrePromptContextTokens(messages, contextWindow);
|
|
969
|
+
if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
|
|
970
|
+
|
|
971
|
+
// Auto-promote first: switching to a larger-context model avoids compacting
|
|
972
|
+
// the history at all. The post-turn threshold path already promotes before
|
|
973
|
+
// compacting; without this, the pre-prompt path would pre-empt promotion and
|
|
974
|
+
// compact (snapcompact/summary) a session that should have just been promoted.
|
|
975
|
+
if (await this.#promoteContextModel()) {
|
|
976
|
+
logger.debug("Pre-prompt context promotion avoided compaction", {
|
|
977
|
+
contextTokens,
|
|
978
|
+
contextWindow,
|
|
979
|
+
model: `${model.provider}/${model.id}`,
|
|
980
|
+
});
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
logger.debug("Pre-prompt context maintenance triggered by pending prompt size", {
|
|
985
|
+
contextTokens,
|
|
986
|
+
contextWindow,
|
|
987
|
+
model: `${model.provider}/${model.id}`,
|
|
988
|
+
});
|
|
989
|
+
await this.runAutoCompaction("threshold", false, false, false, {
|
|
990
|
+
autoContinue: false,
|
|
991
|
+
triggerContextTokens: contextTokens,
|
|
992
|
+
phase: "pre_turn",
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Compact continuing tool-loop runs before the next provider request.
|
|
998
|
+
*
|
|
999
|
+
* `onTurnEnd` is the safe boundary: tool results for the just-finished turn
|
|
1000
|
+
* are already paired in `activeMessages`, the live array the agent loop reads
|
|
1001
|
+
* before its next model call. Before compacting, the just-finished turn is
|
|
1002
|
+
* synchronously persisted if async message hooks have not reached the normal
|
|
1003
|
+
* append path yet. Mid-run handoff is suppressed because resetting the session
|
|
1004
|
+
* while the loop owns `activeMessages` would race the next request; handoff
|
|
1005
|
+
* strategy falls back to in-place context-full compaction here.
|
|
1006
|
+
*/
|
|
1007
|
+
async maintainContextMidRun(
|
|
1008
|
+
activeMessages: AgentMessage[],
|
|
1009
|
+
signal: AbortSignal | undefined,
|
|
1010
|
+
context: AgentTurnEndContext | undefined,
|
|
1011
|
+
): Promise<void> {
|
|
1012
|
+
if (
|
|
1013
|
+
signal?.aborted ||
|
|
1014
|
+
this.#host.isDisposed() ||
|
|
1015
|
+
this.isCompacting ||
|
|
1016
|
+
this.#host.isGeneratingHandoff() ||
|
|
1017
|
+
!context?.willContinue
|
|
1018
|
+
)
|
|
1019
|
+
return;
|
|
1020
|
+
|
|
1021
|
+
const model = this.#model;
|
|
1022
|
+
const contextWindow = model?.contextWindow ?? 0;
|
|
1023
|
+
if (contextWindow <= 0) return;
|
|
1024
|
+
|
|
1025
|
+
const compactionSettings = this.#host.settings.getGroup("compaction");
|
|
1026
|
+
if (
|
|
1027
|
+
!compactionSettings.enabled ||
|
|
1028
|
+
compactionSettings.strategy === "off" ||
|
|
1029
|
+
compactionSettings.midTurnEnabled === false
|
|
1030
|
+
) {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const lastAssistant = [...activeMessages]
|
|
1035
|
+
.reverse()
|
|
1036
|
+
.find((message): message is AssistantMessage => message.role === "assistant");
|
|
1037
|
+
if (!lastAssistant || lastAssistant.stopReason === "aborted" || lastAssistant.stopReason === "error") return;
|
|
1038
|
+
|
|
1039
|
+
if (!(await this.#host.persistTurnMessagesForMidRunCompaction(context))) return;
|
|
1040
|
+
|
|
1041
|
+
const billedContextTokens = calculateContextTokens(lastAssistant.usage);
|
|
1042
|
+
const storedContextTokens = this.#estimateStoredContextTokens();
|
|
1043
|
+
const contextTokens = compactionContextTokens(billedContextTokens, storedContextTokens);
|
|
1044
|
+
if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
|
|
1045
|
+
|
|
1046
|
+
// Promote to a larger-context sibling before compacting, mirroring the
|
|
1047
|
+
// pre-prompt (runPrePromptCompactionIfNeeded) and post-turn threshold
|
|
1048
|
+
// (checkCompaction) paths. Without this, a long mid-turn tool loop that
|
|
1049
|
+
// crosses the threshold compacts the history (and can hit the no-progress
|
|
1050
|
+
// dead-end on a single oversized turn) on a model that should have just
|
|
1051
|
+
// been promoted to a larger window instead.
|
|
1052
|
+
if (await this.#promoteContextModel()) {
|
|
1053
|
+
logger.debug("Mid-run context promotion avoided compaction", {
|
|
1054
|
+
contextTokens,
|
|
1055
|
+
contextWindow,
|
|
1056
|
+
from: `${model?.provider}/${model?.id}`,
|
|
1057
|
+
});
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const messagesBefore = activeMessages.length;
|
|
1062
|
+
await this.runAutoCompaction("threshold", false, false, false, {
|
|
1063
|
+
autoContinue: false,
|
|
1064
|
+
suppressContinuation: true,
|
|
1065
|
+
suppressHandoff: true,
|
|
1066
|
+
triggerContextTokens: contextTokens,
|
|
1067
|
+
phase: "mid_turn",
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
if (signal?.aborted) return;
|
|
1071
|
+
const compactedMessages = this.#host.agent.state.messages;
|
|
1072
|
+
if (compactedMessages !== activeMessages) {
|
|
1073
|
+
activeMessages.splice(0, activeMessages.length, ...compactedMessages);
|
|
1074
|
+
}
|
|
1075
|
+
logger.debug("Mid-run compaction ran between provider calls", {
|
|
1076
|
+
contextTokens,
|
|
1077
|
+
contextWindow,
|
|
1078
|
+
strategy: compactionSettings.strategy,
|
|
1079
|
+
goalActive: this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active",
|
|
1080
|
+
messagesBefore,
|
|
1081
|
+
messagesAfter: activeMessages.length,
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Check if context maintenance or promotion is needed and run it.
|
|
1086
|
+
* Called after agent_end and before prompt submission.
|
|
1087
|
+
*
|
|
1088
|
+
* Four cases (in order):
|
|
1089
|
+
* 1. Input overflow + promotion: promote to larger model, retry without maintenance.
|
|
1090
|
+
* 2. Input overflow + no promotion target: run context maintenance, auto-retry on same model.
|
|
1091
|
+
* 3. Output incomplete (stopReason === "length", e.g. `response.incomplete`): the
|
|
1092
|
+
* model burned its output budget without producing an actionable deliverable
|
|
1093
|
+
* (reasoning-only or truncated). Drop the dead turn, try promotion, otherwise
|
|
1094
|
+
* run compaction/handoff and retry.
|
|
1095
|
+
* 4. Threshold: context over threshold, run context maintenance (no auto-retry).
|
|
1096
|
+
*
|
|
1097
|
+
* @param assistantMessage The assistant message to check
|
|
1098
|
+
* @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
|
|
1099
|
+
* @param allowDefer If true, threshold-driven handoff strategy may schedule itself as a
|
|
1100
|
+
* deferred post-prompt task instead of running inline. Callers running inside the
|
|
1101
|
+
* `agent_end` handler set this to true so `session.prompt()` resolves cleanly; callers
|
|
1102
|
+
* on the pre-prompt path (where the next agent turn is about to start) set it to false
|
|
1103
|
+
* to avoid racing the deferred handoff against the new turn.
|
|
1104
|
+
* @param autoContinue Whether maintenance may schedule the agent-authored continuation prompt.
|
|
1105
|
+
* @returns whether compaction/recovery scheduled a handoff, retry, auto-continue, or
|
|
1106
|
+
* queued-message drain that already owns the next turn. Callers MUST skip
|
|
1107
|
+
* `session_stop` and other agent continuations when `continuationScheduled`
|
|
1108
|
+
* is true.
|
|
1109
|
+
*/
|
|
1110
|
+
async checkCompaction(
|
|
1111
|
+
assistantMessage: AssistantMessage,
|
|
1112
|
+
skipAbortedCheck = true,
|
|
1113
|
+
allowDefer = true,
|
|
1114
|
+
autoContinue = true,
|
|
1115
|
+
): Promise<CompactionCheckResult> {
|
|
1116
|
+
// Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
|
|
1117
|
+
if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return COMPACTION_CHECK_NONE;
|
|
1118
|
+
const contextWindow = this.#model?.contextWindow ?? 0;
|
|
1119
|
+
const generation = this.#host.promptGeneration();
|
|
1120
|
+
// Skip overflow check if the message came from a different model.
|
|
1121
|
+
// This handles the case where user switched from a smaller-context model (e.g. opus)
|
|
1122
|
+
// to a larger-context model (e.g. codex) - the overflow error from the old model
|
|
1123
|
+
// shouldn't trigger compaction for the new model.
|
|
1124
|
+
const sameModel =
|
|
1125
|
+
this.#model && assistantMessage.provider === this.#model.provider && assistantMessage.model === this.#model.id;
|
|
1126
|
+
// This handles the case where an error was kept after compaction (in the "kept" region).
|
|
1127
|
+
// The error shouldn't trigger another compaction since we already compacted.
|
|
1128
|
+
// Example: opus fails -> switch to codex -> compact -> switch back to opus -> opus error
|
|
1129
|
+
// is still in context but shouldn't trigger compaction again.
|
|
1130
|
+
const compactionEntry = getLatestCompactionEntry(this.#host.sessionManager.getBranch());
|
|
1131
|
+
const errorIsFromBeforeCompaction =
|
|
1132
|
+
compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime();
|
|
1133
|
+
if (sameModel && !errorIsFromBeforeCompaction && AIError.isContextOverflow(assistantMessage, contextWindow)) {
|
|
1134
|
+
// Clear the failed turn from active context so the retry (or the next
|
|
1135
|
+
// user prompt) does not replay it. The persisted branch entry stays
|
|
1136
|
+
// for now: when no recovery path runs, the user-facing transcript
|
|
1137
|
+
// MUST keep the only assistant message explaining why the turn
|
|
1138
|
+
// stopped. The branch entry is dropped further down, but only on the
|
|
1139
|
+
// paths that actually schedule a retry/compaction.
|
|
1140
|
+
this.#host.removeAssistantMessageFromActiveContext(assistantMessage);
|
|
1141
|
+
|
|
1142
|
+
// Try context promotion first - switch to a larger model and retry without compacting
|
|
1143
|
+
const promoted = await this.#tryContextPromotion(assistantMessage);
|
|
1144
|
+
if (promoted) {
|
|
1145
|
+
await this.#host.dropPersistedAssistantTurn(assistantMessage);
|
|
1146
|
+
// Retry on the promoted (larger) model without compacting
|
|
1147
|
+
this.#host.scheduleAgentContinue({ delayMs: 100, generation });
|
|
1148
|
+
return COMPACTION_CHECK_CONTINUATION;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// No promotion target available fall through to compaction
|
|
1152
|
+
const compactionSettings = this.#host.settings.getGroup("compaction");
|
|
1153
|
+
if (compactionSettings.enabled && compactionSettings.strategy !== "off") {
|
|
1154
|
+
return await this.#host.runRecoveryCompactionWithRollback("overflow", assistantMessage, allowDefer, {
|
|
1155
|
+
autoContinue,
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
return COMPACTION_CHECK_NONE;
|
|
1159
|
+
}
|
|
1160
|
+
// A context promotion can land while the failing call is already in
|
|
1161
|
+
// flight (or on a run whose loop predates the switch): the overflow
|
|
1162
|
+
// error then arrives stamped with the pre-promotion model while
|
|
1163
|
+
// `this.#host.model()` is already the promoted target. The sameModel guard
|
|
1164
|
+
// above deliberately ignores stale foreign-model errors, but this
|
|
1165
|
+
// state is not stale — recover exactly like the promotion path:
|
|
1166
|
+
// drop the dead turn and retry on the already-promoted model. Gated
|
|
1167
|
+
// narrowly on "current model IS the failed model's promotion target
|
|
1168
|
+
// with a strictly larger window" so genuinely stale errors from
|
|
1169
|
+
// old user-switched models keep surfacing untouched.
|
|
1170
|
+
if (
|
|
1171
|
+
!sameModel &&
|
|
1172
|
+
autoContinue &&
|
|
1173
|
+
!errorIsFromBeforeCompaction &&
|
|
1174
|
+
assistantMessage.stopReason === "error" &&
|
|
1175
|
+
this.#model &&
|
|
1176
|
+
contextWindow > 0 &&
|
|
1177
|
+
this.#host.settings.getGroup("contextPromotion").enabled
|
|
1178
|
+
) {
|
|
1179
|
+
const failedModel = this.#host.modelRegistry.find(assistantMessage.provider, assistantMessage.model);
|
|
1180
|
+
const failedWindow = failedModel?.contextWindow ?? 0;
|
|
1181
|
+
const promotionTarget = failedModel
|
|
1182
|
+
? resolveContextPromotionConfiguredTarget(failedModel, this.#host.modelRegistry.getAvailable())
|
|
1183
|
+
: undefined;
|
|
1184
|
+
if (
|
|
1185
|
+
failedModel &&
|
|
1186
|
+
failedWindow > 0 &&
|
|
1187
|
+
contextWindow > failedWindow &&
|
|
1188
|
+
promotionTarget &&
|
|
1189
|
+
modelsAreEqual(promotionTarget, this.#model) &&
|
|
1190
|
+
AIError.isContextOverflow(assistantMessage, failedWindow)
|
|
1191
|
+
) {
|
|
1192
|
+
this.#host.removeAssistantMessageFromActiveContext(assistantMessage);
|
|
1193
|
+
await this.#host.dropPersistedAssistantTurn(assistantMessage);
|
|
1194
|
+
logger.debug("Overflow on pre-promotion model; retrying on promoted model", {
|
|
1195
|
+
failed: `${assistantMessage.provider}/${assistantMessage.model}`,
|
|
1196
|
+
current: `${this.#model.provider}/${this.#model.id}`,
|
|
1197
|
+
});
|
|
1198
|
+
this.#host.scheduleAgentContinue({ delayMs: 100, generation });
|
|
1199
|
+
return COMPACTION_CHECK_CONTINUATION;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Case 3: Output-side incomplete — `response.incomplete` from OpenAI Responses
|
|
1204
|
+
// (and Codex) maps to stopReason === "length". The model burned its
|
|
1205
|
+
// `max_output_tokens` budget on reasoning/text and emitted no actionable
|
|
1206
|
+
// deliverable. Same recovery class as overflow: promotion if available,
|
|
1207
|
+
// otherwise compaction/handoff. Unlike overflow, the *input* is fine, so we
|
|
1208
|
+
// allow the handoff strategy to actually run.
|
|
1209
|
+
if (sameModel && !errorIsFromBeforeCompaction && assistantMessage.stopReason === "length") {
|
|
1210
|
+
// Same active-context vs persisted-history split as the overflow path
|
|
1211
|
+
// above: clear the dead turn from agent state so it cannot be replayed,
|
|
1212
|
+
// but keep it on the branch unless promotion or compaction actually runs.
|
|
1213
|
+
this.#host.removeAssistantMessageFromActiveContext(assistantMessage);
|
|
1214
|
+
|
|
1215
|
+
const promoted = await this.#tryContextPromotion(assistantMessage);
|
|
1216
|
+
if (promoted) {
|
|
1217
|
+
await this.#host.dropPersistedAssistantTurn(assistantMessage);
|
|
1218
|
+
logger.debug("Context promotion triggered by response.incomplete (length stop)", {
|
|
1219
|
+
from: `${assistantMessage.provider}/${assistantMessage.model}`,
|
|
1220
|
+
});
|
|
1221
|
+
this.#host.scheduleAgentContinue({ delayMs: 100, generation });
|
|
1222
|
+
return COMPACTION_CHECK_CONTINUATION;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
const incompleteCompactionSettings = this.#host.settings.getGroup("compaction");
|
|
1226
|
+
if (incompleteCompactionSettings.enabled && incompleteCompactionSettings.strategy !== "off") {
|
|
1227
|
+
logger.debug("Compaction triggered by response.incomplete (length stop, no promotion target)", {
|
|
1228
|
+
model: `${assistantMessage.provider}/${assistantMessage.model}`,
|
|
1229
|
+
strategy: incompleteCompactionSettings.strategy,
|
|
1230
|
+
});
|
|
1231
|
+
return await this.#host.runRecoveryCompactionWithRollback("incomplete", assistantMessage, allowDefer, {
|
|
1232
|
+
autoContinue,
|
|
1233
|
+
triggerContextTokens: calculateContextTokens(assistantMessage.usage),
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
// Neither promotion nor compaction is available — surface the dead-end so
|
|
1237
|
+
// the user understands why the turn yielded with nothing.
|
|
1238
|
+
logger.warn("response.incomplete with no recovery path (promotion + compaction both unavailable)", {
|
|
1239
|
+
model: `${assistantMessage.provider}/${assistantMessage.model}`,
|
|
1240
|
+
});
|
|
1241
|
+
return COMPACTION_CHECK_NONE;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Stale-result pass runs every turn, before any threshold gating: it is
|
|
1245
|
+
// cheap (bails when no candidate) and independent of the compaction
|
|
1246
|
+
// setting.
|
|
1247
|
+
const supersedeResult = await this.#pruneStaleToolResults();
|
|
1248
|
+
|
|
1249
|
+
const compactionSettings = this.#host.settings.getGroup("compaction");
|
|
1250
|
+
if (!compactionSettings.enabled || compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE;
|
|
1251
|
+
|
|
1252
|
+
// Case 4: Threshold - turn succeeded but context is getting large
|
|
1253
|
+
// Skip if this was an error (non-overflow errors don't have usage data)
|
|
1254
|
+
if (assistantMessage.stopReason === "error") return COMPACTION_CHECK_NONE;
|
|
1255
|
+
const pruneResult = await this.#pruneToolOutputs();
|
|
1256
|
+
const maintenanceTokensFreed = (supersedeResult?.tokensSaved ?? 0) + (pruneResult?.tokensSaved ?? 0);
|
|
1257
|
+
// `errorIsFromBeforeCompaction` (computed above) is the general
|
|
1258
|
+
// "this assistant message predates the latest compaction" predicate here,
|
|
1259
|
+
// not just an error-specific one; alias it locally so the threshold intent
|
|
1260
|
+
// reads clearly (#3412 review).
|
|
1261
|
+
const assistantPredatesCompaction = errorIsFromBeforeCompaction;
|
|
1262
|
+
// An assistant that predates the latest compaction carries stale, pre-rewrite
|
|
1263
|
+
// `usage`: the scheduled auto-continue re-enters this check with the kept
|
|
1264
|
+
// assistant (#promptWithMessage → checkCompaction), and its old high prompt
|
|
1265
|
+
// count would re-trip the threshold on a freshly compacted history. Drop the
|
|
1266
|
+
// stale provider number for those messages and let the live stored estimate
|
|
1267
|
+
// (the floor applied below) drive the decision instead.
|
|
1268
|
+
const assistantUsageContextTokens = assistantPredatesCompaction
|
|
1269
|
+
? 0
|
|
1270
|
+
: calculateContextTokens(assistantMessage.usage);
|
|
1271
|
+
const storedContextTokens = this.#estimateStoredContextTokens();
|
|
1272
|
+
// Pruning frees bytes for the NEXT prompt; it does not change the size of
|
|
1273
|
+
// the prompt the LLM just billed for. Earlier revisions subtracted the
|
|
1274
|
+
// per-turn supersede/prune `tokensSaved` from the threshold input, which
|
|
1275
|
+
// let a long-running `/goal` session sit above `compaction.thresholdTokens`
|
|
1276
|
+
// indefinitely whenever per-turn pruning saved enough to drop the
|
|
1277
|
+
// post-prune estimate below the user-configured trigger — the visible
|
|
1278
|
+
// context (anchored to the same provider billing) still showed >threshold,
|
|
1279
|
+
// but `shouldCompact` no-op'd (#3174). Anchor the initial trigger on the
|
|
1280
|
+
// last turn's billed context tokens, floored by the post-prune
|
|
1281
|
+
// stored-conversation estimate so a payload-compression hook still can't
|
|
1282
|
+
// deflate the trigger.
|
|
1283
|
+
const contextTokens = compactionContextTokens(assistantUsageContextTokens, storedContextTokens);
|
|
1284
|
+
const postMaintenanceContextTokens = compactionContextTokens(
|
|
1285
|
+
Math.max(0, assistantUsageContextTokens - maintenanceTokensFreed),
|
|
1286
|
+
storedContextTokens,
|
|
1287
|
+
);
|
|
1288
|
+
const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
|
|
1289
|
+
const shouldThresholdCompact = shouldCompact(contextTokens, contextWindow, compactionSettings);
|
|
1290
|
+
logger.debug("Auto-compaction threshold decision", {
|
|
1291
|
+
phase: "post-agent-end",
|
|
1292
|
+
goalModeEnabled: this.#goalModeState?.enabled === true,
|
|
1293
|
+
goalStatus: this.#goalModeState?.goal.status,
|
|
1294
|
+
stopReason: assistantMessage.stopReason,
|
|
1295
|
+
sameModel: sameModel === true,
|
|
1296
|
+
contextWindow,
|
|
1297
|
+
strategy: compactionSettings.strategy,
|
|
1298
|
+
thresholdTokens,
|
|
1299
|
+
assistantUsageContextTokens,
|
|
1300
|
+
storedContextTokens,
|
|
1301
|
+
resolvedContextTokens: contextTokens,
|
|
1302
|
+
postMaintenanceContextTokens,
|
|
1303
|
+
maintenanceTokensFreed,
|
|
1304
|
+
shouldCompact: shouldThresholdCompact,
|
|
1305
|
+
contextPromotionEnabled: this.#host.settings.get("contextPromotion.enabled") === true,
|
|
1306
|
+
});
|
|
1307
|
+
if (shouldThresholdCompact) {
|
|
1308
|
+
// Try promotion first — if a larger model is available, switch instead of compacting
|
|
1309
|
+
const promoted = await this.#tryContextPromotion(assistantMessage);
|
|
1310
|
+
if (!promoted) {
|
|
1311
|
+
return await this.runAutoCompaction("threshold", false, false, allowDefer, {
|
|
1312
|
+
autoContinue,
|
|
1313
|
+
triggerContextTokens: postMaintenanceContextTokens,
|
|
1314
|
+
phase: "pre_turn",
|
|
1315
|
+
terminalTextAnswer: isTerminalTextAssistantAnswer(assistantMessage),
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
logger.debug("Auto-compaction threshold satisfied but context promotion took over", {
|
|
1319
|
+
contextTokens,
|
|
1320
|
+
contextWindow,
|
|
1321
|
+
model: `${assistantMessage.provider}/${assistantMessage.model}`,
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
return COMPACTION_CHECK_NONE;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* Attempt context promotion to a larger model.
|
|
1329
|
+
* Returns true if promotion succeeded (caller should retry without compacting).
|
|
1330
|
+
*/
|
|
1331
|
+
async #tryContextPromotion(assistantMessage: AssistantMessage): Promise<boolean> {
|
|
1332
|
+
const currentModel = this.#model;
|
|
1333
|
+
if (!currentModel) return false;
|
|
1334
|
+
// The overflow/length error may have come from a model the user already
|
|
1335
|
+
// switched away from; only promote when the failing turn was this model.
|
|
1336
|
+
if (assistantMessage.provider !== currentModel.provider || assistantMessage.model !== currentModel.id)
|
|
1337
|
+
return false;
|
|
1338
|
+
return this.#promoteContextModel();
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* Switch to a larger-context sibling when context promotion is enabled and a
|
|
1343
|
+
* target with a strictly larger window (and a usable key) exists. Returns true
|
|
1344
|
+
* when the model was switched, so the caller can retry without compacting.
|
|
1345
|
+
* Message-independent core shared by the post-turn overflow path
|
|
1346
|
+
* ({@link #tryContextPromotion}) and the pre-prompt threshold path
|
|
1347
|
+
* ({@link runPrePromptCompactionIfNeeded}).
|
|
1348
|
+
*/
|
|
1349
|
+
async #promoteContextModel(): Promise<boolean> {
|
|
1350
|
+
const promotionSettings = this.#host.settings.getGroup("contextPromotion");
|
|
1351
|
+
if (!promotionSettings.enabled) return false;
|
|
1352
|
+
const currentModel = this.#model;
|
|
1353
|
+
if (!currentModel) return false;
|
|
1354
|
+
const contextWindow = currentModel.contextWindow ?? 0;
|
|
1355
|
+
if (contextWindow <= 0) return false;
|
|
1356
|
+
const targetModel = await this.resolveContextPromotionTarget(currentModel, contextWindow);
|
|
1357
|
+
if (!targetModel) return false;
|
|
1358
|
+
|
|
1359
|
+
try {
|
|
1360
|
+
await this.#host.setModelTemporary(targetModel, undefined, { ephemeral: true });
|
|
1361
|
+
logger.debug("Context promotion switched model on overflow", {
|
|
1362
|
+
from: `${currentModel.provider}/${currentModel.id}`,
|
|
1363
|
+
to: `${targetModel.provider}/${targetModel.id}`,
|
|
1364
|
+
});
|
|
1365
|
+
return true;
|
|
1366
|
+
} catch (error) {
|
|
1367
|
+
logger.warn("Context promotion failed", {
|
|
1368
|
+
from: `${currentModel.provider}/${currentModel.id}`,
|
|
1369
|
+
to: `${targetModel.provider}/${targetModel.id}`,
|
|
1370
|
+
error: String(error),
|
|
1371
|
+
});
|
|
1372
|
+
return false;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
async resolveContextPromotionTarget(currentModel: Model, contextWindow: number): Promise<Model | undefined> {
|
|
1377
|
+
const availableModels = this.#host.modelRegistry.getAvailable();
|
|
1378
|
+
if (availableModels.length === 0) return undefined;
|
|
1379
|
+
|
|
1380
|
+
const candidate = resolveContextPromotionConfiguredTarget(currentModel, availableModels);
|
|
1381
|
+
if (!candidate) return undefined;
|
|
1382
|
+
if (modelsAreEqual(candidate, currentModel)) return undefined;
|
|
1383
|
+
if (candidate.contextWindow == null || candidate.contextWindow <= contextWindow) return undefined;
|
|
1384
|
+
const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId());
|
|
1385
|
+
if (!apiKey) return undefined;
|
|
1386
|
+
return candidate;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
#getCompactionModelCandidates(availableModels: Model[], filter?: (model: Model) => boolean): Model[] {
|
|
1390
|
+
return this.resolveCompactionModelCandidates(this.#model, availableModels, filter);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
resolveCompactionModelCandidates(
|
|
1394
|
+
preferredModel: Model | null | undefined,
|
|
1395
|
+
availableModels: Model[],
|
|
1396
|
+
filter?: (model: Model) => boolean,
|
|
1397
|
+
): Model[] {
|
|
1398
|
+
const candidates: Model[] = [];
|
|
1399
|
+
const seen = new Set<string>();
|
|
1400
|
+
|
|
1401
|
+
const addCandidate = (model: Model | undefined): void => {
|
|
1402
|
+
if (!model) return;
|
|
1403
|
+
const key = `${model.provider}/${model.id}`;
|
|
1404
|
+
if (seen.has(key)) return;
|
|
1405
|
+
seen.add(key);
|
|
1406
|
+
// `seen` still tracks rejected models so the largest-context fallback
|
|
1407
|
+
// scan below doesn't reintroduce them; the filter just suppresses
|
|
1408
|
+
// inclusion in this caller's candidate chain.
|
|
1409
|
+
if (filter && !filter(model)) return;
|
|
1410
|
+
candidates.push(model);
|
|
1411
|
+
};
|
|
1412
|
+
|
|
1413
|
+
if (preferredModel) {
|
|
1414
|
+
addCandidate(resolveCompactionConfiguredTarget(preferredModel, availableModels));
|
|
1415
|
+
}
|
|
1416
|
+
addCandidate(preferredModel ?? undefined);
|
|
1417
|
+
for (const role of MODEL_ROLE_IDS) {
|
|
1418
|
+
addCandidate(
|
|
1419
|
+
resolveRoleModelFull(this.#host.settings, role, availableModels, preferredModel ?? undefined).model,
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const sortedByContext = [...availableModels].sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0));
|
|
1424
|
+
for (const model of sortedByContext) {
|
|
1425
|
+
if (!seen.has(`${model.provider}/${model.id}`)) {
|
|
1426
|
+
addCandidate(model);
|
|
1427
|
+
break;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
return candidates;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
#buildCompactionAuthError(): Error {
|
|
1435
|
+
const currentModel = this.#model;
|
|
1436
|
+
if (!currentModel) {
|
|
1437
|
+
return new Error(
|
|
1438
|
+
"Compaction requires a model with usable credentials, but no authenticated compaction model is available.",
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
return new Error(
|
|
1442
|
+
`Compaction requires usable credentials for ${currentModel.provider}/${currentModel.id}. ` +
|
|
1443
|
+
`Configure ${currentModel.provider} credentials or assign an authenticated fallback role such as modelRoles.smol.`,
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
async #compactWithFallbackModel(
|
|
1448
|
+
preparation: CompactionPreparation,
|
|
1449
|
+
customInstructions: string | undefined,
|
|
1450
|
+
signal: AbortSignal,
|
|
1451
|
+
options?: SummaryOptions,
|
|
1452
|
+
precomputedCandidates?: Model[],
|
|
1453
|
+
): Promise<CompactionResult> {
|
|
1454
|
+
const candidates =
|
|
1455
|
+
precomputedCandidates ?? this.#getCompactionModelCandidates(this.#host.modelRegistry.getAvailable());
|
|
1456
|
+
const telemetry = resolveTelemetry(this.#host.agent.telemetry, this.#host.sessionId());
|
|
1457
|
+
|
|
1458
|
+
for (const candidate of candidates) {
|
|
1459
|
+
const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId());
|
|
1460
|
+
if (!apiKey) continue;
|
|
1461
|
+
|
|
1462
|
+
try {
|
|
1463
|
+
return await compact(
|
|
1464
|
+
this.#host.obfuscatePreparationForProvider(preparation),
|
|
1465
|
+
candidate,
|
|
1466
|
+
this.#host.modelRegistry.resolver(candidate, this.#host.sessionId()),
|
|
1467
|
+
this.#host.obfuscateTextForProvider(customInstructions),
|
|
1468
|
+
signal,
|
|
1469
|
+
{
|
|
1470
|
+
...options,
|
|
1471
|
+
metadata: this.#host.agent.metadataForProvider(candidate.provider),
|
|
1472
|
+
convertToLlm: messages => this.#host.convertToLlmForSideRequest(messages),
|
|
1473
|
+
telemetry,
|
|
1474
|
+
// Honor the user's /model thinking selection (incl. `off`) on
|
|
1475
|
+
// the manual `/compact` path. Clamped per-model inside compact()
|
|
1476
|
+
// via resolveCompactionEffort so unsupported-effort models
|
|
1477
|
+
// (xai-oauth/grok-build) don't trip requireSupportedEffort.
|
|
1478
|
+
thinkingLevel: this.#host.thinkingLevel(),
|
|
1479
|
+
tools: this.#host.agent.state.tools,
|
|
1480
|
+
sessionId: this.#host.sessionId(),
|
|
1481
|
+
promptCacheKey: this.#host.sessionId(),
|
|
1482
|
+
providerSessionState: this.#host.providerSessionState,
|
|
1483
|
+
// Route every summarization HTTP request through the
|
|
1484
|
+
// session's side-stream transport so the provider
|
|
1485
|
+
// concurrency cap (e.g. providers.ollama-cloud.maxConcurrency)
|
|
1486
|
+
// brackets compaction the same way it brackets the live
|
|
1487
|
+
// agent turn — without this, multiple ollama-cloud
|
|
1488
|
+
// subagents auto/manually compacting issued uncapped
|
|
1489
|
+
// summary requests in parallel (chatgpt-codex review on
|
|
1490
|
+
// #3751).
|
|
1491
|
+
completeImpl: async (requestModel, requestContext, requestOptions) => {
|
|
1492
|
+
const stream = await this.#host.sideStreamFn(requestModel, requestContext, requestOptions);
|
|
1493
|
+
return stream.result();
|
|
1494
|
+
},
|
|
1495
|
+
},
|
|
1496
|
+
);
|
|
1497
|
+
} catch (error) {
|
|
1498
|
+
if (!AIError.is(AIError.classify(error, candidate.api), AIError.Flag.AuthFailed)) {
|
|
1499
|
+
throw error;
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
throw this.#buildCompactionAuthError();
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
async #prepareCompactionFromHooks(
|
|
1508
|
+
preparation: CompactionPreparation,
|
|
1509
|
+
hookCompaction: CompactionResult | undefined,
|
|
1510
|
+
): Promise<
|
|
1511
|
+
| {
|
|
1512
|
+
kind: "fromHook";
|
|
1513
|
+
summary: string;
|
|
1514
|
+
shortSummary: string | undefined;
|
|
1515
|
+
firstKeptEntryId: string;
|
|
1516
|
+
tokensBefore: number;
|
|
1517
|
+
details: unknown;
|
|
1518
|
+
preserveData: Record<string, unknown> | undefined;
|
|
1519
|
+
}
|
|
1520
|
+
| {
|
|
1521
|
+
kind: "needsLlm";
|
|
1522
|
+
hookContext: string[] | undefined;
|
|
1523
|
+
hookPrompt: string | undefined;
|
|
1524
|
+
preserveData: Record<string, unknown> | undefined;
|
|
1525
|
+
}
|
|
1526
|
+
> {
|
|
1527
|
+
let hookContext: string[] | undefined;
|
|
1528
|
+
let hookPrompt: string | undefined;
|
|
1529
|
+
let preserveData: Record<string, unknown> | undefined;
|
|
1530
|
+
|
|
1531
|
+
if (!hookCompaction && this.#host.extensionRunner?.hasHandlers("session.compacting")) {
|
|
1532
|
+
const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
|
|
1533
|
+
const result = (await this.#host.extensionRunner.emit({
|
|
1534
|
+
type: "session.compacting",
|
|
1535
|
+
sessionId: this.#host.sessionId(),
|
|
1536
|
+
messages: compactMessages,
|
|
1537
|
+
})) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
|
|
1538
|
+
|
|
1539
|
+
hookContext = result?.context;
|
|
1540
|
+
hookPrompt = result?.prompt;
|
|
1541
|
+
preserveData = result?.preserveData;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
|
|
1545
|
+
if (memoryBackendContext) {
|
|
1546
|
+
hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
if (hookCompaction) {
|
|
1550
|
+
preserveData ??= hookCompaction.preserveData;
|
|
1551
|
+
return {
|
|
1552
|
+
kind: "fromHook",
|
|
1553
|
+
summary: hookCompaction.summary,
|
|
1554
|
+
shortSummary: hookCompaction.shortSummary,
|
|
1555
|
+
firstKeptEntryId: hookCompaction.firstKeptEntryId,
|
|
1556
|
+
tokensBefore: hookCompaction.tokensBefore,
|
|
1557
|
+
details: hookCompaction.details,
|
|
1558
|
+
preserveData,
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
return { kind: "needsLlm", hookContext, hookPrompt, preserveData };
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* Cap on snapcompact frames the post-compaction context can carry without
|
|
1567
|
+
* busting the model window. Mirrors the per-frame token charge used by the
|
|
1568
|
+
* projection ({@link snapcompact.FRAME_TOKEN_ESTIMATE}, the conservative
|
|
1569
|
+
* high-res Anthropic ceiling), so picking `maxFrames` from this helper makes
|
|
1570
|
+
* {@link #projectSnapcompactContextTokens} succeed by construction.
|
|
1571
|
+
*
|
|
1572
|
+
* Skip vs. cap use different reserves on purpose. The **skip** decision
|
|
1573
|
+
* (return `0`) trips only when kept-recent plus non-message tokens already
|
|
1574
|
+
* eat the entire `ctxWindow − reserve` envelope: at that point no archive
|
|
1575
|
+
* shape — frame-bearing or text-only — can fit, and the caller MUST
|
|
1576
|
+
* shortcut to the LLM summarizer instead of re-running snapcompact to
|
|
1577
|
+
* re-emit the "could not bring the context under the limit" warning every
|
|
1578
|
+
* threshold tick. The **cap** calculation subtracts a shape-aware reserve
|
|
1579
|
+
* (`2 × geometry(shape).capacity` chars worth of text edges, billed at the
|
|
1580
|
+
* tiktoken cl100k baseline, plus a 2k summary-template allowance) sized
|
|
1581
|
+
* from the same `shape` snapcompact will use, so the projection still
|
|
1582
|
+
* passes once frames land — but it MUST NOT gate the skip decision, since
|
|
1583
|
+
* a frame-less archive (`text.length <= 2 * edgeCap` short-circuit in
|
|
1584
|
+
* `planArchive`) typically costs only a few hundred tokens of summary
|
|
1585
|
+
* lead and would fit under residual headroom far smaller than the cap
|
|
1586
|
+
* reserve (chatgpt-codex reviews on #3249).
|
|
1587
|
+
*
|
|
1588
|
+
* Returns `1` when the frame charge would overflow but the text-only path
|
|
1589
|
+
* still has room: snapcompact's planner picks the frame-less layout
|
|
1590
|
+
* automatically when the discarded text fits in the edges, so giving it
|
|
1591
|
+
* the minimum cap lets it succeed instead of being skipped outright.
|
|
1592
|
+
*
|
|
1593
|
+
* Without this cap, the bundled `MAX_FRAMES_DEFAULT = 80` × 5024 tokens =
|
|
1594
|
+
* ~402k frame-token projection always overflows any sub-1M-token window
|
|
1595
|
+
* (issue #3247).
|
|
1596
|
+
*/
|
|
1597
|
+
#computeSnapcompactMaxFrames(preparation: CompactionPreparation, settings: CompactionSettings): number {
|
|
1598
|
+
const ctxWindow = this.#model?.contextWindow ?? 0;
|
|
1599
|
+
if (ctxWindow <= 0) return Math.min(snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget());
|
|
1600
|
+
const reserve = effectiveReserveTokens(ctxWindow, settings);
|
|
1601
|
+
let baseTokens = computeNonMessageTokens(this.#host.nonMessageTokenSource());
|
|
1602
|
+
for (const message of preparation.recentMessages) {
|
|
1603
|
+
baseTokens += estimateTokens(message);
|
|
1604
|
+
}
|
|
1605
|
+
const totalBudget = ctxWindow - reserve;
|
|
1606
|
+
// Skip iff there is no headroom whatsoever; a text-only archive costs
|
|
1607
|
+
// far less than the cap reserve below, so any positive residual is
|
|
1608
|
+
// worth attempting and the projection guard catches actual overflow.
|
|
1609
|
+
if (baseTokens >= totalBudget) return 0;
|
|
1610
|
+
// Cap reserve mirrors what `estimateTokens(summaryMessage)` will charge
|
|
1611
|
+
// when frames > 0: `countTokens(summaryTemplate ‖ textHead ‖ textTail)`
|
|
1612
|
+
// plus `numFrames × FRAME_TOKEN_ESTIMATE`. Resolve the shape this
|
|
1613
|
+
// snapcompact pass will actually use (matches the `shape` argument
|
|
1614
|
+
// passed to `snapcompact.compact` in the auto and manual paths) so the
|
|
1615
|
+
// text-edge cost reflects the live frame geometry rather than a fixed
|
|
1616
|
+
// approximation. Reviewer (chatgpt-codex on #3249): a 4k reserve
|
|
1617
|
+
// undersized the ~7k text-edge cost on the default Anthropic
|
|
1618
|
+
// 11on16-bw shape, so the projection then rejected the `maxFrames`
|
|
1619
|
+
// the cap had picked and the warning loop reappeared.
|
|
1620
|
+
//
|
|
1621
|
+
// - `textHead` and `textTail` each consume up to `geometry.capacity`
|
|
1622
|
+
// chars when frames > 0 (one HQ-capacity page per edge: see
|
|
1623
|
+
// `TEXT_EDGE_PAGES = 1` in `planArchive`), so 2 × capacity chars
|
|
1624
|
+
// total. Per-shape capacity: Anthropic 11on16-bw ~13.9k, Opus
|
|
1625
|
+
// 1932px ~21k, Gemini 8on22-bw 2048px ~23.8k, OpenAI 1568px ~13.9k.
|
|
1626
|
+
// - tiktoken cl100k ≈ 4 chars/token on ASCII (verified empirically
|
|
1627
|
+
// for prose, code, and JSON); a 1.15 multiplier absorbs tokenizer
|
|
1628
|
+
// drift on denser content (e.g. dense JSON / tool-result blobs).
|
|
1629
|
+
// - Summary template (intro + FILES section + grid notes) bills
|
|
1630
|
+
// ~2k tokens for typical sessions.
|
|
1631
|
+
const shape = snapcompact.resolveShape(this.#model, this.#host.settings.get("snapcompact.shape"));
|
|
1632
|
+
const edgeCap = snapcompact.geometry(shape).capacity;
|
|
1633
|
+
const textEdgeTokens = Math.ceil((2 * edgeCap * 1.15) / 4);
|
|
1634
|
+
const SUMMARY_TEMPLATE_TOKENS = 2000;
|
|
1635
|
+
const capReserve = textEdgeTokens + SUMMARY_TEMPLATE_TOKENS;
|
|
1636
|
+
const frameBudget = totalBudget - baseTokens - capReserve;
|
|
1637
|
+
if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 1;
|
|
1638
|
+
return Math.min(
|
|
1639
|
+
Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE),
|
|
1640
|
+
snapcompact.MAX_FRAMES_DEFAULT,
|
|
1641
|
+
snapcompact.maxFramesForDataBudget(),
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
#snapcompactFramePayloadBytes(result: snapcompact.CompactionResult): number {
|
|
1646
|
+
const archive = snapcompact.getPreservedArchive(result.preserveData);
|
|
1647
|
+
return archive ? snapcompact.frameDataBytes(archive.frames) : 0;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
/**
|
|
1651
|
+
* Project the post-compaction context size of a snapcompact result: kept
|
|
1652
|
+
* recent messages + the summary message with its re-attached frames + the
|
|
1653
|
+
* fixed non-message overhead (system prompt + tools). Mirrors how the
|
|
1654
|
+
* compacted context is rebuilt, so the estimate matches the wire shape, and
|
|
1655
|
+
* lets the caller decide whether snapcompact brought the context under the
|
|
1656
|
+
* window or should fall back to an LLM summary.
|
|
1657
|
+
*/
|
|
1658
|
+
#projectSnapcompactContextTokens(preparation: CompactionPreparation, result: snapcompact.CompactionResult): number {
|
|
1659
|
+
const archive = snapcompact.getPreservedArchive(result.preserveData);
|
|
1660
|
+
const blocks = archive
|
|
1661
|
+
? snapcompact.historyBlocks(archive, { maxFrameDataBytes: snapcompact.FRAME_DATA_BYTES_BUDGET })
|
|
1662
|
+
: undefined;
|
|
1663
|
+
const summaryMessage = createCompactionSummaryMessage(
|
|
1664
|
+
result.summary,
|
|
1665
|
+
result.tokensBefore,
|
|
1666
|
+
new Date().toISOString(),
|
|
1667
|
+
result.shortSummary,
|
|
1668
|
+
undefined,
|
|
1669
|
+
undefined,
|
|
1670
|
+
blocks,
|
|
1671
|
+
);
|
|
1672
|
+
let tokens = computeNonMessageTokens(this.#host.nonMessageTokenSource()) + estimateTokens(summaryMessage);
|
|
1673
|
+
for (const message of preparation.recentMessages) {
|
|
1674
|
+
tokens += estimateTokens(message);
|
|
1675
|
+
}
|
|
1676
|
+
return tokens;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* Post-maintenance progress check for the context-full / snapcompact tail.
|
|
1681
|
+
*
|
|
1682
|
+
* After `appendCompaction` rewrote history and `replaceMessages` swapped in the
|
|
1683
|
+
* compacted context, measure the residual context off the live message set and
|
|
1684
|
+
* decide whether maintenance actually created headroom. Mirrors the shake
|
|
1685
|
+
* recovery-band logic (#2275): a session whose single most-recent turn already
|
|
1686
|
+
* blows the threshold cannot be reduced by compaction (findCutPoint keeps that
|
|
1687
|
+
* turn verbatim), so re-firing on the next agent_end just thrashes. We only
|
|
1688
|
+
* report progress when residual context lands at or below
|
|
1689
|
+
* `COMPACTION_RECOVERY_BAND × threshold` — a band that sits strictly under the
|
|
1690
|
+
* compaction threshold, so reaching it guarantees the next turn cannot
|
|
1691
|
+
* re-trip threshold compaction.
|
|
1692
|
+
*
|
|
1693
|
+
* When the model/window is unknown we cannot evaluate the band, so we
|
|
1694
|
+
* optimistically allow the continuation (preserving prior behavior).
|
|
1695
|
+
*/
|
|
1696
|
+
#compactionCreatedHeadroom(): boolean {
|
|
1697
|
+
const contextWindow = this.#model?.contextWindow ?? 0;
|
|
1698
|
+
if (contextWindow <= 0) return true;
|
|
1699
|
+
const compactionSettings = this.#host.settings.getGroup("compaction");
|
|
1700
|
+
const residualTokens = compactionContextTokens(
|
|
1701
|
+
this.#host.getContextUsage({ contextWindow })?.tokens ?? 0,
|
|
1702
|
+
this.#estimateStoredContextTokens(),
|
|
1703
|
+
);
|
|
1704
|
+
const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
|
|
1705
|
+
const recoveryBand = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND);
|
|
1706
|
+
// Residual at/below the band is authoritative headroom: the band sits
|
|
1707
|
+
// strictly under the compaction threshold, so the next turn cannot
|
|
1708
|
+
// re-trip threshold compaction regardless of how little this pass shaved.
|
|
1709
|
+
// Don't add a secondary "smaller than the trigger" guard — when stale/
|
|
1710
|
+
// tool-output pruning already dropped context under the band before this
|
|
1711
|
+
// pass, the trigger is itself sub-band, and requiring a strict reduction
|
|
1712
|
+
// would suppress a valid continuation and emit a false no-progress warning
|
|
1713
|
+
// even though compaction left the session safe.
|
|
1714
|
+
return residualTokens <= recoveryBand;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
/**
|
|
1718
|
+
* Retry-side counterpart to {@link #compactionCreatedHeadroom}. An
|
|
1719
|
+
* overflow/incomplete recovery only needs the rebuilt prompt to *fit* the
|
|
1720
|
+
* window again — it does not have to land under the compaction threshold, let
|
|
1721
|
+
* alone the stricter `COMPACTION_RECOVERY_BAND × threshold` hysteresis the
|
|
1722
|
+
* auto-continue thrash guard uses. Reusing the band here turned recoverable
|
|
1723
|
+
* overflows into manual dead-ends: a 200k-window prompt compacted from
|
|
1724
|
+
* overflow down to ~150k is comfortably retryable, but sits above
|
|
1725
|
+
* `0.8 × 170k = 136k` and was wrongly refused (PR #3412 review).
|
|
1726
|
+
*
|
|
1727
|
+
* Measures residual context against the usable budget (`contextWindow - reserve`).
|
|
1728
|
+
* The default absolute reserve can exceed bundled small-context windows, or
|
|
1729
|
+
* nearly consume a 16k-class window; those known-impossible defaults fall
|
|
1730
|
+
* back to the proportional 15% reserve. Explicit valid reserves still define
|
|
1731
|
+
* the usable prompt budget so retries do not enter headroom the user
|
|
1732
|
+
* intentionally reserved. Callers MUST
|
|
1733
|
+
* invoke this AFTER dropping the failed assistant from `this.#host.messages()`, so
|
|
1734
|
+
* the just-failed turn (which the retry prompt will not include) is excluded
|
|
1735
|
+
* from the estimate.
|
|
1736
|
+
*
|
|
1737
|
+
* When the model/window is unknown we cannot evaluate the budget, so we
|
|
1738
|
+
* optimistically allow the retry (preserving prior behavior).
|
|
1739
|
+
*/
|
|
1740
|
+
#compactionCreatedRetryFit(): boolean {
|
|
1741
|
+
const contextWindow = this.#model?.contextWindow ?? 0;
|
|
1742
|
+
if (contextWindow <= 0) return true;
|
|
1743
|
+
const compactionSettings = this.#host.settings.getGroup("compaction");
|
|
1744
|
+
const residualTokens = compactionContextTokens(
|
|
1745
|
+
this.#host.getContextUsage({ contextWindow })?.tokens ?? 0,
|
|
1746
|
+
this.#estimateStoredContextTokens(),
|
|
1747
|
+
);
|
|
1748
|
+
const fitBudget = Math.max(0, contextWindow - resolveBudgetReserveTokens(contextWindow, compactionSettings));
|
|
1749
|
+
return residualTokens <= fitBudget;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
/**
|
|
1753
|
+
* Last-resort tiered reducer when {@link runAutoCompaction} would otherwise
|
|
1754
|
+
* dead-end. The summarizer cut at the only available turn boundary, but the
|
|
1755
|
+
* kept tail is still over the recovery band because a single recent turn (a
|
|
1756
|
+
* large tool-result, a heavy fenced/XML block, attached images) is itself
|
|
1757
|
+
* bigger than the band and `findCutPoint` cannot cut inside one message.
|
|
1758
|
+
*
|
|
1759
|
+
* Tier 1 — `shake("elide")` reaches INSIDE that tail: heavy tool-result /
|
|
1760
|
+
* block content is offloaded to one `artifact://` blob behind a recoverable
|
|
1761
|
+
* placeholder. Skipped when this pass already ran a shake (`skipElide`).
|
|
1762
|
+
* Tier 2 — `dropImages()`: the manual `/shake images` remedy, automated.
|
|
1763
|
+
* Image blocks are stripped from the branch; unlike elided text they are NOT
|
|
1764
|
+
* artifact-recoverable, so this tier only runs once elide has failed the
|
|
1765
|
+
* progress re-test.
|
|
1766
|
+
*
|
|
1767
|
+
* Each tier that rewrote history re-anchors the in-flight context snapshot,
|
|
1768
|
+
* then the caller's progress predicate is re-tested; the first tier that
|
|
1769
|
+
* restores progress emits one info notice describing everything freed and
|
|
1770
|
+
* stops. Returns whether progress was restored — `false` falls through to
|
|
1771
|
+
* the dead-end warning.
|
|
1772
|
+
*/
|
|
1773
|
+
async #rescueCompactionDeadEnd(
|
|
1774
|
+
signal: AbortSignal,
|
|
1775
|
+
options: { skipElide: boolean; hasProgress: () => boolean },
|
|
1776
|
+
): Promise<boolean> {
|
|
1777
|
+
if (signal.aborted) return false;
|
|
1778
|
+
// Tier 0 — a snapcompact pass whose just-written frame archive is itself
|
|
1779
|
+
// the over-budget cost (each pass re-renders the carried-forward text
|
|
1780
|
+
// into MORE frames, so the archive grows past the recovery band and the
|
|
1781
|
+
// elide/image tiers below can never shrink it): rebuild the archive at
|
|
1782
|
+
// a threshold-derived frame budget.
|
|
1783
|
+
const frameRescue = await this.#rescueSnapcompactFrameOverflow(
|
|
1784
|
+
this.#host.sessionManager.getBranch(),
|
|
1785
|
+
this.#host.settings.getGroup("compaction"),
|
|
1786
|
+
signal,
|
|
1787
|
+
);
|
|
1788
|
+
if (frameRescue !== undefined && options.hasProgress()) return true;
|
|
1789
|
+
let elided = 0;
|
|
1790
|
+
let elidedTokens = 0;
|
|
1791
|
+
let elideSink = "placeholders";
|
|
1792
|
+
if (!options.skipElide) {
|
|
1793
|
+
try {
|
|
1794
|
+
const result = await this.#host.shake("elide", { signal });
|
|
1795
|
+
elided = result.toolResultsDropped + result.blocksDropped;
|
|
1796
|
+
elidedTokens = result.tokensFreed;
|
|
1797
|
+
if (result.artifactId) elideSink = "an artifact";
|
|
1798
|
+
if (elided > 0) {
|
|
1799
|
+
// The elide pass rewrote history; re-anchor the in-flight snapshot
|
|
1800
|
+
// so the caller's headroom/retry-fit re-test measures the shaken
|
|
1801
|
+
// context.
|
|
1802
|
+
this.#host.rebaseAfterCompaction();
|
|
1803
|
+
}
|
|
1804
|
+
} catch (error) {
|
|
1805
|
+
logger.warn("Dead-end shake rescue failed", {
|
|
1806
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
if (elided > 0 && options.hasProgress()) {
|
|
1810
|
+
this.#host.emitNotice(
|
|
1811
|
+
"info",
|
|
1812
|
+
`Compaction dead-end recovery: ${this.#describeElideRescue(elided, elidedTokens, elideSink)} so maintenance could make progress.`,
|
|
1813
|
+
"compaction",
|
|
1814
|
+
);
|
|
1815
|
+
return true;
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
if (signal.aborted) return false;
|
|
1819
|
+
let imagesDropped = 0;
|
|
1820
|
+
try {
|
|
1821
|
+
imagesDropped = (await this.#host.dropImages()).removed;
|
|
1822
|
+
if (imagesDropped > 0) this.#host.rebaseAfterCompaction();
|
|
1823
|
+
} catch (error) {
|
|
1824
|
+
logger.warn("Dead-end image-drop rescue failed", {
|
|
1825
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
if (imagesDropped > 0 && options.hasProgress()) {
|
|
1829
|
+
const elidedPart = elided > 0 ? `${this.#describeElideRescue(elided, elidedTokens, elideSink)} and ` : "";
|
|
1830
|
+
this.#host.emitNotice(
|
|
1831
|
+
"info",
|
|
1832
|
+
`Compaction dead-end recovery: ${elidedPart}dropped ${imagesDropped} attached image${imagesDropped === 1 ? "" : "s"} so maintenance could make progress.`,
|
|
1833
|
+
"compaction",
|
|
1834
|
+
);
|
|
1835
|
+
return true;
|
|
1836
|
+
}
|
|
1837
|
+
return false;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
/** Notice fragment for a dead-end elide tier: what was freed and where it went. */
|
|
1841
|
+
#describeElideRescue(elided: number, tokensFreed: number, sink: string): string {
|
|
1842
|
+
return `elided ${elided} heavy block${elided === 1 ? "" : "s"} (~${tokensFreed.toLocaleString()} tokens) to ${sink}`;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/**
|
|
1846
|
+
* Frame budget for {@link #rescueSnapcompactFrameOverflow}: targets
|
|
1847
|
+
* `COMPACTION_RECOVERY_BAND × threshold` (the same band
|
|
1848
|
+
* {@link #compactionCreatedHeadroom} re-tests), not the window-fit budget
|
|
1849
|
+
* {@link #computeSnapcompactMaxFrames} sizes against — a rebuilt archive
|
|
1850
|
+
* must land back under the maintenance trigger, or the next settle
|
|
1851
|
+
* re-enters the same dead-end. Cap reserve mirrors
|
|
1852
|
+
* #computeSnapcompactMaxFrames (text edges + summary template), and
|
|
1853
|
+
* `keptTailTokens` charges the kept entries AFTER the archive so the
|
|
1854
|
+
* budget mirrors what #compactionCreatedHeadroom will actually measure.
|
|
1855
|
+
* Returns 0 when not even one frame fits that budget — the rebuild could
|
|
1856
|
+
* never create headroom, so the caller must not append it.
|
|
1857
|
+
*/
|
|
1858
|
+
#computeSnapcompactRescueMaxFrames(settings: CompactionSettings, keptTailTokens: number): number {
|
|
1859
|
+
const ctxWindow = this.#model?.contextWindow ?? 0;
|
|
1860
|
+
if (ctxWindow <= 0) return Math.min(snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget());
|
|
1861
|
+
const thresholdTokens = resolveThresholdTokens(ctxWindow, settings);
|
|
1862
|
+
const recoveryBandTokens = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND);
|
|
1863
|
+
const baseTokens = computeNonMessageTokens(this.#host.nonMessageTokenSource());
|
|
1864
|
+
const shape = snapcompact.resolveShape(this.#model, this.#host.settings.get("snapcompact.shape"));
|
|
1865
|
+
const edgeCap = snapcompact.geometry(shape).capacity;
|
|
1866
|
+
const textEdgeTokens = Math.ceil((2 * edgeCap * 1.15) / 4);
|
|
1867
|
+
const SUMMARY_TEMPLATE_TOKENS = 2000;
|
|
1868
|
+
const frameBudget = recoveryBandTokens - baseTokens - keptTailTokens - textEdgeTokens - SUMMARY_TEMPLATE_TOKENS;
|
|
1869
|
+
if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 0;
|
|
1870
|
+
// Same hard caps as #computeSnapcompactMaxFrames: a threshold-derived
|
|
1871
|
+
// count above the per-request payload budget would "shrink" a huge
|
|
1872
|
+
// archive to a frame count the rebuilt prompt can never attach anyway.
|
|
1873
|
+
return Math.min(
|
|
1874
|
+
Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE),
|
|
1875
|
+
snapcompact.MAX_FRAMES_DEFAULT,
|
|
1876
|
+
snapcompact.maxFramesForDataBudget(),
|
|
1877
|
+
);
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
/**
|
|
1881
|
+
* Dead-end rescue for a branch whose latest snapcompact CompactionEntry is
|
|
1882
|
+
* itself billed past the maintenance threshold
|
|
1883
|
+
* (`FRAME_TOKEN_ESTIMATE × frames`). Reaching the `!preparation` dead-end
|
|
1884
|
+
* proves everything after that entry is already kept-recent (nothing to
|
|
1885
|
+
* summarize), so the archive is the irreducible cost — and the elide/image
|
|
1886
|
+
* tiers can never touch it: `collectShakeRegions` and `dropImages()` only
|
|
1887
|
+
* inspect "message"/"custom_message" entries, so a `type: "compaction"`
|
|
1888
|
+
* entry falls through both and the session re-warns on every resume (the
|
|
1889
|
+
* shape issue #4786's rescue does not cover).
|
|
1890
|
+
*
|
|
1891
|
+
* Rebuilds the SAME archive locally — no LLM, no network — by re-running
|
|
1892
|
+
* `snapcompact.compact()` over the entry's carried-forward source text at
|
|
1893
|
+
* a maxFrames derived from the trigger threshold instead of the window:
|
|
1894
|
+
* `planArchive` truncates the oldest chars to fit, so the rebuilt entry
|
|
1895
|
+
* genuinely shrinks. The rebuilt entry keeps the stale entry's
|
|
1896
|
+
* `firstKeptEntryId`, so the kept tail is untouched, and persisting
|
|
1897
|
+
* through `appendCompaction()` lets the write-time superseded-compaction
|
|
1898
|
+
* elision drop the stale frame payload from the JSONL automatically.
|
|
1899
|
+
*/
|
|
1900
|
+
async #rescueSnapcompactFrameOverflow(
|
|
1901
|
+
branchEntries: SessionEntry[],
|
|
1902
|
+
settings: CompactionSettings,
|
|
1903
|
+
signal: AbortSignal,
|
|
1904
|
+
): Promise<snapcompact.CompactionResult | undefined> {
|
|
1905
|
+
if (signal.aborted) return undefined;
|
|
1906
|
+
// Re-rendering frames needs a vision-capable model, same gate as the
|
|
1907
|
+
// snapcompact strategy path.
|
|
1908
|
+
if (!this.#model?.input.includes("image")) return undefined;
|
|
1909
|
+
const staleEntry = getLatestCompactionEntry(branchEntries);
|
|
1910
|
+
if (!staleEntry) return undefined;
|
|
1911
|
+
// Only rescue when the archive is the actual source of the overflow.
|
|
1912
|
+
// The frame budget below charges every kept entry the rebuilt context
|
|
1913
|
+
// will still carry — the kept-recent region from `firstKeptEntryId`
|
|
1914
|
+
// (re-emitted before the archive by buildSessionContext) plus the
|
|
1915
|
+
// entries after the archive — on top of the fixed context, mirroring
|
|
1916
|
+
// what #compactionCreatedHeadroom will measure. When not even one
|
|
1917
|
+
// frame fits (e.g. a huge kept tool result dominates), rebuilding
|
|
1918
|
+
// would append the replacement compaction at the leaf — turning the
|
|
1919
|
+
// branch tail into a compaction entry, which prepareCompaction's
|
|
1920
|
+
// last-entry guard can never summarize past even after an elide
|
|
1921
|
+
// shrinks the real culprit. Bail and let the elide/image tiers handle
|
|
1922
|
+
// that tail instead.
|
|
1923
|
+
let keptTailTokens = 0;
|
|
1924
|
+
let inKeptRegion = false;
|
|
1925
|
+
for (const entry of branchEntries) {
|
|
1926
|
+
if (entry.id === staleEntry.firstKeptEntryId) inKeptRegion = true;
|
|
1927
|
+
if (entry.id === staleEntry.id) {
|
|
1928
|
+
// Everything after the archive is always kept.
|
|
1929
|
+
inKeptRegion = true;
|
|
1930
|
+
continue;
|
|
1931
|
+
}
|
|
1932
|
+
if (!inKeptRegion) continue;
|
|
1933
|
+
const message = (entry as { message?: AgentMessage }).message;
|
|
1934
|
+
if (message) keptTailTokens += estimateTokens(message);
|
|
1935
|
+
}
|
|
1936
|
+
const archive = snapcompact.getPreservedArchive(staleEntry.preserveData);
|
|
1937
|
+
if (!archive || archive.frames.length <= 1) return undefined;
|
|
1938
|
+
const archiveText = snapcompact.archiveSourceText(archive);
|
|
1939
|
+
if (!archiveText) return undefined;
|
|
1940
|
+
const maxFrames = this.#computeSnapcompactRescueMaxFrames(settings, keptTailTokens);
|
|
1941
|
+
if (maxFrames < 1 || maxFrames >= archive.frames.length) return undefined;
|
|
1942
|
+
|
|
1943
|
+
const staleDetails = staleEntry.details as snapcompact.CompactionDetails | undefined;
|
|
1944
|
+
const fileOps = snapcompact.createFileOps();
|
|
1945
|
+
for (const file of staleDetails?.readFiles ?? []) fileOps.read.add(file);
|
|
1946
|
+
for (const file of staleDetails?.modifiedFiles ?? []) fileOps.edited.add(file);
|
|
1947
|
+
const shapeSetting = this.#host.settings.get("snapcompact.shape");
|
|
1948
|
+
const shape = snapcompact.resolveShapeForText(archiveText, this.#model, shapeSetting);
|
|
1949
|
+
let result: snapcompact.CompactionResult;
|
|
1950
|
+
try {
|
|
1951
|
+
result = await snapcompact.compact(
|
|
1952
|
+
{
|
|
1953
|
+
firstKeptEntryId: staleEntry.firstKeptEntryId,
|
|
1954
|
+
messagesToSummarize: [],
|
|
1955
|
+
turnPrefixMessages: [],
|
|
1956
|
+
tokensBefore: staleEntry.tokensBefore,
|
|
1957
|
+
previousSummary: staleEntry.summary,
|
|
1958
|
+
previousPreserveData: staleEntry.preserveData,
|
|
1959
|
+
fileOps,
|
|
1960
|
+
},
|
|
1961
|
+
{
|
|
1962
|
+
convertToLlm,
|
|
1963
|
+
model: this.#model,
|
|
1964
|
+
...(shapeSetting === "auto" ? {} : { shape }),
|
|
1965
|
+
maxFrames,
|
|
1966
|
+
},
|
|
1967
|
+
);
|
|
1968
|
+
} catch (error) {
|
|
1969
|
+
logger.warn("Dead-end snapcompact frame rescue failed", {
|
|
1970
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1971
|
+
});
|
|
1972
|
+
return undefined;
|
|
1973
|
+
}
|
|
1974
|
+
if (signal.aborted) return undefined;
|
|
1975
|
+
const rebuilt = snapcompact.getPreservedArchive(result.preserveData);
|
|
1976
|
+
if (!rebuilt || rebuilt.frames.length >= archive.frames.length) return undefined;
|
|
1977
|
+
|
|
1978
|
+
const rebuiltEntryId = this.#host.sessionManager.appendCompaction(
|
|
1979
|
+
result.summary,
|
|
1980
|
+
result.shortSummary,
|
|
1981
|
+
result.firstKeptEntryId,
|
|
1982
|
+
result.tokensBefore,
|
|
1983
|
+
result.details,
|
|
1984
|
+
false,
|
|
1985
|
+
result.preserveData,
|
|
1986
|
+
);
|
|
1987
|
+
const sessionContext = this.#host.buildDisplaySessionContext();
|
|
1988
|
+
this.#host.agent.replaceMessages(sessionContext.messages);
|
|
1989
|
+
this.#host.rebaseAfterCompaction();
|
|
1990
|
+
// Same post-rewrite bookkeeping as the regular compaction append: the
|
|
1991
|
+
// rebuilt context no longer carries the transient plan reference (#1246),
|
|
1992
|
+
// and advisor cursors / todo phases were derived from the replaced
|
|
1993
|
+
// history.
|
|
1994
|
+
this.#host.resetPlanReference();
|
|
1995
|
+
this.#host.resetAdvisorRuntimes();
|
|
1996
|
+
this.#host.syncTodoPhasesFromBranch();
|
|
1997
|
+
this.#host.closeCodexProviderSessionsForHistoryRewrite();
|
|
1998
|
+
// Extensions must see the entry that is now active, not (only) the one
|
|
1999
|
+
// this rebuild just superseded — mirror the regular append path's hook.
|
|
2000
|
+
const rebuiltEntry = this.#host.sessionManager.getEntries().find(e => e.id === rebuiltEntryId) as
|
|
2001
|
+
| CompactionEntry
|
|
2002
|
+
| undefined;
|
|
2003
|
+
if (this.#host.extensionRunner && rebuiltEntry) {
|
|
2004
|
+
await this.#host.extensionRunner.emit({
|
|
2005
|
+
type: "session_compact",
|
|
2006
|
+
compactionEntry: rebuiltEntry,
|
|
2007
|
+
fromExtension: false,
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
this.#host.emitNotice(
|
|
2011
|
+
"info",
|
|
2012
|
+
`Compaction dead-end recovery: rebuilt the trailing snapcompact archive at a smaller frame budget (${archive.frames.length} → ${rebuilt.frames.length} frames) so maintenance could make progress.`,
|
|
2013
|
+
"compaction",
|
|
2014
|
+
);
|
|
2015
|
+
return result;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
/**
|
|
2019
|
+
* Internal: Run auto-compaction with events.
|
|
2020
|
+
*
|
|
2021
|
+
* @param allowDefer If true (default), threshold-driven handoff strategy is allowed to
|
|
2022
|
+
* schedule itself as a deferred post-prompt task and return a deferred-handoff result
|
|
2023
|
+
* immediately. The caller MUST treat that as "compaction will happen async — do not
|
|
2024
|
+
* also schedule `agent.continue()` for this turn", otherwise the deferred handoff
|
|
2025
|
+
* races a fresh streaming turn (the symptom: "Auto-handoff" loader + assistant
|
|
2026
|
+
* message still streaming). Callers on a path that is about to start a new agent
|
|
2027
|
+
* turn (e.g. the pre-prompt check in `#promptWithMessage`) pass `false` to force
|
|
2028
|
+
* inline execution so the handoff completes before the new turn begins.
|
|
2029
|
+
* @returns whether auto-compaction scheduled a follow-up turn.
|
|
2030
|
+
*/
|
|
2031
|
+
async runAutoCompaction(
|
|
2032
|
+
reason: "overflow" | "threshold" | "idle" | "incomplete",
|
|
2033
|
+
willRetry: boolean,
|
|
2034
|
+
deferred = false,
|
|
2035
|
+
allowDefer = true,
|
|
2036
|
+
options: {
|
|
2037
|
+
autoContinue?: boolean;
|
|
2038
|
+
triggerContextTokens?: number;
|
|
2039
|
+
suppressContinuation?: boolean;
|
|
2040
|
+
suppressHandoff?: boolean;
|
|
2041
|
+
phase?: CodexCompactionContext["phase"];
|
|
2042
|
+
terminalTextAnswer?: boolean;
|
|
2043
|
+
} = {},
|
|
2044
|
+
): Promise<CompactionCheckResult> {
|
|
2045
|
+
const compactionSettings = this.#host.settings.getGroup("compaction");
|
|
2046
|
+
if (compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE;
|
|
2047
|
+
if (reason !== "idle" && !compactionSettings.enabled) return COMPACTION_CHECK_NONE;
|
|
2048
|
+
const generation = this.#host.promptGeneration();
|
|
2049
|
+
const terminalTextAnswer =
|
|
2050
|
+
options.terminalTextAnswer ?? isTerminalTextAssistantAnswer(this.#host.findLastAssistantMessage());
|
|
2051
|
+
const suppressContinuation = options.suppressContinuation === true;
|
|
2052
|
+
const shouldAutoContinue =
|
|
2053
|
+
!suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false;
|
|
2054
|
+
const suppressHandoff = options.suppressHandoff === true;
|
|
2055
|
+
let fallbackFromShake = false;
|
|
2056
|
+
// Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake
|
|
2057
|
+
// reclaims nothing we fall through to the summary-compaction body below so
|
|
2058
|
+
// the oversized input still gets resolved.
|
|
2059
|
+
if (compactionSettings.strategy === "shake") {
|
|
2060
|
+
const outcome = await this.#runAutoShake(
|
|
2061
|
+
reason,
|
|
2062
|
+
willRetry,
|
|
2063
|
+
generation,
|
|
2064
|
+
shouldAutoContinue,
|
|
2065
|
+
terminalTextAnswer,
|
|
2066
|
+
options.triggerContextTokens,
|
|
2067
|
+
suppressContinuation,
|
|
2068
|
+
);
|
|
2069
|
+
if (outcome !== "fallback") return outcome;
|
|
2070
|
+
fallbackFromShake = true;
|
|
2071
|
+
}
|
|
2072
|
+
// "overflow" and "incomplete" force inline execution because they are recovery
|
|
2073
|
+
// paths the caller wants resolved before scheduling the next turn. "idle" is
|
|
2074
|
+
// triggered by the idle loop and does its own scheduling.
|
|
2075
|
+
if (
|
|
2076
|
+
!suppressHandoff &&
|
|
2077
|
+
!deferred &&
|
|
2078
|
+
allowDefer &&
|
|
2079
|
+
reason !== "overflow" &&
|
|
2080
|
+
reason !== "incomplete" &&
|
|
2081
|
+
reason !== "idle" &&
|
|
2082
|
+
compactionSettings.strategy === "handoff"
|
|
2083
|
+
) {
|
|
2084
|
+
this.#host.schedulePostPromptTask(
|
|
2085
|
+
async signal => {
|
|
2086
|
+
await Promise.resolve();
|
|
2087
|
+
if (signal.aborted) return;
|
|
2088
|
+
await this.runAutoCompaction(reason, willRetry, true, true, {
|
|
2089
|
+
...options,
|
|
2090
|
+
terminalTextAnswer,
|
|
2091
|
+
});
|
|
2092
|
+
},
|
|
2093
|
+
{ generation },
|
|
2094
|
+
);
|
|
2095
|
+
return {
|
|
2096
|
+
...COMPACTION_CHECK_DEFERRED_HANDOFF,
|
|
2097
|
+
continuationScheduled: shouldAutoContinue,
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
// "overflow" forces context-full because the input itself is broken — a handoff
|
|
2102
|
+
// LLM call would hit the same overflow. "incomplete" is an output-side problem,
|
|
2103
|
+
// so a handoff request on the existing context is still viable.
|
|
2104
|
+
let action: "context-full" | "handoff" | "snapcompact" =
|
|
2105
|
+
compactionSettings.strategy === "snapcompact"
|
|
2106
|
+
? "snapcompact"
|
|
2107
|
+
: compactionSettings.strategy === "handoff" && reason !== "overflow" && !suppressHandoff
|
|
2108
|
+
? "handoff"
|
|
2109
|
+
: "context-full";
|
|
2110
|
+
if (action === "snapcompact" && this.#model && !this.#model.input.includes("image")) {
|
|
2111
|
+
this.#host.emitNotice(
|
|
2112
|
+
"warning",
|
|
2113
|
+
`snapcompact needs a vision-capable active model (${this.#model.id} is text-only); using context-full auto-compaction instead.`,
|
|
2114
|
+
"compaction",
|
|
2115
|
+
);
|
|
2116
|
+
action = "context-full";
|
|
2117
|
+
}
|
|
2118
|
+
// Abort any older auto-compaction before installing this run's controller.
|
|
2119
|
+
this.#autoCompactionAbortController?.abort();
|
|
2120
|
+
const autoCompactionAbortController = new AbortController();
|
|
2121
|
+
this.#autoCompactionAbortController = autoCompactionAbortController;
|
|
2122
|
+
const autoCompactionSignal = autoCompactionAbortController.signal;
|
|
2123
|
+
|
|
2124
|
+
try {
|
|
2125
|
+
// Emit start AFTER the controller is installed so isCompacting is already true
|
|
2126
|
+
// for any listener — and for input routed during this emit's event-loop yield:
|
|
2127
|
+
// a message typed as the compaction loader appears must land in the compaction
|
|
2128
|
+
// queue, not the core steering queue (which handoff's agent.reset() would wipe).
|
|
2129
|
+
await this.#host.emitSessionEvent({ type: "auto_compaction_start", reason, action });
|
|
2130
|
+
if (action === "handoff") {
|
|
2131
|
+
let handoffSwitchCancelled = false;
|
|
2132
|
+
const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS;
|
|
2133
|
+
const handoffResult = await this.#host.runHandoff(handoffFocus, {
|
|
2134
|
+
autoTriggered: true,
|
|
2135
|
+
signal: autoCompactionSignal,
|
|
2136
|
+
onSwitchCancelled: () => {
|
|
2137
|
+
handoffSwitchCancelled = true;
|
|
2138
|
+
},
|
|
2139
|
+
});
|
|
2140
|
+
if (!handoffResult) {
|
|
2141
|
+
const aborted = autoCompactionSignal.aborted || handoffSwitchCancelled;
|
|
2142
|
+
if (aborted) {
|
|
2143
|
+
await this.#host.emitSessionEvent({
|
|
2144
|
+
type: "auto_compaction_end",
|
|
2145
|
+
action,
|
|
2146
|
+
result: undefined,
|
|
2147
|
+
aborted: true,
|
|
2148
|
+
willRetry: false,
|
|
2149
|
+
});
|
|
2150
|
+
return COMPACTION_CHECK_NONE;
|
|
2151
|
+
}
|
|
2152
|
+
logger.warn("Auto-handoff returned no document; falling back to context-full maintenance", {
|
|
2153
|
+
reason,
|
|
2154
|
+
});
|
|
2155
|
+
action = "context-full";
|
|
2156
|
+
}
|
|
2157
|
+
if (handoffResult) {
|
|
2158
|
+
await this.#host.emitSessionEvent({
|
|
2159
|
+
type: "auto_compaction_end",
|
|
2160
|
+
action,
|
|
2161
|
+
result: undefined,
|
|
2162
|
+
aborted: false,
|
|
2163
|
+
willRetry: false,
|
|
2164
|
+
});
|
|
2165
|
+
const continuationScheduled =
|
|
2166
|
+
!autoCompactionSignal.aborted &&
|
|
2167
|
+
this.#host.scheduleCompactionContinuation({
|
|
2168
|
+
generation,
|
|
2169
|
+
autoContinue: reason !== "idle" && shouldAutoContinue,
|
|
2170
|
+
terminalTextAnswer,
|
|
2171
|
+
suppressContinuation,
|
|
2172
|
+
});
|
|
2173
|
+
return {
|
|
2174
|
+
...(continuationScheduled ? COMPACTION_CHECK_CONTINUATION : COMPACTION_CHECK_NONE),
|
|
2175
|
+
historyRewritten: true,
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
if (!this.#model) {
|
|
2181
|
+
await this.#host.emitSessionEvent({
|
|
2182
|
+
type: "auto_compaction_end",
|
|
2183
|
+
action,
|
|
2184
|
+
result: undefined,
|
|
2185
|
+
aborted: false,
|
|
2186
|
+
willRetry: false,
|
|
2187
|
+
skipped: true,
|
|
2188
|
+
});
|
|
2189
|
+
return COMPACTION_CHECK_NONE;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
const availableModels = this.#host.modelRegistry.getAvailable();
|
|
2193
|
+
if (availableModels.length === 0) {
|
|
2194
|
+
await this.#host.emitSessionEvent({
|
|
2195
|
+
type: "auto_compaction_end",
|
|
2196
|
+
action,
|
|
2197
|
+
result: undefined,
|
|
2198
|
+
aborted: false,
|
|
2199
|
+
willRetry: false,
|
|
2200
|
+
skipped: true,
|
|
2201
|
+
});
|
|
2202
|
+
return COMPACTION_CHECK_NONE;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
const pathEntries = this.#host.sessionManager.getBranch();
|
|
2206
|
+
|
|
2207
|
+
let pathEntriesForCompaction = pathEntries;
|
|
2208
|
+
let preparation = prepareCompaction(pathEntriesForCompaction, compactionSettings, this.#model);
|
|
2209
|
+
if (!preparation) {
|
|
2210
|
+
// prepareCompaction found nothing to summarize because the kept region
|
|
2211
|
+
// is a single oversized recent turn — findCutPoint never cuts inside a
|
|
2212
|
+
// tool result, so a huge tool-result / fenced block tail leaves nothing
|
|
2213
|
+
// on the summarizable side and summary compaction cannot even start.
|
|
2214
|
+
// That is exactly the dead-end the elide shake rescues: it reaches
|
|
2215
|
+
// INSIDE the tail and offloads heavy content to an artifact placeholder,
|
|
2216
|
+
// shrinking the tail so findCutPoint can then move the cut and leave
|
|
2217
|
+
// older turns to summarize. Run the same tiered rescue the
|
|
2218
|
+
// post-maintenance guard uses (elide, then image drop), with progress
|
|
2219
|
+
// defined as "prepareCompaction now succeeds on the rewritten branch",
|
|
2220
|
+
// and fall through to the normal compaction body when it does (writing
|
|
2221
|
+
// a compaction entry anchors the stale billed usage so the
|
|
2222
|
+
// auto-continue re-check cannot re-trip and loop the warning — issue
|
|
2223
|
+
// #4786). `skipElide` when we already fell through from a shake
|
|
2224
|
+
// strategy pass (it tried and found nothing); skip entirely on the
|
|
2225
|
+
// idle timer (it re-checks usage on its own cadence).
|
|
2226
|
+
let rescueRewroteHistory = false;
|
|
2227
|
+
// A snapcompact CompactionEntry is invisible to both rescue tiers
|
|
2228
|
+
// below (they only inspect message entries) and to prepareCompaction
|
|
2229
|
+
// itself (last-entry-is-compaction guard), so a frame archive billed
|
|
2230
|
+
// past the threshold dead-ends here on every resume. Rebuild it at a
|
|
2231
|
+
// threshold-derived frame budget first — but treat that as complete
|
|
2232
|
+
// only when it actually created headroom: the latest archive may not
|
|
2233
|
+
// be the oversized tail (e.g. a huge kept tool result after it), and
|
|
2234
|
+
// declaring victory on a mere frame-count shrink would skip the
|
|
2235
|
+
// elide/image tiers that can still reach that tail and suppress a
|
|
2236
|
+
// warning the user should see.
|
|
2237
|
+
let frameRescueResult: snapcompact.CompactionResult | undefined;
|
|
2238
|
+
let frameRescueCreatedHeadroom = false;
|
|
2239
|
+
if (reason !== "idle") {
|
|
2240
|
+
frameRescueResult = await this.#rescueSnapcompactFrameOverflow(
|
|
2241
|
+
pathEntriesForCompaction,
|
|
2242
|
+
compactionSettings,
|
|
2243
|
+
autoCompactionSignal,
|
|
2244
|
+
);
|
|
2245
|
+
if (frameRescueResult) {
|
|
2246
|
+
rescueRewroteHistory = true;
|
|
2247
|
+
pathEntriesForCompaction = this.#host.sessionManager.getBranch();
|
|
2248
|
+
frameRescueCreatedHeadroom = this.#compactionCreatedHeadroom();
|
|
2249
|
+
}
|
|
2250
|
+
if (!frameRescueCreatedHeadroom) {
|
|
2251
|
+
await this.#rescueCompactionDeadEnd(autoCompactionSignal, {
|
|
2252
|
+
skipElide: fallbackFromShake,
|
|
2253
|
+
hasProgress: () => {
|
|
2254
|
+
// Only reached when a tier actually freed something, so the
|
|
2255
|
+
// branch has been rewritten either way.
|
|
2256
|
+
rescueRewroteHistory = true;
|
|
2257
|
+
pathEntriesForCompaction = this.#host.sessionManager.getBranch();
|
|
2258
|
+
preparation = prepareCompaction(pathEntriesForCompaction, compactionSettings, this.#model);
|
|
2259
|
+
return preparation !== undefined;
|
|
2260
|
+
},
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
if (!preparation) {
|
|
2265
|
+
const noProgressDeadEnd = reason !== "idle" && !frameRescueCreatedHeadroom;
|
|
2266
|
+
const deadEndWarning = noProgressDeadEnd
|
|
2267
|
+
? compactionDeadEndWarning("shrink it (e.g. clear large tool output)")
|
|
2268
|
+
: undefined;
|
|
2269
|
+
// A rescue that appended a rebuilt archive without creating
|
|
2270
|
+
// headroom must carry the dead-end badge on the entry the
|
|
2271
|
+
// transcript actually shows (the rebuilt one), or the pause
|
|
2272
|
+
// loses its explanation once the notice scrolls away. Stamp it
|
|
2273
|
+
// BEFORE the auto_compaction_end event: a result-carrying event
|
|
2274
|
+
// makes the TUI rebuild the chat from the current entries
|
|
2275
|
+
// immediately, so a later stamp would not appear until some
|
|
2276
|
+
// unrelated rebuild.
|
|
2277
|
+
if (deadEndWarning && frameRescueResult) {
|
|
2278
|
+
const stampEntry = getLatestCompactionEntry(this.#host.sessionManager.getBranch());
|
|
2279
|
+
if (stampEntry) {
|
|
2280
|
+
stampEntry.warning = deadEndWarning;
|
|
2281
|
+
await this.#host.sessionManager.rewriteEntries();
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
// A successful frame rescue rewrote history and activated a new
|
|
2285
|
+
// compaction entry — surface it as a real (non-skipped) result so
|
|
2286
|
+
// the TUI rebuilds the transcript instead of treating the pass as
|
|
2287
|
+
// a benign no-op.
|
|
2288
|
+
await this.#host.emitSessionEvent({
|
|
2289
|
+
type: "auto_compaction_end",
|
|
2290
|
+
action,
|
|
2291
|
+
result: frameRescueResult,
|
|
2292
|
+
aborted: false,
|
|
2293
|
+
willRetry: false,
|
|
2294
|
+
skipped: frameRescueResult === undefined,
|
|
2295
|
+
});
|
|
2296
|
+
let continuationScheduled = false;
|
|
2297
|
+
if (frameRescueCreatedHeadroom) {
|
|
2298
|
+
continuationScheduled = this.#host.scheduleCompactionContinuation({
|
|
2299
|
+
generation,
|
|
2300
|
+
autoContinue: shouldAutoContinue,
|
|
2301
|
+
terminalTextAnswer,
|
|
2302
|
+
suppressContinuation,
|
|
2303
|
+
});
|
|
2304
|
+
} else if (!suppressContinuation && this.#host.agent.hasQueuedMessages()) {
|
|
2305
|
+
this.#host.scheduleAgentContinue({
|
|
2306
|
+
delayMs: 100,
|
|
2307
|
+
generation,
|
|
2308
|
+
shouldContinue: () => this.#host.agent.hasQueuedMessages(),
|
|
2309
|
+
});
|
|
2310
|
+
continuationScheduled = true;
|
|
2311
|
+
}
|
|
2312
|
+
if (deadEndWarning) {
|
|
2313
|
+
this.#host.emitNotice("warning", deadEndWarning, "compaction");
|
|
2314
|
+
}
|
|
2315
|
+
// A rescue that offloaded content but still could not produce a
|
|
2316
|
+
// preparation rewrote the branch; flag it so the overflow-recovery
|
|
2317
|
+
// rollback does not re-restore the just-failed assistant turn on top
|
|
2318
|
+
// of the elided tail.
|
|
2319
|
+
const base = continuationScheduled
|
|
2320
|
+
? COMPACTION_CHECK_CONTINUATION
|
|
2321
|
+
: noProgressDeadEnd
|
|
2322
|
+
? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION
|
|
2323
|
+
: COMPACTION_CHECK_NONE;
|
|
2324
|
+
return rescueRewroteHistory ? { ...base, historyRewritten: true } : base;
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
let hookCompaction: CompactionResult | undefined;
|
|
2329
|
+
let fromExtension = false;
|
|
2330
|
+
let preserveData: Record<string, unknown> | undefined;
|
|
2331
|
+
let codexCompaction: CodexCompactionContext | undefined;
|
|
2332
|
+
|
|
2333
|
+
if (this.#host.extensionRunner?.hasHandlers("session_before_compact")) {
|
|
2334
|
+
const hookResult = (await this.#host.extensionRunner.emit({
|
|
2335
|
+
type: "session_before_compact",
|
|
2336
|
+
preparation,
|
|
2337
|
+
branchEntries: pathEntriesForCompaction,
|
|
2338
|
+
customInstructions: undefined,
|
|
2339
|
+
signal: autoCompactionSignal,
|
|
2340
|
+
})) as SessionBeforeCompactResult | undefined;
|
|
2341
|
+
|
|
2342
|
+
if (hookResult?.cancel) {
|
|
2343
|
+
await this.#host.emitSessionEvent({
|
|
2344
|
+
type: "auto_compaction_end",
|
|
2345
|
+
action,
|
|
2346
|
+
result: undefined,
|
|
2347
|
+
aborted: true,
|
|
2348
|
+
willRetry: false,
|
|
2349
|
+
});
|
|
2350
|
+
return COMPACTION_CHECK_NONE;
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
if (hookResult?.compaction) {
|
|
2354
|
+
hookCompaction = hookResult.compaction;
|
|
2355
|
+
fromExtension = true;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
|
|
2360
|
+
|
|
2361
|
+
let summary: string;
|
|
2362
|
+
let shortSummary: string | undefined;
|
|
2363
|
+
let firstKeptEntryId: string;
|
|
2364
|
+
let tokensBefore: number;
|
|
2365
|
+
let details: unknown;
|
|
2366
|
+
|
|
2367
|
+
// Snapcompact runs locally first. The post-compaction context = kept-recent
|
|
2368
|
+
// + a summary message carrying the imaged archive at FRAME_TOKEN_ESTIMATE
|
|
2369
|
+
// per frame; #computeSnapcompactMaxFrames sizes the frame cap from the
|
|
2370
|
+
// live window so we don't run snapcompact just to overflow every threshold
|
|
2371
|
+
// tick. Any local blocker (unsupported snapcompact glyphs, kept-history too large,
|
|
2372
|
+
// post-render overflow) downgrades auto maintenance to a context-full LLM
|
|
2373
|
+
// summary instead of wedging the session (#3659) — auto runs the default
|
|
2374
|
+
// strategy on the user's behalf, so a fallback that lets the session keep
|
|
2375
|
+
// running is the right behavior. Manual `/compact snapcompact` keeps the
|
|
2376
|
+
// local-only contract (#3599): the user explicitly picked it.
|
|
2377
|
+
let snapcompactResult: snapcompact.CompactionResult | undefined;
|
|
2378
|
+
let snapcompactBlocker: string | undefined;
|
|
2379
|
+
if (action === "snapcompact" && compactionPrep.kind !== "fromHook") {
|
|
2380
|
+
// Drop `¶think:` sections for Anthropic-dialect targets: the archive
|
|
2381
|
+
// is replayed as text and Claude refuses reproduced reasoning
|
|
2382
|
+
// ("reasoning_extraction", issue #6093).
|
|
2383
|
+
const snapcompactIncludeThinking = preferredDialect(this.#model.id) !== "anthropic";
|
|
2384
|
+
const text = snapcompact.serializeConversation(
|
|
2385
|
+
convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
|
|
2386
|
+
{ includeThinking: snapcompactIncludeThinking },
|
|
2387
|
+
);
|
|
2388
|
+
const probeText = snapcompact.renderabilityProbeText(
|
|
2389
|
+
text,
|
|
2390
|
+
preparation.previousPreserveData,
|
|
2391
|
+
preparation.previousSummary,
|
|
2392
|
+
);
|
|
2393
|
+
const shapeSetting = this.#host.settings.get("snapcompact.shape");
|
|
2394
|
+
const shape = snapcompact.resolveShapeForText(probeText, this.#model, shapeSetting);
|
|
2395
|
+
const renderScan = snapcompact.scanRenderability(probeText, { shape });
|
|
2396
|
+
if (!renderScan.isSafe) {
|
|
2397
|
+
const percent = (renderScan.unrenderableRatio * 100).toFixed(1);
|
|
2398
|
+
logger.warn("Snapcompact disabled: unsupported characters for selected snapcompact font", {
|
|
2399
|
+
model: this.#model?.id,
|
|
2400
|
+
unrenderableRatio: renderScan.unrenderableRatio,
|
|
2401
|
+
});
|
|
2402
|
+
snapcompactBlocker = `snapcompact disabled: unsupported characters for selected snapcompact font (${percent}%); using context-full auto-compaction instead.`;
|
|
2403
|
+
} else {
|
|
2404
|
+
const maxFrames = this.#computeSnapcompactMaxFrames(preparation, compactionSettings);
|
|
2405
|
+
if (maxFrames < 1) {
|
|
2406
|
+
logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
|
|
2407
|
+
model: this.#model?.id,
|
|
2408
|
+
});
|
|
2409
|
+
snapcompactBlocker =
|
|
2410
|
+
"snapcompact: kept history alone exceeds the context budget; using context-full auto-compaction instead.";
|
|
2411
|
+
} else {
|
|
2412
|
+
snapcompactResult = await snapcompact.compact(preparation, {
|
|
2413
|
+
convertToLlm,
|
|
2414
|
+
model: this.#model,
|
|
2415
|
+
...(shapeSetting === "auto" ? {} : { shape }),
|
|
2416
|
+
maxFrames,
|
|
2417
|
+
includeThinking: snapcompactIncludeThinking,
|
|
2418
|
+
});
|
|
2419
|
+
const framePayloadBytes = this.#snapcompactFramePayloadBytes(snapcompactResult);
|
|
2420
|
+
if (framePayloadBytes > snapcompact.FRAME_DATA_BYTES_BUDGET) {
|
|
2421
|
+
logger.warn("Snapcompact exceeded the per-request frame payload budget", {
|
|
2422
|
+
model: this.#model?.id,
|
|
2423
|
+
framePayloadBytes,
|
|
2424
|
+
budget: snapcompact.FRAME_DATA_BYTES_BUDGET,
|
|
2425
|
+
});
|
|
2426
|
+
snapcompactBlocker =
|
|
2427
|
+
"snapcompact produced too much standing image payload; using context-full auto-compaction instead.";
|
|
2428
|
+
snapcompactResult = undefined;
|
|
2429
|
+
}
|
|
2430
|
+
if (snapcompactResult) {
|
|
2431
|
+
const ctxWindow = this.#model?.contextWindow ?? 0;
|
|
2432
|
+
const budget =
|
|
2433
|
+
ctxWindow > 0
|
|
2434
|
+
? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings)
|
|
2435
|
+
: Number.POSITIVE_INFINITY;
|
|
2436
|
+
const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult);
|
|
2437
|
+
if (projected > budget) {
|
|
2438
|
+
logger.warn("Snapcompact still overflows the window after frame-budget sizing", {
|
|
2439
|
+
model: this.#model?.id,
|
|
2440
|
+
projected,
|
|
2441
|
+
budget,
|
|
2442
|
+
});
|
|
2443
|
+
snapcompactBlocker =
|
|
2444
|
+
"snapcompact could not bring the context under the limit; using context-full auto-compaction instead.";
|
|
2445
|
+
snapcompactResult = undefined;
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
if (snapcompactBlocker) {
|
|
2451
|
+
this.#host.emitNotice("warning", snapcompactBlocker, "compaction");
|
|
2452
|
+
action = "context-full";
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
if (compactionPrep.kind === "fromHook") {
|
|
2457
|
+
summary = compactionPrep.summary;
|
|
2458
|
+
shortSummary = compactionPrep.shortSummary;
|
|
2459
|
+
firstKeptEntryId = compactionPrep.firstKeptEntryId;
|
|
2460
|
+
tokensBefore = compactionPrep.tokensBefore;
|
|
2461
|
+
details = compactionPrep.details;
|
|
2462
|
+
preserveData = compactionPrep.preserveData;
|
|
2463
|
+
} else if (snapcompactResult) {
|
|
2464
|
+
summary = snapcompactResult.summary;
|
|
2465
|
+
shortSummary = snapcompactResult.shortSummary;
|
|
2466
|
+
firstKeptEntryId = snapcompactResult.firstKeptEntryId;
|
|
2467
|
+
tokensBefore = snapcompactResult.tokensBefore;
|
|
2468
|
+
details = snapcompactResult.details;
|
|
2469
|
+
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) };
|
|
2470
|
+
} else {
|
|
2471
|
+
const candidates = this.#getCompactionModelCandidates(availableModels);
|
|
2472
|
+
const retrySettings = this.#host.settings.getGroup("retry");
|
|
2473
|
+
const telemetry = resolveTelemetry(this.#host.agent.telemetry, this.#host.sessionId());
|
|
2474
|
+
let compactResult: CompactionResult | undefined;
|
|
2475
|
+
let lastError: unknown;
|
|
2476
|
+
codexCompaction = createCodexCompactionContext({
|
|
2477
|
+
trigger: "auto",
|
|
2478
|
+
reason: "context_limit",
|
|
2479
|
+
phase:
|
|
2480
|
+
options.phase ??
|
|
2481
|
+
(reason === "threshold" ? "pre_turn" : reason === "idle" ? "standalone_turn" : "mid_turn"),
|
|
2482
|
+
});
|
|
2483
|
+
|
|
2484
|
+
for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) {
|
|
2485
|
+
const candidate = candidates[candidateIndex];
|
|
2486
|
+
const hasMoreCandidates = candidateIndex < candidates.length - 1;
|
|
2487
|
+
const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId());
|
|
2488
|
+
if (!apiKey) continue;
|
|
2489
|
+
|
|
2490
|
+
let attempt = 0;
|
|
2491
|
+
while (true) {
|
|
2492
|
+
try {
|
|
2493
|
+
compactResult = await compact(
|
|
2494
|
+
this.#host.obfuscatePreparationForProvider(preparation),
|
|
2495
|
+
candidate,
|
|
2496
|
+
this.#host.modelRegistry.resolver(candidate, this.#host.sessionId()),
|
|
2497
|
+
undefined,
|
|
2498
|
+
autoCompactionSignal,
|
|
2499
|
+
{
|
|
2500
|
+
promptOverride: this.#host.obfuscateTextForProvider(compactionPrep.hookPrompt),
|
|
2501
|
+
extraContext: compactionPrep.hookContext,
|
|
2502
|
+
remoteInstructions: this.#host.baseSystemPrompt().join("\n\n"),
|
|
2503
|
+
metadata: this.#host.agent.metadataForProvider(candidate.provider),
|
|
2504
|
+
initiatorOverride: "agent",
|
|
2505
|
+
convertToLlm: messages => this.#host.convertToLlmForSideRequest(messages),
|
|
2506
|
+
telemetry,
|
|
2507
|
+
// Honor the user's /model thinking selection on the
|
|
2508
|
+
// auto-compaction path — the most-fired compaction
|
|
2509
|
+
// site. Clamped per-model inside compact() via
|
|
2510
|
+
// resolveCompactionEffort.
|
|
2511
|
+
thinkingLevel: this.#host.thinkingLevel(),
|
|
2512
|
+
tools: this.#host.agent.state.tools,
|
|
2513
|
+
sessionId: this.#host.sessionId(),
|
|
2514
|
+
promptCacheKey: this.#host.sessionId(),
|
|
2515
|
+
providerSessionState: this.#host.providerSessionState,
|
|
2516
|
+
codexCompaction,
|
|
2517
|
+
},
|
|
2518
|
+
);
|
|
2519
|
+
break;
|
|
2520
|
+
} catch (error) {
|
|
2521
|
+
if (autoCompactionSignal.aborted) {
|
|
2522
|
+
throw error;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2526
|
+
const id = AIError.classify(error, candidate.api);
|
|
2527
|
+
if (AIError.is(id, AIError.Flag.AuthFailed)) {
|
|
2528
|
+
lastError = this.#buildCompactionAuthError();
|
|
2529
|
+
break;
|
|
2530
|
+
}
|
|
2531
|
+
if (AIError.is(id, AIError.Flag.Timeout)) {
|
|
2532
|
+
logger.warn(
|
|
2533
|
+
hasMoreCandidates
|
|
2534
|
+
? "Auto-compaction summarization timed out, trying next model"
|
|
2535
|
+
: "Auto-compaction summarization timed out, not retrying same model",
|
|
2536
|
+
{
|
|
2537
|
+
error: message,
|
|
2538
|
+
model: `${candidate.provider}/${candidate.id}`,
|
|
2539
|
+
},
|
|
2540
|
+
);
|
|
2541
|
+
lastError = error;
|
|
2542
|
+
break;
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
const retryAfterMs = this.#host.parseRetryAfterMsFromError(message);
|
|
2546
|
+
const shouldRetry =
|
|
2547
|
+
retrySettings.enabled &&
|
|
2548
|
+
attempt < retrySettings.maxRetries &&
|
|
2549
|
+
(retryAfterMs !== undefined ||
|
|
2550
|
+
AIError.is(id, AIError.Flag.Transient) ||
|
|
2551
|
+
AIError.is(id, AIError.Flag.UsageLimit));
|
|
2552
|
+
if (!shouldRetry) {
|
|
2553
|
+
lastError = error;
|
|
2554
|
+
break;
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
const baseDelayMs = retrySettings.baseDelayMs * 2 ** attempt;
|
|
2558
|
+
const delayMs = retryAfterMs !== undefined ? Math.max(baseDelayMs, retryAfterMs) : baseDelayMs;
|
|
2559
|
+
|
|
2560
|
+
// If retry delay is too long (>30s), try next candidate instead of waiting
|
|
2561
|
+
const maxAcceptableDelayMs = 30_000;
|
|
2562
|
+
if (delayMs > maxAcceptableDelayMs && hasMoreCandidates) {
|
|
2563
|
+
logger.warn("Auto-compaction retry delay too long, trying next model", {
|
|
2564
|
+
delayMs,
|
|
2565
|
+
retryAfterMs,
|
|
2566
|
+
error: message,
|
|
2567
|
+
model: `${candidate.provider}/${candidate.id}`,
|
|
2568
|
+
});
|
|
2569
|
+
lastError = error;
|
|
2570
|
+
break; // Exit retry loop, continue to next candidate
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
attempt++;
|
|
2574
|
+
logger.warn("Auto-compaction failed, retrying", {
|
|
2575
|
+
attempt,
|
|
2576
|
+
maxRetries: retrySettings.maxRetries,
|
|
2577
|
+
delayMs,
|
|
2578
|
+
retryAfterMs,
|
|
2579
|
+
error: message,
|
|
2580
|
+
model: `${candidate.provider}/${candidate.id}`,
|
|
2581
|
+
});
|
|
2582
|
+
await scheduler.wait(delayMs, { signal: autoCompactionSignal });
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
if (compactResult) {
|
|
2587
|
+
break;
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
if (!compactResult) {
|
|
2592
|
+
if (lastError) {
|
|
2593
|
+
throw lastError;
|
|
2594
|
+
}
|
|
2595
|
+
throw new Error("Compaction failed: no available model");
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
summary = compactResult.summary;
|
|
2599
|
+
shortSummary = compactResult.shortSummary;
|
|
2600
|
+
firstKeptEntryId = compactResult.firstKeptEntryId;
|
|
2601
|
+
tokensBefore = compactResult.tokensBefore;
|
|
2602
|
+
details = compactResult.details;
|
|
2603
|
+
preserveData = mergeLlmCompactionPreserveData(compactionPrep.preserveData, compactResult.preserveData);
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
if (autoCompactionSignal.aborted) {
|
|
2607
|
+
await this.#host.emitSessionEvent({
|
|
2608
|
+
type: "auto_compaction_end",
|
|
2609
|
+
action,
|
|
2610
|
+
result: undefined,
|
|
2611
|
+
aborted: true,
|
|
2612
|
+
willRetry: false,
|
|
2613
|
+
});
|
|
2614
|
+
return COMPACTION_CHECK_NONE;
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
this.#host.sessionManager.appendCompaction(
|
|
2618
|
+
summary,
|
|
2619
|
+
shortSummary,
|
|
2620
|
+
firstKeptEntryId,
|
|
2621
|
+
tokensBefore,
|
|
2622
|
+
details,
|
|
2623
|
+
fromExtension,
|
|
2624
|
+
preserveData,
|
|
2625
|
+
);
|
|
2626
|
+
const newEntries = this.#host.sessionManager.getEntries();
|
|
2627
|
+
const sessionContext = this.#host.buildDisplaySessionContext();
|
|
2628
|
+
this.#host.agent.replaceMessages(sessionContext.messages);
|
|
2629
|
+
this.#host.rebaseAfterCompaction();
|
|
2630
|
+
// Compaction discarded the conversation history that carried the approved
|
|
2631
|
+
// plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads
|
|
2632
|
+
// the plan from disk and re-injects it on the next turn (issue #1246).
|
|
2633
|
+
this.#host.resetPlanReference();
|
|
2634
|
+
this.#host.resetAdvisorRuntimes();
|
|
2635
|
+
this.#host.syncTodoPhasesFromBranch();
|
|
2636
|
+
if (codexCompaction) {
|
|
2637
|
+
this.#host.resetCodexProviderAfterCompaction(codexCompaction);
|
|
2638
|
+
} else {
|
|
2639
|
+
this.#host.closeCodexProviderSessionsForHistoryRewrite();
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
// Get the saved compaction entry for the hook
|
|
2643
|
+
const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
|
|
2644
|
+
| CompactionEntry
|
|
2645
|
+
| undefined;
|
|
2646
|
+
|
|
2647
|
+
if (this.#host.extensionRunner && savedCompactionEntry) {
|
|
2648
|
+
await this.#host.extensionRunner.emit({
|
|
2649
|
+
type: "session_compact",
|
|
2650
|
+
compactionEntry: savedCompactionEntry,
|
|
2651
|
+
fromExtension,
|
|
2652
|
+
});
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
const result: CompactionResult = {
|
|
2656
|
+
summary,
|
|
2657
|
+
shortSummary,
|
|
2658
|
+
firstKeptEntryId,
|
|
2659
|
+
tokensBefore,
|
|
2660
|
+
details,
|
|
2661
|
+
preserveData,
|
|
2662
|
+
};
|
|
2663
|
+
// Post-maintenance progress guard — evaluated BEFORE emitting
|
|
2664
|
+
// auto_compaction_end so the TUI rebuild triggered by that event
|
|
2665
|
+
// already reflects any rescue rewrite (elide / image-drop) and the
|
|
2666
|
+
// dead-end warning stamped on the compaction entry. Snapcompact can
|
|
2667
|
+
// project over budget and fall back to a context-full summary; the
|
|
2668
|
+
// summarizer keeps `keepRecentTokens` of recent history verbatim and
|
|
2669
|
+
// findCutPoint can only cut at turn boundaries (never tool results),
|
|
2670
|
+
// so a single oversized recent turn (e.g. a huge tool result) leaves
|
|
2671
|
+
// the rewritten context still above threshold. Scheduling the
|
|
2672
|
+
// continuation regardless means the next agent_end re-enters
|
|
2673
|
+
// checkCompaction over the same oversized tail and re-fires forever.
|
|
2674
|
+
// The retry and the threshold auto-continue use different progress
|
|
2675
|
+
// tests (a recoverable overflow only has to fit; the auto-continue
|
|
2676
|
+
// thrash needs the stricter recovery band), so each branch evaluates
|
|
2677
|
+
// its own below.
|
|
2678
|
+
let continuationScheduled = false;
|
|
2679
|
+
// A non-idle pass that wanted to continue (retry or auto-continue) but freed
|
|
2680
|
+
// too little for that path to proceed is a dead-end: warn once so the user
|
|
2681
|
+
// understands why maintenance paused instead of silently looping.
|
|
2682
|
+
let noProgressDeadEnd = false;
|
|
2683
|
+
let retryFits = false;
|
|
2684
|
+
let hasHeadroom = false;
|
|
2685
|
+
|
|
2686
|
+
if (willRetry) {
|
|
2687
|
+
const messages = this.#host.agent.state.messages;
|
|
2688
|
+
const lastMsg = messages[messages.length - 1];
|
|
2689
|
+
if (lastMsg?.role === "assistant") {
|
|
2690
|
+
const lastAssistant = lastMsg as AssistantMessage;
|
|
2691
|
+
// Drop the prior turn before retry when it carries no actionable deliverable:
|
|
2692
|
+
// - "error": failure was kept in history but must not re-enter the next turn's prompt.
|
|
2693
|
+
// - reason === "incomplete" && stopReason === "length": truncated output (typically
|
|
2694
|
+
// reasoning-only) — re-running it produces the same dead-end.
|
|
2695
|
+
const shouldDrop =
|
|
2696
|
+
lastAssistant.stopReason === "error" ||
|
|
2697
|
+
(reason === "incomplete" && lastAssistant.stopReason === "length");
|
|
2698
|
+
if (shouldDrop) {
|
|
2699
|
+
this.#host.agent.replaceMessages(messages.slice(0, -1));
|
|
2700
|
+
this.#host.rebaseAfterCompaction();
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// Retry only needs the rebuilt prompt to fit the window again — measured
|
|
2705
|
+
// AFTER the drop above so the just-failed turn (which the retry prompt
|
|
2706
|
+
// won't include) is excluded. Reusing the auto-continue recovery band
|
|
2707
|
+
// here turned recoverable overflows into manual dead-ends (#3412 review),
|
|
2708
|
+
// so use the looser fit budget.
|
|
2709
|
+
retryFits = this.#compactionCreatedRetryFit();
|
|
2710
|
+
if (!retryFits) {
|
|
2711
|
+
retryFits = await this.#rescueCompactionDeadEnd(autoCompactionSignal, {
|
|
2712
|
+
skipElide: fallbackFromShake,
|
|
2713
|
+
hasProgress: () => this.#compactionCreatedRetryFit(),
|
|
2714
|
+
});
|
|
2715
|
+
}
|
|
2716
|
+
if (!retryFits) {
|
|
2717
|
+
noProgressDeadEnd = true;
|
|
2718
|
+
}
|
|
2719
|
+
} else if (reason !== "idle") {
|
|
2720
|
+
// Mirror the shake recovery-band check: only auto-continue when compaction
|
|
2721
|
+
// landed residual context under `COMPACTION_RECOVERY_BAND × threshold`.
|
|
2722
|
+
// Re-firing on a history that still sits just over the line is the
|
|
2723
|
+
// snapcompact thrash, so require genuine headroom, not a bare fit. Even
|
|
2724
|
+
// when auto-continue is disabled, a no-headroom threshold pass must still
|
|
2725
|
+
// block later automatic continuations (todo reminders/session_stop hooks)
|
|
2726
|
+
// from re-entering the same oversized context.
|
|
2727
|
+
hasHeadroom = this.#compactionCreatedHeadroom();
|
|
2728
|
+
if (!hasHeadroom) {
|
|
2729
|
+
hasHeadroom = await this.#rescueCompactionDeadEnd(autoCompactionSignal, {
|
|
2730
|
+
skipElide: fallbackFromShake,
|
|
2731
|
+
hasProgress: () => this.#compactionCreatedHeadroom(),
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
if (!hasHeadroom) {
|
|
2735
|
+
noProgressDeadEnd = true;
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
const deadEndWarning = noProgressDeadEnd ? compactionDeadEndWarning("clear large tool output") : undefined;
|
|
2740
|
+
if (deadEndWarning) {
|
|
2741
|
+
// Stamp the divider: the compaction bar badges the dead-end and
|
|
2742
|
+
// carries the full warning in its ctrl+o detail, so the pause
|
|
2743
|
+
// stays explained even after the notice row scrolls away. Stamp
|
|
2744
|
+
// the branch's LATEST compaction entry — a frame rescue may have
|
|
2745
|
+
// superseded `savedCompactionEntry` with a rebuilt one, and the
|
|
2746
|
+
// collapsed transcript badges only the active entry.
|
|
2747
|
+
const stampEntry = getLatestCompactionEntry(this.#host.sessionManager.getBranch()) ?? savedCompactionEntry;
|
|
2748
|
+
if (stampEntry) {
|
|
2749
|
+
stampEntry.warning = deadEndWarning;
|
|
2750
|
+
await this.#host.sessionManager.rewriteEntries();
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
await this.#host.emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
|
|
2755
|
+
|
|
2756
|
+
if (retryFits) {
|
|
2757
|
+
this.#host.scheduleAgentContinue({ delayMs: 100, generation });
|
|
2758
|
+
continuationScheduled = true;
|
|
2759
|
+
} else {
|
|
2760
|
+
continuationScheduled = this.#host.scheduleCompactionContinuation({
|
|
2761
|
+
generation,
|
|
2762
|
+
autoContinue: hasHeadroom && shouldAutoContinue,
|
|
2763
|
+
terminalTextAnswer,
|
|
2764
|
+
suppressContinuation,
|
|
2765
|
+
});
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
if (deadEndWarning) {
|
|
2769
|
+
this.#host.emitNotice("warning", deadEndWarning, "compaction");
|
|
2770
|
+
}
|
|
2771
|
+
if (continuationScheduled) return COMPACTION_CHECK_CONTINUATION;
|
|
2772
|
+
return noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE;
|
|
2773
|
+
} catch (error) {
|
|
2774
|
+
if (autoCompactionSignal.aborted) {
|
|
2775
|
+
await this.#host.emitSessionEvent({
|
|
2776
|
+
type: "auto_compaction_end",
|
|
2777
|
+
action,
|
|
2778
|
+
result: undefined,
|
|
2779
|
+
aborted: true,
|
|
2780
|
+
willRetry: false,
|
|
2781
|
+
});
|
|
2782
|
+
return COMPACTION_CHECK_NONE;
|
|
2783
|
+
}
|
|
2784
|
+
const errorMessage = error instanceof Error ? error.message : "compaction failed";
|
|
2785
|
+
await this.#host.emitSessionEvent({
|
|
2786
|
+
type: "auto_compaction_end",
|
|
2787
|
+
action,
|
|
2788
|
+
result: undefined,
|
|
2789
|
+
aborted: false,
|
|
2790
|
+
willRetry: false,
|
|
2791
|
+
errorMessage:
|
|
2792
|
+
reason === "overflow"
|
|
2793
|
+
? `Context overflow recovery failed: ${errorMessage}`
|
|
2794
|
+
: reason === "incomplete"
|
|
2795
|
+
? `Incomplete response recovery failed: ${errorMessage}`
|
|
2796
|
+
: `Auto-compaction failed: ${errorMessage}`,
|
|
2797
|
+
});
|
|
2798
|
+
} finally {
|
|
2799
|
+
if (this.#autoCompactionAbortController === autoCompactionAbortController) {
|
|
2800
|
+
this.#autoCompactionAbortController = undefined;
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
return COMPACTION_CHECK_NONE;
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
/**
|
|
2807
|
+
* Run a shake-strategy auto-maintenance pass. Emits the
|
|
2808
|
+
* `auto_compaction_start`/`auto_compaction_end` pair with a shake `action`,
|
|
2809
|
+
* runs {@link shake} inline against the protect-window config, and schedules
|
|
2810
|
+
* continuation exactly like the context-full tail.
|
|
2811
|
+
*
|
|
2812
|
+
* Returns `"fallback"` only for an overflow recovery where shake reclaimed
|
|
2813
|
+
* nothing (or threw) — the caller then runs the summary-compaction body so
|
|
2814
|
+
* the oversized input still gets resolved. Returns `"handled"` otherwise.
|
|
2815
|
+
*/
|
|
2816
|
+
async #runAutoShake(
|
|
2817
|
+
reason: "overflow" | "threshold" | "idle" | "incomplete",
|
|
2818
|
+
willRetry: boolean,
|
|
2819
|
+
generation: number,
|
|
2820
|
+
autoContinue: boolean,
|
|
2821
|
+
terminalTextAnswer: boolean,
|
|
2822
|
+
triggerContextTokens?: number,
|
|
2823
|
+
suppressContinuation = false,
|
|
2824
|
+
): Promise<CompactionCheckResult | "fallback"> {
|
|
2825
|
+
const action = "shake";
|
|
2826
|
+
this.#autoCompactionAbortController?.abort();
|
|
2827
|
+
const controller = new AbortController();
|
|
2828
|
+
this.#autoCompactionAbortController = controller;
|
|
2829
|
+
const signal = controller.signal;
|
|
2830
|
+
try {
|
|
2831
|
+
await this.#host.emitSessionEvent({ type: "auto_compaction_start", reason, action });
|
|
2832
|
+
const result = await this.#host.shake("elide", { config: DEFAULT_SHAKE_CONFIG, signal });
|
|
2833
|
+
if (signal.aborted) {
|
|
2834
|
+
await this.#host.emitSessionEvent({
|
|
2835
|
+
type: "auto_compaction_end",
|
|
2836
|
+
action,
|
|
2837
|
+
result: undefined,
|
|
2838
|
+
aborted: true,
|
|
2839
|
+
willRetry: false,
|
|
2840
|
+
});
|
|
2841
|
+
return COMPACTION_CHECK_NONE;
|
|
2842
|
+
}
|
|
2843
|
+
const reclaimed = result.toolResultsDropped + result.blocksDropped > 0;
|
|
2844
|
+
// Detect the dead-loop reported in issues #2119/#2275: the threshold check
|
|
2845
|
+
// fires, shake runs, but residual context is still above the configured
|
|
2846
|
+
// threshold. The next agent_end would re-trigger shake, which has nothing
|
|
2847
|
+
// new to drop on the second pass, so the loop spins until the user kills it.
|
|
2848
|
+
// Same hazard for "incomplete" (the retry would re-hit the length cap) and
|
|
2849
|
+
// for the existing "overflow + nothing reclaimed" case. In every recovery
|
|
2850
|
+
// reason we hand off to the summarization-driven context-full path so the
|
|
2851
|
+
// situation actually resolves; "idle" is exempt because its 60s+ timer
|
|
2852
|
+
// re-checks usage before re-firing and cannot dead-loop on its own.
|
|
2853
|
+
//
|
|
2854
|
+
// #2275: the post-shake check MUST stay provider-anchored when caller
|
|
2855
|
+
// usage and local estimates diverge. The local estimator undercounts
|
|
2856
|
+
// thinking-signature payloads, so thinking-heavy sessions can read well
|
|
2857
|
+
// below the provider usage that fired the threshold. Prefer the caller's
|
|
2858
|
+
// context figure when supplied, then subtract shake's own savings and add
|
|
2859
|
+
// hysteresis (80% recovery band) so we don't oscillate at the boundary.
|
|
2860
|
+
// Threshold callers pass the provider-billed trigger after accounting for
|
|
2861
|
+
// any supersede/drop-useless pruning that already rewrote the next prompt;
|
|
2862
|
+
// without that pre-shake savings, shake can fall through to context-full
|
|
2863
|
+
// even though the post-prune history is already inside the recovery band.
|
|
2864
|
+
const contextWindow = this.#model?.contextWindow ?? 0;
|
|
2865
|
+
const compactionSettings = this.#host.settings.getGroup("compaction");
|
|
2866
|
+
let stillOverThreshold = false;
|
|
2867
|
+
if (contextWindow > 0) {
|
|
2868
|
+
if (typeof triggerContextTokens === "number" && Number.isFinite(triggerContextTokens)) {
|
|
2869
|
+
const correctedTokens = Math.max(0, triggerContextTokens - result.tokensFreed);
|
|
2870
|
+
const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
|
|
2871
|
+
const recoveryBand = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND);
|
|
2872
|
+
stillOverThreshold = correctedTokens > recoveryBand;
|
|
2873
|
+
} else {
|
|
2874
|
+
const postShakeTokens = this.#host.getContextUsage({ contextWindow })?.tokens ?? 0;
|
|
2875
|
+
stillOverThreshold = shouldCompact(postShakeTokens, contextWindow, compactionSettings);
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
const shouldFallBack = reason !== "idle" && ((reason === "overflow" && !reclaimed) || stillOverThreshold);
|
|
2879
|
+
if (shouldFallBack) {
|
|
2880
|
+
const errorMessage = reclaimed
|
|
2881
|
+
? `Auto-shake reclaimed ~${result.tokensFreed} tokens but context is still above the threshold; falling back to context-full compaction.`
|
|
2882
|
+
: "Auto-shake found nothing eligible to drop; falling back to context-full compaction.";
|
|
2883
|
+
await this.#host.emitSessionEvent({
|
|
2884
|
+
type: "auto_compaction_end",
|
|
2885
|
+
action,
|
|
2886
|
+
result: undefined,
|
|
2887
|
+
aborted: false,
|
|
2888
|
+
willRetry: false,
|
|
2889
|
+
skipped: !reclaimed,
|
|
2890
|
+
errorMessage,
|
|
2891
|
+
});
|
|
2892
|
+
return "fallback";
|
|
2893
|
+
}
|
|
2894
|
+
await this.#host.emitSessionEvent({
|
|
2895
|
+
type: "auto_compaction_end",
|
|
2896
|
+
action,
|
|
2897
|
+
result: undefined,
|
|
2898
|
+
aborted: false,
|
|
2899
|
+
willRetry,
|
|
2900
|
+
skipped: !reclaimed,
|
|
2901
|
+
});
|
|
2902
|
+
|
|
2903
|
+
let continuationScheduled = false;
|
|
2904
|
+
if (willRetry) {
|
|
2905
|
+
// The shake rebuild replays every entry, so a trailing error/length
|
|
2906
|
+
// assistant from the failed turn re-enters agent state — drop it before
|
|
2907
|
+
// retrying, same as the context-full tail.
|
|
2908
|
+
const messages = this.#host.agent.state.messages;
|
|
2909
|
+
const lastMsg = messages[messages.length - 1];
|
|
2910
|
+
if (lastMsg?.role === "assistant") {
|
|
2911
|
+
const lastAssistant = lastMsg as AssistantMessage;
|
|
2912
|
+
const shouldDrop =
|
|
2913
|
+
lastAssistant.stopReason === "error" ||
|
|
2914
|
+
(reason === "incomplete" && lastAssistant.stopReason === "length");
|
|
2915
|
+
if (shouldDrop) this.#host.agent.replaceMessages(messages.slice(0, -1));
|
|
2916
|
+
}
|
|
2917
|
+
this.#host.scheduleAgentContinue({ delayMs: 100, generation });
|
|
2918
|
+
continuationScheduled = true;
|
|
2919
|
+
} else {
|
|
2920
|
+
continuationScheduled = this.#host.scheduleCompactionContinuation({
|
|
2921
|
+
generation,
|
|
2922
|
+
autoContinue: reason !== "idle" && autoContinue,
|
|
2923
|
+
terminalTextAnswer,
|
|
2924
|
+
suppressContinuation,
|
|
2925
|
+
});
|
|
2926
|
+
}
|
|
2927
|
+
if (!reclaimed) {
|
|
2928
|
+
return willRetry && continuationScheduled
|
|
2929
|
+
? { ...COMPACTION_CHECK_CONTINUATION, historyRewritten: true }
|
|
2930
|
+
: continuationScheduled
|
|
2931
|
+
? COMPACTION_CHECK_CONTINUATION
|
|
2932
|
+
: COMPACTION_CHECK_NONE;
|
|
2933
|
+
}
|
|
2934
|
+
return {
|
|
2935
|
+
...(continuationScheduled ? COMPACTION_CHECK_CONTINUATION : COMPACTION_CHECK_NONE),
|
|
2936
|
+
historyRewritten: true,
|
|
2937
|
+
};
|
|
2938
|
+
} catch (error) {
|
|
2939
|
+
if (signal.aborted) {
|
|
2940
|
+
await this.#host.emitSessionEvent({
|
|
2941
|
+
type: "auto_compaction_end",
|
|
2942
|
+
action,
|
|
2943
|
+
result: undefined,
|
|
2944
|
+
aborted: true,
|
|
2945
|
+
willRetry: false,
|
|
2946
|
+
});
|
|
2947
|
+
return COMPACTION_CHECK_NONE;
|
|
2948
|
+
}
|
|
2949
|
+
const message = error instanceof Error ? error.message : "shake failed";
|
|
2950
|
+
await this.#host.emitSessionEvent({
|
|
2951
|
+
type: "auto_compaction_end",
|
|
2952
|
+
action,
|
|
2953
|
+
result: undefined,
|
|
2954
|
+
aborted: false,
|
|
2955
|
+
willRetry: false,
|
|
2956
|
+
errorMessage: message,
|
|
2957
|
+
skipped: false,
|
|
2958
|
+
});
|
|
2959
|
+
// Overflow still needs recovery even if shake threw.
|
|
2960
|
+
return reason === "overflow" ? "fallback" : COMPACTION_CHECK_NONE;
|
|
2961
|
+
} finally {
|
|
2962
|
+
if (this.#autoCompactionAbortController === controller) {
|
|
2963
|
+
this.#autoCompactionAbortController = undefined;
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
/**
|
|
2969
|
+
* Toggle auto-compaction setting.
|
|
2970
|
+
*/
|
|
2971
|
+
setAutoCompactionEnabled(enabled: boolean): void {
|
|
2972
|
+
this.#host.settings.set("compaction.enabled", enabled);
|
|
2973
|
+
if (enabled && this.#host.settings.get("compaction.strategy") === "off") {
|
|
2974
|
+
const defaultStrategy = getDefault("compaction.strategy");
|
|
2975
|
+
this.#host.settings.set("compaction.strategy", defaultStrategy === "off" ? "context-full" : defaultStrategy);
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
/** Whether auto-compaction is enabled */
|
|
2980
|
+
get autoCompactionEnabled(): boolean {
|
|
2981
|
+
return this.#host.settings.get("compaction.enabled") && this.#host.settings.get("compaction.strategy") !== "off";
|
|
2982
|
+
}
|
|
2983
|
+
}
|