@oh-my-pi/pi-coding-agent 16.1.23 → 16.2.1
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 +82 -0
- package/dist/cli.js +3785 -3695
- package/dist/types/advisor/__tests__/emission-guard.test.d.ts +1 -0
- package/dist/types/advisor/emission-guard.d.ts +73 -0
- package/dist/types/advisor/index.d.ts +1 -0
- package/dist/types/advisor/runtime.d.ts +7 -0
- package/dist/types/advisor/watchdog.d.ts +13 -0
- package/dist/types/cli/flag-tables.d.ts +1 -0
- package/dist/types/cli/gallery-fixtures/search.d.ts +1 -1
- package/dist/types/collab/display-name.d.ts +3 -0
- package/dist/types/collab/host.d.ts +0 -2
- package/dist/types/commands/worktree.d.ts +0 -3
- package/dist/types/config/inline-tool-descriptors-mode.d.ts +13 -0
- package/dist/types/config/model-discovery.d.ts +6 -1
- package/dist/types/config/model-registry.d.ts +6 -2
- package/dist/types/config/model-resolver.d.ts +12 -0
- package/dist/types/config/models-config-schema.d.ts +29 -2
- package/dist/types/config/models-config.d.ts +22 -1
- package/dist/types/config/settings-schema.d.ts +91 -21
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/dap/config.d.ts +1 -1
- package/dist/types/edit/hashline/filesystem.d.ts +4 -2
- package/dist/types/edit/renderer.d.ts +4 -0
- package/dist/types/extensibility/extensions/types.d.ts +17 -17
- package/dist/types/extensibility/hooks/types.d.ts +10 -10
- package/dist/types/extensibility/plugins/marketplace/types.d.ts +1 -0
- package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +1 -0
- package/dist/types/internal-urls/index.d.ts +1 -0
- package/dist/types/internal-urls/registry-helpers.d.ts +2 -0
- package/dist/types/internal-urls/router.d.ts +1 -1
- package/dist/types/internal-urls/ssh-protocol.d.ts +10 -0
- package/dist/types/internal-urls/types.d.ts +19 -2
- package/dist/types/irc/bus.d.ts +6 -0
- package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -0
- package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +1 -0
- package/dist/types/modes/components/custom-editor.d.ts +7 -0
- package/dist/types/modes/components/move-overlay.d.ts +23 -0
- package/dist/types/modes/components/plugin-selector.d.ts +2 -1
- package/dist/types/modes/components/queue-mode-selector.d.ts +2 -1
- package/dist/types/modes/components/select-list-mouse-routing.d.ts +2 -0
- package/dist/types/modes/components/settings-defs.d.ts +4 -1
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/show-images-selector.d.ts +2 -1
- package/dist/types/modes/components/status-line/component.d.ts +2 -0
- package/dist/types/modes/components/status-line/types.d.ts +2 -0
- package/dist/types/modes/components/theme-selector.d.ts +2 -1
- package/dist/types/modes/components/thinking-selector.d.ts +2 -1
- package/dist/types/modes/controllers/command-controller.d.ts +10 -1
- package/dist/types/modes/controllers/streaming-reveal.d.ts +6 -0
- package/dist/types/modes/interactive-mode.d.ts +10 -1
- package/dist/types/modes/internal-url-autocomplete.d.ts +1 -1
- package/dist/types/modes/running-subagent-badge.d.ts +6 -0
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/modes/types.d.ts +9 -1
- package/dist/types/sdk.d.ts +2 -2
- package/dist/types/session/agent-session.d.ts +8 -1
- package/dist/types/session/blob-store.d.ts +4 -0
- package/dist/types/session/session-manager.d.ts +13 -0
- package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
- package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +1 -0
- package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +1 -0
- package/dist/types/ssh/connection-manager.d.ts +2 -0
- package/dist/types/ssh/file-transfer.d.ts +79 -0
- package/dist/types/ssh/utils.d.ts +6 -0
- package/dist/types/system-prompt.d.ts +3 -0
- package/dist/types/task/executor.d.ts +16 -0
- package/dist/types/tiny/text.d.ts +1 -1
- package/dist/types/tools/builtin-names.d.ts +5 -1
- package/dist/types/tools/{find.d.ts → glob.d.ts} +15 -15
- package/dist/types/tools/{search.d.ts → grep.d.ts} +14 -14
- package/dist/types/tools/index.d.ts +3 -3
- package/dist/types/tools/path-utils.d.ts +29 -0
- package/dist/types/tools/read.d.ts +2 -2
- package/dist/types/tools/render-utils.d.ts +8 -0
- package/dist/types/tools/renderers.d.ts +11 -0
- package/dist/types/tools/ssh.d.ts +2 -0
- package/dist/types/tools/todo.d.ts +0 -16
- package/dist/types/tools/write.d.ts +2 -2
- package/dist/types/utils/active-repo-context.d.ts +8 -0
- package/dist/types/utils/image-resize.d.ts +1 -0
- package/dist/types/utils/markit-cache.d.ts +23 -0
- package/dist/types/utils/markit.d.ts +5 -1
- package/dist/types/utils/prompt-path.d.ts +1 -0
- package/dist/types/web/search/providers/duckduckgo.d.ts +14 -0
- package/dist/types/web/search/providers/firecrawl.d.ts +28 -0
- package/dist/types/web/search/providers/tinyfish.d.ts +29 -0
- package/dist/types/web/search/providers/xai.d.ts +13 -0
- package/dist/types/web/search/types.d.ts +18 -2
- package/package.json +30 -15
- package/scripts/build-binary.ts +9 -9
- package/scripts/bundle-dist.ts +2 -2
- package/src/advisor/__tests__/advisor.test.ts +61 -4
- package/src/advisor/__tests__/emission-guard.test.ts +147 -0
- package/src/advisor/advise-tool.ts +1 -1
- package/src/advisor/emission-guard.ts +172 -0
- package/src/advisor/index.ts +1 -0
- package/src/advisor/runtime.ts +11 -0
- package/src/advisor/watchdog.ts +27 -1
- package/src/cli/args.ts +5 -2
- package/src/cli/auth-broker-cli.ts +17 -0
- package/src/cli/config-cli.ts +4 -0
- package/src/cli/flag-tables.ts +7 -4
- package/src/cli/gallery-cli.ts +14 -2
- package/src/cli/gallery-fixtures/edit.ts +60 -0
- package/src/cli/gallery-fixtures/fs.ts +17 -17
- package/src/cli/gallery-fixtures/search.ts +4 -4
- package/src/cli/usage-cli.ts +20 -1
- package/src/cli/web-search-cli.ts +1 -1
- package/src/collab/display-name.ts +13 -0
- package/src/collab/guest.ts +5 -1
- package/src/collab/host.ts +2 -13
- package/src/commands/worktree.ts +6 -0
- package/src/config/inline-tool-descriptors-mode.ts +27 -0
- package/src/config/model-discovery.ts +77 -8
- package/src/config/model-registry.ts +89 -11
- package/src/config/model-resolver.ts +39 -1
- package/src/config/models-config-schema.ts +41 -5
- package/src/config/models-config.ts +4 -1
- package/src/config/settings-schema.ts +85 -27
- package/src/config/settings.ts +172 -1
- package/src/cursor.ts +1 -1
- package/src/dap/config.ts +152 -8
- package/src/dap/session.ts +1 -1
- package/src/discovery/claude-plugins.ts +3 -2
- package/src/discovery/helpers.ts +3 -1
- package/src/edit/hashline/diff.ts +14 -3
- package/src/edit/hashline/execute.ts +42 -6
- package/src/edit/hashline/filesystem.ts +38 -2
- package/src/edit/index.ts +1 -0
- package/src/edit/modes/patch.ts +11 -1
- package/src/edit/renderer.ts +140 -13
- package/src/eval/__tests__/agent-bridge.test.ts +101 -0
- package/src/eval/agent-bridge.ts +26 -3
- package/src/eval/js/tool-bridge.ts +2 -2
- package/src/export/html/index.ts +1 -1
- package/src/export/html/template.js +3 -1
- package/src/export/html/tool-views.generated.js +20 -20
- package/src/extensibility/extensions/types.ts +22 -22
- package/src/extensibility/hooks/types.ts +11 -11
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +8 -3
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +25 -10
- package/src/extensibility/plugins/marketplace/manager.ts +22 -0
- package/src/extensibility/plugins/marketplace/types.ts +1 -0
- package/src/internal-urls/__tests__/ssh-protocol.test.ts +331 -0
- package/src/internal-urls/docs-index.generated.txt +2 -2
- package/src/internal-urls/docs-index.ts +2 -3
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/registry-helpers.ts +19 -4
- package/src/internal-urls/router.ts +5 -3
- package/src/internal-urls/ssh-protocol.ts +367 -0
- package/src/internal-urls/types.ts +19 -2
- package/src/irc/bus.ts +11 -3
- package/src/mcp/tool-bridge.ts +32 -2
- package/src/memories/index.ts +1 -1
- package/src/modes/acp/acp-agent.ts +14 -1
- package/src/modes/acp/acp-event-mapper.ts +91 -27
- package/src/modes/components/__tests__/move-overlay.test.ts +166 -0
- package/src/modes/components/agent-hub.ts +3 -0
- package/src/modes/components/agent-transcript-viewer.ts +9 -7
- package/src/modes/components/custom-editor.ts +90 -5
- package/src/modes/components/move-overlay.ts +282 -0
- package/src/modes/components/plan-review-overlay.ts +35 -35
- package/src/modes/components/plugin-selector.ts +6 -1
- package/src/modes/components/queue-mode-selector.ts +6 -1
- package/src/modes/components/select-list-mouse-routing.ts +35 -0
- package/src/modes/components/session-selector.ts +11 -10
- package/src/modes/components/settings-defs.ts +14 -1
- package/src/modes/components/settings-selector.ts +196 -29
- package/src/modes/components/show-images-selector.ts +6 -1
- package/src/modes/components/status-line/component.ts +108 -28
- package/src/modes/components/status-line/segments.ts +5 -1
- package/src/modes/components/status-line/types.ts +2 -0
- package/src/modes/components/theme-selector.ts +6 -1
- package/src/modes/components/thinking-selector.ts +6 -1
- package/src/modes/components/tool-execution.ts +25 -9
- package/src/modes/components/tree-selector.ts +5 -5
- package/src/modes/controllers/command-controller.ts +140 -47
- package/src/modes/controllers/event-controller.ts +59 -3
- package/src/modes/controllers/input-controller.ts +68 -2
- package/src/modes/controllers/selector-controller.ts +5 -2
- package/src/modes/controllers/streaming-reveal.ts +17 -0
- package/src/modes/controllers/tool-args-reveal.ts +1 -1
- package/src/modes/interactive-mode.ts +116 -50
- package/src/modes/internal-url-autocomplete.ts +17 -2
- package/src/modes/prompt-action-autocomplete.ts +3 -1
- package/src/modes/running-subagent-badge.ts +13 -0
- package/src/modes/setup-wizard/scenes/glyph.ts +2 -13
- package/src/modes/setup-wizard/scenes/providers.ts +2 -1
- package/src/modes/setup-wizard/scenes/theme.ts +6 -12
- package/src/modes/setup-wizard/scenes/web-search.ts +8 -13
- package/src/modes/setup-wizard/wizard-overlay.ts +6 -5
- package/src/modes/theme/theme.ts +9 -1
- package/src/modes/types.ts +9 -1
- package/src/modes/utils/interactive-context-helpers.ts +1 -1
- package/src/priority.json +15 -0
- package/src/prompts/advisor/active-repo-watchdog.md +6 -0
- package/src/prompts/advisor/context-files.md +8 -0
- package/src/prompts/advisor/system.md +21 -7
- package/src/prompts/agents/designer.md +1 -1
- package/src/prompts/agents/explore.md +1 -1
- package/src/prompts/agents/librarian.md +2 -2
- package/src/prompts/agents/plan.md +2 -2
- package/src/prompts/agents/reviewer.md +1 -1
- package/src/prompts/agents/task.md +2 -2
- package/src/prompts/system/active-repo-context.md +4 -0
- package/src/prompts/system/gemini-tool-call-reminder.md +9 -0
- package/src/prompts/system/plan-mode-active.md +3 -3
- package/src/prompts/system/project-prompt.md +2 -2
- package/src/prompts/system/system-prompt.md +6 -6
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/checkpoint.md +1 -1
- package/src/prompts/tools/{find.md → glob.md} +1 -1
- package/src/prompts/tools/{search.md → grep.md} +3 -3
- package/src/prompts/tools/read.md +4 -2
- package/src/sdk.ts +65 -25
- package/src/session/agent-session.ts +519 -132
- package/src/session/blob-store.ts +24 -0
- package/src/session/session-history-format.ts +2 -2
- package/src/session/session-manager.ts +46 -0
- package/src/slash-commands/builtin-registry.ts +138 -20
- package/src/slash-commands/helpers/usage-report.ts +23 -2
- package/src/ssh/__tests__/connection-manager-args.test.ts +69 -0
- package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +68 -0
- package/src/ssh/connection-manager.ts +12 -15
- package/src/ssh/file-transfer.ts +209 -0
- package/src/ssh/utils.ts +24 -0
- package/src/system-prompt.ts +56 -28
- package/src/task/executor.ts +96 -36
- package/src/task/index.ts +3 -3
- package/src/task/render.ts +14 -13
- package/src/task/worktree.ts +38 -1
- package/src/tiny/text.ts +49 -4
- package/src/tiny/worker.ts +3 -3
- package/src/tools/bash.ts +2 -2
- package/src/tools/builtin-names.ts +26 -2
- package/src/tools/{find.ts → glob.ts} +48 -42
- package/src/tools/{search.ts → grep.ts} +311 -129
- package/src/tools/index.ts +13 -14
- package/src/tools/irc.ts +6 -2
- package/src/tools/path-utils.ts +64 -1
- package/src/tools/read.ts +35 -6
- package/src/tools/render-utils.ts +14 -0
- package/src/tools/renderers.ts +15 -4
- package/src/tools/ssh.ts +17 -2
- package/src/tools/todo.ts +4 -26
- package/src/tools/write.ts +25 -9
- package/src/utils/active-repo-context.ts +143 -0
- package/src/utils/image-resize.ts +88 -7
- package/src/utils/lang-from-path.ts +3 -3
- package/src/utils/markit-cache.ts +166 -0
- package/src/utils/markit.ts +86 -18
- package/src/utils/prompt-path.ts +3 -0
- package/src/utils/title-generator.ts +1 -1
- package/src/web/search/provider.ts +54 -34
- package/src/web/search/providers/duckduckgo.ts +140 -0
- package/src/web/search/providers/firecrawl.ts +144 -0
- package/src/web/search/providers/tinyfish.ts +159 -0
- package/src/web/search/providers/xai.ts +292 -0
- package/src/web/search/providers/zai.ts +142 -56
- package/src/web/search/types.ts +6 -2
|
@@ -20,6 +20,7 @@ import { scheduler } from "node:timers/promises";
|
|
|
20
20
|
import { isPromise } from "node:util/types";
|
|
21
21
|
|
|
22
22
|
import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
|
23
|
+
import { Patch } from "@oh-my-pi/hashline";
|
|
23
24
|
import {
|
|
24
25
|
type AfterToolCallContext,
|
|
25
26
|
type AfterToolCallResult,
|
|
@@ -78,6 +79,7 @@ import {
|
|
|
78
79
|
import type { ProtectedToolMatcher } from "@oh-my-pi/pi-agent-core/compaction/tool-protection";
|
|
79
80
|
import type {
|
|
80
81
|
AssistantMessage,
|
|
82
|
+
AssistantMessageEvent,
|
|
81
83
|
ImageContent,
|
|
82
84
|
Message,
|
|
83
85
|
MessageAttribution,
|
|
@@ -107,7 +109,11 @@ import {
|
|
|
107
109
|
streamSimple,
|
|
108
110
|
} from "@oh-my-pi/pi-ai";
|
|
109
111
|
import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
|
|
110
|
-
import {
|
|
112
|
+
import {
|
|
113
|
+
GeminiHeaderRunDetector,
|
|
114
|
+
isGeminiThinkingModel,
|
|
115
|
+
THINKING_LOOP_ERROR_MARKER,
|
|
116
|
+
} from "@oh-my-pi/pi-ai/utils/thinking-loop";
|
|
111
117
|
import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
|
|
112
118
|
import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
113
119
|
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
|
|
@@ -130,6 +136,7 @@ import * as snapcompact from "@oh-my-pi/snapcompact";
|
|
|
130
136
|
import {
|
|
131
137
|
AdviseTool,
|
|
132
138
|
type AdvisorAgent,
|
|
139
|
+
AdvisorEmissionGuard,
|
|
133
140
|
type AdvisorMessageDetails,
|
|
134
141
|
type AdvisorNote,
|
|
135
142
|
AdvisorRuntime,
|
|
@@ -155,15 +162,15 @@ import {
|
|
|
155
162
|
getModelMatchPreferences,
|
|
156
163
|
parseModelString,
|
|
157
164
|
type ResolvedModelRoleValue,
|
|
165
|
+
resolveAdvisorRoleSelection,
|
|
158
166
|
resolveModelOverride,
|
|
159
167
|
resolveModelRoleValue,
|
|
160
|
-
resolveRoleSelection,
|
|
161
168
|
} from "../config/model-resolver";
|
|
162
169
|
import { MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles";
|
|
163
170
|
import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
|
|
164
171
|
import { resolveServiceTierSetting } from "../config/service-tier";
|
|
165
172
|
import type { Settings, SkillsSettings } from "../config/settings";
|
|
166
|
-
import { getDefault, onAppendOnlyModeChanged } from "../config/settings";
|
|
173
|
+
import { getDefault, onAppendOnlyModeChanged, validateProviderMaxInFlightRequests } from "../config/settings";
|
|
167
174
|
import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
|
|
168
175
|
import { loadCapability } from "../discovery";
|
|
169
176
|
import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
|
|
@@ -228,6 +235,7 @@ import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type:
|
|
|
228
235
|
import eagerTaskPrompt from "../prompts/system/eager-task.md" with { type: "text" };
|
|
229
236
|
import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
|
|
230
237
|
import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" };
|
|
238
|
+
import geminiToolReminderTemplate from "../prompts/system/gemini-tool-call-reminder.md" with { type: "text" };
|
|
231
239
|
import ircAutoReplyTemplate from "../prompts/system/irc-autoreply.md" with { type: "text" };
|
|
232
240
|
import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
|
|
233
241
|
import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
|
|
@@ -269,6 +277,7 @@ import {
|
|
|
269
277
|
selectDiscoverableToolNamesByServer,
|
|
270
278
|
} from "../tool-discovery/tool-index";
|
|
271
279
|
import { assertEditableFile } from "../tools/auto-generated-guard";
|
|
280
|
+
import { normalizeToolNames } from "../tools/builtin-names";
|
|
272
281
|
import type { CheckpointState } from "../tools/checkpoint";
|
|
273
282
|
import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta";
|
|
274
283
|
import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
|
|
@@ -312,7 +321,7 @@ import { formatSessionDumpText } from "./session-dump-format";
|
|
|
312
321
|
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions } from "./session-entries";
|
|
313
322
|
import { EPHEMERAL_MODEL_CHANGE_ROLE } from "./session-entries";
|
|
314
323
|
import { formatSessionHistoryMarkdown } from "./session-history-format";
|
|
315
|
-
import type
|
|
324
|
+
import { cleanupEmptyMoveSession, type SessionManager } from "./session-manager";
|
|
316
325
|
import type { ShakeMode, ShakeResult } from "./shake-types";
|
|
317
326
|
import { ToolChoiceQueue } from "./tool-choice-queue";
|
|
318
327
|
import { classifyUnexpectedStop, isUnexpectedStopCandidate } from "./unexpected-stop-classifier";
|
|
@@ -320,6 +329,12 @@ import { YieldQueue } from "./yield-queue";
|
|
|
320
329
|
|
|
321
330
|
const SESSION_STOP_CONTINUATION_CAP = 8;
|
|
322
331
|
|
|
332
|
+
/** Abort reason for the Gemini reasoning-header runaway interrupt. Surfaced on the
|
|
333
|
+
* discarded assistant turn only; never reaches the model. */
|
|
334
|
+
const GEMINI_HEADER_INTERRUPT_REASON = "Interrupted: emit a tool call instead of more planning";
|
|
335
|
+
/** `customType` for the hidden tool-call reminder injected after the interrupt. */
|
|
336
|
+
const GEMINI_TOOL_REMINDER_TYPE = "gemini-tool-call-reminder";
|
|
337
|
+
|
|
323
338
|
// A side-channel assistant response is signed for the hidden prompt/history that
|
|
324
339
|
// produced it. If we persist that response under a different user turn, native
|
|
325
340
|
// replay anchors become invalid; keep only visible, non-cryptographic content.
|
|
@@ -383,6 +398,7 @@ const RETRY_BACKOFF_MAX_DELAY_MS = 8_000;
|
|
|
383
398
|
type CompactionCheckResult = Readonly<{
|
|
384
399
|
deferredHandoff: boolean;
|
|
385
400
|
continuationScheduled: boolean;
|
|
401
|
+
automaticContinuationBlocked?: boolean;
|
|
386
402
|
}>;
|
|
387
403
|
|
|
388
404
|
const COMPACTION_CHECK_NONE: CompactionCheckResult = {
|
|
@@ -397,6 +413,11 @@ const COMPACTION_CHECK_CONTINUATION: CompactionCheckResult = {
|
|
|
397
413
|
deferredHandoff: false,
|
|
398
414
|
continuationScheduled: true,
|
|
399
415
|
};
|
|
416
|
+
const COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION: CompactionCheckResult = {
|
|
417
|
+
deferredHandoff: false,
|
|
418
|
+
continuationScheduled: false,
|
|
419
|
+
automaticContinuationBlocked: true,
|
|
420
|
+
};
|
|
400
421
|
|
|
401
422
|
/**
|
|
402
423
|
* Per-turn prune cache window. A tool result whose all-message suffix exceeds
|
|
@@ -419,14 +440,17 @@ export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "la
|
|
|
419
440
|
|
|
420
441
|
const RETRY_BACKOFF_JITTER_RATIO = 0.25;
|
|
421
442
|
/**
|
|
422
|
-
* Hysteresis band for the post-
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
443
|
+
* Hysteresis band for the post-maintenance "did we actually create headroom?"
|
|
444
|
+
* check shared by the shake tail and the context-full / snapcompact tail. A
|
|
445
|
+
* pass counts as having resolved threshold pressure only when residual context
|
|
446
|
+
* lands at or below `COMPACTION_RECOVERY_BAND × threshold`. Re-checking against
|
|
447
|
+
* the raw threshold lets a pass keep reclaiming a trickle of the previous
|
|
448
|
+
* turn's output and land just under the line every turn, sustaining the
|
|
449
|
+
* auto-continue dead loop reported in #2275; the same band stops the
|
|
450
|
+
* context-full / snapcompact tail from re-firing on a history whose single
|
|
451
|
+
* most-recent kept turn already exceeds the threshold (the snapcompact thrash).
|
|
428
452
|
*/
|
|
429
|
-
const
|
|
453
|
+
const COMPACTION_RECOVERY_BAND = 0.8;
|
|
430
454
|
|
|
431
455
|
function calculateRetryBackoffDelayMs(baseDelayMs: number, attempt: number): number {
|
|
432
456
|
const cappedDelayMs = Math.min(Math.max(0, baseDelayMs) * 2 ** Math.max(0, attempt - 1), RETRY_BACKOFF_MAX_DELAY_MS);
|
|
@@ -565,6 +589,12 @@ export interface AgentSessionConfig {
|
|
|
565
589
|
advisorReadOnlyTools?: AgentTool[];
|
|
566
590
|
/** Preloaded watchdog prompt content for the advisor. */
|
|
567
591
|
advisorWatchdogPrompt?: string;
|
|
592
|
+
/**
|
|
593
|
+
* Preloaded project context files (AGENTS.md, etc.) rendered as a system-prompt
|
|
594
|
+
* block for the advisor — the same standing instructions the primary agent
|
|
595
|
+
* receives, so the reviewer holds the agent to them.
|
|
596
|
+
*/
|
|
597
|
+
advisorContextPrompt?: string;
|
|
568
598
|
/**
|
|
569
599
|
* Strip tool descriptions from provider-bound tool specs on side requests
|
|
570
600
|
* (handoff). Must match the session-start value used to build the system
|
|
@@ -929,6 +959,15 @@ function getEditDestructiveIntent(args: unknown): { kind: "delete" | "move"; pat
|
|
|
929
959
|
|
|
930
960
|
const input = getStringProperty(a, "input");
|
|
931
961
|
if (input) {
|
|
962
|
+
try {
|
|
963
|
+
const patch = Patch.parse(input);
|
|
964
|
+
for (const section of patch.sections) {
|
|
965
|
+
if (section.fileOp?.kind === "rem") return { kind: "delete", paths: [section.path] };
|
|
966
|
+
if (section.fileOp?.kind === "move") return { kind: "move", paths: [section.path, section.fileOp.dest] };
|
|
967
|
+
}
|
|
968
|
+
} catch {
|
|
969
|
+
// Not a hashline patch — fall through to apply_patch parsing.
|
|
970
|
+
}
|
|
932
971
|
try {
|
|
933
972
|
const entries = expandApplyPatchToEntries({ input });
|
|
934
973
|
const deleteEntry = entries.find(entry => entry.op === "delete");
|
|
@@ -1172,6 +1211,11 @@ export class AgentSession {
|
|
|
1172
1211
|
#advisorAutoResumeSuppressed = false;
|
|
1173
1212
|
#advisorPrimaryTurnsCompleted = 0;
|
|
1174
1213
|
#advisorInterruptImmuneTurnStart: number | undefined;
|
|
1214
|
+
/** Dedupe + per-update rate-limit + content-free-phrase filter applied to
|
|
1215
|
+
* every accepted advisor `advise()` call. Owned by the session because the
|
|
1216
|
+
* session is what routes accepted notes back to the primary transcript.
|
|
1217
|
+
* Reset on advisor reset (compaction, session switch, `/new`). */
|
|
1218
|
+
readonly #advisorEmissionGuard = new AdvisorEmissionGuard();
|
|
1175
1219
|
#planModeState: PlanModeState | undefined;
|
|
1176
1220
|
#goalModeState: GoalModeState | undefined;
|
|
1177
1221
|
#goalRuntime: GoalRuntime;
|
|
@@ -1182,6 +1226,7 @@ export class AgentSession {
|
|
|
1182
1226
|
#advisorAdviseTool?: AdviseTool;
|
|
1183
1227
|
#advisorReadOnlyTools?: AgentTool[];
|
|
1184
1228
|
#advisorWatchdogPrompt?: string;
|
|
1229
|
+
#advisorContextPrompt?: string;
|
|
1185
1230
|
#advisorYieldQueueUnsubscribe?: () => void;
|
|
1186
1231
|
/** Persists the advisor agent's turns to `<session>/__advisor.jsonl` for stats
|
|
1187
1232
|
* attribution and Agent Hub observability. Undefined when no advisor is active. */
|
|
@@ -1197,6 +1242,8 @@ export class AgentSession {
|
|
|
1197
1242
|
#allowAcpAgentInitiatedTurns = false;
|
|
1198
1243
|
/** Per-session memory of allow_always / reject_always decisions for gated tools. */
|
|
1199
1244
|
#acpPermissionDecisions: Map<string, "allow_always" | "reject_always"> = new Map();
|
|
1245
|
+
/** Session file created by this session's `/move`; removed on dispose if it stayed empty. */
|
|
1246
|
+
#movedFromEmptySessionFile?: string;
|
|
1200
1247
|
|
|
1201
1248
|
// Compaction state
|
|
1202
1249
|
#compactionAbortController: AbortController | undefined = undefined;
|
|
@@ -1352,6 +1399,12 @@ export class AgentSession {
|
|
|
1352
1399
|
#streamingEditPrecheckedToolCallIds = new Set<string>();
|
|
1353
1400
|
|
|
1354
1401
|
#streamingEditFileCache = new Map<string, string>();
|
|
1402
|
+
|
|
1403
|
+
/** Active Gemini reasoning-header runaway detector for the current block.
|
|
1404
|
+
* (Re)created on each `thinking_start` when the guard applies (see
|
|
1405
|
+
* `#geminiHeaderGuardActive`); undefined for non-Gemini models or when the
|
|
1406
|
+
* guard is off. Fed thinking deltas in the assistant-message interceptor. */
|
|
1407
|
+
#geminiHeaderDetector: GeminiHeaderRunDetector | undefined;
|
|
1355
1408
|
#promptInFlightCount = 0;
|
|
1356
1409
|
#abortInProgress = false;
|
|
1357
1410
|
// Wire-level agent_end emission deferred until #promptInFlightCount drops to 0.
|
|
@@ -1597,6 +1650,7 @@ export class AgentSession {
|
|
|
1597
1650
|
this.agent.serviceTierResolver = model => this.#effectiveServiceTier(model);
|
|
1598
1651
|
this.#advisorReadOnlyTools = config.advisorReadOnlyTools;
|
|
1599
1652
|
this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt;
|
|
1653
|
+
this.#advisorContextPrompt = config.advisorContextPrompt;
|
|
1600
1654
|
this.#pruneToolDescriptions = config.pruneToolDescriptions === true;
|
|
1601
1655
|
this.#validateRetryFallbackChains();
|
|
1602
1656
|
this.#toolRegistry = config.toolRegistry ?? new Map();
|
|
@@ -1716,6 +1770,7 @@ export class AgentSession {
|
|
|
1716
1770
|
};
|
|
1717
1771
|
this.#preCacheStreamingEditFile(event);
|
|
1718
1772
|
this.#maybeAbortStreamingEdit(event);
|
|
1773
|
+
this.#maybeInterruptGeminiHeaderRunaway(message, assistantMessageEvent);
|
|
1719
1774
|
});
|
|
1720
1775
|
// Per-tool TTSR reminders are folded into the matched tool's result via this hook.
|
|
1721
1776
|
this.agent.afterToolCall = ctx => this.#ttsrAfterToolCall(ctx);
|
|
@@ -1817,6 +1872,7 @@ export class AgentSession {
|
|
|
1817
1872
|
this.#advisorAgentUnsubscribe = undefined;
|
|
1818
1873
|
this.#advisorRuntime?.reset();
|
|
1819
1874
|
this.#advisorAdviseTool?.resetDeliveredNotes();
|
|
1875
|
+
this.#advisorEmissionGuard.reset();
|
|
1820
1876
|
this.#attachAdvisorRecorderFeed();
|
|
1821
1877
|
this.#advisorPrimaryTurnsCompleted = 0;
|
|
1822
1878
|
this.#advisorInterruptImmuneTurnStart = undefined;
|
|
@@ -1834,8 +1890,7 @@ export class AgentSession {
|
|
|
1834
1890
|
if (!this.#advisorEnabled) return false;
|
|
1835
1891
|
if (this.#agentKind !== "main" && !this.settings.get("advisor.subagents")) return false;
|
|
1836
1892
|
|
|
1837
|
-
const advisorSel =
|
|
1838
|
-
["advisor"],
|
|
1893
|
+
const advisorSel = resolveAdvisorRoleSelection(
|
|
1839
1894
|
this.settings,
|
|
1840
1895
|
this.#modelRegistry.getAvailable(),
|
|
1841
1896
|
this.#modelRegistry,
|
|
@@ -1856,7 +1911,17 @@ export class AgentSession {
|
|
|
1856
1911
|
// since steering an active run auto-resumes nothing; parking it there would
|
|
1857
1912
|
// strand the advice and dump the backlog as one burst at the next prompt. A
|
|
1858
1913
|
// plain nit always rides the non-interrupting YieldQueue aside.
|
|
1914
|
+
// Apply the per-session emission policy (one-advise-per-update gate,
|
|
1915
|
+
// exact-text dedupe, content-free phrase filter) before any routing.
|
|
1916
|
+
// Suppression here means the advisor model called `advise()` but the call
|
|
1917
|
+
// is dropped silently — the model still sees `Recorded.` from the tool, so
|
|
1918
|
+
// telling it "suppressed" doesn't tempt it into rephrasing the same useless
|
|
1919
|
+
// note to bypass the dedupe.
|
|
1859
1920
|
const enqueueAdvice = (note: string, severity?: AdvisorSeverity) => {
|
|
1921
|
+
if (!this.#advisorEmissionGuard.accept(note)) {
|
|
1922
|
+
logger.debug("advisor advice suppressed by emission guard", { severity });
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1860
1925
|
const interrupting = isInterruptingSeverity(severity);
|
|
1861
1926
|
const channel = resolveAdvisorDeliveryChannel({
|
|
1862
1927
|
severity,
|
|
@@ -1903,6 +1968,9 @@ export class AgentSession {
|
|
|
1903
1968
|
const appendOnlyContext = new AppendOnlyContextManager();
|
|
1904
1969
|
const advisorThinkingLevel = advisorSel.thinkingLevel ?? ThinkingLevel.Medium;
|
|
1905
1970
|
const systemPrompt = [advisorSystemPrompt];
|
|
1971
|
+
if (this.#advisorContextPrompt) {
|
|
1972
|
+
systemPrompt.push(this.#advisorContextPrompt);
|
|
1973
|
+
}
|
|
1906
1974
|
if (this.#advisorWatchdogPrompt) {
|
|
1907
1975
|
systemPrompt.push(this.#advisorWatchdogPrompt);
|
|
1908
1976
|
}
|
|
@@ -1980,6 +2048,7 @@ export class AgentSession {
|
|
|
1980
2048
|
enqueueAdvice,
|
|
1981
2049
|
maintainContext: incomingTokens => this.#maintainAdvisorContext(incomingTokens),
|
|
1982
2050
|
obfuscator: this.#obfuscator,
|
|
2051
|
+
beginAdvisorUpdate: () => this.#advisorEmissionGuard.beginUpdate(),
|
|
1983
2052
|
});
|
|
1984
2053
|
if (seedToCurrent) {
|
|
1985
2054
|
this.#advisorRuntime.seedTo(this.agent.state.messages.length);
|
|
@@ -2049,8 +2118,7 @@ export class AgentSession {
|
|
|
2049
2118
|
const targetModel = await this.#resolveContextPromotionTarget(currentModel, contextWindow);
|
|
2050
2119
|
if (!targetModel) return false;
|
|
2051
2120
|
|
|
2052
|
-
const advisorSel =
|
|
2053
|
-
["advisor"],
|
|
2121
|
+
const advisorSel = resolveAdvisorRoleSelection(
|
|
2054
2122
|
this.settings,
|
|
2055
2123
|
this.#modelRegistry.getAvailable(),
|
|
2056
2124
|
this.#modelRegistry,
|
|
@@ -2665,6 +2733,7 @@ export class AgentSession {
|
|
|
2665
2733
|
if (this.#sessionMessageAlreadyPersisted(message)) return;
|
|
2666
2734
|
if (message.role === "assistant") {
|
|
2667
2735
|
const assistantMsg = message as AssistantMessage;
|
|
2736
|
+
if (this.#isClassifierRefusal(assistantMsg)) return;
|
|
2668
2737
|
if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
|
|
2669
2738
|
assistantMsg.contextSnapshot = {
|
|
2670
2739
|
promptTokens: calculatePromptTokens(assistantMsg.usage),
|
|
@@ -3095,10 +3164,15 @@ export class AgentSession {
|
|
|
3095
3164
|
this.#trackPostPromptTask(compactionTask);
|
|
3096
3165
|
compactionResult = await compactionTask;
|
|
3097
3166
|
checkedCompaction = true;
|
|
3098
|
-
if (
|
|
3099
|
-
|
|
3167
|
+
if (
|
|
3168
|
+
compactionResult.deferredHandoff ||
|
|
3169
|
+
compactionResult.continuationScheduled ||
|
|
3170
|
+
compactionResult.automaticContinuationBlocked
|
|
3171
|
+
) {
|
|
3172
|
+
maintenanceRoute("active-goal-pre-empt-compaction-handled", {
|
|
3100
3173
|
deferredHandoff: compactionResult.deferredHandoff,
|
|
3101
3174
|
continuationScheduled: compactionResult.continuationScheduled,
|
|
3175
|
+
automaticContinuationBlocked: compactionResult.automaticContinuationBlocked === true,
|
|
3102
3176
|
});
|
|
3103
3177
|
this.#resolveRetry();
|
|
3104
3178
|
await emitAgentEndNotification();
|
|
@@ -3137,7 +3211,6 @@ export class AgentSession {
|
|
|
3137
3211
|
return;
|
|
3138
3212
|
}
|
|
3139
3213
|
}
|
|
3140
|
-
// Check for retryable errors first (overloaded, rate limit, server errors)
|
|
3141
3214
|
if (this.#isRetryableError(msg)) {
|
|
3142
3215
|
const didRetry = await this.#handleRetryableError(msg);
|
|
3143
3216
|
if (didRetry) {
|
|
@@ -3145,6 +3218,14 @@ export class AgentSession {
|
|
|
3145
3218
|
return;
|
|
3146
3219
|
}
|
|
3147
3220
|
}
|
|
3221
|
+
// Classifier refusals are persisted-skipped above; also prune the trailing
|
|
3222
|
+
// stub from active context so the next turn's prompt does not replay it.
|
|
3223
|
+
// Fall through to the standard error tail so `session_stop` hooks (block,
|
|
3224
|
+
// continue, telemetry) still fire — matching the pre-fix flow for
|
|
3225
|
+
// `stopReason === "error"`.
|
|
3226
|
+
if (this.#isClassifierRefusal(msg)) {
|
|
3227
|
+
this.#removeAssistantMessageFromActiveContext(msg);
|
|
3228
|
+
}
|
|
3148
3229
|
this.#resolveRetry();
|
|
3149
3230
|
|
|
3150
3231
|
if (!checkedCompaction) {
|
|
@@ -3159,11 +3240,15 @@ export class AgentSession {
|
|
|
3159
3240
|
await emitAgentEndNotification();
|
|
3160
3241
|
return;
|
|
3161
3242
|
}
|
|
3162
|
-
// When compaction queued recovery, skip the
|
|
3163
|
-
// any reminder or hook continuation we append
|
|
3164
|
-
// retry, auto-continue prompt,
|
|
3165
|
-
//
|
|
3166
|
-
if (
|
|
3243
|
+
// When compaction queued recovery or hit a deliberate dead-end, skip the
|
|
3244
|
+
// rewind/todo/session_stop passes: any reminder or hook continuation we append
|
|
3245
|
+
// here would race the handoff, retry, auto-continue prompt, queued-message
|
|
3246
|
+
// drain, or the explicit pause that is preventing a compaction loop.
|
|
3247
|
+
if (
|
|
3248
|
+
compactionResult.deferredHandoff ||
|
|
3249
|
+
compactionResult.continuationScheduled ||
|
|
3250
|
+
compactionResult.automaticContinuationBlocked
|
|
3251
|
+
) {
|
|
3167
3252
|
await emitAgentEndNotification();
|
|
3168
3253
|
return;
|
|
3169
3254
|
}
|
|
@@ -3178,7 +3263,7 @@ export class AgentSession {
|
|
|
3178
3263
|
return;
|
|
3179
3264
|
}
|
|
3180
3265
|
}
|
|
3181
|
-
await this.#emitSessionStopEvent(settledMessages);
|
|
3266
|
+
await this.#emitSessionStopEvent(settledMessages, msg);
|
|
3182
3267
|
await emitAgentEndNotification();
|
|
3183
3268
|
}
|
|
3184
3269
|
};
|
|
@@ -3849,6 +3934,100 @@ export class AgentSession {
|
|
|
3849
3934
|
this.#streamingEditFileCache.clear();
|
|
3850
3935
|
}
|
|
3851
3936
|
|
|
3937
|
+
/**
|
|
3938
|
+
* Whether the Gemini header-runaway guard applies to the current model: the loop
|
|
3939
|
+
* guard is on (settings + `PI_NO_THINKING_LOOP_GUARD`), the tool-call reminder is
|
|
3940
|
+
* enabled, and the active model is a Gemini thinking model.
|
|
3941
|
+
*/
|
|
3942
|
+
#geminiHeaderGuardActive(): boolean {
|
|
3943
|
+
const model = this.model;
|
|
3944
|
+
return (
|
|
3945
|
+
process.env.PI_NO_THINKING_LOOP_GUARD !== "1" &&
|
|
3946
|
+
this.settings.get("model.loopGuard.enabled") === true &&
|
|
3947
|
+
this.settings.get("model.loopGuard.toolCallReminder") === true &&
|
|
3948
|
+
model !== undefined &&
|
|
3949
|
+
isGeminiThinkingModel(model)
|
|
3950
|
+
);
|
|
3951
|
+
}
|
|
3952
|
+
|
|
3953
|
+
/**
|
|
3954
|
+
* Feed streamed assistant events to the Gemini header-runaway detector. Each
|
|
3955
|
+
* reasoning block (`thinking_start`) re-arms a fresh detector when the guard
|
|
3956
|
+
* applies; thinking deltas accumulate thought-summary headers; assistant prose
|
|
3957
|
+
* or a tool call ends the run. On the threshold hit, interrupts the stream (see
|
|
3958
|
+
* {@link #interruptGeminiHeaderRunaway}). Runs synchronously inside the
|
|
3959
|
+
* assistant-message interceptor so the abort lands before more budget burns.
|
|
3960
|
+
* Armed on `thinking_start` (not `turn_start`, which the agent loop skips for the
|
|
3961
|
+
* first turn) so the very first reasoning block is guarded too.
|
|
3962
|
+
*/
|
|
3963
|
+
#maybeInterruptGeminiHeaderRunaway(message: AssistantMessage, event: AssistantMessageEvent): void {
|
|
3964
|
+
if (event.type === "thinking_start") {
|
|
3965
|
+
this.#geminiHeaderDetector = this.#geminiHeaderGuardActive() ? new GeminiHeaderRunDetector() : undefined;
|
|
3966
|
+
return;
|
|
3967
|
+
}
|
|
3968
|
+
const detector = this.#geminiHeaderDetector;
|
|
3969
|
+
if (!detector) return;
|
|
3970
|
+
if (event.type === "thinking_delta") {
|
|
3971
|
+
if (detector.push(event.delta)) this.#interruptGeminiHeaderRunaway(detector.count, message.timestamp);
|
|
3972
|
+
return;
|
|
3973
|
+
}
|
|
3974
|
+
// Leaving the reasoning channel ends the run: the consecutive-header count
|
|
3975
|
+
// only matters within one uninterrupted stretch of reasoning.
|
|
3976
|
+
if (event.type === "text_start" || event.type === "toolcall_start") {
|
|
3977
|
+
detector.reset();
|
|
3978
|
+
}
|
|
3979
|
+
}
|
|
3980
|
+
|
|
3981
|
+
/**
|
|
3982
|
+
* Interrupt a Gemini reasoning stream that has emitted too many consecutive
|
|
3983
|
+
* planning headers without calling a tool. Aborts the live turn, discards the
|
|
3984
|
+
* stalled reasoning-only turn (so its partial, loop-fueling thinking is neither
|
|
3985
|
+
* replayed nor reloaded), injects a hidden tool-call reminder, and continues.
|
|
3986
|
+
* `targetTimestamp` identifies the turn being aborted so the post-prompt task
|
|
3987
|
+
* can drop exactly it.
|
|
3988
|
+
*/
|
|
3989
|
+
#interruptGeminiHeaderRunaway(headerCount: number, targetTimestamp: number): void {
|
|
3990
|
+
logger.warn("Gemini reasoning-header runaway; interrupting to require a tool call", {
|
|
3991
|
+
model: this.model?.id,
|
|
3992
|
+
provider: this.model?.provider,
|
|
3993
|
+
headers: headerCount,
|
|
3994
|
+
});
|
|
3995
|
+
this.emitNotice(
|
|
3996
|
+
"warning",
|
|
3997
|
+
`Interrupted ${headerCount} planning headers with no tool call; reminded the model to issue one.`,
|
|
3998
|
+
"loop-guard",
|
|
3999
|
+
);
|
|
4000
|
+
this.agent.abort(GEMINI_HEADER_INTERRUPT_REASON);
|
|
4001
|
+
const generation = this.#promptGeneration;
|
|
4002
|
+
this.#schedulePostPromptTask(async signal => {
|
|
4003
|
+
if (signal.aborted || this.#isDisposed || this.#promptGeneration !== generation) return;
|
|
4004
|
+
// Let the aborted stream finish unwinding so continue() doesn't race it.
|
|
4005
|
+
await this.agent.waitForIdle();
|
|
4006
|
+
if (signal.aborted || this.#isDisposed || this.#promptGeneration !== generation) return;
|
|
4007
|
+
const aborted = this.agent.state.messages.findLast(
|
|
4008
|
+
(m): m is AssistantMessage => m.role === "assistant" && m.timestamp === targetTimestamp,
|
|
4009
|
+
);
|
|
4010
|
+
if (aborted) this.#discardAssistantTurn(aborted);
|
|
4011
|
+
const content = prompt.render(geminiToolReminderTemplate, { count: headerCount });
|
|
4012
|
+
const details = { headers: headerCount };
|
|
4013
|
+
this.agent.appendMessage({
|
|
4014
|
+
role: "custom",
|
|
4015
|
+
customType: GEMINI_TOOL_REMINDER_TYPE,
|
|
4016
|
+
content,
|
|
4017
|
+
display: false,
|
|
4018
|
+
details,
|
|
4019
|
+
attribution: "agent",
|
|
4020
|
+
timestamp: Date.now(),
|
|
4021
|
+
});
|
|
4022
|
+
this.sessionManager.appendCustomMessageEntry(GEMINI_TOOL_REMINDER_TYPE, content, false, details, "agent");
|
|
4023
|
+
try {
|
|
4024
|
+
await this.agent.continue();
|
|
4025
|
+
} catch (err) {
|
|
4026
|
+
logger.warn("gemini tool-call reminder continue failed", { error: String(err) });
|
|
4027
|
+
}
|
|
4028
|
+
});
|
|
4029
|
+
}
|
|
4030
|
+
|
|
3852
4031
|
#getStreamingEditToolCall(event: AgentEvent):
|
|
3853
4032
|
| {
|
|
3854
4033
|
toolCall: ToolCall;
|
|
@@ -4169,13 +4348,16 @@ export class AgentSession {
|
|
|
4169
4348
|
await this.#extensionRunner?.emit({ type: "agent_end", messages });
|
|
4170
4349
|
}
|
|
4171
4350
|
|
|
4172
|
-
async #emitSessionStopEvent(
|
|
4351
|
+
async #emitSessionStopEvent(
|
|
4352
|
+
messages: AgentMessage[],
|
|
4353
|
+
lastAssistantMessage = this.getLastAssistantMessage(),
|
|
4354
|
+
): Promise<void> {
|
|
4173
4355
|
if (this.#agentKind === "sub" || !this.#extensionRunner?.hasHandlers("session_stop")) return;
|
|
4174
4356
|
const generation = this.#promptGeneration;
|
|
4175
4357
|
const result = await this.#extensionRunner.emitSessionStop({
|
|
4176
4358
|
messages,
|
|
4177
4359
|
turn_id: Math.max(0, this.#turnIndex - 1),
|
|
4178
|
-
last_assistant_message:
|
|
4360
|
+
last_assistant_message: lastAssistantMessage,
|
|
4179
4361
|
session_id: this.sessionId,
|
|
4180
4362
|
session_file: this.sessionFile,
|
|
4181
4363
|
stop_hook_active: this.#sessionStopHookActive,
|
|
@@ -4470,6 +4652,10 @@ export class AgentSession {
|
|
|
4470
4652
|
return this.#isDisposed;
|
|
4471
4653
|
}
|
|
4472
4654
|
|
|
4655
|
+
markMovedFromEmptySessionFile(sessionFile: string): void {
|
|
4656
|
+
this.#movedFromEmptySessionFile = path.resolve(sessionFile);
|
|
4657
|
+
}
|
|
4658
|
+
|
|
4473
4659
|
/**
|
|
4474
4660
|
* Synchronously mark the session as disposing so new work is rejected
|
|
4475
4661
|
* immediately: eval starts throw, queued asides are dropped, and the
|
|
@@ -4543,6 +4729,9 @@ export class AgentSession {
|
|
|
4543
4729
|
await disposeJuliaKernelSessionsByOwner(this.#evalKernelOwnerId);
|
|
4544
4730
|
await shutdownTinyTitleClient();
|
|
4545
4731
|
this.#releasePowerAssertion();
|
|
4732
|
+
// Clean up an empty session created by this session's /move so it doesn't accumulate.
|
|
4733
|
+
await cleanupEmptyMoveSession(this.sessionManager, this.#movedFromEmptySessionFile);
|
|
4734
|
+
this.#movedFromEmptySessionFile = undefined;
|
|
4546
4735
|
await this.sessionManager.close();
|
|
4547
4736
|
// beginDispose() stopped the advisor and captured its recorder close; await
|
|
4548
4737
|
// it so the final advisor turn is flushed before the process may exit.
|
|
@@ -5106,7 +5295,7 @@ export class AgentSession {
|
|
|
5106
5295
|
toolNames: string[],
|
|
5107
5296
|
options?: { persistMCPSelection?: boolean; previousSelectedMCPToolNames?: string[] },
|
|
5108
5297
|
): Promise<void> {
|
|
5109
|
-
toolNames =
|
|
5298
|
+
toolNames = normalizeToolNames(toolNames);
|
|
5110
5299
|
const previousSelectedMCPToolNames = options?.previousSelectedMCPToolNames ?? this.getSelectedMCPToolNames();
|
|
5111
5300
|
const tools: AgentTool[] = [];
|
|
5112
5301
|
const validToolNames: string[] = [];
|
|
@@ -5633,6 +5822,9 @@ export class AgentSession {
|
|
|
5633
5822
|
...options,
|
|
5634
5823
|
...(openrouterVariant !== undefined && { openrouterVariant }),
|
|
5635
5824
|
...(antigravityEndpointMode !== undefined && { antigravityEndpointMode }),
|
|
5825
|
+
maxInFlightRequests: validateProviderMaxInFlightRequests(
|
|
5826
|
+
options.maxInFlightRequests ?? this.settings.get("providers.maxInFlightRequests"),
|
|
5827
|
+
),
|
|
5636
5828
|
loopGuard: {
|
|
5637
5829
|
enabled: this.settings.get("model.loopGuard.enabled"),
|
|
5638
5830
|
checkAssistantContent: this.settings.get("model.loopGuard.checkAssistantContent"),
|
|
@@ -8089,16 +8281,25 @@ export class AgentSession {
|
|
|
8089
8281
|
const effectiveSettings = compactMode
|
|
8090
8282
|
? { ...compactionSettings, ...compactMode.overrides }
|
|
8091
8283
|
: compactionSettings;
|
|
8092
|
-
|
|
8093
|
-
|
|
8094
|
-
|
|
8095
|
-
|
|
8096
|
-
|
|
8097
|
-
|
|
8098
|
-
|
|
8099
|
-
|
|
8100
|
-
|
|
8101
|
-
|
|
8284
|
+
// /compact remote demands provider-native compaction. When no remote
|
|
8285
|
+
// endpoint is configured (one would override per-model gating in
|
|
8286
|
+
// compact()), drop fallback candidates that aren't remote-capable so the
|
|
8287
|
+
// engine never silently runs a local summary on a configured-but-non-
|
|
8288
|
+
// remote compactionModel. If filtering empties the chain, warn and fall
|
|
8289
|
+
// back to the full chain so the operation still completes.
|
|
8290
|
+
const availableModels = this.#modelRegistry.getAvailable();
|
|
8291
|
+
const requireProviderRemote = Boolean(compactMode?.requiresRemote && !effectiveSettings.remoteEndpoint);
|
|
8292
|
+
let compactionCandidates = this.#getCompactionModelCandidates(
|
|
8293
|
+
availableModels,
|
|
8294
|
+
requireProviderRemote ? shouldUseOpenAiRemoteCompaction : undefined,
|
|
8295
|
+
);
|
|
8296
|
+
if (requireProviderRemote && compactionCandidates.length === 0) {
|
|
8297
|
+
this.emitNotice(
|
|
8298
|
+
"warning",
|
|
8299
|
+
`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`,
|
|
8300
|
+
"compaction",
|
|
8301
|
+
);
|
|
8302
|
+
compactionCandidates = this.#getCompactionModelCandidates(availableModels);
|
|
8102
8303
|
}
|
|
8103
8304
|
const pathEntries = this.sessionManager.getBranch();
|
|
8104
8305
|
const preparation = prepareCompaction(pathEntries, effectiveSettings);
|
|
@@ -8137,27 +8338,32 @@ export class AgentSession {
|
|
|
8137
8338
|
const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
|
|
8138
8339
|
|
|
8139
8340
|
// Strategy honored on manual /compact too. Custom instructions imply a
|
|
8140
|
-
// directed LLM summary; a text-only model cannot read
|
|
8141
|
-
//
|
|
8341
|
+
// directed LLM summary; a text-only model cannot read snapcompact frames.
|
|
8342
|
+
// When snapcompact itself was requested, fail locally instead of silently
|
|
8343
|
+
// converting the "no LLM call" path into a provider-backed summary.
|
|
8142
8344
|
const wantsSnapcompact =
|
|
8143
8345
|
compactionPrep.kind !== "fromHook" && effectiveSettings.strategy === "snapcompact" && !customInstructions;
|
|
8144
|
-
|
|
8145
|
-
if (wantsSnapcompact && !
|
|
8346
|
+
const snapcompactReady = wantsSnapcompact;
|
|
8347
|
+
if (wantsSnapcompact && !this.model.input.includes("image")) {
|
|
8146
8348
|
this.emitNotice(
|
|
8147
8349
|
"warning",
|
|
8148
|
-
`snapcompact needs a vision-capable model (${this.model.id} is text-only)
|
|
8350
|
+
`snapcompact needs a vision-capable model (${this.model.id} is text-only)`,
|
|
8149
8351
|
"compaction",
|
|
8150
8352
|
);
|
|
8353
|
+
throw new Error(`snapcompact cannot run locally: ${this.model.id} is text-only.`);
|
|
8151
8354
|
} else if (snapcompactReady) {
|
|
8152
8355
|
const text = snapcompact.serializeConversation(convertToLlm(preparation.messagesToSummarize));
|
|
8153
8356
|
const renderScan = snapcompact.scanRenderability(text);
|
|
8154
8357
|
if (!renderScan.isSafe) {
|
|
8358
|
+
const percent = (renderScan.unrenderableRatio * 100).toFixed(1);
|
|
8155
8359
|
this.emitNotice(
|
|
8156
8360
|
"warning",
|
|
8157
|
-
`snapcompact disabled: high non-ASCII rate detected (${
|
|
8361
|
+
`snapcompact disabled: high non-ASCII rate detected (${percent}%). No LLM fallback was attempted.`,
|
|
8158
8362
|
"compaction",
|
|
8159
8363
|
);
|
|
8160
|
-
|
|
8364
|
+
throw new Error(
|
|
8365
|
+
`snapcompact cannot render this conversation locally: high non-ASCII rate detected (${percent}%).`,
|
|
8366
|
+
);
|
|
8161
8367
|
}
|
|
8162
8368
|
}
|
|
8163
8369
|
|
|
@@ -8169,8 +8375,8 @@ export class AgentSession {
|
|
|
8169
8375
|
|
|
8170
8376
|
// Snapcompact runs locally first. The frame cap is sized from the live
|
|
8171
8377
|
// model window via #computeSnapcompactMaxFrames so the post-render context
|
|
8172
|
-
// fits without the warning loop (issue #3247). Zero-frame budget
|
|
8173
|
-
// snapcompact
|
|
8378
|
+
// fits without the warning loop (issue #3247). Zero-frame budget now fails
|
|
8379
|
+
// the snapcompact request locally rather than falling back to an LLM call.
|
|
8174
8380
|
let snapcompactResult: snapcompact.CompactionResult | undefined;
|
|
8175
8381
|
if (snapcompactReady) {
|
|
8176
8382
|
const maxFrames = this.#computeSnapcompactMaxFrames(preparation, effectiveSettings);
|
|
@@ -8180,9 +8386,10 @@ export class AgentSession {
|
|
|
8180
8386
|
});
|
|
8181
8387
|
this.emitNotice(
|
|
8182
8388
|
"warning",
|
|
8183
|
-
"snapcompact: kept history alone exceeds the context budget
|
|
8389
|
+
"snapcompact: kept history alone exceeds the context budget. No LLM fallback was attempted.",
|
|
8184
8390
|
"compaction",
|
|
8185
8391
|
);
|
|
8392
|
+
throw new Error("snapcompact cannot run locally: kept history alone exceeds the context budget.");
|
|
8186
8393
|
} else {
|
|
8187
8394
|
snapcompactResult = await snapcompact.compact(preparation, {
|
|
8188
8395
|
convertToLlm,
|
|
@@ -8196,15 +8403,15 @@ export class AgentSession {
|
|
|
8196
8403
|
? ctxWindow - effectiveReserveTokens(ctxWindow, effectiveSettings)
|
|
8197
8404
|
: Number.POSITIVE_INFINITY;
|
|
8198
8405
|
if (this.#projectSnapcompactContextTokens(preparation, snapcompactResult) > budget) {
|
|
8199
|
-
logger.warn("Snapcompact still overflows the window after frame-budget sizing
|
|
8406
|
+
logger.warn("Snapcompact still overflows the window after frame-budget sizing", {
|
|
8200
8407
|
model: this.model?.id,
|
|
8201
8408
|
});
|
|
8202
8409
|
this.emitNotice(
|
|
8203
8410
|
"warning",
|
|
8204
|
-
"snapcompact could not bring the context under the limit
|
|
8411
|
+
"snapcompact could not bring the context under the limit. No LLM fallback was attempted.",
|
|
8205
8412
|
"compaction",
|
|
8206
8413
|
);
|
|
8207
|
-
|
|
8414
|
+
throw new Error("snapcompact could not bring the context under the limit locally.");
|
|
8208
8415
|
}
|
|
8209
8416
|
}
|
|
8210
8417
|
}
|
|
@@ -8246,6 +8453,7 @@ export class AgentSession {
|
|
|
8246
8453
|
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
|
|
8247
8454
|
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
|
|
8248
8455
|
},
|
|
8456
|
+
compactionCandidates,
|
|
8249
8457
|
);
|
|
8250
8458
|
summary = result.summary;
|
|
8251
8459
|
shortSummary = result.shortSummary;
|
|
@@ -8679,6 +8887,21 @@ export class AgentSession {
|
|
|
8679
8887
|
const contextTokens = compactionContextTokens(billedContextTokens, storedContextTokens);
|
|
8680
8888
|
if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
|
|
8681
8889
|
|
|
8890
|
+
// Promote to a larger-context sibling before compacting, mirroring the
|
|
8891
|
+
// pre-prompt (#runPrePromptCompactionIfNeeded) and post-turn threshold
|
|
8892
|
+
// (#checkCompaction) paths. Without this, a long mid-turn tool loop that
|
|
8893
|
+
// crosses the threshold compacts the history (and can hit the no-progress
|
|
8894
|
+
// dead-end on a single oversized turn) on a model that should have just
|
|
8895
|
+
// been promoted to a larger window instead.
|
|
8896
|
+
if (await this.#promoteContextModel()) {
|
|
8897
|
+
logger.debug("Mid-run context promotion avoided compaction", {
|
|
8898
|
+
contextTokens,
|
|
8899
|
+
contextWindow,
|
|
8900
|
+
from: `${model?.provider}/${model?.id}`,
|
|
8901
|
+
});
|
|
8902
|
+
return;
|
|
8903
|
+
}
|
|
8904
|
+
|
|
8682
8905
|
const messagesBefore = activeMessages.length;
|
|
8683
8906
|
await this.#runAutoCompaction("threshold", false, false, false, {
|
|
8684
8907
|
autoContinue: false,
|
|
@@ -8827,7 +9050,20 @@ export class AgentSession {
|
|
|
8827
9050
|
if (assistantMessage.stopReason === "error") return COMPACTION_CHECK_NONE;
|
|
8828
9051
|
const pruneResult = await this.#pruneToolOutputs();
|
|
8829
9052
|
const maintenanceTokensFreed = (supersedeResult?.tokensSaved ?? 0) + (pruneResult?.tokensSaved ?? 0);
|
|
8830
|
-
|
|
9053
|
+
// `errorIsFromBeforeCompaction` (computed above) is the general
|
|
9054
|
+
// "this assistant message predates the latest compaction" predicate here,
|
|
9055
|
+
// not just an error-specific one; alias it locally so the threshold intent
|
|
9056
|
+
// reads clearly (#3412 review).
|
|
9057
|
+
const assistantPredatesCompaction = errorIsFromBeforeCompaction;
|
|
9058
|
+
// An assistant that predates the latest compaction carries stale, pre-rewrite
|
|
9059
|
+
// `usage`: the scheduled auto-continue re-enters this check with the kept
|
|
9060
|
+
// assistant (#promptWithMessage → #checkCompaction), and its old high prompt
|
|
9061
|
+
// count would re-trip the threshold on a freshly compacted history. Drop the
|
|
9062
|
+
// stale provider number for those messages and let the live stored estimate
|
|
9063
|
+
// (the floor applied below) drive the decision instead.
|
|
9064
|
+
const assistantUsageContextTokens = assistantPredatesCompaction
|
|
9065
|
+
? 0
|
|
9066
|
+
: calculateContextTokens(assistantMessage.usage);
|
|
8831
9067
|
const storedContextTokens = this.#estimateStoredContextTokens();
|
|
8832
9068
|
// Pruning frees bytes for the NEXT prompt; it does not change the size of
|
|
8833
9069
|
// the prompt the LLM just billed for. Earlier revisions subtracted the
|
|
@@ -8931,11 +9167,11 @@ export class AgentSession {
|
|
|
8931
9167
|
// Tool-use orphans corrupt Anthropic message history (tool_result without
|
|
8932
9168
|
// matching tool_use). Always remove them even when the retry cap is hit.
|
|
8933
9169
|
if (assistantMessage.stopReason === "toolUse") {
|
|
8934
|
-
this.#
|
|
9170
|
+
this.#discardAssistantTurn(assistantMessage);
|
|
8935
9171
|
}
|
|
8936
9172
|
return false;
|
|
8937
9173
|
}
|
|
8938
|
-
this.#
|
|
9174
|
+
this.#discardAssistantTurn(assistantMessage);
|
|
8939
9175
|
this.agent.appendMessage({
|
|
8940
9176
|
role: "developer",
|
|
8941
9177
|
content: [{ type: "text", text: this.#emptyStopRetryReminder() }],
|
|
@@ -9042,7 +9278,7 @@ export class AgentSession {
|
|
|
9042
9278
|
});
|
|
9043
9279
|
}
|
|
9044
9280
|
|
|
9045
|
-
#
|
|
9281
|
+
#removeAssistantMessageFromActiveContext(assistantMessage: AssistantMessage): void {
|
|
9046
9282
|
const messages = this.agent.state.messages;
|
|
9047
9283
|
const lastMessage = messages[messages.length - 1];
|
|
9048
9284
|
if (
|
|
@@ -9051,8 +9287,19 @@ export class AgentSession {
|
|
|
9051
9287
|
) {
|
|
9052
9288
|
this.agent.replaceMessages(messages.slice(0, -1));
|
|
9053
9289
|
}
|
|
9290
|
+
}
|
|
9054
9291
|
|
|
9055
|
-
|
|
9292
|
+
/**
|
|
9293
|
+
* Drop an assistant turn from BOTH the live agent context and the persisted
|
|
9294
|
+
* session branch (reparenting the leaf to the turn's parent), so a discarded
|
|
9295
|
+
* turn does not resurface on reload. Used for empty/reasoning-only stops and
|
|
9296
|
+
* the Gemini header-runaway interrupt, which must not replay a partial,
|
|
9297
|
+
* loop-fueling thinking block.
|
|
9298
|
+
*/
|
|
9299
|
+
#discardAssistantTurn(assistantMessage: AssistantMessage): void {
|
|
9300
|
+
this.#removeAssistantMessageFromActiveContext(assistantMessage);
|
|
9301
|
+
|
|
9302
|
+
const branchEntry = this.sessionManager
|
|
9056
9303
|
.getBranch()
|
|
9057
9304
|
.slice()
|
|
9058
9305
|
.reverse()
|
|
@@ -9062,13 +9309,13 @@ export class AgentSession {
|
|
|
9062
9309
|
entry.message.role === "assistant" &&
|
|
9063
9310
|
this.#isSameAssistantMessage(entry.message as AssistantMessage, assistantMessage),
|
|
9064
9311
|
);
|
|
9065
|
-
if (!
|
|
9312
|
+
if (!branchEntry) {
|
|
9066
9313
|
return;
|
|
9067
9314
|
}
|
|
9068
|
-
if (
|
|
9315
|
+
if (branchEntry.parentId === null) {
|
|
9069
9316
|
this.sessionManager.resetLeaf();
|
|
9070
9317
|
} else {
|
|
9071
|
-
this.sessionManager.branch(
|
|
9318
|
+
this.sessionManager.branch(branchEntry.parentId);
|
|
9072
9319
|
}
|
|
9073
9320
|
}
|
|
9074
9321
|
|
|
@@ -9774,11 +10021,15 @@ export class AgentSession {
|
|
|
9774
10021
|
});
|
|
9775
10022
|
return formatModelSelectorValue(modelKey, thinkingLevel);
|
|
9776
10023
|
}
|
|
9777
|
-
#
|
|
9778
|
-
|
|
9779
|
-
|
|
10024
|
+
#resolveConfiguredModelTarget(
|
|
10025
|
+
configuredTarget: string | undefined,
|
|
10026
|
+
currentModel: Model,
|
|
10027
|
+
availableModels: Model[],
|
|
10028
|
+
): Model | undefined {
|
|
10029
|
+
const trimmedTarget = configuredTarget?.trim();
|
|
10030
|
+
if (!trimmedTarget) return undefined;
|
|
9780
10031
|
|
|
9781
|
-
const parsed = parseModelString(
|
|
10032
|
+
const parsed = parseModelString(trimmedTarget, {
|
|
9782
10033
|
allowMaxAlias: true,
|
|
9783
10034
|
isLiteralModelId: (provider, id) =>
|
|
9784
10035
|
availableModels.some(model => model.provider === provider && model.id === id),
|
|
@@ -9788,7 +10039,15 @@ export class AgentSession {
|
|
|
9788
10039
|
if (explicitModel) return explicitModel;
|
|
9789
10040
|
}
|
|
9790
10041
|
|
|
9791
|
-
return availableModels.find(m => m.provider === currentModel.provider && m.id ===
|
|
10042
|
+
return availableModels.find(m => m.provider === currentModel.provider && m.id === trimmedTarget);
|
|
10043
|
+
}
|
|
10044
|
+
|
|
10045
|
+
#resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
|
|
10046
|
+
return this.#resolveConfiguredModelTarget(currentModel.contextPromotionTarget, currentModel, availableModels);
|
|
10047
|
+
}
|
|
10048
|
+
|
|
10049
|
+
#resolveCompactionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
|
|
10050
|
+
return this.#resolveConfiguredModelTarget(currentModel.compactionModel, currentModel, availableModels);
|
|
9792
10051
|
}
|
|
9793
10052
|
|
|
9794
10053
|
#resolveRoleModelFull(
|
|
@@ -9813,11 +10072,15 @@ export class AgentSession {
|
|
|
9813
10072
|
});
|
|
9814
10073
|
}
|
|
9815
10074
|
|
|
9816
|
-
#getCompactionModelCandidates(availableModels: Model[]): Model[] {
|
|
9817
|
-
return this.#resolveCompactionModelCandidates(this.model, availableModels);
|
|
10075
|
+
#getCompactionModelCandidates(availableModels: Model[], filter?: (model: Model) => boolean): Model[] {
|
|
10076
|
+
return this.#resolveCompactionModelCandidates(this.model, availableModels, filter);
|
|
9818
10077
|
}
|
|
9819
10078
|
|
|
9820
|
-
#resolveCompactionModelCandidates(
|
|
10079
|
+
#resolveCompactionModelCandidates(
|
|
10080
|
+
preferredModel: Model | null | undefined,
|
|
10081
|
+
availableModels: Model[],
|
|
10082
|
+
filter?: (model: Model) => boolean,
|
|
10083
|
+
): Model[] {
|
|
9821
10084
|
const candidates: Model[] = [];
|
|
9822
10085
|
const seen = new Set<string>();
|
|
9823
10086
|
|
|
@@ -9826,9 +10089,16 @@ export class AgentSession {
|
|
|
9826
10089
|
const key = this.#getModelKey(model);
|
|
9827
10090
|
if (seen.has(key)) return;
|
|
9828
10091
|
seen.add(key);
|
|
10092
|
+
// `seen` still tracks rejected models so the largest-context fallback
|
|
10093
|
+
// scan below doesn't reintroduce them; the filter just suppresses
|
|
10094
|
+
// inclusion in this caller's candidate chain.
|
|
10095
|
+
if (filter && !filter(model)) return;
|
|
9829
10096
|
candidates.push(model);
|
|
9830
10097
|
};
|
|
9831
10098
|
|
|
10099
|
+
if (preferredModel) {
|
|
10100
|
+
addCandidate(this.#resolveCompactionConfiguredTarget(preferredModel, availableModels));
|
|
10101
|
+
}
|
|
9832
10102
|
addCandidate(preferredModel ?? undefined);
|
|
9833
10103
|
for (const role of MODEL_ROLE_IDS) {
|
|
9834
10104
|
addCandidate(this.#resolveRoleModelFull(role, availableModels, preferredModel ?? undefined).model);
|
|
@@ -9875,8 +10145,10 @@ export class AgentSession {
|
|
|
9875
10145
|
customInstructions: string | undefined,
|
|
9876
10146
|
signal: AbortSignal,
|
|
9877
10147
|
options?: SummaryOptions,
|
|
10148
|
+
precomputedCandidates?: Model[],
|
|
9878
10149
|
): Promise<CompactionResult> {
|
|
9879
|
-
const candidates =
|
|
10150
|
+
const candidates =
|
|
10151
|
+
precomputedCandidates ?? this.#getCompactionModelCandidates(this.#modelRegistry.getAvailable());
|
|
9880
10152
|
const telemetry = resolveTelemetry(this.agent.telemetry, this.sessionId);
|
|
9881
10153
|
|
|
9882
10154
|
for (const candidate of candidates) {
|
|
@@ -10073,6 +10345,80 @@ export class AgentSession {
|
|
|
10073
10345
|
return tokens;
|
|
10074
10346
|
}
|
|
10075
10347
|
|
|
10348
|
+
/**
|
|
10349
|
+
* Post-maintenance progress check for the context-full / snapcompact tail.
|
|
10350
|
+
*
|
|
10351
|
+
* After `appendCompaction` rewrote history and `replaceMessages` swapped in the
|
|
10352
|
+
* compacted context, measure the residual context off the live message set and
|
|
10353
|
+
* decide whether maintenance actually created headroom. Mirrors the shake
|
|
10354
|
+
* recovery-band logic (#2275): a session whose single most-recent turn already
|
|
10355
|
+
* blows the threshold cannot be reduced by compaction (findCutPoint keeps that
|
|
10356
|
+
* turn verbatim), so re-firing on the next agent_end just thrashes. We only
|
|
10357
|
+
* report progress when residual context lands at or below
|
|
10358
|
+
* `COMPACTION_RECOVERY_BAND × threshold` — a band that sits strictly under the
|
|
10359
|
+
* compaction threshold, so reaching it guarantees the next turn cannot
|
|
10360
|
+
* re-trip threshold compaction.
|
|
10361
|
+
*
|
|
10362
|
+
* When the model/window is unknown we cannot evaluate the band, so we
|
|
10363
|
+
* optimistically allow the continuation (preserving prior behavior).
|
|
10364
|
+
*/
|
|
10365
|
+
#compactionCreatedHeadroom(): boolean {
|
|
10366
|
+
const contextWindow = this.model?.contextWindow ?? 0;
|
|
10367
|
+
if (contextWindow <= 0) return true;
|
|
10368
|
+
const compactionSettings = this.settings.getGroup("compaction");
|
|
10369
|
+
const residualTokens = compactionContextTokens(
|
|
10370
|
+
this.getContextUsage({ contextWindow })?.tokens ?? 0,
|
|
10371
|
+
this.#estimateStoredContextTokens(),
|
|
10372
|
+
);
|
|
10373
|
+
const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
|
|
10374
|
+
const recoveryBand = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND);
|
|
10375
|
+
// Residual at/below the band is authoritative headroom: the band sits
|
|
10376
|
+
// strictly under the compaction threshold, so the next turn cannot
|
|
10377
|
+
// re-trip threshold compaction regardless of how little this pass shaved.
|
|
10378
|
+
// Don't add a secondary "smaller than the trigger" guard — when stale/
|
|
10379
|
+
// tool-output pruning already dropped context under the band before this
|
|
10380
|
+
// pass, the trigger is itself sub-band, and requiring a strict reduction
|
|
10381
|
+
// would suppress a valid continuation and emit a false no-progress warning
|
|
10382
|
+
// even though compaction left the session safe.
|
|
10383
|
+
return residualTokens <= recoveryBand;
|
|
10384
|
+
}
|
|
10385
|
+
|
|
10386
|
+
/**
|
|
10387
|
+
* Retry-side counterpart to {@link #compactionCreatedHeadroom}. An
|
|
10388
|
+
* overflow/incomplete recovery only needs the rebuilt prompt to *fit* the
|
|
10389
|
+
* window again — it does not have to land under the compaction threshold, let
|
|
10390
|
+
* alone the stricter `COMPACTION_RECOVERY_BAND × threshold` hysteresis the
|
|
10391
|
+
* auto-continue thrash guard uses. Reusing the band here turned recoverable
|
|
10392
|
+
* overflows into manual dead-ends: a 200k-window prompt compacted from
|
|
10393
|
+
* overflow down to ~150k is comfortably retryable, but sits above
|
|
10394
|
+
* `0.8 × 170k = 136k` and was wrongly refused (PR #3412 review).
|
|
10395
|
+
*
|
|
10396
|
+
* Measures residual context against the usable budget (`contextWindow - reserve`).
|
|
10397
|
+
* The default absolute reserve can exceed bundled small-context windows, so
|
|
10398
|
+
* the retry path clamps that reserve back to the proportional 15% default
|
|
10399
|
+
* before comparing; otherwise a prompt that fits the model window would still
|
|
10400
|
+
* see a negative budget and dead-end. Callers MUST invoke this AFTER dropping
|
|
10401
|
+
* the failed assistant from `this.messages`, so the just-failed turn (which
|
|
10402
|
+
* the retry prompt will not include) is excluded from the estimate.
|
|
10403
|
+
*
|
|
10404
|
+
* When the model/window is unknown we cannot evaluate the budget, so we
|
|
10405
|
+
* optimistically allow the retry (preserving prior behavior).
|
|
10406
|
+
*/
|
|
10407
|
+
#compactionCreatedRetryFit(): boolean {
|
|
10408
|
+
const contextWindow = this.model?.contextWindow ?? 0;
|
|
10409
|
+
if (contextWindow <= 0) return true;
|
|
10410
|
+
const compactionSettings = this.settings.getGroup("compaction");
|
|
10411
|
+
const residualTokens = compactionContextTokens(
|
|
10412
|
+
this.getContextUsage({ contextWindow })?.tokens ?? 0,
|
|
10413
|
+
this.#estimateStoredContextTokens(),
|
|
10414
|
+
);
|
|
10415
|
+
const reserveTokens = effectiveReserveTokens(contextWindow, compactionSettings);
|
|
10416
|
+
const defaultReserveTokens = Math.floor(contextWindow * 0.15);
|
|
10417
|
+
const fitReserveTokens = Math.min(reserveTokens, defaultReserveTokens);
|
|
10418
|
+
const fitBudget = Math.max(0, contextWindow - fitReserveTokens);
|
|
10419
|
+
return residualTokens <= fitBudget;
|
|
10420
|
+
}
|
|
10421
|
+
|
|
10076
10422
|
/**
|
|
10077
10423
|
* Internal: Run auto-compaction with events.
|
|
10078
10424
|
*
|
|
@@ -10146,26 +10492,14 @@ export class AgentSession {
|
|
|
10146
10492
|
// "overflow" forces context-full because the input itself is broken — a handoff
|
|
10147
10493
|
// LLM call would hit the same overflow. "incomplete" is an output-side problem,
|
|
10148
10494
|
// so a handoff request on the existing context is still viable. Snapcompact is
|
|
10149
|
-
//
|
|
10150
|
-
//
|
|
10495
|
+
// a local-only strategy: if it cannot run, report the local blocker instead of
|
|
10496
|
+
// silently swapping in a provider-backed summary.
|
|
10151
10497
|
let action: "context-full" | "handoff" | "snapcompact" =
|
|
10152
|
-
compactionSettings.strategy === "
|
|
10153
|
-
? "
|
|
10154
|
-
: "
|
|
10155
|
-
|
|
10156
|
-
|
|
10157
|
-
action = "snapcompact";
|
|
10158
|
-
} else {
|
|
10159
|
-
logger.warn("Snapcompact compaction requires a vision-capable model; falling back to context-full", {
|
|
10160
|
-
model: this.model?.id,
|
|
10161
|
-
});
|
|
10162
|
-
this.emitNotice(
|
|
10163
|
-
"warning",
|
|
10164
|
-
`snapcompact needs a vision-capable model (${this.model?.id ?? "unknown"} is text-only) — using an LLM summary instead`,
|
|
10165
|
-
"compaction",
|
|
10166
|
-
);
|
|
10167
|
-
}
|
|
10168
|
-
}
|
|
10498
|
+
compactionSettings.strategy === "snapcompact"
|
|
10499
|
+
? "snapcompact"
|
|
10500
|
+
: compactionSettings.strategy === "handoff" && reason !== "overflow" && !suppressHandoff
|
|
10501
|
+
? "handoff"
|
|
10502
|
+
: "context-full";
|
|
10169
10503
|
// Abort any older auto-compaction before installing this run's controller.
|
|
10170
10504
|
this.#autoCompactionAbortController?.abort();
|
|
10171
10505
|
const autoCompactionAbortController = new AbortController();
|
|
@@ -10306,48 +10640,59 @@ export class AgentSession {
|
|
|
10306
10640
|
// Snapcompact runs locally first. The post-compaction context = kept-recent
|
|
10307
10641
|
// + a summary message carrying the imaged archive at FRAME_TOKEN_ESTIMATE
|
|
10308
10642
|
// per frame; #computeSnapcompactMaxFrames sizes the frame cap from the
|
|
10309
|
-
// live window so we don't run snapcompact just to overflow
|
|
10310
|
-
//
|
|
10311
|
-
//
|
|
10312
|
-
// only a defensive guard for summary-text drift.
|
|
10643
|
+
// live window so we don't run snapcompact just to overflow every threshold
|
|
10644
|
+
// tick. Any local blocker fails the snapcompact maintenance pass rather
|
|
10645
|
+
// than falling back to a provider-backed LLM summary.
|
|
10313
10646
|
let snapcompactResult: snapcompact.CompactionResult | undefined;
|
|
10314
10647
|
if (action === "snapcompact" && compactionPrep.kind !== "fromHook") {
|
|
10648
|
+
if (!this.model?.input.includes("image")) {
|
|
10649
|
+
logger.warn("Snapcompact compaction requires a vision-capable model", {
|
|
10650
|
+
model: this.model?.id,
|
|
10651
|
+
});
|
|
10652
|
+
this.emitNotice(
|
|
10653
|
+
"warning",
|
|
10654
|
+
`snapcompact needs a vision-capable model (${this.model?.id ?? "unknown"} is text-only). No LLM fallback was attempted.`,
|
|
10655
|
+
"compaction",
|
|
10656
|
+
);
|
|
10657
|
+
throw new Error(`snapcompact cannot run locally: ${this.model?.id ?? "unknown"} is text-only.`);
|
|
10658
|
+
}
|
|
10315
10659
|
const text = snapcompact.serializeConversation(
|
|
10316
10660
|
convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
|
|
10317
10661
|
);
|
|
10318
10662
|
const renderScan = snapcompact.scanRenderability(text);
|
|
10319
10663
|
if (!renderScan.isSafe) {
|
|
10320
|
-
|
|
10664
|
+
const percent = (renderScan.unrenderableRatio * 100).toFixed(1);
|
|
10665
|
+
logger.warn("Snapcompact disabled: high non-ASCII rate detected", {
|
|
10321
10666
|
model: this.model?.id,
|
|
10322
10667
|
unrenderableRatio: renderScan.unrenderableRatio,
|
|
10323
10668
|
});
|
|
10324
10669
|
this.emitNotice(
|
|
10325
10670
|
"warning",
|
|
10326
|
-
`snapcompact disabled: high non-ASCII rate detected (${
|
|
10671
|
+
`snapcompact disabled: high non-ASCII rate detected (${percent}%). No LLM fallback was attempted.`,
|
|
10327
10672
|
"compaction",
|
|
10328
10673
|
);
|
|
10329
|
-
|
|
10330
|
-
|
|
10331
|
-
|
|
10332
|
-
if (maxFrames < 1) {
|
|
10333
|
-
logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
|
|
10334
|
-
model: this.model?.id,
|
|
10335
|
-
});
|
|
10336
|
-
this.emitNotice(
|
|
10337
|
-
"warning",
|
|
10338
|
-
"snapcompact: kept history alone exceeds the context budget — using an LLM summary instead",
|
|
10339
|
-
"compaction",
|
|
10340
|
-
);
|
|
10341
|
-
action = "context-full";
|
|
10342
|
-
} else {
|
|
10343
|
-
snapcompactResult = await snapcompact.compact(preparation, {
|
|
10344
|
-
convertToLlm,
|
|
10345
|
-
model: this.model,
|
|
10346
|
-
shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
|
|
10347
|
-
maxFrames,
|
|
10348
|
-
});
|
|
10349
|
-
}
|
|
10674
|
+
throw new Error(
|
|
10675
|
+
`snapcompact cannot render this conversation locally: high non-ASCII rate detected (${percent}%).`,
|
|
10676
|
+
);
|
|
10350
10677
|
}
|
|
10678
|
+
const maxFrames = this.#computeSnapcompactMaxFrames(preparation, compactionSettings);
|
|
10679
|
+
if (maxFrames < 1) {
|
|
10680
|
+
logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
|
|
10681
|
+
model: this.model?.id,
|
|
10682
|
+
});
|
|
10683
|
+
this.emitNotice(
|
|
10684
|
+
"warning",
|
|
10685
|
+
"snapcompact: kept history alone exceeds the context budget. No LLM fallback was attempted.",
|
|
10686
|
+
"compaction",
|
|
10687
|
+
);
|
|
10688
|
+
throw new Error("snapcompact cannot run locally: kept history alone exceeds the context budget.");
|
|
10689
|
+
}
|
|
10690
|
+
snapcompactResult = await snapcompact.compact(preparation, {
|
|
10691
|
+
convertToLlm,
|
|
10692
|
+
model: this.model,
|
|
10693
|
+
shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
|
|
10694
|
+
maxFrames,
|
|
10695
|
+
});
|
|
10351
10696
|
|
|
10352
10697
|
if (snapcompactResult) {
|
|
10353
10698
|
const ctxWindow = this.model?.contextWindow ?? 0;
|
|
@@ -10357,18 +10702,17 @@ export class AgentSession {
|
|
|
10357
10702
|
: Number.POSITIVE_INFINITY;
|
|
10358
10703
|
const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult);
|
|
10359
10704
|
if (projected > budget) {
|
|
10360
|
-
logger.warn("Snapcompact still overflows the window after frame-budget sizing
|
|
10705
|
+
logger.warn("Snapcompact still overflows the window after frame-budget sizing", {
|
|
10361
10706
|
model: this.model?.id,
|
|
10362
10707
|
projected,
|
|
10363
10708
|
budget,
|
|
10364
10709
|
});
|
|
10365
10710
|
this.emitNotice(
|
|
10366
10711
|
"warning",
|
|
10367
|
-
"snapcompact could not bring the context under the limit
|
|
10712
|
+
"snapcompact could not bring the context under the limit. No LLM fallback was attempted.",
|
|
10368
10713
|
"compaction",
|
|
10369
10714
|
);
|
|
10370
|
-
|
|
10371
|
-
snapcompactResult = undefined;
|
|
10715
|
+
throw new Error("snapcompact could not bring the context under the limit locally.");
|
|
10372
10716
|
}
|
|
10373
10717
|
}
|
|
10374
10718
|
}
|
|
@@ -10564,11 +10908,21 @@ export class AgentSession {
|
|
|
10564
10908
|
};
|
|
10565
10909
|
await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
|
|
10566
10910
|
|
|
10911
|
+
// Post-maintenance progress guard. Snapcompact can project over budget and
|
|
10912
|
+
// fall back to a context-full summary; the summarizer keeps `keepRecentTokens`
|
|
10913
|
+
// of recent history verbatim and findCutPoint can only cut at turn
|
|
10914
|
+
// boundaries (never tool results), so a single oversized recent turn (e.g. a
|
|
10915
|
+
// huge tool result) leaves the rewritten context still above threshold.
|
|
10916
|
+
// Scheduling the continuation regardless means the next agent_end re-enters
|
|
10917
|
+
// #checkCompaction over the same oversized tail and re-fires forever. The
|
|
10918
|
+
// retry and the threshold auto-continue use different progress tests (a
|
|
10919
|
+
// recoverable overflow only has to fit; the auto-continue thrash needs the
|
|
10920
|
+
// stricter recovery band), so each branch evaluates its own below.
|
|
10567
10921
|
let continuationScheduled = false;
|
|
10568
|
-
|
|
10569
|
-
|
|
10570
|
-
|
|
10571
|
-
|
|
10922
|
+
// A non-idle pass that wanted to continue (retry or auto-continue) but freed
|
|
10923
|
+
// too little for that path to proceed is a dead-end: warn once so the user
|
|
10924
|
+
// understands why maintenance paused instead of silently looping.
|
|
10925
|
+
let noProgressDeadEnd = false;
|
|
10572
10926
|
|
|
10573
10927
|
if (willRetry) {
|
|
10574
10928
|
const messages = this.agent.state.messages;
|
|
@@ -10587,11 +10941,38 @@ export class AgentSession {
|
|
|
10587
10941
|
}
|
|
10588
10942
|
}
|
|
10589
10943
|
|
|
10590
|
-
|
|
10591
|
-
|
|
10592
|
-
|
|
10944
|
+
// Retry only needs the rebuilt prompt to fit the window again — measured
|
|
10945
|
+
// AFTER the drop above so the just-failed turn (which the retry prompt
|
|
10946
|
+
// won't include) is excluded. Reusing the auto-continue recovery band
|
|
10947
|
+
// here turned recoverable overflows into manual dead-ends (#3412 review),
|
|
10948
|
+
// so use the looser fit budget.
|
|
10949
|
+
if (this.#compactionCreatedRetryFit()) {
|
|
10950
|
+
this.#scheduleAgentContinue({ delayMs: 100, generation });
|
|
10951
|
+
continuationScheduled = true;
|
|
10952
|
+
} else {
|
|
10953
|
+
noProgressDeadEnd = true;
|
|
10954
|
+
}
|
|
10955
|
+
} else if (reason !== "idle") {
|
|
10956
|
+
// Mirror the shake recovery-band check: only auto-continue when compaction
|
|
10957
|
+
// landed residual context under `COMPACTION_RECOVERY_BAND × threshold`.
|
|
10958
|
+
// Re-firing on a history that still sits just over the line is the
|
|
10959
|
+
// snapcompact thrash, so require genuine headroom, not a bare fit. Even
|
|
10960
|
+
// when auto-continue is disabled, a no-headroom threshold pass must still
|
|
10961
|
+
// block later automatic continuations (todo reminders/session_stop hooks)
|
|
10962
|
+
// from re-entering the same oversized context.
|
|
10963
|
+
if (this.#compactionCreatedHeadroom()) {
|
|
10964
|
+
if (shouldAutoContinue) {
|
|
10965
|
+
this.#scheduleAutoContinuePrompt(generation);
|
|
10966
|
+
continuationScheduled = true;
|
|
10967
|
+
}
|
|
10968
|
+
} else {
|
|
10969
|
+
noProgressDeadEnd = true;
|
|
10970
|
+
}
|
|
10971
|
+
}
|
|
10972
|
+
if (!continuationScheduled && !suppressContinuation && this.agent.hasQueuedMessages()) {
|
|
10593
10973
|
// Auto-compaction can complete while follow-up/steering/custom messages are waiting.
|
|
10594
|
-
// Kick the loop so queued messages are actually delivered.
|
|
10974
|
+
// Kick the loop so queued messages are actually delivered. This remains separate
|
|
10975
|
+
// from the no-progress warning: pausing maintenance must not strand user input.
|
|
10595
10976
|
this.#scheduleAgentContinue({
|
|
10596
10977
|
delayMs: 100,
|
|
10597
10978
|
generation,
|
|
@@ -10599,7 +10980,16 @@ export class AgentSession {
|
|
|
10599
10980
|
});
|
|
10600
10981
|
continuationScheduled = true;
|
|
10601
10982
|
}
|
|
10602
|
-
|
|
10983
|
+
|
|
10984
|
+
if (noProgressDeadEnd) {
|
|
10985
|
+
this.emitNotice(
|
|
10986
|
+
"warning",
|
|
10987
|
+
"Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. The most recent turn alone is too large to reduce further; shrink it (e.g. clear large tool output) or switch to a larger-context model.",
|
|
10988
|
+
"compaction",
|
|
10989
|
+
);
|
|
10990
|
+
}
|
|
10991
|
+
if (continuationScheduled) return COMPACTION_CHECK_CONTINUATION;
|
|
10992
|
+
return noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE;
|
|
10603
10993
|
} catch (error) {
|
|
10604
10994
|
if (autoCompactionSignal.aborted) {
|
|
10605
10995
|
await this.#emitSessionEvent({
|
|
@@ -10697,7 +11087,7 @@ export class AgentSession {
|
|
|
10697
11087
|
if (typeof triggerContextTokens === "number" && Number.isFinite(triggerContextTokens)) {
|
|
10698
11088
|
const correctedTokens = Math.max(0, triggerContextTokens - result.tokensFreed);
|
|
10699
11089
|
const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
|
|
10700
|
-
const recoveryBand = Math.floor(thresholdTokens *
|
|
11090
|
+
const recoveryBand = Math.floor(thresholdTokens * COMPACTION_RECOVERY_BAND);
|
|
10701
11091
|
stillOverThreshold = correctedTokens > recoveryBand;
|
|
10702
11092
|
} else {
|
|
10703
11093
|
const postShakeTokens = this.getContextUsage({ contextWindow })?.tokens ?? 0;
|
|
@@ -11464,11 +11854,8 @@ export class AgentSession {
|
|
|
11464
11854
|
errorMessage,
|
|
11465
11855
|
});
|
|
11466
11856
|
|
|
11467
|
-
// Remove
|
|
11468
|
-
|
|
11469
|
-
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
|
|
11470
|
-
this.agent.replaceMessages(messages.slice(0, -1));
|
|
11471
|
-
}
|
|
11857
|
+
// Remove the failed assistant message from active context before retrying.
|
|
11858
|
+
this.#removeAssistantMessageFromActiveContext(message);
|
|
11472
11859
|
|
|
11473
11860
|
// Wait with exponential backoff (abortable).
|
|
11474
11861
|
const retryAbortController = new AbortController();
|