@oh-my-pi/pi-coding-agent 16.1.15 → 16.1.17
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 +78 -0
- package/dist/cli.js +3531 -3812
- package/dist/types/auto-thinking/classifier.d.ts +4 -2
- package/dist/types/cli/args.d.ts +2 -5
- package/dist/types/cli/flag-tables.d.ts +2 -2
- package/dist/types/cli/session-picker.d.ts +4 -2
- package/dist/types/commands/launch.d.ts +1 -1
- package/dist/types/config/model-discovery.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/settings-schema.d.ts +12 -1
- package/dist/types/eval/agent-bridge.d.ts +19 -0
- package/dist/types/eval/js/shared/helpers.d.ts +1 -13
- package/dist/types/eval/js/shared/types.d.ts +1 -1
- package/dist/types/eval/js/worker-protocol.d.ts +1 -1
- package/dist/types/eval/py/executor.d.ts +1 -1
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
- package/dist/types/extensibility/plugins/loader.d.ts +16 -9
- package/dist/types/extensibility/tool-event-input.d.ts +2 -0
- package/dist/types/hindsight/content.d.ts +2 -10
- package/dist/types/internal-urls/local-protocol.d.ts +18 -1
- package/dist/types/main.d.ts +2 -0
- package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/components/plugin-settings.d.ts +5 -0
- package/dist/types/modes/components/session-selector.d.ts +25 -0
- package/dist/types/modes/components/status-line/types.d.ts +1 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -0
- package/dist/types/modes/controllers/input-controller.d.ts +14 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
- package/dist/types/session/agent-session.d.ts +6 -6
- package/dist/types/session/provider-image-budget.d.ts +3 -0
- package/dist/types/session/session-context.d.ts +6 -5
- package/dist/types/task/isolation-runner.d.ts +128 -0
- package/dist/types/task/parallel.d.ts +4 -0
- package/dist/types/task/worktree.d.ts +14 -1
- package/dist/types/thinking.d.ts +23 -1
- package/dist/types/tiny/title-client.d.ts +2 -2
- package/dist/types/tools/eval-render.d.ts +3 -0
- package/dist/types/tools/eval.d.ts +11 -17
- package/dist/types/tools/todo.d.ts +26 -28
- package/dist/types/tui/output-block.d.ts +8 -0
- package/dist/types/utils/image-resize.d.ts +2 -0
- package/dist/types/utils/tools-manager.d.ts +2 -0
- package/dist/types/web/search/providers/exa.d.ts +2 -0
- package/package.json +12 -12
- package/scripts/build-binary.ts +18 -4
- package/src/auto-thinking/classifier.ts +7 -2
- package/src/cli/args.ts +4 -5
- package/src/cli/flag-tables.ts +3 -3
- package/src/cli/gallery-fixtures/interaction.ts +6 -9
- package/src/cli/gallery-fixtures/shell.ts +15 -23
- package/src/cli/profile-alias.ts +38 -7
- package/src/cli/session-picker.ts +17 -3
- package/src/cli/usage-cli.ts +5 -1
- package/src/commands/launch.ts +3 -3
- package/src/config/model-discovery.ts +59 -8
- package/src/config/model-registry.ts +74 -3
- package/src/config/settings-schema.ts +13 -1
- package/src/discovery/omp-extension-roots.ts +1 -3
- package/src/edit/renderer.ts +34 -12
- package/src/eval/__tests__/agent-bridge.test.ts +462 -3
- package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
- package/src/eval/__tests__/julia-prelude.test.ts +1 -30
- package/src/eval/__tests__/prelude-agent.test.ts +42 -8
- package/src/eval/agent-bridge.ts +301 -71
- package/src/eval/jl/prelude.jl +32 -227
- package/src/eval/jl/runner.jl +38 -12
- package/src/eval/js/shared/helpers.ts +1 -114
- package/src/eval/js/shared/prelude.txt +13 -27
- package/src/eval/js/shared/runtime.ts +0 -6
- package/src/eval/js/shared/types.ts +1 -1
- package/src/eval/js/worker-protocol.ts +1 -1
- package/src/eval/py/__tests__/prelude.test.ts +13 -0
- package/src/eval/py/executor.ts +1 -1
- package/src/eval/py/prelude.py +47 -105
- package/src/eval/py/runner.py +0 -6
- package/src/eval/rb/prelude.rb +21 -189
- package/src/eval/rb/runner.rb +116 -9
- package/src/export/html/tool-views.generated.js +29 -29
- package/src/extensibility/extensions/wrapper.ts +3 -2
- package/src/extensibility/hooks/tool-wrapper.ts +4 -3
- package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
- package/src/extensibility/plugins/loader.ts +71 -23
- package/src/extensibility/plugins/marketplace/manager.ts +134 -0
- package/src/extensibility/tool-event-input.ts +57 -0
- package/src/hindsight/content.ts +21 -12
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/internal-urls/local-protocol.ts +100 -53
- package/src/main.ts +15 -4
- package/src/mcp/tool-bridge.ts +27 -2
- package/src/mnemopi/state.ts +5 -2
- package/src/modes/acp/acp-event-mapper.ts +7 -2
- package/src/modes/components/agent-transcript-viewer.ts +193 -41
- package/src/modes/components/chat-transcript-builder.ts +6 -0
- package/src/modes/components/custom-editor.test.ts +18 -1
- package/src/modes/components/custom-editor.ts +77 -45
- package/src/modes/components/hook-editor.ts +15 -2
- package/src/modes/components/plugin-settings.ts +7 -1
- package/src/modes/components/session-selector.ts +143 -29
- package/src/modes/components/settings-selector.ts +2 -2
- package/src/modes/components/status-line/component.ts +52 -8
- package/src/modes/components/status-line/segments.ts +5 -1
- package/src/modes/components/status-line/types.ts +1 -0
- package/src/modes/components/welcome.ts +12 -14
- package/src/modes/controllers/command-controller.ts +21 -5
- package/src/modes/controllers/input-controller.ts +115 -3
- package/src/modes/controllers/selector-controller.ts +19 -1
- package/src/modes/interactive-mode.ts +3 -3
- package/src/modes/rpc/rpc-mode.ts +6 -0
- package/src/modes/utils/copy-targets.ts +7 -2
- package/src/modes/utils/ui-helpers.ts +3 -3
- package/src/prompts/system/system-prompt.md +3 -3
- package/src/prompts/system/workflow-notice.md +3 -3
- package/src/prompts/tools/bash.md +16 -0
- package/src/prompts/tools/eval.md +19 -19
- package/src/prompts/tools/todo.md +1 -1
- package/src/sdk.ts +8 -10
- package/src/session/agent-session.ts +422 -97
- package/src/session/provider-image-budget.ts +86 -0
- package/src/session/session-context.ts +14 -7
- package/src/session/session-storage.ts +24 -2
- package/src/session/snapcompact-inline.ts +19 -3
- package/src/slash-commands/builtin-registry.ts +0 -22
- package/src/slash-commands/helpers/usage-report.ts +9 -1
- package/src/task/index.ts +61 -207
- package/src/task/isolation-runner.ts +354 -0
- package/src/task/parallel.ts +6 -1
- package/src/task/worktree.ts +46 -9
- package/src/thinking.ts +29 -2
- package/src/tiny/title-client.ts +75 -21
- package/src/tools/ask.ts +44 -38
- package/src/tools/bash.ts +9 -2
- package/src/tools/browser/tab-worker.ts +1 -1
- package/src/tools/eval-render.ts +34 -27
- package/src/tools/eval.ts +100 -103
- package/src/tools/index.ts +8 -1
- package/src/tools/read.ts +136 -60
- package/src/tools/todo.ts +60 -64
- package/src/tui/code-cell.ts +1 -1
- package/src/tui/output-block.ts +11 -0
- package/src/utils/image-resize.ts +30 -0
- package/src/utils/tools-manager.ts +67 -10
- package/src/web/search/providers/exa.ts +85 -1
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
CompactionCancelledError,
|
|
45
45
|
type CompactionPreparation,
|
|
46
46
|
type CompactionResult,
|
|
47
|
+
type CompactionSettings,
|
|
47
48
|
calculateContextTokens,
|
|
48
49
|
calculatePromptTokens,
|
|
49
50
|
collectEntriesForBranchSummary,
|
|
@@ -55,8 +56,9 @@ import {
|
|
|
55
56
|
effectiveReserveTokens,
|
|
56
57
|
estimateTokens,
|
|
57
58
|
generateBranchSummary,
|
|
58
|
-
|
|
59
|
+
generateHandoffFromContext,
|
|
59
60
|
prepareCompaction,
|
|
61
|
+
renderHandoffPrompt,
|
|
60
62
|
resolveThresholdTokens,
|
|
61
63
|
type SessionEntry,
|
|
62
64
|
type SessionMessageEntry,
|
|
@@ -103,7 +105,7 @@ import {
|
|
|
103
105
|
resolveServiceTier,
|
|
104
106
|
streamSimple,
|
|
105
107
|
} from "@oh-my-pi/pi-ai";
|
|
106
|
-
import {
|
|
108
|
+
import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
|
|
107
109
|
import { THINKING_LOOP_ERROR_MARKER } from "@oh-my-pi/pi-ai/utils/thinking-loop";
|
|
108
110
|
import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
|
|
109
111
|
import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
@@ -302,7 +304,7 @@ import {
|
|
|
302
304
|
stripImagesFromMessage,
|
|
303
305
|
USER_INTERRUPT_LABEL,
|
|
304
306
|
} from "./messages";
|
|
305
|
-
import type { SessionContext } from "./session-context";
|
|
307
|
+
import type { BuildSessionContextOptions, SessionContext } from "./session-context";
|
|
306
308
|
import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-context";
|
|
307
309
|
import { formatSessionDumpText } from "./session-dump-format";
|
|
308
310
|
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions } from "./session-entries";
|
|
@@ -1364,6 +1366,13 @@ export class AgentSession {
|
|
|
1364
1366
|
#pendingRewindReport: string | undefined = undefined;
|
|
1365
1367
|
#rewoundToolResultIds = new Set<string>();
|
|
1366
1368
|
#lastSuccessfulYieldToolCallId: string | undefined = undefined;
|
|
1369
|
+
/**
|
|
1370
|
+
* Sticky across an in-flight prompt run: a successful `yield` makes the run
|
|
1371
|
+
* terminal for execution purposes, so any trailing empty/aborted assistant
|
|
1372
|
+
* stop must NOT trigger empty-stop/unexpected-stop/compaction continuations.
|
|
1373
|
+
* Cleared in `#promptWithMessage` so the next prompt evaluates cleanly.
|
|
1374
|
+
*/
|
|
1375
|
+
#yieldTerminationPending = false;
|
|
1367
1376
|
#providerSessionState = new Map<string, ProviderSessionState>();
|
|
1368
1377
|
#hindsightSessionState: HindsightSessionState | undefined = undefined;
|
|
1369
1378
|
readonly rawSseDebugBuffer: RawSseDebugBuffer;
|
|
@@ -1619,6 +1628,7 @@ export class AgentSession {
|
|
|
1619
1628
|
await this.#advisorRuntime.waitForCatchup(30000, threshold, signal);
|
|
1620
1629
|
}
|
|
1621
1630
|
}
|
|
1631
|
+
await this.#maintainContextMidRun(messages, signal);
|
|
1622
1632
|
});
|
|
1623
1633
|
this.yieldQueue = new YieldQueue({
|
|
1624
1634
|
isStreaming: () => this.isStreaming,
|
|
@@ -2552,6 +2562,7 @@ export class AgentSession {
|
|
|
2552
2562
|
}
|
|
2553
2563
|
if (event.type === "tool_execution_end" && event.toolName === "yield" && !event.isError) {
|
|
2554
2564
|
this.#lastSuccessfulYieldToolCallId = event.toolCallId;
|
|
2565
|
+
this.#yieldTerminationPending = true;
|
|
2555
2566
|
}
|
|
2556
2567
|
|
|
2557
2568
|
// TTSR: Check for pattern matches on assistant text/thinking and tool argument deltas
|
|
@@ -2777,10 +2788,32 @@ export class AgentSession {
|
|
|
2777
2788
|
this.#lastAssistantMessage = undefined;
|
|
2778
2789
|
if (!msg) {
|
|
2779
2790
|
this.#lastSuccessfulYieldToolCallId = undefined;
|
|
2791
|
+
logger.debug("agent_end maintenance routing", {
|
|
2792
|
+
reason: "no-assistant-message",
|
|
2793
|
+
goalModeEnabled: this.#goalModeState?.enabled === true,
|
|
2794
|
+
goalStatus: this.#goalModeState?.goal.status,
|
|
2795
|
+
});
|
|
2780
2796
|
await emitAgentEndNotification();
|
|
2781
2797
|
return;
|
|
2782
2798
|
}
|
|
2783
2799
|
|
|
2800
|
+
const maintenanceRoute = (route: string, extra?: Record<string, unknown>) => {
|
|
2801
|
+
logger.debug("agent_end maintenance routing", {
|
|
2802
|
+
route,
|
|
2803
|
+
stopReason: msg.stopReason,
|
|
2804
|
+
provider: msg.provider,
|
|
2805
|
+
model: msg.model,
|
|
2806
|
+
contentBlocks: msg.content.length,
|
|
2807
|
+
hasToolCalls: msg.content.some(content => content.type === "toolCall"),
|
|
2808
|
+
hasText: msg.content.some(content => content.type === "text"),
|
|
2809
|
+
goalModeEnabled: this.#goalModeState?.enabled === true,
|
|
2810
|
+
goalStatus: this.#goalModeState?.goal.status,
|
|
2811
|
+
successfulYield: this.#assistantEndedWithSuccessfulYield(msg),
|
|
2812
|
+
...extra,
|
|
2813
|
+
});
|
|
2814
|
+
};
|
|
2815
|
+
maintenanceRoute("entered");
|
|
2816
|
+
|
|
2784
2817
|
// Invalidate GitHub Copilot credentials on auth failure so stale tokens
|
|
2785
2818
|
// aren't reused on the next request
|
|
2786
2819
|
if (
|
|
@@ -2794,27 +2827,70 @@ export class AgentSession {
|
|
|
2794
2827
|
if (this.#skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) {
|
|
2795
2828
|
this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
|
|
2796
2829
|
this.#lastSuccessfulYieldToolCallId = undefined;
|
|
2830
|
+
maintenanceRoute("skip-post-turn-maintenance");
|
|
2797
2831
|
await emitAgentEndNotification();
|
|
2798
2832
|
return;
|
|
2799
2833
|
}
|
|
2800
2834
|
|
|
2801
|
-
|
|
2835
|
+
const activeGoal = this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active";
|
|
2836
|
+
const yieldOnThisMessage = this.#assistantEndedWithSuccessfulYield(msg);
|
|
2837
|
+
// A successful `yield` in this run is terminal for execution purposes.
|
|
2838
|
+
// Suppress empty-stop retry, unexpected-stop retry, queued-message drain,
|
|
2839
|
+
// and compaction-driven continuations for the rest of this prompt cycle:
|
|
2840
|
+
// the executor consumed the yield as the terminal result, so a trailing
|
|
2841
|
+
// empty/aborted assistant stop must NOT revive the agent loop. The
|
|
2842
|
+
// `#yieldTerminationPending` sticky flag clears on the next `prompt()`.
|
|
2843
|
+
if (yieldOnThisMessage || this.#yieldTerminationPending) {
|
|
2802
2844
|
this.#lastSuccessfulYieldToolCallId = undefined;
|
|
2803
|
-
if (
|
|
2845
|
+
if (yieldOnThisMessage && activeGoal) {
|
|
2846
|
+
maintenanceRoute("successful-yield-active-goal-checkCompaction");
|
|
2804
2847
|
const compactionTask = this.#checkCompaction(msg);
|
|
2805
2848
|
this.#trackPostPromptTask(compactionTask);
|
|
2806
2849
|
await compactionTask;
|
|
2850
|
+
} else if (yieldOnThisMessage) {
|
|
2851
|
+
maintenanceRoute("successful-yield-no-active-goal");
|
|
2852
|
+
} else {
|
|
2853
|
+
maintenanceRoute("post-yield-trailing-stop-suppressed");
|
|
2807
2854
|
}
|
|
2808
2855
|
await emitAgentEndNotification();
|
|
2809
2856
|
return;
|
|
2810
2857
|
}
|
|
2811
2858
|
this.#lastSuccessfulYieldToolCallId = undefined;
|
|
2812
2859
|
|
|
2860
|
+
// Empty-stop cleanup MUST run before any compaction continuation: an
|
|
2861
|
+
// empty toolUse stop must be stripped from active context + session
|
|
2862
|
+
// history before we schedule another turn, otherwise the next
|
|
2863
|
+
// Anthropic turn carries a tool_use block with no matching
|
|
2864
|
+
// tool_result and corrupts message history. The handler also
|
|
2865
|
+
// schedules its own retry, so a real empty stop never needs the
|
|
2866
|
+
// active-goal threshold pre-empt below.
|
|
2813
2867
|
if (await this.#handleEmptyAssistantStop(msg)) {
|
|
2868
|
+
maintenanceRoute("empty-stop-handled");
|
|
2814
2869
|
await emitAgentEndNotification();
|
|
2815
2870
|
return;
|
|
2816
2871
|
}
|
|
2872
|
+
|
|
2873
|
+
let compactionResult = COMPACTION_CHECK_NONE;
|
|
2874
|
+
let checkedCompaction = false;
|
|
2875
|
+
if (activeGoal) {
|
|
2876
|
+
maintenanceRoute("active-goal-pre-empt-checkCompaction");
|
|
2877
|
+
const compactionTask = this.#checkCompaction(msg);
|
|
2878
|
+
this.#trackPostPromptTask(compactionTask);
|
|
2879
|
+
compactionResult = await compactionTask;
|
|
2880
|
+
checkedCompaction = true;
|
|
2881
|
+
if (compactionResult.deferredHandoff || compactionResult.continuationScheduled) {
|
|
2882
|
+
maintenanceRoute("active-goal-pre-empt-continuation-scheduled", {
|
|
2883
|
+
deferredHandoff: compactionResult.deferredHandoff,
|
|
2884
|
+
continuationScheduled: compactionResult.continuationScheduled,
|
|
2885
|
+
});
|
|
2886
|
+
this.#resolveRetry();
|
|
2887
|
+
await emitAgentEndNotification();
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2817
2892
|
if (await this.#handleUnexpectedAssistantStop(msg)) {
|
|
2893
|
+
maintenanceRoute("unexpected-stop-handled");
|
|
2818
2894
|
await emitAgentEndNotification();
|
|
2819
2895
|
return;
|
|
2820
2896
|
}
|
|
@@ -2854,9 +2930,12 @@ export class AgentSession {
|
|
|
2854
2930
|
}
|
|
2855
2931
|
this.#resolveRetry();
|
|
2856
2932
|
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2933
|
+
if (!checkedCompaction) {
|
|
2934
|
+
maintenanceRoute("bottom-checkCompaction");
|
|
2935
|
+
const compactionTask = this.#checkCompaction(msg);
|
|
2936
|
+
this.#trackPostPromptTask(compactionTask);
|
|
2937
|
+
compactionResult = await compactionTask;
|
|
2938
|
+
}
|
|
2860
2939
|
// Check for incomplete todos only after a final assistant stop, not intermediate tool-use turns.
|
|
2861
2940
|
const hasToolCalls = msg.content.some(content => content.type === "toolCall");
|
|
2862
2941
|
if (hasToolCalls) {
|
|
@@ -5227,13 +5306,21 @@ export class AgentSession {
|
|
|
5227
5306
|
}
|
|
5228
5307
|
|
|
5229
5308
|
/**
|
|
5230
|
-
*
|
|
5231
|
-
*
|
|
5232
|
-
*
|
|
5233
|
-
*
|
|
5309
|
+
* Transcript for TUI display. Full history is kept for export/resume-style
|
|
5310
|
+
* callers; live chat can collapse compacted history to keep the hot render
|
|
5311
|
+
* surface bounded. Display-only — NEVER feed the result to
|
|
5312
|
+
* `agent.replaceMessages` or a provider.
|
|
5234
5313
|
*/
|
|
5235
|
-
buildTranscriptSessionContext(
|
|
5236
|
-
|
|
5314
|
+
buildTranscriptSessionContext(
|
|
5315
|
+
options?: Pick<BuildSessionContextOptions, "collapseCompactedHistory">,
|
|
5316
|
+
): SessionContext {
|
|
5317
|
+
return deobfuscateSessionContext(
|
|
5318
|
+
this.sessionManager.buildSessionContext({
|
|
5319
|
+
transcript: true,
|
|
5320
|
+
collapseCompactedHistory: options?.collapseCompactedHistory,
|
|
5321
|
+
}),
|
|
5322
|
+
this.#obfuscator,
|
|
5323
|
+
);
|
|
5237
5324
|
}
|
|
5238
5325
|
|
|
5239
5326
|
#obfuscateTextForProvider(text: string | undefined): string | undefined {
|
|
@@ -5939,6 +6026,10 @@ export class AgentSession {
|
|
|
5939
6026
|
this.#todoReminderAwaitingProgress = false;
|
|
5940
6027
|
this.#emptyStopRetryCount = 0;
|
|
5941
6028
|
this.#unexpectedStopRetryCount = 0;
|
|
6029
|
+
// A new prompt cycle starts: drop any sticky yield-termination from the
|
|
6030
|
+
// previous run so empty-stop / unexpected-stop / compaction maintenance
|
|
6031
|
+
// can evaluate this turn normally.
|
|
6032
|
+
this.#yieldTerminationPending = false;
|
|
5942
6033
|
|
|
5943
6034
|
await this.#maybeRestoreRetryFallbackPrimary();
|
|
5944
6035
|
|
|
@@ -6967,20 +7058,22 @@ export class AgentSession {
|
|
|
6967
7058
|
throw new Error(`No API key for ${model.provider}/${model.id}`);
|
|
6968
7059
|
}
|
|
6969
7060
|
|
|
7061
|
+
const targetModel = await this.#modelRegistry.refreshSelectedModelMetadata(model);
|
|
7062
|
+
|
|
6970
7063
|
this.#clearActiveRetryFallback();
|
|
6971
|
-
this.#setModelWithProviderSessionReset(
|
|
6972
|
-
this.sessionManager.appendModelChange(`${
|
|
7064
|
+
this.#setModelWithProviderSessionReset(targetModel);
|
|
7065
|
+
this.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, role);
|
|
6973
7066
|
if (options?.persist) {
|
|
6974
7067
|
this.settings.setModelRole(
|
|
6975
7068
|
role,
|
|
6976
|
-
this.#formatRoleModelValue(role,
|
|
7069
|
+
this.#formatRoleModelValue(role, targetModel, options.selector, options.thinkingLevel),
|
|
6977
7070
|
);
|
|
6978
7071
|
}
|
|
6979
|
-
this.settings.getStorage()?.recordModelUsage(`${
|
|
7072
|
+
this.settings.getStorage()?.recordModelUsage(`${targetModel.provider}/${targetModel.id}`);
|
|
6980
7073
|
|
|
6981
7074
|
// Re-apply thinking for the newly selected model. Prefer the model's
|
|
6982
7075
|
// configured defaultLevel; otherwise preserve the current level (or auto).
|
|
6983
|
-
this.#reapplyThinkingLevel(
|
|
7076
|
+
this.#reapplyThinkingLevel(targetModel.thinking?.defaultLevel);
|
|
6984
7077
|
await this.#syncAfterModelChange(previousEditMode);
|
|
6985
7078
|
}
|
|
6986
7079
|
|
|
@@ -7001,20 +7094,22 @@ export class AgentSession {
|
|
|
7001
7094
|
throw new Error(`No API key for ${model.provider}/${model.id}`);
|
|
7002
7095
|
}
|
|
7003
7096
|
|
|
7097
|
+
const targetModel = await this.#modelRegistry.refreshSelectedModelMetadata(model);
|
|
7098
|
+
|
|
7004
7099
|
this.#clearActiveRetryFallback();
|
|
7005
|
-
this.#setModelWithProviderSessionReset(
|
|
7100
|
+
this.#setModelWithProviderSessionReset(targetModel);
|
|
7006
7101
|
this.sessionManager.appendModelChange(
|
|
7007
|
-
`${
|
|
7102
|
+
`${targetModel.provider}/${targetModel.id}`,
|
|
7008
7103
|
options?.ephemeral ? EPHEMERAL_MODEL_CHANGE_ROLE : "temporary",
|
|
7009
7104
|
);
|
|
7010
|
-
this.settings.getStorage()?.recordModelUsage(`${
|
|
7105
|
+
this.settings.getStorage()?.recordModelUsage(`${targetModel.provider}/${targetModel.id}`);
|
|
7011
7106
|
|
|
7012
7107
|
// Apply explicit thinking level if given; otherwise prefer the model's
|
|
7013
7108
|
// configured defaultLevel; otherwise re-clamp the current level (or auto).
|
|
7014
7109
|
if (thinkingLevel !== undefined) {
|
|
7015
7110
|
this.setThinkingLevel(thinkingLevel);
|
|
7016
7111
|
} else {
|
|
7017
|
-
this.#reapplyThinkingLevel(
|
|
7112
|
+
this.#reapplyThinkingLevel(targetModel.thinking?.defaultLevel);
|
|
7018
7113
|
}
|
|
7019
7114
|
await this.#syncAfterModelChange(previousEditMode);
|
|
7020
7115
|
}
|
|
@@ -7302,6 +7397,10 @@ export class AgentSession {
|
|
|
7302
7397
|
async #applyAutoThinkingLevel(promptText: string, generation: number): Promise<void> {
|
|
7303
7398
|
const model = this.model;
|
|
7304
7399
|
if (!model?.reasoning) return;
|
|
7400
|
+
// Models with reasoning but no controllable effort surface (devin-agent
|
|
7401
|
+
// Cascade routes effort via sibling model ids, not a wire param) have
|
|
7402
|
+
// nothing to pick — skip classification rather than discard its result.
|
|
7403
|
+
if (getSupportedEfforts(model).length === 0) return;
|
|
7305
7404
|
|
|
7306
7405
|
let resolved: Effort | undefined;
|
|
7307
7406
|
if (this.#magicKeywordEnabled("ultrathink") && containsUltrathink(promptText)) {
|
|
@@ -7798,31 +7897,45 @@ export class AgentSession {
|
|
|
7798
7897
|
let tokensBefore: number;
|
|
7799
7898
|
let details: unknown;
|
|
7800
7899
|
|
|
7801
|
-
// Snapcompact runs locally first
|
|
7802
|
-
//
|
|
7803
|
-
//
|
|
7900
|
+
// Snapcompact runs locally first. The frame cap is sized from the live
|
|
7901
|
+
// model window via #computeSnapcompactMaxFrames so the post-render context
|
|
7902
|
+
// fits without the warning loop (issue #3247). Zero-frame budget → skip
|
|
7903
|
+
// snapcompact and take the summarizer path immediately.
|
|
7804
7904
|
let snapcompactResult: snapcompact.CompactionResult | undefined;
|
|
7805
7905
|
if (snapcompactReady) {
|
|
7806
|
-
|
|
7807
|
-
|
|
7808
|
-
|
|
7809
|
-
shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
|
|
7810
|
-
});
|
|
7811
|
-
const ctxWindow = this.model?.contextWindow ?? 0;
|
|
7812
|
-
const budget =
|
|
7813
|
-
ctxWindow > 0
|
|
7814
|
-
? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings)
|
|
7815
|
-
: Number.POSITIVE_INFINITY;
|
|
7816
|
-
if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) {
|
|
7817
|
-
logger.warn("Snapcompact still overflows the window; falling back to an LLM summary", {
|
|
7906
|
+
const maxFrames = this.#computeSnapcompactMaxFrames(preparation, effectiveSettings);
|
|
7907
|
+
if (maxFrames < 1) {
|
|
7908
|
+
logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
|
|
7818
7909
|
model: this.model?.id,
|
|
7819
7910
|
});
|
|
7820
7911
|
this.emitNotice(
|
|
7821
7912
|
"warning",
|
|
7822
|
-
"snapcompact
|
|
7913
|
+
"snapcompact: kept history alone exceeds the context budget — using an LLM summary instead",
|
|
7823
7914
|
"compaction",
|
|
7824
7915
|
);
|
|
7825
|
-
|
|
7916
|
+
} else {
|
|
7917
|
+
snapcompactResult = await snapcompact.compact(preparation, {
|
|
7918
|
+
convertToLlm,
|
|
7919
|
+
model: this.model,
|
|
7920
|
+
shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
|
|
7921
|
+
maxFrames,
|
|
7922
|
+
});
|
|
7923
|
+
const ctxWindow = this.model?.contextWindow ?? 0;
|
|
7924
|
+
const budget =
|
|
7925
|
+
ctxWindow > 0
|
|
7926
|
+
? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings)
|
|
7927
|
+
: Number.POSITIVE_INFINITY;
|
|
7928
|
+
if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) {
|
|
7929
|
+
logger.warn("Snapcompact still overflows the window after frame-budget sizing; falling back", {
|
|
7930
|
+
model: this.model?.id,
|
|
7931
|
+
});
|
|
7932
|
+
this.emitNotice(
|
|
7933
|
+
"warning",
|
|
7934
|
+
"snapcompact could not bring the context under the limit — using an LLM summary instead",
|
|
7935
|
+
"compaction",
|
|
7936
|
+
);
|
|
7937
|
+
snapcompactResult = undefined;
|
|
7938
|
+
}
|
|
7826
7939
|
}
|
|
7827
7940
|
}
|
|
7828
7941
|
|
|
@@ -8049,27 +8162,60 @@ export class AgentSession {
|
|
|
8049
8162
|
throw new Error(`No API key for ${model.provider}`);
|
|
8050
8163
|
}
|
|
8051
8164
|
|
|
8052
|
-
|
|
8053
|
-
|
|
8054
|
-
|
|
8055
|
-
|
|
8165
|
+
// Build the handoff request through the SAME pipeline a live turn uses
|
|
8166
|
+
// (`runEphemeralTurn` / `/btw` share it) so the oneshot reads the
|
|
8167
|
+
// provider prompt cache the main turn populated instead of cold-missing
|
|
8168
|
+
// the whole prefix: identical system prompt, normalized tools, and
|
|
8169
|
+
// transform-/obfuscation-matched message history via
|
|
8170
|
+
// `convertMessagesToLlm` + `buildSideRequestContext`, plus the live turn's
|
|
8171
|
+
// effective provider cache key with a unique side `sessionId` so
|
|
8172
|
+
// OpenAI/Codex append-only state never mixes with the live turn.
|
|
8173
|
+
const cacheSessionId = this.sessionId;
|
|
8174
|
+
// The loop sends `promptCacheKey` (providerPromptCacheKey) and falls back to
|
|
8175
|
+
// the provider session id; providers route on `promptCacheKey ?? sessionId`.
|
|
8176
|
+
// Both can diverge from this.sessionId (tan/subagent/shared sessions), so
|
|
8177
|
+
// mirror exactly what the live turn populated the cache under.
|
|
8178
|
+
const handoffPromptCacheKey = this.agent.promptCacheKey ?? this.agent.sessionId;
|
|
8179
|
+
const handoffPromptText = renderHandoffPrompt(this.#obfuscateTextForProvider(customInstructions));
|
|
8180
|
+
const handoffSnapshot: AgentMessage[] = [
|
|
8181
|
+
...this.agent.state.messages,
|
|
8056
8182
|
{
|
|
8057
|
-
|
|
8058
|
-
|
|
8059
|
-
|
|
8060
|
-
|
|
8061
|
-
|
|
8062
|
-
|
|
8183
|
+
role: "user",
|
|
8184
|
+
content: [{ type: "text", text: handoffPromptText }],
|
|
8185
|
+
attribution: "agent",
|
|
8186
|
+
timestamp: Date.now(),
|
|
8187
|
+
},
|
|
8188
|
+
];
|
|
8189
|
+
const handoffLlmMessages = await this.convertMessagesToLlm(handoffSnapshot, handoffSignal);
|
|
8190
|
+
// Base system prompt, not a per-turn `before_agent_start` hook override —
|
|
8191
|
+
// the handoff seeds a fresh session and must not carry prompt-specific
|
|
8192
|
+
// hook state. Matches the prompt the old handoff path sent.
|
|
8193
|
+
const handoffContext = await this.agent.buildSideRequestContext(handoffLlmMessages, this.#baseSystemPrompt);
|
|
8194
|
+
const handoffStreamOptions = this.prepareSimpleStreamOptions(
|
|
8195
|
+
{
|
|
8196
|
+
apiKey: this.#modelRegistry.resolver(model, cacheSessionId),
|
|
8197
|
+
sessionId: `${cacheSessionId}:side:${Snowflake.next()}`,
|
|
8198
|
+
promptCacheKey: handoffPromptCacheKey,
|
|
8199
|
+
preferWebsockets: false,
|
|
8200
|
+
serviceTier: this.#effectiveServiceTier(model),
|
|
8201
|
+
hideThinkingSummary: this.agent.hideThinkingSummary,
|
|
8063
8202
|
initiatorOverride: "agent",
|
|
8064
|
-
|
|
8203
|
+
signal: handoffSignal,
|
|
8204
|
+
},
|
|
8205
|
+
model.provider,
|
|
8206
|
+
);
|
|
8207
|
+
const rawHandoffText = await generateHandoffFromContext(
|
|
8208
|
+
obfuscateProviderContext(this.#obfuscator, handoffContext),
|
|
8209
|
+
model,
|
|
8210
|
+
{
|
|
8211
|
+
streamOptions: handoffStreamOptions,
|
|
8065
8212
|
telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
|
|
8066
|
-
// Honor the user's /model thinking selection on the handoff
|
|
8067
|
-
//
|
|
8068
|
-
// resolveCompactionEffort so unsupported-effort models don't
|
|
8069
|
-
//
|
|
8213
|
+
// Honor the user's /model thinking selection on the handoff path.
|
|
8214
|
+
// Clamped per-model inside generateHandoffFromContext via
|
|
8215
|
+
// resolveCompactionEffort so unsupported-effort models don't trip
|
|
8216
|
+
// requireSupportedEffort.
|
|
8070
8217
|
thinkingLevel: this.thinkingLevel,
|
|
8071
8218
|
},
|
|
8072
|
-
handoffSignal,
|
|
8073
8219
|
);
|
|
8074
8220
|
const handoffText = this.#deobfuscateFromProvider(rawHandoffText);
|
|
8075
8221
|
|
|
@@ -8213,6 +8359,58 @@ export class AgentSession {
|
|
|
8213
8359
|
});
|
|
8214
8360
|
}
|
|
8215
8361
|
|
|
8362
|
+
/**
|
|
8363
|
+
* Compact active `/goal` runs that never settle to `agent_end`.
|
|
8364
|
+
*
|
|
8365
|
+
* Long autonomous goals can keep producing tool calls inside one agent run.
|
|
8366
|
+
* The post-turn `agent_end` threshold check never fires in that shape, so
|
|
8367
|
+
* context can grow until provider overflow. `onTurnEnd` is the safe boundary:
|
|
8368
|
+
* tool results for the just-finished turn are already paired in
|
|
8369
|
+
* `activeMessages`, the live array the agent loop reads before its next
|
|
8370
|
+
* model call. Run maintenance here and splice the compacted state back into
|
|
8371
|
+
* that array, mirroring [`AgentSession.#applyRewind`].
|
|
8372
|
+
*/
|
|
8373
|
+
async #maintainContextMidRun(activeMessages: AgentMessage[], signal?: AbortSignal): Promise<void> {
|
|
8374
|
+
if (signal?.aborted || this.#isDisposed || this.isCompacting || this.isGeneratingHandoff) return;
|
|
8375
|
+
if (!(this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active")) return;
|
|
8376
|
+
|
|
8377
|
+
const model = this.model;
|
|
8378
|
+
const contextWindow = model?.contextWindow ?? 0;
|
|
8379
|
+
if (contextWindow <= 0) return;
|
|
8380
|
+
|
|
8381
|
+
const compactionSettings = this.settings.getGroup("compaction");
|
|
8382
|
+
if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
|
|
8383
|
+
|
|
8384
|
+
const lastAssistant = [...activeMessages]
|
|
8385
|
+
.reverse()
|
|
8386
|
+
.find((message): message is AssistantMessage => message.role === "assistant");
|
|
8387
|
+
if (!lastAssistant || lastAssistant.stopReason === "aborted" || lastAssistant.stopReason === "error") return;
|
|
8388
|
+
|
|
8389
|
+
const billedContextTokens = calculateContextTokens(lastAssistant.usage);
|
|
8390
|
+
const storedContextTokens = this.#estimateStoredContextTokens();
|
|
8391
|
+
const contextTokens = compactionContextTokens(billedContextTokens, storedContextTokens);
|
|
8392
|
+
if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
|
|
8393
|
+
|
|
8394
|
+
const messagesBefore = activeMessages.length;
|
|
8395
|
+
await this.#runAutoCompaction("threshold", false, false, false, {
|
|
8396
|
+
autoContinue: false,
|
|
8397
|
+
suppressContinuation: true,
|
|
8398
|
+
triggerContextTokens: contextTokens,
|
|
8399
|
+
});
|
|
8400
|
+
|
|
8401
|
+
if (signal?.aborted) return;
|
|
8402
|
+
const compactedMessages = this.agent.state.messages;
|
|
8403
|
+
if (compactedMessages !== activeMessages) {
|
|
8404
|
+
activeMessages.splice(0, activeMessages.length, ...compactedMessages);
|
|
8405
|
+
}
|
|
8406
|
+
logger.debug("Mid-run goal compaction ran between tool-call turns", {
|
|
8407
|
+
contextTokens,
|
|
8408
|
+
contextWindow,
|
|
8409
|
+
strategy: compactionSettings.strategy,
|
|
8410
|
+
messagesBefore,
|
|
8411
|
+
messagesAfter: activeMessages.length,
|
|
8412
|
+
});
|
|
8413
|
+
}
|
|
8216
8414
|
/**
|
|
8217
8415
|
* Check if context maintenance or promotion is needed and run it.
|
|
8218
8416
|
* Called after agent_end and before prompt submission.
|
|
@@ -8338,28 +8536,58 @@ export class AgentSession {
|
|
|
8338
8536
|
// Skip if this was an error (non-overflow errors don't have usage data)
|
|
8339
8537
|
if (assistantMessage.stopReason === "error") return COMPACTION_CHECK_NONE;
|
|
8340
8538
|
const pruneResult = await this.#pruneToolOutputs();
|
|
8341
|
-
|
|
8342
|
-
|
|
8343
|
-
|
|
8344
|
-
|
|
8345
|
-
|
|
8346
|
-
|
|
8347
|
-
|
|
8348
|
-
//
|
|
8349
|
-
//
|
|
8350
|
-
//
|
|
8351
|
-
//
|
|
8352
|
-
//
|
|
8353
|
-
|
|
8354
|
-
|
|
8539
|
+
const maintenanceTokensFreed = (supersedeResult?.tokensSaved ?? 0) + (pruneResult?.tokensSaved ?? 0);
|
|
8540
|
+
const assistantUsageContextTokens = calculateContextTokens(assistantMessage.usage);
|
|
8541
|
+
const storedContextTokens = this.#estimateStoredContextTokens();
|
|
8542
|
+
// Pruning frees bytes for the NEXT prompt; it does not change the size of
|
|
8543
|
+
// the prompt the LLM just billed for. Earlier revisions subtracted the
|
|
8544
|
+
// per-turn supersede/prune `tokensSaved` from the threshold input, which
|
|
8545
|
+
// let a long-running `/goal` session sit above `compaction.thresholdTokens`
|
|
8546
|
+
// indefinitely whenever per-turn pruning saved enough to drop the
|
|
8547
|
+
// post-prune estimate below the user-configured trigger — the visible
|
|
8548
|
+
// context (anchored to the same provider billing) still showed >threshold,
|
|
8549
|
+
// but `shouldCompact` no-op'd (#3174). Anchor the initial trigger on the
|
|
8550
|
+
// last turn's billed context tokens, floored by the post-prune
|
|
8551
|
+
// stored-conversation estimate so a payload-compression hook still can't
|
|
8552
|
+
// deflate the trigger.
|
|
8553
|
+
const contextTokens = compactionContextTokens(assistantUsageContextTokens, storedContextTokens);
|
|
8554
|
+
const postMaintenanceContextTokens = compactionContextTokens(
|
|
8555
|
+
Math.max(0, assistantUsageContextTokens - maintenanceTokensFreed),
|
|
8556
|
+
storedContextTokens,
|
|
8557
|
+
);
|
|
8558
|
+
const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
|
|
8559
|
+
const shouldThresholdCompact = shouldCompact(contextTokens, contextWindow, compactionSettings);
|
|
8560
|
+
logger.debug("Auto-compaction threshold decision", {
|
|
8561
|
+
phase: "post-agent-end",
|
|
8562
|
+
goalModeEnabled: this.#goalModeState?.enabled === true,
|
|
8563
|
+
goalStatus: this.#goalModeState?.goal.status,
|
|
8564
|
+
stopReason: assistantMessage.stopReason,
|
|
8565
|
+
sameModel: sameModel === true,
|
|
8566
|
+
contextWindow,
|
|
8567
|
+
strategy: compactionSettings.strategy,
|
|
8568
|
+
thresholdTokens,
|
|
8569
|
+
assistantUsageContextTokens,
|
|
8570
|
+
storedContextTokens,
|
|
8571
|
+
resolvedContextTokens: contextTokens,
|
|
8572
|
+
postMaintenanceContextTokens,
|
|
8573
|
+
maintenanceTokensFreed,
|
|
8574
|
+
shouldCompact: shouldThresholdCompact,
|
|
8575
|
+
contextPromotionEnabled: this.settings.get("contextPromotion.enabled") === true,
|
|
8576
|
+
});
|
|
8577
|
+
if (shouldThresholdCompact) {
|
|
8355
8578
|
// Try promotion first — if a larger model is available, switch instead of compacting
|
|
8356
8579
|
const promoted = await this.#tryContextPromotion(assistantMessage);
|
|
8357
8580
|
if (!promoted) {
|
|
8358
8581
|
return await this.#runAutoCompaction("threshold", false, false, allowDefer, {
|
|
8359
8582
|
autoContinue,
|
|
8360
|
-
triggerContextTokens:
|
|
8583
|
+
triggerContextTokens: postMaintenanceContextTokens,
|
|
8361
8584
|
});
|
|
8362
8585
|
}
|
|
8586
|
+
logger.debug("Auto-compaction threshold satisfied but context promotion took over", {
|
|
8587
|
+
contextTokens,
|
|
8588
|
+
contextWindow,
|
|
8589
|
+
model: `${assistantMessage.provider}/${assistantMessage.model}`,
|
|
8590
|
+
});
|
|
8363
8591
|
}
|
|
8364
8592
|
return COMPACTION_CHECK_NONE;
|
|
8365
8593
|
}
|
|
@@ -9438,6 +9666,82 @@ export class AgentSession {
|
|
|
9438
9666
|
return { kind: "needsLlm", hookContext, hookPrompt, preserveData };
|
|
9439
9667
|
}
|
|
9440
9668
|
|
|
9669
|
+
/**
|
|
9670
|
+
* Cap on snapcompact frames the post-compaction context can carry without
|
|
9671
|
+
* busting the model window. Mirrors the per-frame token charge used by the
|
|
9672
|
+
* projection ({@link snapcompact.FRAME_TOKEN_ESTIMATE}, the conservative
|
|
9673
|
+
* high-res Anthropic ceiling), so picking `maxFrames` from this helper makes
|
|
9674
|
+
* {@link #projectSnapcompactContextTokens} succeed by construction.
|
|
9675
|
+
*
|
|
9676
|
+
* Skip vs. cap use different reserves on purpose. The **skip** decision
|
|
9677
|
+
* (return `0`) trips only when kept-recent plus non-message tokens already
|
|
9678
|
+
* eat the entire `ctxWindow − reserve` envelope: at that point no archive
|
|
9679
|
+
* shape — frame-bearing or text-only — can fit, and the caller MUST
|
|
9680
|
+
* shortcut to the LLM summarizer instead of re-running snapcompact to
|
|
9681
|
+
* re-emit the "could not bring the context under the limit" warning every
|
|
9682
|
+
* threshold tick. The **cap** calculation subtracts a shape-aware reserve
|
|
9683
|
+
* (`2 × geometry(shape).capacity` chars worth of text edges, billed at the
|
|
9684
|
+
* tiktoken cl100k baseline, plus a 2k summary-template allowance) sized
|
|
9685
|
+
* from the same `shape` snapcompact will use, so the projection still
|
|
9686
|
+
* passes once frames land — but it MUST NOT gate the skip decision, since
|
|
9687
|
+
* a frame-less archive (`text.length <= 2 * edgeCap` short-circuit in
|
|
9688
|
+
* `planArchive`) typically costs only a few hundred tokens of summary
|
|
9689
|
+
* lead and would fit under residual headroom far smaller than the cap
|
|
9690
|
+
* reserve (chatgpt-codex reviews on #3249).
|
|
9691
|
+
*
|
|
9692
|
+
* Returns `1` when the frame charge would overflow but the text-only path
|
|
9693
|
+
* still has room: snapcompact's planner picks the frame-less layout
|
|
9694
|
+
* automatically when the discarded text fits in the edges, so giving it
|
|
9695
|
+
* the minimum cap lets it succeed instead of being skipped outright.
|
|
9696
|
+
*
|
|
9697
|
+
* Without this cap, the bundled `MAX_FRAMES_DEFAULT = 80` × 5024 tokens =
|
|
9698
|
+
* ~402k frame-token projection always overflows any sub-1M-token window
|
|
9699
|
+
* (issue #3247).
|
|
9700
|
+
*/
|
|
9701
|
+
#computeSnapcompactMaxFrames(preparation: CompactionPreparation, settings: CompactionSettings): number {
|
|
9702
|
+
const ctxWindow = this.model?.contextWindow ?? 0;
|
|
9703
|
+
if (ctxWindow <= 0) return snapcompact.MAX_FRAMES_DEFAULT;
|
|
9704
|
+
const reserve = effectiveReserveTokens(ctxWindow, settings);
|
|
9705
|
+
let baseTokens = computeNonMessageTokens(this);
|
|
9706
|
+
for (const message of preparation.recentMessages) {
|
|
9707
|
+
baseTokens += estimateTokens(message);
|
|
9708
|
+
}
|
|
9709
|
+
const totalBudget = ctxWindow - reserve;
|
|
9710
|
+
// Skip iff there is no headroom whatsoever; a text-only archive costs
|
|
9711
|
+
// far less than the cap reserve below, so any positive residual is
|
|
9712
|
+
// worth attempting and the projection guard catches actual overflow.
|
|
9713
|
+
if (baseTokens >= totalBudget) return 0;
|
|
9714
|
+
// Cap reserve mirrors what `estimateTokens(summaryMessage)` will charge
|
|
9715
|
+
// when frames > 0: `countTokens(summaryTemplate ‖ textHead ‖ textTail)`
|
|
9716
|
+
// plus `numFrames × FRAME_TOKEN_ESTIMATE`. Resolve the shape this
|
|
9717
|
+
// snapcompact pass will actually use (matches the `shape` argument
|
|
9718
|
+
// passed to `snapcompact.compact` in the auto and manual paths) so the
|
|
9719
|
+
// text-edge cost reflects the live frame geometry rather than a fixed
|
|
9720
|
+
// approximation. Reviewer (chatgpt-codex on #3249): a 4k reserve
|
|
9721
|
+
// undersized the ~7k text-edge cost on the default Anthropic
|
|
9722
|
+
// 11on16-bw shape, so the projection then rejected the `maxFrames`
|
|
9723
|
+
// the cap had picked and the warning loop reappeared.
|
|
9724
|
+
//
|
|
9725
|
+
// - `textHead` and `textTail` each consume up to `geometry.capacity`
|
|
9726
|
+
// chars when frames > 0 (one HQ-capacity page per edge: see
|
|
9727
|
+
// `TEXT_EDGE_PAGES = 1` in `planArchive`), so 2 × capacity chars
|
|
9728
|
+
// total. Per-shape capacity: Anthropic 11on16-bw ~13.9k, Opus
|
|
9729
|
+
// 1932px ~21k, Gemini 8on22-bw 2048px ~23.8k, OpenAI 1568px ~13.9k.
|
|
9730
|
+
// - tiktoken cl100k ≈ 4 chars/token on ASCII (verified empirically
|
|
9731
|
+
// for prose, code, and JSON); a 1.15 multiplier absorbs tokenizer
|
|
9732
|
+
// drift on denser content (e.g. dense JSON / tool-result blobs).
|
|
9733
|
+
// - Summary template (intro + FILES section + grid notes) bills
|
|
9734
|
+
// ~2k tokens for typical sessions.
|
|
9735
|
+
const shape = snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape"));
|
|
9736
|
+
const edgeCap = snapcompact.geometry(shape).capacity;
|
|
9737
|
+
const textEdgeTokens = Math.ceil((2 * edgeCap * 1.15) / 4);
|
|
9738
|
+
const SUMMARY_TEMPLATE_TOKENS = 2000;
|
|
9739
|
+
const capReserve = textEdgeTokens + SUMMARY_TEMPLATE_TOKENS;
|
|
9740
|
+
const frameBudget = totalBudget - baseTokens - capReserve;
|
|
9741
|
+
if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 1;
|
|
9742
|
+
return Math.min(Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE), snapcompact.MAX_FRAMES_DEFAULT);
|
|
9743
|
+
}
|
|
9744
|
+
|
|
9441
9745
|
/**
|
|
9442
9746
|
* Project the post-compaction context size of a snapcompact result: kept
|
|
9443
9747
|
* recent messages + the summary message with its re-attached frames + the
|
|
@@ -9483,13 +9787,15 @@ export class AgentSession {
|
|
|
9483
9787
|
willRetry: boolean,
|
|
9484
9788
|
deferred = false,
|
|
9485
9789
|
allowDefer = true,
|
|
9486
|
-
options: { autoContinue?: boolean; triggerContextTokens?: number } = {},
|
|
9790
|
+
options: { autoContinue?: boolean; triggerContextTokens?: number; suppressContinuation?: boolean } = {},
|
|
9487
9791
|
): Promise<CompactionCheckResult> {
|
|
9488
9792
|
const compactionSettings = this.settings.getGroup("compaction");
|
|
9489
9793
|
if (compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE;
|
|
9490
9794
|
if (reason !== "idle" && !compactionSettings.enabled) return COMPACTION_CHECK_NONE;
|
|
9491
9795
|
const generation = this.#promptGeneration;
|
|
9492
|
-
const
|
|
9796
|
+
const suppressContinuation = options.suppressContinuation === true;
|
|
9797
|
+
const shouldAutoContinue =
|
|
9798
|
+
!suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false;
|
|
9493
9799
|
// Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake
|
|
9494
9800
|
// reclaims nothing we fall through to the summary-compaction body below so
|
|
9495
9801
|
// the oversized input still gets resolved.
|
|
@@ -9500,6 +9806,7 @@ export class AgentSession {
|
|
|
9500
9806
|
generation,
|
|
9501
9807
|
shouldAutoContinue,
|
|
9502
9808
|
options.triggerContextTokens,
|
|
9809
|
+
suppressContinuation,
|
|
9503
9810
|
);
|
|
9504
9811
|
if (outcome !== "fallback") return outcome;
|
|
9505
9812
|
}
|
|
@@ -9683,24 +9990,20 @@ export class AgentSession {
|
|
|
9683
9990
|
let tokensBefore: number;
|
|
9684
9991
|
let details: unknown;
|
|
9685
9992
|
|
|
9686
|
-
// Snapcompact runs locally first
|
|
9687
|
-
//
|
|
9688
|
-
//
|
|
9689
|
-
//
|
|
9690
|
-
//
|
|
9993
|
+
// Snapcompact runs locally first. The post-compaction context = kept-recent
|
|
9994
|
+
// + a summary message carrying the imaged archive at FRAME_TOKEN_ESTIMATE
|
|
9995
|
+
// per frame; #computeSnapcompactMaxFrames sizes the frame cap from the
|
|
9996
|
+
// live window so we don't run snapcompact just to overflow and fall back
|
|
9997
|
+
// every threshold tick. Kept-recent already over budget → skip snapcompact
|
|
9998
|
+
// outright (a single frame won't fit). Otherwise the projection below is
|
|
9999
|
+
// only a defensive guard for summary-text drift.
|
|
9691
10000
|
let snapcompactResult: snapcompact.CompactionResult | undefined;
|
|
9692
10001
|
if (action === "snapcompact" && compactionPrep.kind !== "fromHook") {
|
|
9693
10002
|
const text = snapcompact.serializeConversation(
|
|
9694
10003
|
convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
|
|
9695
10004
|
);
|
|
9696
10005
|
const renderScan = snapcompact.scanRenderability(text);
|
|
9697
|
-
if (renderScan.isSafe) {
|
|
9698
|
-
snapcompactResult = await snapcompact.compact(preparation, {
|
|
9699
|
-
convertToLlm,
|
|
9700
|
-
model: this.model,
|
|
9701
|
-
shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
|
|
9702
|
-
});
|
|
9703
|
-
} else {
|
|
10006
|
+
if (!renderScan.isSafe) {
|
|
9704
10007
|
logger.warn("Snapcompact disabled: high non-ASCII rate detected; falling back to an LLM summary", {
|
|
9705
10008
|
model: this.model?.id,
|
|
9706
10009
|
unrenderableRatio: renderScan.unrenderableRatio,
|
|
@@ -9711,6 +10014,26 @@ export class AgentSession {
|
|
|
9711
10014
|
"compaction",
|
|
9712
10015
|
);
|
|
9713
10016
|
action = "context-full";
|
|
10017
|
+
} else {
|
|
10018
|
+
const maxFrames = this.#computeSnapcompactMaxFrames(preparation, compactionSettings);
|
|
10019
|
+
if (maxFrames < 1) {
|
|
10020
|
+
logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
|
|
10021
|
+
model: this.model?.id,
|
|
10022
|
+
});
|
|
10023
|
+
this.emitNotice(
|
|
10024
|
+
"warning",
|
|
10025
|
+
"snapcompact: kept history alone exceeds the context budget — using an LLM summary instead",
|
|
10026
|
+
"compaction",
|
|
10027
|
+
);
|
|
10028
|
+
action = "context-full";
|
|
10029
|
+
} else {
|
|
10030
|
+
snapcompactResult = await snapcompact.compact(preparation, {
|
|
10031
|
+
convertToLlm,
|
|
10032
|
+
model: this.model,
|
|
10033
|
+
shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
|
|
10034
|
+
maxFrames,
|
|
10035
|
+
});
|
|
10036
|
+
}
|
|
9714
10037
|
}
|
|
9715
10038
|
|
|
9716
10039
|
if (snapcompactResult) {
|
|
@@ -9721,7 +10044,7 @@ export class AgentSession {
|
|
|
9721
10044
|
: Number.POSITIVE_INFINITY;
|
|
9722
10045
|
const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult);
|
|
9723
10046
|
if (projected > budget) {
|
|
9724
|
-
logger.warn("Snapcompact still overflows the window; falling back
|
|
10047
|
+
logger.warn("Snapcompact still overflows the window after frame-budget sizing; falling back", {
|
|
9725
10048
|
model: this.model?.id,
|
|
9726
10049
|
projected,
|
|
9727
10050
|
budget,
|
|
@@ -9953,7 +10276,7 @@ export class AgentSession {
|
|
|
9953
10276
|
|
|
9954
10277
|
this.#scheduleAgentContinue({ delayMs: 100, generation });
|
|
9955
10278
|
continuationScheduled = true;
|
|
9956
|
-
} else if (this.agent.hasQueuedMessages()) {
|
|
10279
|
+
} else if (!suppressContinuation && this.agent.hasQueuedMessages()) {
|
|
9957
10280
|
// Auto-compaction can complete while follow-up/steering/custom messages are waiting.
|
|
9958
10281
|
// Kick the loop so queued messages are actually delivered.
|
|
9959
10282
|
this.#scheduleAgentContinue({
|
|
@@ -10013,6 +10336,7 @@ export class AgentSession {
|
|
|
10013
10336
|
generation: number,
|
|
10014
10337
|
autoContinue: boolean,
|
|
10015
10338
|
triggerContextTokens?: number,
|
|
10339
|
+
suppressContinuation = false,
|
|
10016
10340
|
): Promise<CompactionCheckResult | "fallback"> {
|
|
10017
10341
|
const action = "shake";
|
|
10018
10342
|
this.#autoCompactionAbortController?.abort();
|
|
@@ -10043,15 +10367,16 @@ export class AgentSession {
|
|
|
10043
10367
|
// situation actually resolves; "idle" is exempt because its 60s+ timer
|
|
10044
10368
|
// re-checks usage before re-firing and cannot dead-loop on its own.
|
|
10045
10369
|
//
|
|
10046
|
-
// #2275: the post-shake check MUST
|
|
10047
|
-
//
|
|
10048
|
-
//
|
|
10049
|
-
//
|
|
10050
|
-
//
|
|
10051
|
-
//
|
|
10052
|
-
//
|
|
10053
|
-
//
|
|
10054
|
-
//
|
|
10370
|
+
// #2275: the post-shake check MUST stay provider-anchored when caller
|
|
10371
|
+
// usage and local estimates diverge. The local estimator undercounts
|
|
10372
|
+
// thinking-signature payloads, so thinking-heavy sessions can read well
|
|
10373
|
+
// below the provider usage that fired the threshold. Prefer the caller's
|
|
10374
|
+
// context figure when supplied, then subtract shake's own savings and add
|
|
10375
|
+
// hysteresis (80% recovery band) so we don't oscillate at the boundary.
|
|
10376
|
+
// Threshold callers pass the provider-billed trigger after accounting for
|
|
10377
|
+
// any supersede/drop-useless pruning that already rewrote the next prompt;
|
|
10378
|
+
// without that pre-shake savings, shake can fall through to context-full
|
|
10379
|
+
// even though the post-prune history is already inside the recovery band.
|
|
10055
10380
|
const contextWindow = this.model?.contextWindow ?? 0;
|
|
10056
10381
|
const compactionSettings = this.settings.getGroup("compaction");
|
|
10057
10382
|
let stillOverThreshold = false;
|
|
@@ -10111,7 +10436,7 @@ export class AgentSession {
|
|
|
10111
10436
|
}
|
|
10112
10437
|
this.#scheduleAgentContinue({ delayMs: 100, generation });
|
|
10113
10438
|
continuationScheduled = true;
|
|
10114
|
-
} else if (this.agent.hasQueuedMessages()) {
|
|
10439
|
+
} else if (!suppressContinuation && this.agent.hasQueuedMessages()) {
|
|
10115
10440
|
this.#scheduleAgentContinue({
|
|
10116
10441
|
delayMs: 100,
|
|
10117
10442
|
generation,
|