@nghyane/arcane 0.1.13 → 0.1.15
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 +28 -0
- package/package.json +21 -70
- package/scripts/format-prompts.ts +1 -3
- package/src/cli/args.ts +2 -7
- package/src/cli/config-cli.ts +1 -1
- package/src/cli/plugin-cli.ts +1 -1
- package/src/cli/setup-cli.ts +1 -1
- package/src/cli/update-cli.ts +1 -1
- package/src/cli/web-search-cli.ts +1 -1
- package/src/cli.ts +0 -1
- package/src/commands/config.ts +1 -1
- package/src/commands/grep.ts +1 -1
- package/src/commands/jupyter.ts +1 -1
- package/src/commands/plugin.ts +1 -1
- package/src/commands/setup.ts +1 -1
- package/src/commands/shell.ts +1 -1
- package/src/commands/ssh.ts +1 -1
- package/src/commands/stats.ts +1 -1
- package/src/commands/update.ts +1 -1
- package/src/config/model-registry.ts +3 -4
- package/src/config/model-resolver.ts +36 -9
- package/src/config/prompt-templates.ts +1 -9
- package/src/config/settings-schema.ts +32 -88
- package/src/config/settings.ts +3 -4
- package/src/debug/index.ts +1 -1
- package/src/debug/log-formatting.ts +1 -1
- package/src/debug/log-viewer.ts +2 -2
- package/src/discovery/helpers.ts +13 -3
- package/src/exa/index.ts +1 -35
- package/src/exa/render.ts +30 -190
- package/src/export/html/index.ts +1 -1
- package/src/extensibility/custom-tools/loader.ts +1 -1
- package/src/extensibility/custom-tools/types.ts +5 -1
- package/src/extensibility/custom-tools/wrapper.ts +1 -1
- package/src/extensibility/extensions/runner.ts +1 -1
- package/src/extensibility/extensions/types.ts +1 -1
- package/src/extensibility/extensions/wrapper.ts +7 -15
- package/src/extensibility/hooks/runner.ts +1 -1
- package/src/extensibility/hooks/types.ts +1 -1
- package/src/extensibility/plugins/doctor.ts +1 -1
- package/src/index.ts +13 -13
- package/src/lsp/index.ts +77 -24
- package/src/lsp/render.ts +34 -583
- package/src/lsp/types.ts +3 -3
- package/src/lsp/utils.ts +1 -1
- package/src/main.ts +1 -1
- package/src/mcp/tool-bridge.ts +1 -24
- package/src/modes/components/assistant-message.ts +7 -7
- package/src/modes/components/bash-execution.ts +50 -112
- package/src/modes/components/bordered-loader.ts +1 -1
- package/src/modes/components/branch-summary-message.ts +16 -10
- package/src/modes/components/compaction-summary-message.ts +20 -12
- package/src/modes/components/context-group.ts +106 -0
- package/src/modes/components/custom-message.ts +4 -5
- package/src/modes/components/diff.ts +2 -2
- package/src/modes/components/dynamic-border.ts +1 -1
- package/src/modes/components/extensions/extension-dashboard.ts +1 -1
- package/src/modes/components/extensions/extension-list.ts +1 -1
- package/src/modes/components/extensions/inspector-panel.ts +1 -1
- package/src/modes/components/footer.ts +2 -2
- package/src/modes/components/history-search.ts +1 -1
- package/src/modes/components/hook-editor.ts +1 -1
- package/src/modes/components/hook-input.ts +1 -1
- package/src/modes/components/hook-message.ts +4 -5
- package/src/modes/components/hook-selector.ts +1 -1
- package/src/modes/components/index.ts +0 -2
- package/src/modes/components/keybinding-hints.ts +1 -1
- package/src/modes/components/login-dialog.ts +1 -1
- package/src/modes/components/mcp-add-wizard.ts +1 -1
- package/src/modes/components/model-selector.ts +1 -1
- package/src/modes/components/oauth-selector.ts +1 -1
- package/src/modes/components/plugin-settings.ts +1 -1
- package/src/modes/components/python-execution.ts +51 -91
- package/src/modes/components/queue-mode-selector.ts +1 -1
- package/src/modes/components/session-selector.ts +1 -1
- package/src/modes/components/settings-defs.ts +5 -10
- package/src/modes/components/settings-selector.ts +1 -1
- package/src/modes/components/show-images-selector.ts +1 -1
- package/src/modes/components/skill-message.ts +4 -4
- package/src/modes/components/status-line/segments.ts +2 -2
- package/src/modes/components/status-line/separators.ts +1 -1
- package/src/modes/components/status-line-segment-editor.ts +1 -1
- package/src/modes/components/status-line.ts +1 -1
- package/src/modes/components/theme-selector.ts +1 -1
- package/src/modes/components/thinking-selector.ts +1 -1
- package/src/modes/components/todo-display.ts +2 -4
- package/src/modes/components/todo-reminder.ts +4 -4
- package/src/modes/components/tool-execution.ts +118 -440
- package/src/modes/components/tool-image-display.ts +107 -0
- package/src/modes/components/tree-selector.ts +2 -2
- package/src/modes/components/ttsr-notification.ts +4 -17
- package/src/modes/components/user-message-selector.ts +1 -1
- package/src/modes/components/user-message.ts +9 -10
- package/src/modes/components/welcome.ts +1 -1
- package/src/modes/controllers/command-controller.ts +1 -1
- package/src/modes/controllers/event-controller.ts +58 -187
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +3 -1
- package/src/modes/controllers/mcp-command-controller.ts +1 -1
- package/src/modes/controllers/selector-controller.ts +3 -26
- package/src/modes/controllers/ssh-command-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +3 -7
- package/src/modes/print-mode.ts +5 -5
- package/src/modes/rpc/rpc-mode.ts +1 -1
- package/src/modes/types.ts +1 -2
- package/src/modes/utils/ui-helpers.ts +34 -32
- package/src/patch/edit-tool.ts +742 -0
- package/src/patch/index.ts +32 -898
- package/src/patch/schemas.ts +208 -0
- package/src/patch/shared.ts +83 -151
- package/src/prompts/agents/explore.md +22 -37
- package/src/prompts/agents/init.md +1 -1
- package/src/prompts/agents/librarian.md +29 -20
- package/src/prompts/agents/oracle.md +9 -2
- package/src/prompts/agents/reviewer.md +14 -48
- package/src/prompts/agents/task.md +16 -8
- package/src/prompts/compaction/branch-summary.md +4 -1
- package/src/prompts/compaction/compaction-summary.md +4 -1
- package/src/prompts/system/subagent-system-prompt.md +1 -1
- package/src/prompts/system/system-prompt.md +162 -178
- package/src/prompts/system/verification-reminder.md +6 -0
- package/src/sdk.ts +0 -9
- package/src/session/agent-session.ts +244 -1459
- package/src/session/model-controller.ts +406 -0
- package/src/session/retry-utils.ts +71 -0
- package/src/session/session-manager.ts +22 -186
- package/src/session/session-types.ts +312 -0
- package/src/session/stats.ts +387 -0
- package/src/session/streaming-edit.ts +258 -0
- package/src/session/ttsr.ts +213 -0
- package/src/slash-commands/builtin-registry.ts +0 -8
- package/src/stt/recorder.ts +2 -2
- package/src/system-prompt.ts +1 -14
- package/src/task/agents.ts +7 -33
- package/src/task/executor.ts +50 -438
- package/src/task/index.ts +104 -71
- package/src/task/progress-tracker.ts +390 -0
- package/src/task/render.ts +371 -187
- package/src/task/subprocess-tool-registry.ts +1 -1
- package/src/task/types.ts +14 -47
- package/src/tools/ask.ts +31 -42
- package/src/tools/bash-interactive.ts +2 -2
- package/src/tools/bash-interceptor.ts +2 -2
- package/src/tools/bash-normalize.ts +1 -1
- package/src/tools/bash-skill-urls.ts +2 -2
- package/src/tools/bash.ts +87 -136
- package/src/tools/browser.ts +54 -84
- package/src/tools/create-tools.ts +186 -0
- package/src/tools/default-renderer.ts +104 -0
- package/src/tools/explore.ts +11 -10
- package/src/tools/fetch.ts +24 -114
- package/src/tools/find.ts +48 -132
- package/src/tools/gemini-image.ts +5 -15
- package/src/tools/github.ts +450 -0
- package/src/tools/grep.ts +43 -179
- package/src/tools/index.ts +35 -198
- package/src/tools/json-tree.ts +3 -3
- package/src/tools/librarian.ts +18 -18
- package/src/tools/list-limit.ts +2 -2
- package/src/tools/notebook.ts +35 -87
- package/src/tools/oracle.ts +25 -25
- package/src/tools/output-meta.ts +89 -4
- package/src/tools/output-utils.ts +2 -2
- package/src/tools/python.ts +86 -637
- package/src/tools/read.ts +36 -119
- package/src/tools/reviewer-tool.ts +19 -21
- package/src/tools/search-code.ts +128 -0
- package/src/tools/ssh.ts +67 -126
- package/src/tools/subagent-tool.ts +197 -123
- package/src/tools/todo-write.ts +15 -31
- package/src/tools/tool-errors.ts +0 -30
- package/src/tools/undo-edit.ts +30 -67
- package/src/tools/write.ts +78 -127
- package/src/tui/code-cell.ts +4 -4
- package/src/tui/file-list.ts +2 -2
- package/src/tui/output-block.ts +1 -1
- package/src/tui/status-line.ts +1 -1
- package/src/tui/tree-list.ts +2 -2
- package/src/tui/types.ts +1 -1
- package/src/tui/utils.ts +1 -1
- package/src/{tools → ui}/render-utils.ts +87 -126
- package/src/utils/external-editor.ts +4 -4
- package/src/utils/file-mentions.ts +1 -1
- package/src/utils/index.ts +30 -0
- package/src/utils/tools-manager.ts +9 -19
- package/src/web/github-client.ts +290 -0
- package/src/web/scrapers/github.ts +11 -62
- package/src/web/search/auth.ts +1 -3
- package/src/web/search/index.ts +82 -46
- package/src/web/search/provider.ts +11 -16
- package/src/web/search/providers/grep.ts +160 -0
- package/src/web/search/render.ts +48 -235
- package/src/web/search/types.ts +1 -1
- package/src/commands/commit.ts +0 -36
- package/src/commit/agentic/agent.ts +0 -311
- package/src/commit/agentic/fallback.ts +0 -96
- package/src/commit/agentic/index.ts +0 -359
- package/src/commit/agentic/prompts/analyze-file.md +0 -22
- package/src/commit/agentic/prompts/session-user.md +0 -25
- package/src/commit/agentic/prompts/split-confirm.md +0 -1
- package/src/commit/agentic/prompts/system.md +0 -38
- package/src/commit/agentic/state.ts +0 -69
- package/src/commit/agentic/tools/analyze-file.ts +0 -118
- package/src/commit/agentic/tools/git-file-diff.ts +0 -194
- package/src/commit/agentic/tools/git-hunk.ts +0 -50
- package/src/commit/agentic/tools/git-overview.ts +0 -84
- package/src/commit/agentic/tools/index.ts +0 -56
- package/src/commit/agentic/tools/propose-changelog.ts +0 -128
- package/src/commit/agentic/tools/propose-commit.ts +0 -154
- package/src/commit/agentic/tools/recent-commits.ts +0 -81
- package/src/commit/agentic/tools/split-commit.ts +0 -280
- package/src/commit/agentic/topo-sort.ts +0 -44
- package/src/commit/agentic/trivial.ts +0 -51
- package/src/commit/agentic/validation.ts +0 -200
- package/src/commit/analysis/conventional.ts +0 -165
- package/src/commit/analysis/index.ts +0 -4
- package/src/commit/analysis/scope.ts +0 -242
- package/src/commit/analysis/summary.ts +0 -112
- package/src/commit/analysis/validation.ts +0 -66
- package/src/commit/changelog/detect.ts +0 -37
- package/src/commit/changelog/generate.ts +0 -110
- package/src/commit/changelog/index.ts +0 -234
- package/src/commit/changelog/parse.ts +0 -44
- package/src/commit/cli.ts +0 -93
- package/src/commit/git/diff.ts +0 -148
- package/src/commit/git/errors.ts +0 -9
- package/src/commit/git/index.ts +0 -211
- package/src/commit/git/operations.ts +0 -54
- package/src/commit/index.ts +0 -5
- package/src/commit/map-reduce/index.ts +0 -64
- package/src/commit/map-reduce/map-phase.ts +0 -178
- package/src/commit/map-reduce/reduce-phase.ts +0 -145
- package/src/commit/map-reduce/utils.ts +0 -9
- package/src/commit/message.ts +0 -11
- package/src/commit/model-selection.ts +0 -69
- package/src/commit/pipeline.ts +0 -243
- package/src/commit/prompts/analysis-system.md +0 -148
- package/src/commit/prompts/analysis-user.md +0 -38
- package/src/commit/prompts/changelog-system.md +0 -50
- package/src/commit/prompts/changelog-user.md +0 -18
- package/src/commit/prompts/file-observer-system.md +0 -24
- package/src/commit/prompts/file-observer-user.md +0 -8
- package/src/commit/prompts/reduce-system.md +0 -50
- package/src/commit/prompts/reduce-user.md +0 -17
- package/src/commit/prompts/summary-retry.md +0 -3
- package/src/commit/prompts/summary-system.md +0 -38
- package/src/commit/prompts/summary-user.md +0 -13
- package/src/commit/prompts/types-description.md +0 -2
- package/src/commit/types.ts +0 -109
- package/src/commit/utils/exclusions.ts +0 -42
- package/src/mcp/render.ts +0 -123
- package/src/modes/components/agent-dashboard.ts +0 -1130
- package/src/modes/components/codemode-group.ts +0 -369
- package/src/modes/components/read-tool-group.ts +0 -119
- package/src/modes/components/visual-truncate.ts +0 -63
- package/src/prompts/system/subagent-user-prompt.md +0 -8
- package/src/prompts/tools/ask.md +0 -44
- package/src/prompts/tools/bash.md +0 -24
- package/src/prompts/tools/browser.md +0 -33
- package/src/prompts/tools/calculator.md +0 -12
- package/src/prompts/tools/explore.md +0 -29
- package/src/prompts/tools/fetch.md +0 -16
- package/src/prompts/tools/find.md +0 -18
- package/src/prompts/tools/gemini-image.md +0 -23
- package/src/prompts/tools/grep.md +0 -28
- package/src/prompts/tools/hashline.md +0 -232
- package/src/prompts/tools/librarian.md +0 -24
- package/src/prompts/tools/lsp.md +0 -28
- package/src/prompts/tools/oracle.md +0 -26
- package/src/prompts/tools/patch.md +0 -74
- package/src/prompts/tools/python.md +0 -66
- package/src/prompts/tools/read.md +0 -36
- package/src/prompts/tools/replace.md +0 -38
- package/src/prompts/tools/reviewer.md +0 -41
- package/src/prompts/tools/ssh.md +0 -51
- package/src/prompts/tools/task-summary.md +0 -28
- package/src/prompts/tools/task.md +0 -146
- package/src/prompts/tools/todo-write.md +0 -65
- package/src/prompts/tools/undo-edit.md +0 -7
- package/src/prompts/tools/web-search.md +0 -19
- package/src/prompts/tools/write.md +0 -18
- package/src/task/batch.ts +0 -102
- package/src/task/discovery.ts +0 -126
- package/src/task/parallel.ts +0 -84
- package/src/task/template.ts +0 -32
- package/src/tools/calculator.ts +0 -537
- package/src/tools/jtd-to-typescript.ts +0 -198
- package/src/tools/renderers.ts +0 -60
- package/src/tools/tool-result.ts +0 -86
- /package/src/{modes/theme → theme}/dark.json +0 -0
- /package/src/{modes/theme → theme}/defaults/dark-catppuccin.json +0 -0
- /package/src/{modes/theme → theme}/defaults/dark-dracula.json +0 -0
- /package/src/{modes/theme → theme}/defaults/dark-gruvbox.json +0 -0
- /package/src/{modes/theme → theme}/defaults/dark-solarized.json +0 -0
- /package/src/{modes/theme → theme}/defaults/dark-tokyo-night.json +0 -0
- /package/src/{modes/theme → theme}/defaults/index.ts +0 -0
- /package/src/{modes/theme → theme}/defaults/light-catppuccin.json +0 -0
- /package/src/{modes/theme → theme}/defaults/light-github.json +0 -0
- /package/src/{modes/theme → theme}/defaults/light-solarized.json +0 -0
- /package/src/{modes/theme → theme}/light.json +0 -0
- /package/src/{modes/theme → theme}/mermaid-cache.ts +0 -0
- /package/src/{modes/theme → theme}/theme-schema.json +0 -0
- /package/src/{modes/theme → theme}/theme.ts +0 -0
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import * as fs from "node:fs";
|
|
17
|
-
import * as path from "node:path";
|
|
18
17
|
|
|
19
18
|
import {
|
|
20
19
|
type Agent,
|
|
@@ -32,17 +31,13 @@ import type {
|
|
|
32
31
|
Model,
|
|
33
32
|
ProviderSessionState,
|
|
34
33
|
TextContent,
|
|
35
|
-
ToolCall,
|
|
36
34
|
ToolChoice,
|
|
37
|
-
Usage,
|
|
38
35
|
UsageReport,
|
|
39
36
|
} from "@nghyane/arcane-ai";
|
|
40
|
-
import { isContextOverflow, modelsAreEqual
|
|
37
|
+
import { isContextOverflow, modelsAreEqual } from "@nghyane/arcane-ai";
|
|
41
38
|
import { abortableSleep, isEnoent, logger } from "@nghyane/arcane-utils";
|
|
42
39
|
import { getAgentDbPath } from "@nghyane/arcane-utils/dirs";
|
|
43
|
-
import type {
|
|
44
|
-
import { MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "../config/model-registry";
|
|
45
|
-
import { expandRoleAlias, parseModelString } from "../config/model-resolver";
|
|
40
|
+
import type { ModelRegistry, ModelRole } from "../config/model-registry";
|
|
46
41
|
import {
|
|
47
42
|
expandPromptTemplate,
|
|
48
43
|
type PromptTemplate,
|
|
@@ -51,7 +46,6 @@ import {
|
|
|
51
46
|
} from "../config/prompt-templates";
|
|
52
47
|
import type { Settings, SkillsSettings } from "../config/settings";
|
|
53
48
|
import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor";
|
|
54
|
-
import { exportSessionToHtml } from "../export/html";
|
|
55
49
|
import type { TtsrManager, TtsrMatchContext } from "../export/ttsr";
|
|
56
50
|
import type { LoadedCustomCommand } from "../extensibility/custom-commands";
|
|
57
51
|
import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
|
|
@@ -80,14 +74,12 @@ import type { HookCommandContext } from "../extensibility/hooks/types";
|
|
|
80
74
|
import type { Skill, SkillWarning } from "../extensibility/skills";
|
|
81
75
|
import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
|
|
82
76
|
import { executePython as executePythonCommand, type PythonResult } from "../ipy/executor";
|
|
83
|
-
import
|
|
84
|
-
import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../patch";
|
|
85
|
-
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
|
|
77
|
+
import verificationReminderTemplate from "../prompts/system/verification-reminder.md" with { type: "text" };
|
|
86
78
|
import type { SecretObfuscator } from "../secrets/obfuscator";
|
|
87
79
|
import { closeAllConnections } from "../ssh/connection-manager";
|
|
88
80
|
import { unmountAll } from "../ssh/sshfs-mount";
|
|
81
|
+
import { theme } from "../theme/theme";
|
|
89
82
|
import { outputMeta } from "../tools/output-meta";
|
|
90
|
-
import { resolveToCwd } from "../tools/path-utils";
|
|
91
83
|
import type { TodoItem } from "../tools/todo-write";
|
|
92
84
|
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
|
93
85
|
import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
|
|
@@ -96,134 +88,58 @@ import {
|
|
|
96
88
|
calculateContextTokens,
|
|
97
89
|
collectEntriesForBranchSummary,
|
|
98
90
|
compact,
|
|
99
|
-
estimateTokens,
|
|
100
91
|
generateBranchSummary,
|
|
101
92
|
prepareCompaction,
|
|
102
93
|
shouldCompact,
|
|
103
94
|
} from "./compaction";
|
|
104
95
|
import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "./compaction/pruning";
|
|
105
|
-
import {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
bashExecutionToText,
|
|
109
|
-
type CompactionSummaryMessage,
|
|
110
|
-
type CustomMessage,
|
|
111
|
-
type FileMentionMessage,
|
|
112
|
-
type HookMessage,
|
|
113
|
-
type PythonExecutionMessage,
|
|
114
|
-
pythonExecutionToText,
|
|
115
|
-
} from "./messages";
|
|
96
|
+
import type { BashExecutionMessage, CustomMessage, PythonExecutionMessage } from "./messages";
|
|
97
|
+
import { ModelController } from "./model-controller";
|
|
98
|
+
import { isRetryableErrorMessage, isUsageLimitErrorMessage, parseRetryAfterMs } from "./retry-utils";
|
|
116
99
|
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager";
|
|
117
100
|
import { getLatestCompactionEntry } from "./session-manager";
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
skillsSettings?: Required<SkillsSettings>;
|
|
161
|
-
/** Model registry for API key resolution and model discovery */
|
|
162
|
-
modelRegistry: ModelRegistry;
|
|
163
|
-
/** Tool registry for LSP and settings */
|
|
164
|
-
toolRegistry?: Map<string, AgentTool>;
|
|
165
|
-
/** System prompt builder that can consider tool availability */
|
|
166
|
-
rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>;
|
|
167
|
-
/** TTSR manager for time-traveling stream rules */
|
|
168
|
-
ttsrManager?: TtsrManager;
|
|
169
|
-
/** Force X-Initiator: agent for GitHub Copilot model selections in this session. */
|
|
170
|
-
forceCopilotAgentInitiator?: boolean;
|
|
171
|
-
/** Secret obfuscator for deobfuscating streaming edit content */
|
|
172
|
-
obfuscator?: SecretObfuscator;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** Options for AgentSession.prompt() */
|
|
176
|
-
export interface PromptOptions {
|
|
177
|
-
/** Whether to expand file-based prompt templates (default: true) */
|
|
178
|
-
expandPromptTemplates?: boolean;
|
|
179
|
-
/** Image attachments */
|
|
180
|
-
images?: ImageContent[];
|
|
181
|
-
/** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). */
|
|
182
|
-
streamingBehavior?: "steer" | "followUp";
|
|
183
|
-
/** Optional tool choice override for the next LLM call. */
|
|
184
|
-
toolChoice?: ToolChoice;
|
|
185
|
-
/** Mark the user message as synthetic (system-injected). */
|
|
186
|
-
synthetic?: boolean;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** Result from cycleModel() */
|
|
190
|
-
export interface ModelCycleResult {
|
|
191
|
-
model: Model;
|
|
192
|
-
thinkingLevel: ThinkingLevel;
|
|
193
|
-
/** Whether cycling through scoped models (--models flag) or all available */
|
|
194
|
-
isScoped: boolean;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** Result from cycleRoleModels() */
|
|
198
|
-
export interface RoleModelCycleResult {
|
|
199
|
-
model: Model;
|
|
200
|
-
thinkingLevel: ThinkingLevel;
|
|
201
|
-
role: ModelRole;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** Session statistics for /session command */
|
|
205
|
-
export interface SessionStats {
|
|
206
|
-
sessionFile: string | undefined;
|
|
207
|
-
sessionId: string;
|
|
208
|
-
userMessages: number;
|
|
209
|
-
assistantMessages: number;
|
|
210
|
-
toolCalls: number;
|
|
211
|
-
toolResults: number;
|
|
212
|
-
totalMessages: number;
|
|
213
|
-
tokens: {
|
|
214
|
-
input: number;
|
|
215
|
-
output: number;
|
|
216
|
-
cacheRead: number;
|
|
217
|
-
cacheWrite: number;
|
|
218
|
-
total: number;
|
|
219
|
-
};
|
|
220
|
-
cost: number;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** Result from handoff() */
|
|
224
|
-
export interface HandoffResult {
|
|
225
|
-
document: string;
|
|
226
|
-
}
|
|
101
|
+
import type {
|
|
102
|
+
AgentSessionConfig,
|
|
103
|
+
AgentSessionEvent,
|
|
104
|
+
AgentSessionEventListener,
|
|
105
|
+
HandoffResult,
|
|
106
|
+
ModelCycleResult,
|
|
107
|
+
PromptOptions,
|
|
108
|
+
RoleModelCycleResult,
|
|
109
|
+
SessionStats,
|
|
110
|
+
} from "./session-types";
|
|
111
|
+
import * as sessionStats from "./stats";
|
|
112
|
+
import {
|
|
113
|
+
createStreamingEditState,
|
|
114
|
+
invalidateFileCacheForPath,
|
|
115
|
+
maybeAbortStreamingEdit,
|
|
116
|
+
preCacheStreamingEditFile,
|
|
117
|
+
resetStreamingEditState,
|
|
118
|
+
rewriteToolCallArgs,
|
|
119
|
+
} from "./streaming-edit";
|
|
120
|
+
import {
|
|
121
|
+
addPendingTtsrInjections,
|
|
122
|
+
createTtsrState,
|
|
123
|
+
extractTtsrRuleNames,
|
|
124
|
+
findTtsrAssistantIndex,
|
|
125
|
+
getTtsrInjectionContent,
|
|
126
|
+
getTtsrToolMatchContext,
|
|
127
|
+
markTtsrInjected,
|
|
128
|
+
queueDeferredTtsrInjectionIfNeeded,
|
|
129
|
+
shouldInterruptForTtsrMatch,
|
|
130
|
+
} from "./ttsr";
|
|
131
|
+
|
|
132
|
+
// Re-export types for downstream consumers
|
|
133
|
+
export type {
|
|
134
|
+
AgentSessionConfig,
|
|
135
|
+
AgentSessionEvent,
|
|
136
|
+
AgentSessionEventListener,
|
|
137
|
+
HandoffResult,
|
|
138
|
+
ModelCycleResult,
|
|
139
|
+
PromptOptions,
|
|
140
|
+
RoleModelCycleResult,
|
|
141
|
+
SessionStats,
|
|
142
|
+
};
|
|
227
143
|
|
|
228
144
|
/** Internal marker for hook messages queued through the agent loop */
|
|
229
145
|
// ============================================================================
|
|
@@ -231,10 +147,6 @@ export interface HandoffResult {
|
|
|
231
147
|
// ============================================================================
|
|
232
148
|
|
|
233
149
|
/** Standard thinking levels */
|
|
234
|
-
const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"];
|
|
235
|
-
|
|
236
|
-
/** Thinking levels including xhigh (for supported models) */
|
|
237
|
-
const THINKING_LEVELS_WITH_XHIGH: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
238
150
|
|
|
239
151
|
const noOpUIContext: ExtensionUIContext = {
|
|
240
152
|
select: async (_title, _options, _dialogOptions) => undefined,
|
|
@@ -282,7 +194,7 @@ export class AgentSession {
|
|
|
282
194
|
readonly sessionManager: SessionManager;
|
|
283
195
|
readonly settings: Settings;
|
|
284
196
|
|
|
285
|
-
#
|
|
197
|
+
#model: ModelController;
|
|
286
198
|
#promptTemplates: PromptTemplate[];
|
|
287
199
|
#slashCommands: FileSlashCommand[];
|
|
288
200
|
|
|
@@ -317,6 +229,10 @@ export class AgentSession {
|
|
|
317
229
|
// Todo completion reminder state
|
|
318
230
|
#todoReminderCount = 0;
|
|
319
231
|
|
|
232
|
+
// Verification loop state
|
|
233
|
+
#verificationReminderCount = 0;
|
|
234
|
+
#turnHasFileModifications = false;
|
|
235
|
+
|
|
320
236
|
// Bash execution state
|
|
321
237
|
#bashAbortController: AbortController | undefined = undefined;
|
|
322
238
|
#pendingBashMessages: BashExecutionMessage[] = [];
|
|
@@ -337,34 +253,28 @@ export class AgentSession {
|
|
|
337
253
|
|
|
338
254
|
#skillsSettings: Required<SkillsSettings> | undefined;
|
|
339
255
|
|
|
340
|
-
// Model registry for API key resolution
|
|
341
|
-
#modelRegistry: ModelRegistry;
|
|
342
|
-
|
|
343
256
|
// Tool registry and prompt builder for extensions
|
|
344
257
|
#toolRegistry: Map<string, AgentTool>;
|
|
345
258
|
#rebuildSystemPrompt: ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>) | undefined;
|
|
346
259
|
#baseSystemPrompt: string;
|
|
347
|
-
#forceCopilotAgentInitiator = false;
|
|
348
260
|
|
|
349
261
|
// TTSR manager for time-traveling stream rules
|
|
350
262
|
#ttsrManager: TtsrManager | undefined = undefined;
|
|
351
|
-
#
|
|
352
|
-
#ttsrAbortPending = false;
|
|
353
|
-
#ttsrRetryToken = 0;
|
|
263
|
+
#ttsr = createTtsrState();
|
|
354
264
|
|
|
355
|
-
#
|
|
356
|
-
#streamingEditCheckedLineCounts = new Map<string, number>();
|
|
357
|
-
#streamingEditFileCache = new Map<string, string>();
|
|
265
|
+
#streamingEdit = createStreamingEditState();
|
|
358
266
|
#promptInFlight = false;
|
|
359
267
|
#obfuscator: SecretObfuscator | undefined;
|
|
360
268
|
#promptGeneration = 0;
|
|
361
|
-
#providerSessionState = new Map<string, ProviderSessionState>();
|
|
362
269
|
|
|
363
270
|
constructor(config: AgentSessionConfig) {
|
|
364
271
|
this.agent = config.agent;
|
|
365
272
|
this.sessionManager = config.sessionManager;
|
|
366
273
|
this.settings = config.settings;
|
|
367
|
-
this.#
|
|
274
|
+
this.#model = new ModelController(config.agent, config.settings, config.sessionManager, config.modelRegistry, {
|
|
275
|
+
scopedModels: config.scopedModels,
|
|
276
|
+
forceCopilotAgentInitiator: config.forceCopilotAgentInitiator,
|
|
277
|
+
});
|
|
368
278
|
this.#promptTemplates = config.promptTemplates ?? [];
|
|
369
279
|
this.#slashCommands = config.slashCommands ?? [];
|
|
370
280
|
this.#extensionRunner = config.extensionRunner;
|
|
@@ -372,14 +282,12 @@ export class AgentSession {
|
|
|
372
282
|
this.#skillWarnings = config.skillWarnings ?? [];
|
|
373
283
|
this.#customCommands = config.customCommands ?? [];
|
|
374
284
|
this.#skillsSettings = config.skillsSettings;
|
|
375
|
-
this.#modelRegistry = config.modelRegistry;
|
|
376
285
|
this.#toolRegistry = config.toolRegistry ?? new Map();
|
|
377
286
|
this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
|
|
378
287
|
this.#baseSystemPrompt = this.agent.state.systemPrompt;
|
|
379
288
|
this.#ttsrManager = config.ttsrManager;
|
|
380
|
-
this.#forceCopilotAgentInitiator = config.forceCopilotAgentInitiator ?? false;
|
|
381
289
|
this.#obfuscator = config.obfuscator;
|
|
382
|
-
this.agent.providerSessionState = this.#providerSessionState;
|
|
290
|
+
this.agent.providerSessionState = this.#model.providerSessionState;
|
|
383
291
|
|
|
384
292
|
// Always subscribe to agent events for internal handling
|
|
385
293
|
// (session persistence, hooks, auto-compaction, retry logic)
|
|
@@ -388,12 +296,12 @@ export class AgentSession {
|
|
|
388
296
|
|
|
389
297
|
/** Model registry for API key resolution and model discovery */
|
|
390
298
|
get modelRegistry(): ModelRegistry {
|
|
391
|
-
return this.#
|
|
299
|
+
return this.#model.registry;
|
|
392
300
|
}
|
|
393
301
|
|
|
394
302
|
/** Provider-scoped mutable state store for transport/session caches. */
|
|
395
303
|
get providerSessionState(): Map<string, ProviderSessionState> {
|
|
396
|
-
return this.#providerSessionState;
|
|
304
|
+
return this.#model.providerSessionState;
|
|
397
305
|
}
|
|
398
306
|
|
|
399
307
|
/** TTSR manager for time-traveling stream rules */
|
|
@@ -403,7 +311,7 @@ export class AgentSession {
|
|
|
403
311
|
|
|
404
312
|
/** Whether a TTSR abort is pending (stream was aborted to inject rules) */
|
|
405
313
|
get isTtsrAbortPending(): boolean {
|
|
406
|
-
return this.#
|
|
314
|
+
return this.#ttsr.abortPending;
|
|
407
315
|
}
|
|
408
316
|
|
|
409
317
|
// =========================================================================
|
|
@@ -451,7 +359,7 @@ export class AgentSession {
|
|
|
451
359
|
await this.#emitSessionEvent(event);
|
|
452
360
|
|
|
453
361
|
if (event.type === "turn_start") {
|
|
454
|
-
this.#
|
|
362
|
+
resetStreamingEditState(this.#streamingEdit);
|
|
455
363
|
// TTSR: Reset buffer on turn start
|
|
456
364
|
this.#ttsrManager?.resetBuffer();
|
|
457
365
|
}
|
|
@@ -471,7 +379,11 @@ export class AgentSession {
|
|
|
471
379
|
} else if (assistantEvent.type === "thinking_delta") {
|
|
472
380
|
matchContext = { source: "thinking" };
|
|
473
381
|
} else if (assistantEvent.type === "toolcall_delta") {
|
|
474
|
-
matchContext =
|
|
382
|
+
matchContext = getTtsrToolMatchContext(
|
|
383
|
+
event.message,
|
|
384
|
+
assistantEvent.contentIndex,
|
|
385
|
+
this.sessionManager.getCwd(),
|
|
386
|
+
);
|
|
475
387
|
}
|
|
476
388
|
|
|
477
389
|
if (matchContext && "delta" in assistantEvent) {
|
|
@@ -479,42 +391,45 @@ export class AgentSession {
|
|
|
479
391
|
if (matches.length > 0) {
|
|
480
392
|
// Queue rules for injection; mark as injected only after successful enqueue.
|
|
481
393
|
|
|
482
|
-
this.#
|
|
394
|
+
addPendingTtsrInjections(this.#ttsr, matches);
|
|
483
395
|
|
|
484
|
-
if (this.#
|
|
396
|
+
if (shouldInterruptForTtsrMatch(this.#ttsrManager, matchContext)) {
|
|
485
397
|
// Abort the stream immediately — do not gate on extension callbacks
|
|
486
|
-
this.#
|
|
398
|
+
this.#ttsr.abortPending = true;
|
|
487
399
|
this.agent.abort();
|
|
488
400
|
// Notify extensions (fire-and-forget, does not block abort)
|
|
489
401
|
this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
|
|
490
402
|
// Schedule retry after a short delay
|
|
491
|
-
const retryToken = ++this.#
|
|
403
|
+
const retryToken = ++this.#ttsr.retryToken;
|
|
492
404
|
const generation = this.#promptGeneration;
|
|
493
405
|
const targetMessageTimestamp =
|
|
494
406
|
event.message.role === "assistant" ? event.message.timestamp : undefined;
|
|
495
407
|
setTimeout(async () => {
|
|
496
|
-
if (this.#
|
|
408
|
+
if (this.#ttsr.retryToken !== retryToken) {
|
|
497
409
|
return;
|
|
498
410
|
}
|
|
499
411
|
|
|
500
|
-
const targetAssistantIndex =
|
|
412
|
+
const targetAssistantIndex = findTtsrAssistantIndex(
|
|
413
|
+
this.agent.state.messages,
|
|
414
|
+
targetMessageTimestamp,
|
|
415
|
+
);
|
|
501
416
|
if (
|
|
502
|
-
!this.#
|
|
417
|
+
!this.#ttsr.abortPending ||
|
|
503
418
|
this.#promptGeneration !== generation ||
|
|
504
419
|
targetAssistantIndex === -1
|
|
505
420
|
) {
|
|
506
|
-
this.#
|
|
507
|
-
this.#
|
|
421
|
+
this.#ttsr.abortPending = false;
|
|
422
|
+
this.#ttsr.pendingInjections = [];
|
|
508
423
|
return;
|
|
509
424
|
}
|
|
510
|
-
this.#
|
|
425
|
+
this.#ttsr.abortPending = false;
|
|
511
426
|
const ttsrSettings = this.#ttsrManager?.getSettings();
|
|
512
427
|
if (ttsrSettings?.contextMode === "discard") {
|
|
513
428
|
// Remove the partial/aborted assistant turn from agent state
|
|
514
429
|
this.agent.replaceMessages(this.agent.state.messages.slice(0, targetAssistantIndex));
|
|
515
430
|
}
|
|
516
431
|
// Inject TTSR rules as system reminder before retry
|
|
517
|
-
const injection = this.#
|
|
432
|
+
const injection = getTtsrInjectionContent(this.#ttsr);
|
|
518
433
|
if (injection) {
|
|
519
434
|
const details = { rules: injection.rules.map(rule => rule.name) };
|
|
520
435
|
this.agent.appendMessage({
|
|
@@ -531,7 +446,7 @@ export class AgentSession {
|
|
|
531
446
|
false,
|
|
532
447
|
details,
|
|
533
448
|
);
|
|
534
|
-
this.#
|
|
449
|
+
markTtsrInjected(this.#ttsrManager, this.sessionManager, details.rules);
|
|
535
450
|
}
|
|
536
451
|
this.agent.continue().catch(() => {});
|
|
537
452
|
}, 50);
|
|
@@ -542,14 +457,21 @@ export class AgentSession {
|
|
|
542
457
|
}
|
|
543
458
|
|
|
544
459
|
if (event.type === "message_update" && event.assistantMessageEvent.type === "toolcall_start") {
|
|
545
|
-
this.#
|
|
460
|
+
preCacheStreamingEditFile(event, this.#streamingEdit, this.settings, this.sessionManager.getCwd());
|
|
546
461
|
}
|
|
547
462
|
|
|
548
463
|
if (
|
|
549
464
|
event.type === "message_update" &&
|
|
550
465
|
(event.assistantMessageEvent.type === "toolcall_end" || event.assistantMessageEvent.type === "toolcall_delta")
|
|
551
466
|
) {
|
|
552
|
-
|
|
467
|
+
maybeAbortStreamingEdit(
|
|
468
|
+
event,
|
|
469
|
+
this.#streamingEdit,
|
|
470
|
+
this.settings,
|
|
471
|
+
this.agent,
|
|
472
|
+
this.sessionManager.getCwd(),
|
|
473
|
+
this.#obfuscator,
|
|
474
|
+
);
|
|
553
475
|
}
|
|
554
476
|
|
|
555
477
|
// Handle session persistence
|
|
@@ -564,7 +486,7 @@ export class AgentSession {
|
|
|
564
486
|
event.message.details,
|
|
565
487
|
);
|
|
566
488
|
if (event.message.role === "custom" && event.message.customType === "ttsr-injection") {
|
|
567
|
-
|
|
489
|
+
markTtsrInjected(this.#ttsrManager, this.sessionManager, extractTtsrRuleNames(event.message.details));
|
|
568
490
|
}
|
|
569
491
|
} else if (
|
|
570
492
|
event.message.role === "user" ||
|
|
@@ -581,7 +503,7 @@ export class AgentSession {
|
|
|
581
503
|
if (event.message.role === "assistant") {
|
|
582
504
|
this.#lastAssistantMessage = event.message;
|
|
583
505
|
const assistantMsg = event.message as AssistantMessage;
|
|
584
|
-
this.#
|
|
506
|
+
queueDeferredTtsrInjectionIfNeeded(this.#ttsr, this.agent, assistantMsg);
|
|
585
507
|
if (this.#handoffAbortController) {
|
|
586
508
|
this.#skipPostTurnMaintenanceAssistantTimestamp = assistantMsg.timestamp;
|
|
587
509
|
}
|
|
@@ -610,11 +532,15 @@ export class AgentSession {
|
|
|
610
532
|
content?: Array<TextContent | ImageContent>;
|
|
611
533
|
};
|
|
612
534
|
if ($normative && toolCallId && this.settings.get("normativeRewrite")) {
|
|
613
|
-
await
|
|
535
|
+
await rewriteToolCallArgs(this.agent, this.sessionManager, toolCallId, $normative);
|
|
614
536
|
}
|
|
615
537
|
// Invalidate streaming edit cache when edit tool completes to prevent stale data
|
|
616
538
|
if (toolName === "edit" && details?.path) {
|
|
617
|
-
this.#
|
|
539
|
+
invalidateFileCacheForPath(this.#streamingEdit, details.path, this.sessionManager.getCwd());
|
|
540
|
+
}
|
|
541
|
+
// Track file modifications for auto-verification
|
|
542
|
+
if ((toolName === "edit" || toolName === "write") && !isError) {
|
|
543
|
+
this.#turnHasFileModifications = true;
|
|
618
544
|
}
|
|
619
545
|
if (toolName === "todo_write" && isError) {
|
|
620
546
|
const errorText = content?.find(part => part.type === "text")?.text;
|
|
@@ -656,6 +582,12 @@ export class AgentSession {
|
|
|
656
582
|
|
|
657
583
|
await this.#checkCompaction(msg);
|
|
658
584
|
|
|
585
|
+
// Check verification (if agent modified files without verifying)
|
|
586
|
+
if (msg.stopReason !== "error" && msg.stopReason !== "aborted") {
|
|
587
|
+
const didRemind = await this.#checkVerification();
|
|
588
|
+
if (didRemind) return;
|
|
589
|
+
}
|
|
590
|
+
|
|
659
591
|
// Check for incomplete todos (unless there was an error or abort)
|
|
660
592
|
if (msg.stopReason !== "error" && msg.stopReason !== "aborted") {
|
|
661
593
|
await this.#checkTodoCompletion();
|
|
@@ -671,186 +603,6 @@ export class AgentSession {
|
|
|
671
603
|
this.#retryPromise = undefined;
|
|
672
604
|
}
|
|
673
605
|
}
|
|
674
|
-
|
|
675
|
-
/** Get TTSR injection payload and clear pending injections. */
|
|
676
|
-
#getTtsrInjectionContent(): { content: string; rules: Rule[] } | undefined {
|
|
677
|
-
if (this.#pendingTtsrInjections.length === 0) return undefined;
|
|
678
|
-
const rules = this.#pendingTtsrInjections;
|
|
679
|
-
const content = rules
|
|
680
|
-
.map(r => renderPromptTemplate(ttsrInterruptTemplate, { name: r.name, path: r.path, content: r.content }))
|
|
681
|
-
.join("\n\n");
|
|
682
|
-
this.#pendingTtsrInjections = [];
|
|
683
|
-
return { content, rules };
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
#addPendingTtsrInjections(rules: Rule[]): void {
|
|
687
|
-
const seen = new Set(this.#pendingTtsrInjections.map(rule => rule.name));
|
|
688
|
-
for (const rule of rules) {
|
|
689
|
-
if (seen.has(rule.name)) continue;
|
|
690
|
-
this.#pendingTtsrInjections.push(rule);
|
|
691
|
-
seen.add(rule.name);
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
#extractTtsrRuleNames(details: unknown): string[] {
|
|
696
|
-
if (!details || typeof details !== "object" || Array.isArray(details)) {
|
|
697
|
-
return [];
|
|
698
|
-
}
|
|
699
|
-
const rules = (details as { rules?: unknown }).rules;
|
|
700
|
-
if (!Array.isArray(rules)) {
|
|
701
|
-
return [];
|
|
702
|
-
}
|
|
703
|
-
return rules.filter((ruleName): ruleName is string => typeof ruleName === "string");
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
#markTtsrInjected(ruleNames: string[]): void {
|
|
707
|
-
const uniqueRuleNames = Array.from(
|
|
708
|
-
new Set(ruleNames.map(ruleName => ruleName.trim()).filter(ruleName => ruleName.length > 0)),
|
|
709
|
-
);
|
|
710
|
-
if (uniqueRuleNames.length === 0) {
|
|
711
|
-
return;
|
|
712
|
-
}
|
|
713
|
-
this.#ttsrManager?.markInjectedByNames(uniqueRuleNames);
|
|
714
|
-
this.sessionManager.appendTtsrInjection(uniqueRuleNames);
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
#findTtsrAssistantIndex(targetTimestamp: number | undefined): number {
|
|
718
|
-
const messages = this.agent.state.messages;
|
|
719
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
720
|
-
const message = messages[i];
|
|
721
|
-
if (message.role !== "assistant") {
|
|
722
|
-
continue;
|
|
723
|
-
}
|
|
724
|
-
if (targetTimestamp === undefined || message.timestamp === targetTimestamp) {
|
|
725
|
-
return i;
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
return -1;
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
#shouldInterruptForTtsrMatch(matchContext: TtsrMatchContext): boolean {
|
|
732
|
-
const mode = this.#ttsrManager?.getSettings().interruptMode ?? "always";
|
|
733
|
-
if (mode === "never") {
|
|
734
|
-
return false;
|
|
735
|
-
}
|
|
736
|
-
if (mode === "prose-only") {
|
|
737
|
-
return matchContext.source === "text" || matchContext.source === "thinking";
|
|
738
|
-
}
|
|
739
|
-
if (mode === "tool-only") {
|
|
740
|
-
return matchContext.source === "tool";
|
|
741
|
-
}
|
|
742
|
-
return true;
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
#queueDeferredTtsrInjectionIfNeeded(assistantMsg: AssistantMessage): void {
|
|
746
|
-
if (this.#ttsrAbortPending || this.#pendingTtsrInjections.length === 0) {
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
|
-
if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") {
|
|
750
|
-
this.#pendingTtsrInjections = [];
|
|
751
|
-
return;
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
const injection = this.#getTtsrInjectionContent();
|
|
755
|
-
if (!injection) {
|
|
756
|
-
return;
|
|
757
|
-
}
|
|
758
|
-
this.agent.followUp({
|
|
759
|
-
role: "custom",
|
|
760
|
-
customType: "ttsr-injection",
|
|
761
|
-
content: injection.content,
|
|
762
|
-
display: false,
|
|
763
|
-
details: { rules: injection.rules.map(rule => rule.name) },
|
|
764
|
-
timestamp: Date.now(),
|
|
765
|
-
});
|
|
766
|
-
// Mark as injected after this custom message is delivered and persisted (handled in message_end).
|
|
767
|
-
// followUp() only enqueues; resume on the next tick once streaming settles.
|
|
768
|
-
setTimeout(() => {
|
|
769
|
-
if (this.agent.state.isStreaming || !this.agent.hasQueuedMessages()) {
|
|
770
|
-
return;
|
|
771
|
-
}
|
|
772
|
-
this.agent.continue().catch(() => {});
|
|
773
|
-
}, 0);
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
/** Build TTSR match context for tool call argument deltas. */
|
|
777
|
-
#getTtsrToolMatchContext(message: AgentMessage, contentIndex: number): TtsrMatchContext {
|
|
778
|
-
const context: TtsrMatchContext = { source: "tool" };
|
|
779
|
-
if (message.role !== "assistant") {
|
|
780
|
-
return context;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
const content = message.content;
|
|
784
|
-
if (!Array.isArray(content) || contentIndex < 0 || contentIndex >= content.length) {
|
|
785
|
-
return context;
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
const block = content[contentIndex];
|
|
789
|
-
if (!block || typeof block !== "object" || block.type !== "toolCall") {
|
|
790
|
-
return context;
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
const toolCall = block as ToolCall;
|
|
794
|
-
context.toolName = toolCall.name;
|
|
795
|
-
context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`;
|
|
796
|
-
context.filePaths = this.#extractTtsrFilePathsFromArgs(toolCall.arguments);
|
|
797
|
-
return context;
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
/** Extract path-like arguments from tool call payload for TTSR glob matching. */
|
|
801
|
-
#extractTtsrFilePathsFromArgs(args: unknown): string[] | undefined {
|
|
802
|
-
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
|
803
|
-
return undefined;
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
const rawPaths: string[] = [];
|
|
807
|
-
for (const [key, value] of Object.entries(args)) {
|
|
808
|
-
const normalizedKey = key.toLowerCase();
|
|
809
|
-
if (typeof value === "string" && (normalizedKey === "path" || normalizedKey.endsWith("path"))) {
|
|
810
|
-
rawPaths.push(value);
|
|
811
|
-
continue;
|
|
812
|
-
}
|
|
813
|
-
if (Array.isArray(value) && (normalizedKey === "paths" || normalizedKey.endsWith("paths"))) {
|
|
814
|
-
for (const candidate of value) {
|
|
815
|
-
if (typeof candidate === "string") {
|
|
816
|
-
rawPaths.push(candidate);
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
const normalizedPaths = rawPaths.flatMap(pathValue => this.#normalizeTtsrPathCandidates(pathValue));
|
|
823
|
-
if (normalizedPaths.length === 0) {
|
|
824
|
-
return undefined;
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
return Array.from(new Set(normalizedPaths));
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
/** Convert a path argument into stable relative/absolute candidates for glob checks. */
|
|
831
|
-
#normalizeTtsrPathCandidates(rawPath: string): string[] {
|
|
832
|
-
const trimmed = rawPath.trim();
|
|
833
|
-
if (trimmed.length === 0) {
|
|
834
|
-
return [];
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
const normalizedInput = trimmed.replaceAll("\\", "/");
|
|
838
|
-
const candidates = new Set<string>([normalizedInput]);
|
|
839
|
-
if (normalizedInput.startsWith("./")) {
|
|
840
|
-
candidates.add(normalizedInput.slice(2));
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
const cwd = this.sessionManager.getCwd();
|
|
844
|
-
const absolutePath = path.isAbsolute(trimmed) ? path.normalize(trimmed) : path.resolve(cwd, trimmed);
|
|
845
|
-
candidates.add(absolutePath.replaceAll("\\", "/"));
|
|
846
|
-
|
|
847
|
-
const relativePath = path.relative(cwd, absolutePath).replaceAll("\\", "/");
|
|
848
|
-
if (relativePath && relativePath !== "." && !relativePath.startsWith("../") && relativePath !== "..") {
|
|
849
|
-
candidates.add(relativePath);
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
return Array.from(candidates);
|
|
853
|
-
}
|
|
854
606
|
/** Extract text content from a message */
|
|
855
607
|
#getUserMessageText(message: Message): string {
|
|
856
608
|
if (message.role !== "user") return "";
|
|
@@ -875,216 +627,6 @@ export class AgentSession {
|
|
|
875
627
|
return undefined;
|
|
876
628
|
}
|
|
877
629
|
|
|
878
|
-
#resetStreamingEditState(): void {
|
|
879
|
-
this.#streamingEditAbortTriggered = false;
|
|
880
|
-
this.#streamingEditCheckedLineCounts.clear();
|
|
881
|
-
this.#streamingEditFileCache.clear();
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
async #preCacheStreamingEditFile(event: AgentEvent): Promise<void> {
|
|
885
|
-
if (!this.settings.get("edit.streamingAbort")) return;
|
|
886
|
-
if (event.type !== "message_update") return;
|
|
887
|
-
const assistantEvent = event.assistantMessageEvent;
|
|
888
|
-
if (assistantEvent.type !== "toolcall_start") return;
|
|
889
|
-
if (event.message.role !== "assistant") return;
|
|
890
|
-
|
|
891
|
-
const contentIndex = assistantEvent.contentIndex;
|
|
892
|
-
const messageContent = event.message.content;
|
|
893
|
-
if (!Array.isArray(messageContent) || contentIndex >= messageContent.length) return;
|
|
894
|
-
const toolCall = messageContent[contentIndex] as ToolCall;
|
|
895
|
-
if (toolCall.name !== "edit") return;
|
|
896
|
-
|
|
897
|
-
const args = toolCall.arguments;
|
|
898
|
-
if (!args || typeof args !== "object" || Array.isArray(args)) return;
|
|
899
|
-
if ("old_text" in args || "new_text" in args) return;
|
|
900
|
-
|
|
901
|
-
const path = typeof args.path === "string" ? args.path : undefined;
|
|
902
|
-
if (!path) return;
|
|
903
|
-
|
|
904
|
-
const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd());
|
|
905
|
-
this.#ensureFileCache(resolvedPath);
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
#ensureFileCache(resolvedPath: string): void {
|
|
909
|
-
if (this.#streamingEditFileCache.has(resolvedPath)) return;
|
|
910
|
-
|
|
911
|
-
try {
|
|
912
|
-
const rawText = fs.readFileSync(resolvedPath, "utf-8");
|
|
913
|
-
const { text } = stripBom(rawText);
|
|
914
|
-
this.#streamingEditFileCache.set(resolvedPath, normalizeToLF(text));
|
|
915
|
-
} catch {
|
|
916
|
-
// Don't cache on read errors (including ENOENT) - let the edit tool handle them
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
/** Invalidate cache for a file after an edit completes to prevent stale data */
|
|
921
|
-
#invalidateFileCacheForPath(path: string): void {
|
|
922
|
-
const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd());
|
|
923
|
-
this.#streamingEditFileCache.delete(resolvedPath);
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
#maybeAbortStreamingEdit(event: AgentEvent): void {
|
|
927
|
-
if (!this.settings.get("edit.streamingAbort")) return;
|
|
928
|
-
if (this.#streamingEditAbortTriggered) return;
|
|
929
|
-
if (event.type !== "message_update") return;
|
|
930
|
-
const assistantEvent = event.assistantMessageEvent;
|
|
931
|
-
if (assistantEvent.type !== "toolcall_end" && assistantEvent.type !== "toolcall_delta") return;
|
|
932
|
-
if (event.message.role !== "assistant") return;
|
|
933
|
-
|
|
934
|
-
const contentIndex = assistantEvent.contentIndex;
|
|
935
|
-
const messageContent = event.message.content;
|
|
936
|
-
if (!Array.isArray(messageContent) || contentIndex >= messageContent.length) return;
|
|
937
|
-
const toolCall = messageContent[contentIndex] as ToolCall;
|
|
938
|
-
if (toolCall.name !== "edit" || !toolCall.id) return;
|
|
939
|
-
|
|
940
|
-
const args = toolCall.arguments;
|
|
941
|
-
if (!args || typeof args !== "object" || Array.isArray(args)) return;
|
|
942
|
-
if ("old_text" in args || "new_text" in args) return;
|
|
943
|
-
|
|
944
|
-
const path = typeof args.path === "string" ? args.path : undefined;
|
|
945
|
-
const diff = typeof args.diff === "string" ? args.diff : undefined;
|
|
946
|
-
const op = typeof args.op === "string" ? args.op : undefined;
|
|
947
|
-
if (!path || !diff) return;
|
|
948
|
-
if (op && op !== "update") return;
|
|
949
|
-
|
|
950
|
-
if (!diff.includes("\n")) return;
|
|
951
|
-
const lastNewlineIndex = diff.lastIndexOf("\n");
|
|
952
|
-
if (lastNewlineIndex < 0) return;
|
|
953
|
-
const diffForCheck = diff.endsWith("\n") ? diff : diff.slice(0, lastNewlineIndex + 1);
|
|
954
|
-
if (diffForCheck.trim().length === 0) return;
|
|
955
|
-
|
|
956
|
-
let normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, ""));
|
|
957
|
-
if (!normalizedDiff) return;
|
|
958
|
-
// Deobfuscate the diff so removed lines match real file content
|
|
959
|
-
if (this.#obfuscator) normalizedDiff = this.#obfuscator.deobfuscate(normalizedDiff);
|
|
960
|
-
if (!normalizedDiff) return;
|
|
961
|
-
const lines = normalizedDiff.split("\n");
|
|
962
|
-
const hasChangeLine = lines.some(line => line.startsWith("+") || line.startsWith("-"));
|
|
963
|
-
if (!hasChangeLine) return;
|
|
964
|
-
|
|
965
|
-
const lineCount = lines.length;
|
|
966
|
-
const lastChecked = this.#streamingEditCheckedLineCounts.get(toolCall.id);
|
|
967
|
-
if (lastChecked !== undefined && lineCount <= lastChecked) return;
|
|
968
|
-
this.#streamingEditCheckedLineCounts.set(toolCall.id, lineCount);
|
|
969
|
-
|
|
970
|
-
const rename = typeof args.rename === "string" ? args.rename : undefined;
|
|
971
|
-
|
|
972
|
-
const removedLines = lines
|
|
973
|
-
.filter(line => line.startsWith("-") && !line.startsWith("--- "))
|
|
974
|
-
.map(line => line.slice(1));
|
|
975
|
-
if (removedLines.length > 0) {
|
|
976
|
-
const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd());
|
|
977
|
-
let cachedContent = this.#streamingEditFileCache.get(resolvedPath);
|
|
978
|
-
if (cachedContent === undefined) {
|
|
979
|
-
this.#ensureFileCache(resolvedPath);
|
|
980
|
-
cachedContent = this.#streamingEditFileCache.get(resolvedPath);
|
|
981
|
-
}
|
|
982
|
-
if (cachedContent !== undefined) {
|
|
983
|
-
const missing = removedLines.find(line => !cachedContent.includes(normalizeToLF(line)));
|
|
984
|
-
if (missing) {
|
|
985
|
-
this.#streamingEditAbortTriggered = true;
|
|
986
|
-
logger.warn("Streaming edit aborted due to patch preview failure", {
|
|
987
|
-
toolCallId: toolCall.id,
|
|
988
|
-
path,
|
|
989
|
-
error: `Failed to find expected lines in ${path}:\n${missing}`,
|
|
990
|
-
});
|
|
991
|
-
this.agent.abort();
|
|
992
|
-
}
|
|
993
|
-
return;
|
|
994
|
-
}
|
|
995
|
-
if (assistantEvent.type === "toolcall_delta") return;
|
|
996
|
-
void this.#checkRemovedLinesAsync(toolCall.id, path, resolvedPath, removedLines);
|
|
997
|
-
return;
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
if (assistantEvent.type === "toolcall_delta") return;
|
|
1001
|
-
void this.#checkPreviewPatchAsync(toolCall.id, path, rename, normalizedDiff);
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
async #checkRemovedLinesAsync(
|
|
1005
|
-
toolCallId: string,
|
|
1006
|
-
path: string,
|
|
1007
|
-
resolvedPath: string,
|
|
1008
|
-
removedLines: string[],
|
|
1009
|
-
): Promise<void> {
|
|
1010
|
-
if (this.#streamingEditAbortTriggered) return;
|
|
1011
|
-
try {
|
|
1012
|
-
const { text } = stripBom(await Bun.file(resolvedPath).text());
|
|
1013
|
-
const normalizedContent = normalizeToLF(text);
|
|
1014
|
-
const missing = removedLines.find(line => !normalizedContent.includes(normalizeToLF(line)));
|
|
1015
|
-
if (missing) {
|
|
1016
|
-
this.#streamingEditAbortTriggered = true;
|
|
1017
|
-
logger.warn("Streaming edit aborted due to patch preview failure", {
|
|
1018
|
-
toolCallId,
|
|
1019
|
-
path,
|
|
1020
|
-
error: `Failed to find expected lines in ${path}:\n${missing}`,
|
|
1021
|
-
});
|
|
1022
|
-
this.agent.abort();
|
|
1023
|
-
}
|
|
1024
|
-
} catch (err) {
|
|
1025
|
-
// Ignore ENOENT (file not found) - let the edit tool handle missing files
|
|
1026
|
-
// Also ignore other errors during async fallback
|
|
1027
|
-
if (!isEnoent(err)) {
|
|
1028
|
-
// Log unexpected errors but don't abort
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
async #checkPreviewPatchAsync(
|
|
1034
|
-
toolCallId: string,
|
|
1035
|
-
path: string,
|
|
1036
|
-
rename: string | undefined,
|
|
1037
|
-
normalizedDiff: string,
|
|
1038
|
-
): Promise<void> {
|
|
1039
|
-
if (this.#streamingEditAbortTriggered) return;
|
|
1040
|
-
try {
|
|
1041
|
-
await previewPatch(
|
|
1042
|
-
{ path, op: "update", rename, diff: normalizedDiff },
|
|
1043
|
-
{
|
|
1044
|
-
cwd: this.sessionManager.getCwd(),
|
|
1045
|
-
allowFuzzy: this.settings.get("edit.fuzzyMatch"),
|
|
1046
|
-
fuzzyThreshold: this.settings.get("edit.fuzzyThreshold"),
|
|
1047
|
-
},
|
|
1048
|
-
);
|
|
1049
|
-
} catch (error) {
|
|
1050
|
-
if (error instanceof ParseError) return;
|
|
1051
|
-
this.#streamingEditAbortTriggered = true;
|
|
1052
|
-
logger.warn("Streaming edit aborted due to patch preview failure", {
|
|
1053
|
-
toolCallId,
|
|
1054
|
-
path,
|
|
1055
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1056
|
-
});
|
|
1057
|
-
this.agent.abort();
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
/** Rewrite tool call arguments in agent state and persisted session history. */
|
|
1062
|
-
async #rewriteToolCallArgs(toolCallId: string, args: Record<string, unknown>): Promise<void> {
|
|
1063
|
-
let updated = false;
|
|
1064
|
-
const messages = this.agent.state.messages;
|
|
1065
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1066
|
-
const msg = messages[i];
|
|
1067
|
-
if (msg.role !== "assistant") continue;
|
|
1068
|
-
const assistantMsg = msg as AssistantMessage;
|
|
1069
|
-
if (!Array.isArray(assistantMsg.content)) continue;
|
|
1070
|
-
for (const block of assistantMsg.content) {
|
|
1071
|
-
if (typeof block !== "object" || block === null) continue;
|
|
1072
|
-
if (!("type" in block) || (block as { type?: string }).type !== "toolCall") continue;
|
|
1073
|
-
const toolCall = block as { id?: string; arguments?: Record<string, unknown> };
|
|
1074
|
-
if (toolCall.id === toolCallId) {
|
|
1075
|
-
toolCall.arguments = args;
|
|
1076
|
-
updated = true;
|
|
1077
|
-
break;
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
if (updated) break;
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
if (updated) {
|
|
1084
|
-
await this.sessionManager.rewriteAssistantToolCallArgs(toolCallId, args);
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
630
|
/** Emit extension events based on session events */
|
|
1089
631
|
async #emitExtensionEvent(event: AgentSessionEvent): Promise<void> {
|
|
1090
632
|
if (!this.#extensionRunner) return;
|
|
@@ -1237,10 +779,10 @@ export class AgentSession {
|
|
|
1237
779
|
async dispose(): Promise<void> {
|
|
1238
780
|
await this.sessionManager.flush();
|
|
1239
781
|
await cleanupSshResources();
|
|
1240
|
-
for (const state of this.#providerSessionState.values()) {
|
|
782
|
+
for (const state of this.#model.providerSessionState.values()) {
|
|
1241
783
|
state.close();
|
|
1242
784
|
}
|
|
1243
|
-
this.#providerSessionState.clear();
|
|
785
|
+
this.#model.providerSessionState.clear();
|
|
1244
786
|
this.#disconnectFromAgent();
|
|
1245
787
|
this.#eventListeners = [];
|
|
1246
788
|
}
|
|
@@ -1258,20 +800,6 @@ export class AgentSession {
|
|
|
1258
800
|
get model(): Model | undefined {
|
|
1259
801
|
return this.agent.state.model;
|
|
1260
802
|
}
|
|
1261
|
-
|
|
1262
|
-
#applySessionModelOverrides(model: Model): Model {
|
|
1263
|
-
if (!this.#forceCopilotAgentInitiator || model.provider !== "github-copilot") {
|
|
1264
|
-
return model;
|
|
1265
|
-
}
|
|
1266
|
-
return {
|
|
1267
|
-
...model,
|
|
1268
|
-
headers: {
|
|
1269
|
-
...model.headers,
|
|
1270
|
-
"X-Initiator": "agent",
|
|
1271
|
-
},
|
|
1272
|
-
};
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
803
|
/** Current thinking level */
|
|
1276
804
|
get thinkingLevel(): ThinkingLevel {
|
|
1277
805
|
return this.agent.state.thinkingLevel;
|
|
@@ -1367,7 +895,7 @@ export class AgentSession {
|
|
|
1367
895
|
|
|
1368
896
|
const getCustomToolContext = (): CustomToolContext => ({
|
|
1369
897
|
sessionManager: this.sessionManager,
|
|
1370
|
-
modelRegistry: this.#
|
|
898
|
+
modelRegistry: this.#model.registry,
|
|
1371
899
|
model: this.model,
|
|
1372
900
|
isIdle: () => !this.isStreaming,
|
|
1373
901
|
hasQueuedMessages: () => this.queuedMessageCount > 0,
|
|
@@ -1440,11 +968,11 @@ export class AgentSession {
|
|
|
1440
968
|
|
|
1441
969
|
/** Scoped models for cycling (from --models flag) */
|
|
1442
970
|
get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel: ThinkingLevel }> {
|
|
1443
|
-
return this.#scopedModels;
|
|
971
|
+
return this.#model.scopedModels;
|
|
1444
972
|
}
|
|
1445
973
|
|
|
1446
974
|
resolveRoleModel(role: ModelRole): Model | undefined {
|
|
1447
|
-
return this.#resolveRoleModel(role
|
|
975
|
+
return this.#model.resolveRoleModel(role);
|
|
1448
976
|
}
|
|
1449
977
|
|
|
1450
978
|
get promptTemplates(): ReadonlyArray<PromptTemplate> {
|
|
@@ -1579,6 +1107,8 @@ export class AgentSession {
|
|
|
1579
1107
|
|
|
1580
1108
|
// Reset todo reminder count on new user prompt
|
|
1581
1109
|
this.#todoReminderCount = 0;
|
|
1110
|
+
this.#verificationReminderCount = 0;
|
|
1111
|
+
this.#turnHasFileModifications = false;
|
|
1582
1112
|
|
|
1583
1113
|
// Validate model
|
|
1584
1114
|
if (!this.model) {
|
|
@@ -1590,7 +1120,7 @@ export class AgentSession {
|
|
|
1590
1120
|
}
|
|
1591
1121
|
|
|
1592
1122
|
// Validate API key
|
|
1593
|
-
const apiKey = await this.#
|
|
1123
|
+
const apiKey = await this.#model.registry.getApiKey(this.model, this.sessionId);
|
|
1594
1124
|
if (!apiKey) {
|
|
1595
1125
|
throw new Error(
|
|
1596
1126
|
`No API key found for ${this.model.provider}.\n\n` +
|
|
@@ -1712,7 +1242,7 @@ export class AgentSession {
|
|
|
1712
1242
|
hasUI: false,
|
|
1713
1243
|
cwd: this.sessionManager.getCwd(),
|
|
1714
1244
|
sessionManager: this.sessionManager,
|
|
1715
|
-
modelRegistry: this.#
|
|
1245
|
+
modelRegistry: this.#model.registry,
|
|
1716
1246
|
model: this.model ?? undefined,
|
|
1717
1247
|
isIdle: () => !this.isStreaming,
|
|
1718
1248
|
abort: () => {
|
|
@@ -2164,278 +1694,47 @@ export class AgentSession {
|
|
|
2164
1694
|
// Model Management
|
|
2165
1695
|
// =========================================================================
|
|
2166
1696
|
|
|
2167
|
-
/**
|
|
2168
|
-
* Set model directly.
|
|
2169
|
-
* Validates API key, saves to session and settings.
|
|
2170
|
-
* @throws Error if no API key available for the model
|
|
2171
|
-
*/
|
|
2172
1697
|
async setModel(model: Model, role: ModelRole = "default"): Promise<void> {
|
|
2173
|
-
|
|
2174
|
-
if (!apiKey) {
|
|
2175
|
-
throw new Error(`No API key for ${model.provider}/${model.id}`);
|
|
2176
|
-
}
|
|
2177
|
-
|
|
2178
|
-
this.#setModelWithProviderSessionReset(model);
|
|
2179
|
-
this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, role);
|
|
2180
|
-
this.settings.setModelRole(role, `${model.provider}/${model.id}`);
|
|
2181
|
-
this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
|
|
2182
|
-
|
|
2183
|
-
// Re-clamp thinking level for new model's capabilities without persisting settings
|
|
2184
|
-
this.setThinkingLevel(this.thinkingLevel);
|
|
1698
|
+
return this.#model.setModel(model, role);
|
|
2185
1699
|
}
|
|
2186
1700
|
|
|
2187
|
-
/**
|
|
2188
|
-
* Set model temporarily (for this session only).
|
|
2189
|
-
* Validates API key, saves to session log but NOT to settings.
|
|
2190
|
-
* @throws Error if no API key available for the model
|
|
2191
|
-
*/
|
|
2192
1701
|
async setModelTemporary(model: Model): Promise<void> {
|
|
2193
|
-
|
|
2194
|
-
if (!apiKey) {
|
|
2195
|
-
throw new Error(`No API key for ${model.provider}/${model.id}`);
|
|
2196
|
-
}
|
|
2197
|
-
|
|
2198
|
-
this.#setModelWithProviderSessionReset(model);
|
|
2199
|
-
this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "temporary");
|
|
2200
|
-
this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
|
|
2201
|
-
|
|
2202
|
-
// Re-clamp thinking level for new model's capabilities without persisting settings
|
|
2203
|
-
this.setThinkingLevel(this.thinkingLevel);
|
|
1702
|
+
return this.#model.setModelTemporary(model);
|
|
2204
1703
|
}
|
|
2205
1704
|
|
|
2206
|
-
/**
|
|
2207
|
-
* Cycle to next/previous model.
|
|
2208
|
-
* Uses scoped models (from --models flag) if available, otherwise all available models.
|
|
2209
|
-
* @param direction - "forward" (default) or "backward"
|
|
2210
|
-
* @returns The new model info, or undefined if only one model available
|
|
2211
|
-
*/
|
|
2212
1705
|
async cycleModel(direction: "forward" | "backward" = "forward"): Promise<ModelCycleResult | undefined> {
|
|
2213
|
-
|
|
2214
|
-
return this.#cycleScopedModel(direction);
|
|
2215
|
-
}
|
|
2216
|
-
return this.#cycleAvailableModel(direction);
|
|
1706
|
+
return this.#model.cycleModel(direction);
|
|
2217
1707
|
}
|
|
2218
1708
|
|
|
2219
|
-
/**
|
|
2220
|
-
* Cycle through configured role models in a fixed order.
|
|
2221
|
-
* Skips missing roles.
|
|
2222
|
-
* @param roleOrder - Order of roles to cycle through (e.g., ["oracle", "default", "fast"])
|
|
2223
|
-
* @param options - Optional settings: `temporary` to not persist to settings
|
|
2224
|
-
*/
|
|
2225
1709
|
async cycleRoleModels(
|
|
2226
1710
|
roleOrder: readonly ModelRole[],
|
|
2227
1711
|
options?: { temporary?: boolean },
|
|
2228
1712
|
): Promise<RoleModelCycleResult | undefined> {
|
|
2229
|
-
|
|
2230
|
-
if (availableModels.length === 0) return undefined;
|
|
2231
|
-
|
|
2232
|
-
const currentModel = this.model;
|
|
2233
|
-
if (!currentModel) return undefined;
|
|
2234
|
-
const roleModels: Array<{ role: ModelRole; model: Model }> = [];
|
|
2235
|
-
|
|
2236
|
-
for (const role of roleOrder) {
|
|
2237
|
-
const roleModelStr =
|
|
2238
|
-
role === "default"
|
|
2239
|
-
? (this.settings.getModelRole("default") ?? `${currentModel.provider}/${currentModel.id}`)
|
|
2240
|
-
: this.settings.getModelRole(role);
|
|
2241
|
-
if (!roleModelStr) continue;
|
|
2242
|
-
|
|
2243
|
-
const expandedRoleModelStr = expandRoleAlias(roleModelStr, this.settings);
|
|
2244
|
-
const parsed = parseModelString(expandedRoleModelStr);
|
|
2245
|
-
let match: Model | undefined;
|
|
2246
|
-
if (parsed) {
|
|
2247
|
-
match = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
|
|
2248
|
-
}
|
|
2249
|
-
if (!match) {
|
|
2250
|
-
match = availableModels.find(m => m.id.toLowerCase() === expandedRoleModelStr.toLowerCase());
|
|
2251
|
-
}
|
|
2252
|
-
if (!match) continue;
|
|
2253
|
-
|
|
2254
|
-
roleModels.push({ role, model: match });
|
|
2255
|
-
}
|
|
2256
|
-
|
|
2257
|
-
if (roleModels.length <= 1) return undefined;
|
|
2258
|
-
|
|
2259
|
-
const lastRole = this.sessionManager.getLastModelChangeRole();
|
|
2260
|
-
let currentIndex = lastRole
|
|
2261
|
-
? roleModels.findIndex(entry => entry.role === lastRole)
|
|
2262
|
-
: roleModels.findIndex(entry => modelsAreEqual(entry.model, currentModel));
|
|
2263
|
-
if (currentIndex === -1) currentIndex = 0;
|
|
2264
|
-
|
|
2265
|
-
const nextIndex = (currentIndex + 1) % roleModels.length;
|
|
2266
|
-
const next = roleModels[nextIndex];
|
|
2267
|
-
|
|
2268
|
-
if (options?.temporary) {
|
|
2269
|
-
await this.setModelTemporary(next.model);
|
|
2270
|
-
} else {
|
|
2271
|
-
await this.setModel(next.model, next.role);
|
|
2272
|
-
}
|
|
2273
|
-
|
|
2274
|
-
return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role };
|
|
2275
|
-
}
|
|
2276
|
-
|
|
2277
|
-
async #getScopedModelsWithApiKey(): Promise<Array<{ model: Model; thinkingLevel: ThinkingLevel }>> {
|
|
2278
|
-
const apiKeysByProvider = new Map<string, string | undefined>();
|
|
2279
|
-
const result: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [];
|
|
2280
|
-
|
|
2281
|
-
for (const scoped of this.#scopedModels) {
|
|
2282
|
-
const provider = scoped.model.provider;
|
|
2283
|
-
let apiKey: string | undefined;
|
|
2284
|
-
if (apiKeysByProvider.has(provider)) {
|
|
2285
|
-
apiKey = apiKeysByProvider.get(provider);
|
|
2286
|
-
} else {
|
|
2287
|
-
apiKey = await this.#modelRegistry.getApiKeyForProvider(provider, this.sessionId);
|
|
2288
|
-
apiKeysByProvider.set(provider, apiKey);
|
|
2289
|
-
}
|
|
2290
|
-
|
|
2291
|
-
if (apiKey) {
|
|
2292
|
-
result.push(scoped);
|
|
2293
|
-
}
|
|
2294
|
-
}
|
|
2295
|
-
|
|
2296
|
-
return result;
|
|
2297
|
-
}
|
|
2298
|
-
|
|
2299
|
-
async #cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
|
|
2300
|
-
const scopedModels = await this.#getScopedModelsWithApiKey();
|
|
2301
|
-
if (scopedModels.length <= 1) return undefined;
|
|
2302
|
-
|
|
2303
|
-
const currentModel = this.model;
|
|
2304
|
-
let currentIndex = scopedModels.findIndex(sm => modelsAreEqual(sm.model, currentModel));
|
|
2305
|
-
|
|
2306
|
-
if (currentIndex === -1) currentIndex = 0;
|
|
2307
|
-
const len = scopedModels.length;
|
|
2308
|
-
const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
|
|
2309
|
-
const next = scopedModels[nextIndex];
|
|
2310
|
-
|
|
2311
|
-
// Apply model
|
|
2312
|
-
this.#setModelWithProviderSessionReset(next.model);
|
|
2313
|
-
this.sessionManager.appendModelChange(`${next.model.provider}/${next.model.id}`);
|
|
2314
|
-
this.settings.setModelRole("default", `${next.model.provider}/${next.model.id}`);
|
|
2315
|
-
this.settings.getStorage()?.recordModelUsage(`${next.model.provider}/${next.model.id}`);
|
|
2316
|
-
|
|
2317
|
-
// Apply thinking level (setThinkingLevel clamps to model capabilities)
|
|
2318
|
-
this.setThinkingLevel(next.thinkingLevel);
|
|
2319
|
-
|
|
2320
|
-
return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
|
|
2321
|
-
}
|
|
2322
|
-
|
|
2323
|
-
async #cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
|
|
2324
|
-
const availableModels = this.#modelRegistry.getAvailable();
|
|
2325
|
-
if (availableModels.length <= 1) return undefined;
|
|
2326
|
-
|
|
2327
|
-
const currentModel = this.model;
|
|
2328
|
-
let currentIndex = availableModels.findIndex(m => modelsAreEqual(m, currentModel));
|
|
2329
|
-
|
|
2330
|
-
if (currentIndex === -1) currentIndex = 0;
|
|
2331
|
-
const len = availableModels.length;
|
|
2332
|
-
const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
|
|
2333
|
-
const nextModel = availableModels[nextIndex];
|
|
2334
|
-
|
|
2335
|
-
const apiKey = await this.#modelRegistry.getApiKey(nextModel, this.sessionId);
|
|
2336
|
-
if (!apiKey) {
|
|
2337
|
-
throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`);
|
|
2338
|
-
}
|
|
2339
|
-
|
|
2340
|
-
this.#setModelWithProviderSessionReset(nextModel);
|
|
2341
|
-
this.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`);
|
|
2342
|
-
this.settings.setModelRole("default", `${nextModel.provider}/${nextModel.id}`);
|
|
2343
|
-
this.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
|
|
2344
|
-
|
|
2345
|
-
// Re-clamp thinking level for new model's capabilities without persisting settings
|
|
2346
|
-
this.setThinkingLevel(this.thinkingLevel);
|
|
2347
|
-
|
|
2348
|
-
return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
|
|
1713
|
+
return this.#model.cycleRoleModels(roleOrder, options);
|
|
2349
1714
|
}
|
|
2350
1715
|
|
|
2351
|
-
/**
|
|
2352
|
-
* Get all available models with valid API keys.
|
|
2353
|
-
*/
|
|
2354
1716
|
getAvailableModels(): Model[] {
|
|
2355
|
-
return this.#
|
|
1717
|
+
return this.#model.getAvailableModels();
|
|
2356
1718
|
}
|
|
2357
1719
|
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
// =========================================================================
|
|
2361
|
-
|
|
2362
|
-
/**
|
|
2363
|
-
* Set thinking level.
|
|
2364
|
-
* Clamps to model capabilities based on available thinking levels.
|
|
2365
|
-
* Saves to session and settings only if the level actually changes.
|
|
2366
|
-
*/
|
|
2367
|
-
setThinkingLevel(level: ThinkingLevel, persist: boolean = false): void {
|
|
2368
|
-
const availableLevels = this.getAvailableThinkingLevels();
|
|
2369
|
-
const effectiveLevel = availableLevels.includes(level) ? level : this.#clampThinkingLevel(level, availableLevels);
|
|
2370
|
-
|
|
2371
|
-
// Only persist if actually changing
|
|
2372
|
-
const isChanging = effectiveLevel !== this.agent.state.thinkingLevel;
|
|
2373
|
-
|
|
2374
|
-
this.agent.setThinkingLevel(effectiveLevel);
|
|
2375
|
-
|
|
2376
|
-
if (isChanging) {
|
|
2377
|
-
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
|
|
2378
|
-
if (persist) {
|
|
2379
|
-
this.settings.set("defaultThinkingLevel", effectiveLevel);
|
|
2380
|
-
}
|
|
2381
|
-
}
|
|
1720
|
+
setThinkingLevel(level: ThinkingLevel, persist = false): void {
|
|
1721
|
+
this.#model.setThinkingLevel(level, persist);
|
|
2382
1722
|
}
|
|
2383
1723
|
|
|
2384
|
-
/**
|
|
2385
|
-
* Cycle to next thinking level.
|
|
2386
|
-
* @returns New level, or undefined if model doesn't support thinking
|
|
2387
|
-
*/
|
|
2388
1724
|
cycleThinkingLevel(): ThinkingLevel | undefined {
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
const levels = this.getAvailableThinkingLevels();
|
|
2392
|
-
const currentIndex = levels.indexOf(this.thinkingLevel);
|
|
2393
|
-
const nextIndex = (currentIndex + 1) % levels.length;
|
|
2394
|
-
const nextLevel = levels[nextIndex];
|
|
2395
|
-
|
|
2396
|
-
this.setThinkingLevel(nextLevel);
|
|
2397
|
-
return nextLevel;
|
|
1725
|
+
return this.#model.cycleThinkingLevel();
|
|
2398
1726
|
}
|
|
2399
1727
|
|
|
2400
|
-
/**
|
|
2401
|
-
* Get available thinking levels for current model.
|
|
2402
|
-
* The provider will clamp to what the specific model supports internally.
|
|
2403
|
-
*/
|
|
2404
1728
|
getAvailableThinkingLevels(): ThinkingLevel[] {
|
|
2405
|
-
|
|
2406
|
-
return this.supportsXhighThinking() ? THINKING_LEVELS_WITH_XHIGH : THINKING_LEVELS;
|
|
1729
|
+
return this.#model.getAvailableThinkingLevels();
|
|
2407
1730
|
}
|
|
2408
1731
|
|
|
2409
|
-
/**
|
|
2410
|
-
* Check if current model supports xhigh thinking level.
|
|
2411
|
-
*/
|
|
2412
1732
|
supportsXhighThinking(): boolean {
|
|
2413
|
-
return this.
|
|
1733
|
+
return this.#model.supportsXhighThinking();
|
|
2414
1734
|
}
|
|
2415
1735
|
|
|
2416
|
-
/**
|
|
2417
|
-
* Check if current model supports thinking/reasoning.
|
|
2418
|
-
*/
|
|
2419
1736
|
supportsThinking(): boolean {
|
|
2420
|
-
return
|
|
2421
|
-
}
|
|
2422
|
-
|
|
2423
|
-
#clampThinkingLevel(level: ThinkingLevel, availableLevels: ThinkingLevel[]): ThinkingLevel {
|
|
2424
|
-
const ordered = THINKING_LEVELS_WITH_XHIGH;
|
|
2425
|
-
const available = new Set(availableLevels);
|
|
2426
|
-
const requestedIndex = ordered.indexOf(level);
|
|
2427
|
-
if (requestedIndex === -1) {
|
|
2428
|
-
return availableLevels[0] ?? "off";
|
|
2429
|
-
}
|
|
2430
|
-
for (let i = requestedIndex; i < ordered.length; i++) {
|
|
2431
|
-
const candidate = ordered[i];
|
|
2432
|
-
if (available.has(candidate)) return candidate;
|
|
2433
|
-
}
|
|
2434
|
-
for (let i = requestedIndex - 1; i >= 0; i--) {
|
|
2435
|
-
const candidate = ordered[i];
|
|
2436
|
-
if (available.has(candidate)) return candidate;
|
|
2437
|
-
}
|
|
2438
|
-
return availableLevels[0] ?? "off";
|
|
1737
|
+
return this.#model.supportsThinking();
|
|
2439
1738
|
}
|
|
2440
1739
|
|
|
2441
1740
|
// =========================================================================
|
|
@@ -2483,7 +1782,7 @@ export class AgentSession {
|
|
|
2483
1782
|
await this.sessionManager.rewriteEntries();
|
|
2484
1783
|
const sessionContext = this.sessionManager.buildSessionContext();
|
|
2485
1784
|
this.agent.replaceMessages(sessionContext.messages);
|
|
2486
|
-
this.#closeCodexProviderSessionsForHistoryRewrite();
|
|
1785
|
+
this.#model.closeCodexProviderSessionsForHistoryRewrite();
|
|
2487
1786
|
return result;
|
|
2488
1787
|
}
|
|
2489
1788
|
|
|
@@ -2505,7 +1804,7 @@ export class AgentSession {
|
|
|
2505
1804
|
|
|
2506
1805
|
const compactionSettings = this.settings.getGroup("compaction");
|
|
2507
1806
|
const compactionModel = this.model;
|
|
2508
|
-
const apiKey = await this.#
|
|
1807
|
+
const apiKey = await this.#model.registry.getApiKey(compactionModel, this.sessionId);
|
|
2509
1808
|
if (!apiKey) {
|
|
2510
1809
|
throw new Error(`No API key for ${compactionModel.provider}`);
|
|
2511
1810
|
}
|
|
@@ -2607,7 +1906,7 @@ export class AgentSession {
|
|
|
2607
1906
|
const newEntries = this.sessionManager.getEntries();
|
|
2608
1907
|
const sessionContext = this.sessionManager.buildSessionContext();
|
|
2609
1908
|
this.agent.replaceMessages(sessionContext.messages);
|
|
2610
|
-
this.#closeCodexProviderSessionsForHistoryRewrite();
|
|
1909
|
+
this.#model.closeCodexProviderSessionsForHistoryRewrite();
|
|
2611
1910
|
|
|
2612
1911
|
// Get the saved compaction entry for the hook
|
|
2613
1912
|
const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
|
|
@@ -2731,34 +2030,32 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
2731
2030
|
|
|
2732
2031
|
// Create a promise that resolves when the agent completes
|
|
2733
2032
|
let handoffText: string | undefined;
|
|
2734
|
-
const completionPromise =
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2033
|
+
const { promise: completionPromise, resolve, reject } = Promise.withResolvers<void>();
|
|
2034
|
+
const unsubscribe = this.subscribe(event => {
|
|
2035
|
+
if (this.#handoffAbortController?.signal.aborted) {
|
|
2036
|
+
unsubscribe();
|
|
2037
|
+
reject(new Error("Handoff cancelled"));
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2741
2040
|
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
break;
|
|
2756
|
-
}
|
|
2041
|
+
if (event.type === "agent_end") {
|
|
2042
|
+
unsubscribe();
|
|
2043
|
+
const messages = this.agent.state.messages;
|
|
2044
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2045
|
+
const msg = messages[i];
|
|
2046
|
+
if (msg.role === "assistant") {
|
|
2047
|
+
const content = (msg as AssistantMessage).content;
|
|
2048
|
+
const textParts = content
|
|
2049
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
2050
|
+
.map(c => c.text);
|
|
2051
|
+
if (textParts.length > 0) {
|
|
2052
|
+
handoffText = textParts.join("\n");
|
|
2053
|
+
break;
|
|
2757
2054
|
}
|
|
2758
2055
|
}
|
|
2759
|
-
resolve();
|
|
2760
2056
|
}
|
|
2761
|
-
|
|
2057
|
+
resolve();
|
|
2058
|
+
}
|
|
2762
2059
|
});
|
|
2763
2060
|
|
|
2764
2061
|
try {
|
|
@@ -2867,6 +2164,72 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
2867
2164
|
}
|
|
2868
2165
|
}
|
|
2869
2166
|
}
|
|
2167
|
+
/**
|
|
2168
|
+
* Check if agent stopped after modifying files without running verification.
|
|
2169
|
+
* If so, inject a reminder to verify and continue the conversation.
|
|
2170
|
+
*/
|
|
2171
|
+
async #checkVerification(): Promise<boolean> {
|
|
2172
|
+
if (!this.settings.get("verification.autoCheck")) {
|
|
2173
|
+
this.#verificationReminderCount = 0;
|
|
2174
|
+
return false;
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
if (!this.#turnHasFileModifications) {
|
|
2178
|
+
return false;
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
const maxReminders = this.settings.get("verification.maxReminders");
|
|
2182
|
+
if (this.#verificationReminderCount >= maxReminders) {
|
|
2183
|
+
logger.debug("Verification: max reminders reached", { count: this.#verificationReminderCount });
|
|
2184
|
+
return false;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
if (this.#turnHasVerificationCommand()) {
|
|
2188
|
+
this.#verificationReminderCount = 0;
|
|
2189
|
+
this.#turnHasFileModifications = false;
|
|
2190
|
+
return false;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
this.#verificationReminderCount++;
|
|
2194
|
+
const reminder = renderPromptTemplate(verificationReminderTemplate, {
|
|
2195
|
+
attempt: this.#verificationReminderCount,
|
|
2196
|
+
maxAttempts: maxReminders,
|
|
2197
|
+
});
|
|
2198
|
+
|
|
2199
|
+
logger.debug("Verification: sending reminder", {
|
|
2200
|
+
attempt: this.#verificationReminderCount,
|
|
2201
|
+
});
|
|
2202
|
+
|
|
2203
|
+
this.agent.appendMessage({
|
|
2204
|
+
role: "user",
|
|
2205
|
+
content: [{ type: "text", text: reminder }],
|
|
2206
|
+
timestamp: Date.now(),
|
|
2207
|
+
});
|
|
2208
|
+
this.agent.continue().catch(() => {});
|
|
2209
|
+
return true;
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
/**
|
|
2213
|
+
* Check if the current turn included a bash command that looks like a verification step.
|
|
2214
|
+
*/
|
|
2215
|
+
#turnHasVerificationCommand(): boolean {
|
|
2216
|
+
const messages = this.agent.state.messages;
|
|
2217
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2218
|
+
const msg = messages[i];
|
|
2219
|
+
if (msg.role === "user") break;
|
|
2220
|
+
if (msg.role !== "assistant") continue;
|
|
2221
|
+
const assistant = msg as AssistantMessage;
|
|
2222
|
+
for (const block of assistant.content) {
|
|
2223
|
+
if (block.type !== "toolCall" || block.name !== "bash") continue;
|
|
2224
|
+
const cmd = (block.arguments as { command?: string })?.command ?? "";
|
|
2225
|
+
if (/\b(check|lint|fmt|format|test|typecheck|tsc|biome|eslint|clippy)\b/.test(cmd)) {
|
|
2226
|
+
return true;
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
return false;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2870
2233
|
/**
|
|
2871
2234
|
* Check if agent stopped with incomplete todos and prompt to continue.
|
|
2872
2235
|
*/
|
|
@@ -2974,20 +2337,20 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
2974
2337
|
}
|
|
2975
2338
|
|
|
2976
2339
|
async #resolveContextPromotionTarget(currentModel: Model, contextWindow: number): Promise<Model | undefined> {
|
|
2977
|
-
const availableModels = this.#
|
|
2340
|
+
const availableModels = this.#model.registry.getAvailable();
|
|
2978
2341
|
if (availableModels.length === 0) return undefined;
|
|
2979
2342
|
|
|
2980
2343
|
const candidates: Model[] = [];
|
|
2981
2344
|
const seen = new Set<string>();
|
|
2982
2345
|
const addCandidate = (candidate: Model | undefined): void => {
|
|
2983
2346
|
if (!candidate) return;
|
|
2984
|
-
const key = this.#getModelKey(candidate);
|
|
2347
|
+
const key = this.#model.getModelKey(candidate);
|
|
2985
2348
|
if (seen.has(key)) return;
|
|
2986
2349
|
seen.add(key);
|
|
2987
2350
|
candidates.push(candidate);
|
|
2988
2351
|
};
|
|
2989
2352
|
|
|
2990
|
-
addCandidate(this.#
|
|
2353
|
+
addCandidate(this.#model.resolveContextPromotionTarget(currentModel, availableModels));
|
|
2991
2354
|
|
|
2992
2355
|
const sameProviderLarger = [...availableModels]
|
|
2993
2356
|
.filter(
|
|
@@ -2998,109 +2361,13 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
2998
2361
|
for (const candidate of candidates) {
|
|
2999
2362
|
if (modelsAreEqual(candidate, currentModel)) continue;
|
|
3000
2363
|
if (candidate.contextWindow <= contextWindow) continue;
|
|
3001
|
-
const apiKey = await this.#
|
|
2364
|
+
const apiKey = await this.#model.registry.getApiKey(candidate, this.sessionId);
|
|
3002
2365
|
if (!apiKey) continue;
|
|
3003
2366
|
return candidate;
|
|
3004
2367
|
}
|
|
3005
2368
|
|
|
3006
2369
|
return undefined;
|
|
3007
2370
|
}
|
|
3008
|
-
|
|
3009
|
-
#setModelWithProviderSessionReset(model: Model): void {
|
|
3010
|
-
const currentModel = this.model;
|
|
3011
|
-
if (currentModel) {
|
|
3012
|
-
this.#closeProviderSessionsForModelSwitch(currentModel, model);
|
|
3013
|
-
}
|
|
3014
|
-
this.agent.setModel(this.#applySessionModelOverrides(model));
|
|
3015
|
-
}
|
|
3016
|
-
|
|
3017
|
-
#closeCodexProviderSessionsForHistoryRewrite(): void {
|
|
3018
|
-
const currentModel = this.model;
|
|
3019
|
-
if (!currentModel || currentModel.api !== "openai-codex-responses") return;
|
|
3020
|
-
this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
|
|
3021
|
-
}
|
|
3022
|
-
|
|
3023
|
-
#closeProviderSessionsForModelSwitch(currentModel: Model, nextModel: Model): void {
|
|
3024
|
-
if (currentModel.api !== "openai-codex-responses" && nextModel.api !== "openai-codex-responses") return;
|
|
3025
|
-
|
|
3026
|
-
const providerKey = "openai-codex-responses";
|
|
3027
|
-
const state = this.#providerSessionState.get(providerKey);
|
|
3028
|
-
if (!state) return;
|
|
3029
|
-
|
|
3030
|
-
try {
|
|
3031
|
-
state.close();
|
|
3032
|
-
} catch (error) {
|
|
3033
|
-
logger.warn("Failed to close provider session state during model switch", {
|
|
3034
|
-
providerKey,
|
|
3035
|
-
error: String(error),
|
|
3036
|
-
});
|
|
3037
|
-
}
|
|
3038
|
-
|
|
3039
|
-
this.#providerSessionState.delete(providerKey);
|
|
3040
|
-
}
|
|
3041
|
-
|
|
3042
|
-
#getModelKey(model: Model): string {
|
|
3043
|
-
return `${model.provider}/${model.id}`;
|
|
3044
|
-
}
|
|
3045
|
-
|
|
3046
|
-
#resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
|
|
3047
|
-
const configuredTarget = currentModel.contextPromotionTarget?.trim();
|
|
3048
|
-
if (!configuredTarget) return undefined;
|
|
3049
|
-
|
|
3050
|
-
const parsed = parseModelString(configuredTarget);
|
|
3051
|
-
if (parsed) {
|
|
3052
|
-
const explicitModel = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
|
|
3053
|
-
if (explicitModel) return explicitModel;
|
|
3054
|
-
}
|
|
3055
|
-
|
|
3056
|
-
return availableModels.find(m => m.provider === currentModel.provider && m.id === configuredTarget);
|
|
3057
|
-
}
|
|
3058
|
-
|
|
3059
|
-
#resolveRoleModel(role: ModelRole, availableModels: Model[], currentModel: Model | undefined): Model | undefined {
|
|
3060
|
-
const roleModelStr =
|
|
3061
|
-
role === "default"
|
|
3062
|
-
? (this.settings.getModelRole("default") ??
|
|
3063
|
-
(currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined))
|
|
3064
|
-
: this.settings.getModelRole(role);
|
|
3065
|
-
|
|
3066
|
-
if (!roleModelStr) return undefined;
|
|
3067
|
-
|
|
3068
|
-
const parsed = parseModelString(roleModelStr);
|
|
3069
|
-
if (parsed) {
|
|
3070
|
-
return availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
|
|
3071
|
-
}
|
|
3072
|
-
const roleLower = roleModelStr.toLowerCase();
|
|
3073
|
-
return availableModels.find(m => m.id.toLowerCase() === roleLower);
|
|
3074
|
-
}
|
|
3075
|
-
|
|
3076
|
-
#getCompactionModelCandidates(availableModels: Model[]): Model[] {
|
|
3077
|
-
const candidates: Model[] = [];
|
|
3078
|
-
const seen = new Set<string>();
|
|
3079
|
-
|
|
3080
|
-
const addCandidate = (model: Model | undefined): void => {
|
|
3081
|
-
if (!model) return;
|
|
3082
|
-
const key = this.#getModelKey(model);
|
|
3083
|
-
if (seen.has(key)) return;
|
|
3084
|
-
seen.add(key);
|
|
3085
|
-
candidates.push(model);
|
|
3086
|
-
};
|
|
3087
|
-
|
|
3088
|
-
const currentModel = this.model;
|
|
3089
|
-
for (const role of MODEL_ROLE_IDS) {
|
|
3090
|
-
addCandidate(this.#resolveRoleModel(role, availableModels, currentModel));
|
|
3091
|
-
}
|
|
3092
|
-
|
|
3093
|
-
const sortedByContext = [...availableModels].sort((a, b) => b.contextWindow - a.contextWindow);
|
|
3094
|
-
for (const model of sortedByContext) {
|
|
3095
|
-
if (!seen.has(this.#getModelKey(model))) {
|
|
3096
|
-
addCandidate(model);
|
|
3097
|
-
break;
|
|
3098
|
-
}
|
|
3099
|
-
}
|
|
3100
|
-
|
|
3101
|
-
return candidates;
|
|
3102
|
-
}
|
|
3103
|
-
|
|
3104
2371
|
/**
|
|
3105
2372
|
* Internal: Run auto-compaction with events.
|
|
3106
2373
|
*/
|
|
@@ -3125,7 +2392,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
3125
2392
|
return;
|
|
3126
2393
|
}
|
|
3127
2394
|
|
|
3128
|
-
const availableModels = this.#
|
|
2395
|
+
const availableModels = this.#model.registry.getAvailable();
|
|
3129
2396
|
if (availableModels.length === 0) {
|
|
3130
2397
|
await this.#emitSessionEvent({
|
|
3131
2398
|
type: "auto_compaction_end",
|
|
@@ -3208,13 +2475,13 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
3208
2475
|
details = hookCompaction.details;
|
|
3209
2476
|
preserveData ??= hookCompaction.preserveData;
|
|
3210
2477
|
} else {
|
|
3211
|
-
const candidates = this.#getCompactionModelCandidates(availableModels);
|
|
2478
|
+
const candidates = this.#model.getCompactionModelCandidates(availableModels);
|
|
3212
2479
|
const retrySettings = this.settings.getGroup("retry");
|
|
3213
2480
|
let compactResult: CompactionResult | undefined;
|
|
3214
2481
|
let lastError: unknown;
|
|
3215
2482
|
|
|
3216
2483
|
for (const candidate of candidates) {
|
|
3217
|
-
const apiKey = await this.#
|
|
2484
|
+
const apiKey = await this.#model.registry.getApiKey(candidate, this.sessionId);
|
|
3218
2485
|
if (!apiKey) continue;
|
|
3219
2486
|
|
|
3220
2487
|
let attempt = 0;
|
|
@@ -3235,11 +2502,11 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
3235
2502
|
}
|
|
3236
2503
|
|
|
3237
2504
|
const message = error instanceof Error ? error.message : String(error);
|
|
3238
|
-
const retryAfterMs =
|
|
2505
|
+
const retryAfterMs = parseRetryAfterMs(message);
|
|
3239
2506
|
const shouldRetry =
|
|
3240
2507
|
retrySettings.enabled &&
|
|
3241
2508
|
attempt < retrySettings.maxRetries &&
|
|
3242
|
-
(retryAfterMs !== undefined ||
|
|
2509
|
+
(retryAfterMs !== undefined || isRetryableErrorMessage(message));
|
|
3243
2510
|
if (!shouldRetry) {
|
|
3244
2511
|
lastError = error;
|
|
3245
2512
|
break;
|
|
@@ -3319,7 +2586,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
3319
2586
|
const newEntries = this.sessionManager.getEntries();
|
|
3320
2587
|
const sessionContext = this.sessionManager.buildSessionContext();
|
|
3321
2588
|
this.agent.replaceMessages(sessionContext.messages);
|
|
3322
|
-
this.#closeCodexProviderSessionsForHistoryRewrite();
|
|
2589
|
+
this.#model.closeCodexProviderSessionsForHistoryRewrite();
|
|
3323
2590
|
|
|
3324
2591
|
// Get the saved compaction entry for the hook
|
|
3325
2592
|
const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
|
|
@@ -3422,65 +2689,8 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
3422
2689
|
if (isContextOverflow(message, contextWindow)) return false;
|
|
3423
2690
|
|
|
3424
2691
|
const err = message.errorMessage;
|
|
3425
|
-
return
|
|
2692
|
+
return isRetryableErrorMessage(err);
|
|
3426
2693
|
}
|
|
3427
|
-
|
|
3428
|
-
#isRetryableErrorMessage(errorMessage: string): boolean {
|
|
3429
|
-
// Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error, fetch failed, retry delay exceeded
|
|
3430
|
-
return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|unable to connect|fetch failed|retry delay/i.test(
|
|
3431
|
-
errorMessage,
|
|
3432
|
-
);
|
|
3433
|
-
}
|
|
3434
|
-
|
|
3435
|
-
#isUsageLimitErrorMessage(errorMessage: string): boolean {
|
|
3436
|
-
return /usage.?limit|usage_limit_reached|limit_reached/i.test(errorMessage);
|
|
3437
|
-
}
|
|
3438
|
-
|
|
3439
|
-
#parseRetryAfterMsFromError(errorMessage: string): number | undefined {
|
|
3440
|
-
const now = Date.now();
|
|
3441
|
-
const retryAfterMsMatch = /retry-after-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
|
|
3442
|
-
if (retryAfterMsMatch) {
|
|
3443
|
-
return Math.max(0, Number(retryAfterMsMatch[1]));
|
|
3444
|
-
}
|
|
3445
|
-
|
|
3446
|
-
const retryAfterMatch = /retry-after\s*[:=]\s*([^\s,;]+)/i.exec(errorMessage);
|
|
3447
|
-
if (retryAfterMatch) {
|
|
3448
|
-
const value = retryAfterMatch[1];
|
|
3449
|
-
const seconds = Number(value);
|
|
3450
|
-
if (!Number.isNaN(seconds)) {
|
|
3451
|
-
return Math.max(0, seconds * 1000);
|
|
3452
|
-
}
|
|
3453
|
-
const dateMs = Date.parse(value);
|
|
3454
|
-
if (!Number.isNaN(dateMs)) {
|
|
3455
|
-
return Math.max(0, dateMs - now);
|
|
3456
|
-
}
|
|
3457
|
-
}
|
|
3458
|
-
|
|
3459
|
-
const resetMsMatch = /x-ratelimit-reset-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
|
|
3460
|
-
if (resetMsMatch) {
|
|
3461
|
-
const resetMs = Number(resetMsMatch[1]);
|
|
3462
|
-
if (!Number.isNaN(resetMs)) {
|
|
3463
|
-
if (resetMs > 1_000_000_000_000) {
|
|
3464
|
-
return Math.max(0, resetMs - now);
|
|
3465
|
-
}
|
|
3466
|
-
return Math.max(0, resetMs);
|
|
3467
|
-
}
|
|
3468
|
-
}
|
|
3469
|
-
|
|
3470
|
-
const resetMatch = /x-ratelimit-reset\s*[:=]\s*(\d+)/i.exec(errorMessage);
|
|
3471
|
-
if (resetMatch) {
|
|
3472
|
-
const resetSeconds = Number(resetMatch[1]);
|
|
3473
|
-
if (!Number.isNaN(resetSeconds)) {
|
|
3474
|
-
if (resetSeconds > 1_000_000_000) {
|
|
3475
|
-
return Math.max(0, resetSeconds * 1000 - now);
|
|
3476
|
-
}
|
|
3477
|
-
return Math.max(0, resetSeconds * 1000);
|
|
3478
|
-
}
|
|
3479
|
-
}
|
|
3480
|
-
|
|
3481
|
-
return undefined;
|
|
3482
|
-
}
|
|
3483
|
-
|
|
3484
2694
|
/**
|
|
3485
2695
|
* Handle retryable errors with exponential backoff.
|
|
3486
2696
|
* @returns true if retry was initiated, false if max retries exceeded or disabled
|
|
@@ -3515,9 +2725,9 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
3515
2725
|
const errorMessage = message.errorMessage || "Unknown error";
|
|
3516
2726
|
let delayMs = retrySettings.baseDelayMs * 2 ** (this.#retryAttempt - 1);
|
|
3517
2727
|
|
|
3518
|
-
if (this.model &&
|
|
3519
|
-
const retryAfterMs =
|
|
3520
|
-
const switched = await this.#
|
|
2728
|
+
if (this.model && isUsageLimitErrorMessage(errorMessage)) {
|
|
2729
|
+
const retryAfterMs = parseRetryAfterMs(errorMessage);
|
|
2730
|
+
const switched = await this.#model.registry.authStorage.markUsageLimitReached(
|
|
3521
2731
|
this.model.provider,
|
|
3522
2732
|
this.sessionId,
|
|
3523
2733
|
{
|
|
@@ -3899,10 +3109,10 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
3899
3109
|
if (slashIdx > 0) {
|
|
3900
3110
|
const provider = defaultModelStr.slice(0, slashIdx);
|
|
3901
3111
|
const modelId = defaultModelStr.slice(slashIdx + 1);
|
|
3902
|
-
const availableModels = this.#
|
|
3112
|
+
const availableModels = this.#model.registry.getAvailable();
|
|
3903
3113
|
const match = availableModels.find(m => m.provider === provider && m.id === modelId);
|
|
3904
3114
|
if (match) {
|
|
3905
|
-
this.#
|
|
3115
|
+
this.#model.setModelDirect(match);
|
|
3906
3116
|
}
|
|
3907
3117
|
}
|
|
3908
3118
|
}
|
|
@@ -3917,7 +3127,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
3917
3127
|
const availableLevels = this.getAvailableThinkingLevels();
|
|
3918
3128
|
const effectiveLevel = availableLevels.includes(defaultThinkingLevel)
|
|
3919
3129
|
? defaultThinkingLevel
|
|
3920
|
-
: this.#clampThinkingLevel(defaultThinkingLevel, availableLevels);
|
|
3130
|
+
: this.#model.clampThinkingLevel(defaultThinkingLevel, availableLevels);
|
|
3921
3131
|
this.agent.setThinkingLevel(effectiveLevel);
|
|
3922
3132
|
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
|
|
3923
3133
|
}
|
|
@@ -4069,7 +3279,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
4069
3279
|
let summaryDetails: unknown;
|
|
4070
3280
|
if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) {
|
|
4071
3281
|
const model = this.model!;
|
|
4072
|
-
const apiKey = await this.#
|
|
3282
|
+
const apiKey = await this.#model.registry.getApiKey(model, this.sessionId);
|
|
4073
3283
|
if (!apiKey) {
|
|
4074
3284
|
throw new Error(`No API key for ${model.provider}`);
|
|
4075
3285
|
}
|
|
@@ -4186,456 +3396,31 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
4186
3396
|
return "";
|
|
4187
3397
|
}
|
|
4188
3398
|
|
|
4189
|
-
/**
|
|
4190
|
-
* Get session statistics.
|
|
4191
|
-
*/
|
|
4192
3399
|
getSessionStats(): SessionStats {
|
|
4193
|
-
|
|
4194
|
-
const userMessages = state.messages.filter(m => m.role === "user").length;
|
|
4195
|
-
const assistantMessages = state.messages.filter(m => m.role === "assistant").length;
|
|
4196
|
-
const toolResults = state.messages.filter(m => m.role === "toolResult").length;
|
|
4197
|
-
|
|
4198
|
-
let toolCalls = 0;
|
|
4199
|
-
let totalInput = 0;
|
|
4200
|
-
let totalOutput = 0;
|
|
4201
|
-
let totalCacheRead = 0;
|
|
4202
|
-
let totalCacheWrite = 0;
|
|
4203
|
-
let totalCost = 0;
|
|
4204
|
-
|
|
4205
|
-
const getTaskToolUsage = (details: unknown): Usage | undefined => {
|
|
4206
|
-
if (!details || typeof details !== "object") return undefined;
|
|
4207
|
-
const record = details as Record<string, unknown>;
|
|
4208
|
-
const usage = record.usage;
|
|
4209
|
-
if (!usage || typeof usage !== "object") return undefined;
|
|
4210
|
-
return usage as Usage;
|
|
4211
|
-
};
|
|
4212
|
-
|
|
4213
|
-
for (const message of state.messages) {
|
|
4214
|
-
if (message.role === "assistant") {
|
|
4215
|
-
const assistantMsg = message as AssistantMessage;
|
|
4216
|
-
toolCalls += assistantMsg.content.filter(c => c.type === "toolCall").length;
|
|
4217
|
-
totalInput += assistantMsg.usage.input;
|
|
4218
|
-
totalOutput += assistantMsg.usage.output;
|
|
4219
|
-
totalCacheRead += assistantMsg.usage.cacheRead;
|
|
4220
|
-
totalCacheWrite += assistantMsg.usage.cacheWrite;
|
|
4221
|
-
totalCost += assistantMsg.usage.cost.total;
|
|
4222
|
-
}
|
|
4223
|
-
|
|
4224
|
-
if (message.role === "toolResult" && message.toolName === "task") {
|
|
4225
|
-
const usage = getTaskToolUsage(message.details);
|
|
4226
|
-
if (usage) {
|
|
4227
|
-
totalInput += usage.input;
|
|
4228
|
-
totalOutput += usage.output;
|
|
4229
|
-
totalCacheRead += usage.cacheRead;
|
|
4230
|
-
totalCacheWrite += usage.cacheWrite;
|
|
4231
|
-
totalCost += usage.cost.total;
|
|
4232
|
-
}
|
|
4233
|
-
}
|
|
4234
|
-
}
|
|
4235
|
-
|
|
4236
|
-
return {
|
|
4237
|
-
sessionFile: this.sessionFile,
|
|
4238
|
-
sessionId: this.sessionId,
|
|
4239
|
-
userMessages,
|
|
4240
|
-
assistantMessages,
|
|
4241
|
-
toolCalls,
|
|
4242
|
-
toolResults,
|
|
4243
|
-
totalMessages: state.messages.length,
|
|
4244
|
-
tokens: {
|
|
4245
|
-
input: totalInput,
|
|
4246
|
-
output: totalOutput,
|
|
4247
|
-
cacheRead: totalCacheRead,
|
|
4248
|
-
cacheWrite: totalCacheWrite,
|
|
4249
|
-
total: totalInput + totalOutput + totalCacheRead + totalCacheWrite,
|
|
4250
|
-
},
|
|
4251
|
-
cost: totalCost,
|
|
4252
|
-
};
|
|
3400
|
+
return sessionStats.getSessionStats(this.messages, this.sessionFile, this.sessionId);
|
|
4253
3401
|
}
|
|
4254
3402
|
|
|
4255
|
-
/**
|
|
4256
|
-
* Get current context usage statistics.
|
|
4257
|
-
* Uses the last assistant message's usage data when available,
|
|
4258
|
-
* otherwise estimates tokens for all messages.
|
|
4259
|
-
*/
|
|
4260
3403
|
getContextUsage(): ContextUsage | undefined {
|
|
4261
|
-
|
|
4262
|
-
if (!model) return undefined;
|
|
4263
|
-
|
|
4264
|
-
const contextWindow = model.contextWindow ?? 0;
|
|
4265
|
-
if (contextWindow <= 0) return undefined;
|
|
4266
|
-
|
|
4267
|
-
// After compaction, the last assistant usage reflects pre-compaction context size.
|
|
4268
|
-
// We can only trust usage from an assistant that responded after the latest compaction.
|
|
4269
|
-
// If no such assistant exists, context token count is unknown until the next LLM response.
|
|
4270
|
-
const branchEntries = this.sessionManager.getBranch();
|
|
4271
|
-
const latestCompaction = getLatestCompactionEntry(branchEntries);
|
|
4272
|
-
|
|
4273
|
-
if (latestCompaction) {
|
|
4274
|
-
// Check if there's a valid assistant usage after the compaction boundary
|
|
4275
|
-
const compactionIndex = branchEntries.lastIndexOf(latestCompaction);
|
|
4276
|
-
let hasPostCompactionUsage = false;
|
|
4277
|
-
for (let i = branchEntries.length - 1; i > compactionIndex; i--) {
|
|
4278
|
-
const entry = branchEntries[i];
|
|
4279
|
-
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
4280
|
-
const assistant = entry.message;
|
|
4281
|
-
if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error") {
|
|
4282
|
-
const contextTokens = calculateContextTokens(assistant.usage);
|
|
4283
|
-
if (contextTokens > 0) {
|
|
4284
|
-
hasPostCompactionUsage = true;
|
|
4285
|
-
}
|
|
4286
|
-
break;
|
|
4287
|
-
}
|
|
4288
|
-
}
|
|
4289
|
-
}
|
|
4290
|
-
|
|
4291
|
-
if (!hasPostCompactionUsage) {
|
|
4292
|
-
return { tokens: null, contextWindow, percent: null };
|
|
4293
|
-
}
|
|
4294
|
-
}
|
|
4295
|
-
|
|
4296
|
-
const estimate = this.#estimateContextTokens();
|
|
4297
|
-
const percent = (estimate.tokens / contextWindow) * 100;
|
|
4298
|
-
|
|
4299
|
-
return {
|
|
4300
|
-
tokens: estimate.tokens,
|
|
4301
|
-
contextWindow,
|
|
4302
|
-
percent,
|
|
4303
|
-
};
|
|
3404
|
+
return sessionStats.getContextUsage(this.model, this.messages, this.sessionManager);
|
|
4304
3405
|
}
|
|
4305
3406
|
|
|
4306
3407
|
async fetchUsageReports(): Promise<UsageReport[] | null> {
|
|
4307
|
-
|
|
4308
|
-
if (!authStorage.fetchUsageReports) return null;
|
|
4309
|
-
return authStorage.fetchUsageReports({
|
|
4310
|
-
baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
|
|
4311
|
-
});
|
|
3408
|
+
return sessionStats.fetchUsageReports(this.#model.registry) ?? null;
|
|
4312
3409
|
}
|
|
4313
|
-
|
|
4314
|
-
/**
|
|
4315
|
-
* Estimate context tokens from messages, using the last assistant usage when available.
|
|
4316
|
-
*/
|
|
4317
|
-
#estimateContextTokens(): {
|
|
4318
|
-
tokens: number;
|
|
4319
|
-
} {
|
|
4320
|
-
const messages = this.messages;
|
|
4321
|
-
|
|
4322
|
-
// Find last assistant message with usage
|
|
4323
|
-
let lastUsageIndex: number | null = null;
|
|
4324
|
-
let lastUsage: Usage | undefined;
|
|
4325
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
4326
|
-
const msg = messages[i];
|
|
4327
|
-
if (msg.role === "assistant") {
|
|
4328
|
-
const assistantMsg = msg as AssistantMessage;
|
|
4329
|
-
if (assistantMsg.usage) {
|
|
4330
|
-
lastUsage = assistantMsg.usage;
|
|
4331
|
-
lastUsageIndex = i;
|
|
4332
|
-
break;
|
|
4333
|
-
}
|
|
4334
|
-
}
|
|
4335
|
-
}
|
|
4336
|
-
|
|
4337
|
-
if (!lastUsage || lastUsageIndex === null) {
|
|
4338
|
-
// No usage data - estimate all messages
|
|
4339
|
-
let estimated = 0;
|
|
4340
|
-
for (const message of messages) {
|
|
4341
|
-
estimated += estimateTokens(message);
|
|
4342
|
-
}
|
|
4343
|
-
return {
|
|
4344
|
-
tokens: estimated,
|
|
4345
|
-
};
|
|
4346
|
-
}
|
|
4347
|
-
|
|
4348
|
-
const usageTokens = calculateContextTokens(lastUsage);
|
|
4349
|
-
let trailingTokens = 0;
|
|
4350
|
-
for (let i = lastUsageIndex + 1; i < messages.length; i++) {
|
|
4351
|
-
trailingTokens += estimateTokens(messages[i]);
|
|
4352
|
-
}
|
|
4353
|
-
|
|
4354
|
-
return {
|
|
4355
|
-
tokens: usageTokens + trailingTokens,
|
|
4356
|
-
};
|
|
4357
|
-
}
|
|
4358
|
-
|
|
4359
|
-
/**
|
|
4360
|
-
* Export session to HTML.
|
|
4361
|
-
* @param outputPath Optional output path (defaults to session directory)
|
|
4362
|
-
* @returns Path to exported file
|
|
4363
|
-
*/
|
|
4364
3410
|
async exportToHtml(outputPath?: string): Promise<string> {
|
|
4365
|
-
|
|
4366
|
-
return exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName });
|
|
3411
|
+
return sessionStats.exportToHtml(this.sessionManager, this.state, outputPath);
|
|
4367
3412
|
}
|
|
4368
3413
|
|
|
4369
|
-
// =========================================================================
|
|
4370
|
-
// Utilities
|
|
4371
|
-
// =========================================================================
|
|
4372
|
-
|
|
4373
|
-
/**
|
|
4374
|
-
* Get text content of last assistant message.
|
|
4375
|
-
* Useful for /copy command.
|
|
4376
|
-
* @returns Text content, or undefined if no assistant message exists
|
|
4377
|
-
*/
|
|
4378
3414
|
getLastAssistantText(): string | undefined {
|
|
4379
|
-
|
|
4380
|
-
.slice()
|
|
4381
|
-
.reverse()
|
|
4382
|
-
.find(m => {
|
|
4383
|
-
if (m.role !== "assistant") return false;
|
|
4384
|
-
const msg = m as AssistantMessage;
|
|
4385
|
-
// Skip aborted messages with no content
|
|
4386
|
-
if (msg.stopReason === "aborted" && msg.content.length === 0) return false;
|
|
4387
|
-
return true;
|
|
4388
|
-
});
|
|
4389
|
-
|
|
4390
|
-
if (!lastAssistant) return undefined;
|
|
4391
|
-
|
|
4392
|
-
let text = "";
|
|
4393
|
-
for (const content of (lastAssistant as AssistantMessage).content) {
|
|
4394
|
-
if (content.type === "text") {
|
|
4395
|
-
text += content.text;
|
|
4396
|
-
}
|
|
4397
|
-
}
|
|
4398
|
-
|
|
4399
|
-
return text.trim() || undefined;
|
|
3415
|
+
return sessionStats.getLastAssistantText(this.messages);
|
|
4400
3416
|
}
|
|
4401
3417
|
|
|
4402
|
-
/**
|
|
4403
|
-
* Format the entire session as plain text for clipboard export.
|
|
4404
|
-
* Includes user messages, assistant text, thinking blocks, tool calls, and tool results.
|
|
4405
|
-
*/
|
|
4406
3418
|
formatSessionAsText(): string {
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
/** Serialize an object as XML parameter elements, one per key. */
|
|
4410
|
-
function formatArgsAsXml(args: Record<string, unknown>, indent = "\t"): string {
|
|
4411
|
-
const parts: string[] = [];
|
|
4412
|
-
for (const [key, value] of Object.entries(args)) {
|
|
4413
|
-
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
4414
|
-
parts.push(`${indent}<parameter name="${key}">${text}</parameter>`);
|
|
4415
|
-
}
|
|
4416
|
-
return parts.join("\n");
|
|
4417
|
-
}
|
|
4418
|
-
|
|
4419
|
-
// Include system prompt at the beginning
|
|
4420
|
-
const systemPrompt = this.agent.state.systemPrompt;
|
|
4421
|
-
if (systemPrompt) {
|
|
4422
|
-
lines.push("## System Prompt\n");
|
|
4423
|
-
lines.push(systemPrompt);
|
|
4424
|
-
lines.push("\n");
|
|
4425
|
-
}
|
|
4426
|
-
|
|
4427
|
-
// Include model and thinking level
|
|
4428
|
-
const model = this.agent.state.model;
|
|
4429
|
-
const thinkingLevel = this.agent.state.thinkingLevel;
|
|
4430
|
-
lines.push("## Configuration\n");
|
|
4431
|
-
lines.push(`Model: ${model.provider}/${model.id}`);
|
|
4432
|
-
lines.push(`Thinking Level: ${thinkingLevel}`);
|
|
4433
|
-
lines.push("\n");
|
|
4434
|
-
|
|
4435
|
-
// Include available tools
|
|
4436
|
-
const tools = this.agent.state.tools;
|
|
4437
|
-
|
|
4438
|
-
// Recursively strip all fields starting with 'TypeBox.' from an object
|
|
4439
|
-
function stripTypeBoxFields(obj: any): any {
|
|
4440
|
-
if (Array.isArray(obj)) {
|
|
4441
|
-
return obj.map(stripTypeBoxFields);
|
|
4442
|
-
}
|
|
4443
|
-
if (obj && typeof obj === "object") {
|
|
4444
|
-
const result: Record<string, any> = {};
|
|
4445
|
-
for (const [k, v] of Object.entries(obj)) {
|
|
4446
|
-
if (!k.startsWith("TypeBox.")) {
|
|
4447
|
-
result[k] = stripTypeBoxFields(v);
|
|
4448
|
-
}
|
|
4449
|
-
}
|
|
4450
|
-
return result;
|
|
4451
|
-
}
|
|
4452
|
-
return obj;
|
|
4453
|
-
}
|
|
4454
|
-
|
|
4455
|
-
if (tools.length > 0) {
|
|
4456
|
-
lines.push("## Available Tools\n");
|
|
4457
|
-
for (const tool of tools) {
|
|
4458
|
-
lines.push(`<tool name="${tool.name}">`);
|
|
4459
|
-
lines.push(tool.description);
|
|
4460
|
-
const parametersClean = stripTypeBoxFields(tool.parameters);
|
|
4461
|
-
lines.push(`\nParameters:\n${formatArgsAsXml(parametersClean as Record<string, unknown>)}`);
|
|
4462
|
-
lines.push("<" + "/tool>\n");
|
|
4463
|
-
}
|
|
4464
|
-
lines.push("\n");
|
|
4465
|
-
}
|
|
4466
|
-
|
|
4467
|
-
for (const msg of this.messages) {
|
|
4468
|
-
if (msg.role === "user") {
|
|
4469
|
-
lines.push("## User\n");
|
|
4470
|
-
if (typeof msg.content === "string") {
|
|
4471
|
-
lines.push(msg.content);
|
|
4472
|
-
} else {
|
|
4473
|
-
for (const c of msg.content) {
|
|
4474
|
-
if (c.type === "text") {
|
|
4475
|
-
lines.push(c.text);
|
|
4476
|
-
} else if (c.type === "image") {
|
|
4477
|
-
lines.push("[Image]");
|
|
4478
|
-
}
|
|
4479
|
-
}
|
|
4480
|
-
}
|
|
4481
|
-
lines.push("\n");
|
|
4482
|
-
} else if (msg.role === "assistant") {
|
|
4483
|
-
const assistantMsg = msg as AssistantMessage;
|
|
4484
|
-
lines.push("## Assistant\n");
|
|
4485
|
-
|
|
4486
|
-
for (const c of assistantMsg.content) {
|
|
4487
|
-
if (c.type === "text") {
|
|
4488
|
-
lines.push(c.text);
|
|
4489
|
-
} else if (c.type === "thinking") {
|
|
4490
|
-
lines.push("<thinking>");
|
|
4491
|
-
lines.push(c.thinking);
|
|
4492
|
-
lines.push("</thinking>\n");
|
|
4493
|
-
} else if (c.type === "toolCall") {
|
|
4494
|
-
lines.push(`<invoke name="${c.name}">`);
|
|
4495
|
-
if (c.arguments && typeof c.arguments === "object") {
|
|
4496
|
-
lines.push(formatArgsAsXml(c.arguments as Record<string, unknown>));
|
|
4497
|
-
}
|
|
4498
|
-
lines.push("<" + "/invoke>\n");
|
|
4499
|
-
}
|
|
4500
|
-
}
|
|
4501
|
-
lines.push("");
|
|
4502
|
-
} else if (msg.role === "toolResult") {
|
|
4503
|
-
lines.push(`### Tool Result: ${msg.toolName}`);
|
|
4504
|
-
if (msg.isError) {
|
|
4505
|
-
lines.push("(error)");
|
|
4506
|
-
}
|
|
4507
|
-
for (const c of msg.content) {
|
|
4508
|
-
if (c.type === "text") {
|
|
4509
|
-
lines.push("```");
|
|
4510
|
-
lines.push(c.text);
|
|
4511
|
-
lines.push("```");
|
|
4512
|
-
} else if (c.type === "image") {
|
|
4513
|
-
lines.push("[Image output]");
|
|
4514
|
-
}
|
|
4515
|
-
}
|
|
4516
|
-
lines.push("");
|
|
4517
|
-
} else if (msg.role === "bashExecution") {
|
|
4518
|
-
const bashMsg = msg as BashExecutionMessage;
|
|
4519
|
-
if (!bashMsg.excludeFromContext) {
|
|
4520
|
-
lines.push("## Bash Execution\n");
|
|
4521
|
-
lines.push(bashExecutionToText(bashMsg));
|
|
4522
|
-
lines.push("\n");
|
|
4523
|
-
}
|
|
4524
|
-
} else if (msg.role === "pythonExecution") {
|
|
4525
|
-
const pythonMsg = msg as PythonExecutionMessage;
|
|
4526
|
-
if (!pythonMsg.excludeFromContext) {
|
|
4527
|
-
lines.push("## Python Execution\n");
|
|
4528
|
-
lines.push(pythonExecutionToText(pythonMsg));
|
|
4529
|
-
lines.push("\n");
|
|
4530
|
-
}
|
|
4531
|
-
} else if (msg.role === "custom" || msg.role === "hookMessage") {
|
|
4532
|
-
const customMsg = msg as CustomMessage | HookMessage;
|
|
4533
|
-
lines.push(`## ${customMsg.customType}\n`);
|
|
4534
|
-
if (typeof customMsg.content === "string") {
|
|
4535
|
-
lines.push(customMsg.content);
|
|
4536
|
-
} else {
|
|
4537
|
-
for (const c of customMsg.content) {
|
|
4538
|
-
if (c.type === "text") {
|
|
4539
|
-
lines.push(c.text);
|
|
4540
|
-
} else if (c.type === "image") {
|
|
4541
|
-
lines.push("[Image]");
|
|
4542
|
-
}
|
|
4543
|
-
}
|
|
4544
|
-
}
|
|
4545
|
-
lines.push("\n");
|
|
4546
|
-
} else if (msg.role === "branchSummary") {
|
|
4547
|
-
const branchMsg = msg as BranchSummaryMessage;
|
|
4548
|
-
lines.push("## Branch Summary\n");
|
|
4549
|
-
lines.push(`(from branch: ${branchMsg.fromId})\n`);
|
|
4550
|
-
lines.push(branchMsg.summary);
|
|
4551
|
-
lines.push("\n");
|
|
4552
|
-
} else if (msg.role === "compactionSummary") {
|
|
4553
|
-
const compactMsg = msg as CompactionSummaryMessage;
|
|
4554
|
-
lines.push("## Compaction Summary\n");
|
|
4555
|
-
lines.push(`(${compactMsg.tokensBefore} tokens before compaction)\n`);
|
|
4556
|
-
lines.push(compactMsg.summary);
|
|
4557
|
-
lines.push("\n");
|
|
4558
|
-
} else if (msg.role === "fileMention") {
|
|
4559
|
-
const fileMsg = msg as FileMentionMessage;
|
|
4560
|
-
lines.push("## File Mention\n");
|
|
4561
|
-
for (const file of fileMsg.files) {
|
|
4562
|
-
lines.push(`<file path="${file.path}">`);
|
|
4563
|
-
if (file.content) {
|
|
4564
|
-
lines.push(file.content);
|
|
4565
|
-
}
|
|
4566
|
-
if (file.image) {
|
|
4567
|
-
lines.push("[Image attached]");
|
|
4568
|
-
}
|
|
4569
|
-
lines.push("</file>\n");
|
|
4570
|
-
}
|
|
4571
|
-
lines.push("\n");
|
|
4572
|
-
}
|
|
4573
|
-
}
|
|
4574
|
-
|
|
4575
|
-
return lines.join("\n").trim();
|
|
3419
|
+
return sessionStats.formatSessionAsText(this.state);
|
|
4576
3420
|
}
|
|
4577
3421
|
|
|
4578
|
-
/**
|
|
4579
|
-
* Format the conversation as compact context for subagents.
|
|
4580
|
-
* Includes only user messages and assistant text responses.
|
|
4581
|
-
* Excludes: system prompt, tool definitions, tool calls/results, thinking blocks.
|
|
4582
|
-
*/
|
|
4583
3422
|
formatCompactContext(): string {
|
|
4584
|
-
|
|
4585
|
-
lines.push("# Conversation Context");
|
|
4586
|
-
lines.push("");
|
|
4587
|
-
lines.push(
|
|
4588
|
-
"This is a summary of the parent conversation. Read this if you need additional context about what was discussed or decided.",
|
|
4589
|
-
);
|
|
4590
|
-
lines.push("");
|
|
4591
|
-
|
|
4592
|
-
for (const msg of this.messages) {
|
|
4593
|
-
if (msg.role === "user") {
|
|
4594
|
-
lines.push("## User");
|
|
4595
|
-
lines.push("");
|
|
4596
|
-
if (typeof msg.content === "string") {
|
|
4597
|
-
lines.push(msg.content);
|
|
4598
|
-
} else {
|
|
4599
|
-
for (const c of msg.content) {
|
|
4600
|
-
if (c.type === "text") {
|
|
4601
|
-
lines.push(c.text);
|
|
4602
|
-
} else if (c.type === "image") {
|
|
4603
|
-
lines.push("[Image attached]");
|
|
4604
|
-
}
|
|
4605
|
-
}
|
|
4606
|
-
}
|
|
4607
|
-
lines.push("");
|
|
4608
|
-
} else if (msg.role === "assistant") {
|
|
4609
|
-
const assistantMsg = msg as AssistantMessage;
|
|
4610
|
-
// Only include text content, skip tool calls and thinking
|
|
4611
|
-
const textParts: string[] = [];
|
|
4612
|
-
for (const c of assistantMsg.content) {
|
|
4613
|
-
if (c.type === "text" && c.text.trim()) {
|
|
4614
|
-
textParts.push(c.text);
|
|
4615
|
-
}
|
|
4616
|
-
}
|
|
4617
|
-
if (textParts.length > 0) {
|
|
4618
|
-
lines.push("## Assistant");
|
|
4619
|
-
lines.push("");
|
|
4620
|
-
lines.push(textParts.join("\n\n"));
|
|
4621
|
-
lines.push("");
|
|
4622
|
-
}
|
|
4623
|
-
} else if (msg.role === "fileMention") {
|
|
4624
|
-
const fileMsg = msg as FileMentionMessage;
|
|
4625
|
-
const paths = fileMsg.files.map(f => f.path).join(", ");
|
|
4626
|
-
lines.push(`[Files referenced: ${paths}]`);
|
|
4627
|
-
lines.push("");
|
|
4628
|
-
} else if (msg.role === "compactionSummary") {
|
|
4629
|
-
const compactMsg = msg as CompactionSummaryMessage;
|
|
4630
|
-
lines.push("## Earlier Context (Summarized)");
|
|
4631
|
-
lines.push("");
|
|
4632
|
-
lines.push(compactMsg.summary);
|
|
4633
|
-
lines.push("");
|
|
4634
|
-
}
|
|
4635
|
-
// Skip: toolResult, bashExecution, pythonExecution, branchSummary, custom, hookMessage
|
|
4636
|
-
}
|
|
4637
|
-
|
|
4638
|
-
return lines.join("\n").trim();
|
|
3423
|
+
return sessionStats.formatCompactContext(this.messages);
|
|
4639
3424
|
}
|
|
4640
3425
|
|
|
4641
3426
|
// =========================================================================
|