@kolisachint/hoocode-agent 0.4.108 → 0.4.109
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 +2 -0
- package/dist/core/agent-session-compaction.d.ts +79 -0
- package/dist/core/agent-session-compaction.d.ts.map +1 -0
- package/dist/core/agent-session-compaction.js +346 -0
- package/dist/core/agent-session-compaction.js.map +1 -0
- package/dist/core/agent-session-retry.d.ts +76 -0
- package/dist/core/agent-session-retry.d.ts.map +1 -0
- package/dist/core/agent-session-retry.js +192 -0
- package/dist/core/agent-session-retry.js.map +1 -0
- package/dist/core/agent-session-skills.d.ts +34 -0
- package/dist/core/agent-session-skills.d.ts.map +1 -0
- package/dist/core/agent-session-skills.js +52 -0
- package/dist/core/agent-session-skills.js.map +1 -0
- package/dist/core/agent-session-stats.d.ts +74 -0
- package/dist/core/agent-session-stats.d.ts.map +1 -0
- package/dist/core/agent-session-stats.js +187 -0
- package/dist/core/agent-session-stats.js.map +1 -0
- package/dist/core/agent-session-tree-navigation.d.ts +69 -0
- package/dist/core/agent-session-tree-navigation.d.ts.map +1 -0
- package/dist/core/agent-session-tree-navigation.js +198 -0
- package/dist/core/agent-session-tree-navigation.js.map +1 -0
- package/dist/core/agent-session.d.ts +10 -66
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +96 -806
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/context-files.d.ts +25 -0
- package/dist/core/context-files.d.ts.map +1 -0
- package/dist/core/context-files.js +97 -0
- package/dist/core/context-files.js.map +1 -0
- package/dist/core/package-manager.d.ts.map +1 -1
- package/dist/core/package-manager.js +3 -519
- package/dist/core/package-manager.js.map +1 -1
- package/dist/core/package-resource-discovery.d.ts +62 -0
- package/dist/core/package-resource-discovery.d.ts.map +1 -0
- package/dist/core/package-resource-discovery.js +530 -0
- package/dist/core/package-resource-discovery.js.map +1 -0
- package/dist/core/resource-loader.d.ts +1 -10
- package/dist/core/resource-loader.d.ts.map +1 -1
- package/dist/core/resource-loader.js +4 -83
- package/dist/core/resource-loader.js.map +1 -1
- package/dist/core/settings-manager.d.ts +5 -140
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +4 -81
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/settings-storage.d.ts +29 -0
- package/dist/core/settings-storage.d.ts.map +1 -0
- package/dist/core/settings-storage.js +90 -0
- package/dist/core/settings-storage.js.map +1 -0
- package/dist/core/settings-types.d.ts +128 -0
- package/dist/core/settings-types.d.ts.map +1 -0
- package/dist/core/settings-types.js +9 -0
- package/dist/core/settings-types.js.map +1 -0
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
|
@@ -12,14 +12,15 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Modes use this class and add their own I/O layer on top.
|
|
14
14
|
*/
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import { calculateContextTokens, collectEntriesForBranchSummary, compact, estimateContextTokens, generateBranchSummary, prepareCompaction, shouldCompact, } from "@kolisachint/hoocode-agent-core";
|
|
18
|
-
import { clampThinkingLevel, cleanupSessionResources, getSupportedThinkingLevels, isContextOverflow, modelsAreEqual, resetApiProviders, } from "@kolisachint/hoocode-ai";
|
|
15
|
+
import { basename, dirname } from "node:path";
|
|
16
|
+
import { clampThinkingLevel, cleanupSessionResources, getSupportedThinkingLevels, modelsAreEqual, resetApiProviders, } from "@kolisachint/hoocode-ai";
|
|
19
17
|
import { theme } from "../modes/interactive/theme/theme.js";
|
|
20
|
-
import { stripFrontmatter } from "../utils/frontmatter.js";
|
|
21
|
-
import { sleep } from "../utils/sleep.js";
|
|
22
18
|
import { loadAgentRegistry } from "./agent-registry.js";
|
|
19
|
+
import { CompactionController } from "./agent-session-compaction.js";
|
|
20
|
+
import { AutoRetryController } from "./agent-session-retry.js";
|
|
21
|
+
import { expandSkillCommand } from "./agent-session-skills.js";
|
|
22
|
+
import { collectUserMessagesForForking, computeContextUsage, computeSessionStats, exportSessionBranchToJsonl, getLastAssistantText, } from "./agent-session-stats.js";
|
|
23
|
+
import { TreeNavigationController, } from "./agent-session-tree-navigation.js";
|
|
23
24
|
import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.js";
|
|
24
25
|
import { executeBashWithOperations } from "./bash-executor.js";
|
|
25
26
|
import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
|
|
@@ -29,7 +30,6 @@ import { ExtensionRunner, wrapRegisteredTools, } from "./extensions/index.js";
|
|
|
29
30
|
import { emitSessionShutdownEvent } from "./extensions/runner.js";
|
|
30
31
|
import { expandPromptTemplate, tryExpandPromptTemplate } from "./prompt-templates.js";
|
|
31
32
|
import { clearProviderExhaustion, isProviderQuotaError, markProviderExhausted } from "./provider-health.js";
|
|
32
|
-
import { CURRENT_SESSION_VERSION, getLatestCompactionEntry } from "./session-manager.js";
|
|
33
33
|
import { createSyntheticSourceInfo } from "./source-info.js";
|
|
34
34
|
import { updateSubagentSkillPaths } from "./subagent-pool-instance.js";
|
|
35
35
|
import { buildSystemPrompt } from "./system-prompt.js";
|
|
@@ -37,27 +37,7 @@ import { createLocalBashOperations } from "./tools/bash.js";
|
|
|
37
37
|
import { createAllToolDefinitions } from "./tools/index.js";
|
|
38
38
|
import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.js";
|
|
39
39
|
import { updateWarmSubagentSkillPaths } from "./warm-subagent-pool-instance.js";
|
|
40
|
-
|
|
41
|
-
* Retryable error signatures (overloaded, rate limit, server/network errors,
|
|
42
|
-
* transport closes). Compiled once at module load instead of on every assistant
|
|
43
|
-
* response. Context-overflow errors are handled separately by compaction.
|
|
44
|
-
*/
|
|
45
|
-
const RETRYABLE_ERROR_PATTERN = /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
|
|
46
|
-
/**
|
|
47
|
-
* Parse a skill block from message text.
|
|
48
|
-
* Returns null if the text doesn't contain a skill block.
|
|
49
|
-
*/
|
|
50
|
-
export function parseSkillBlock(text) {
|
|
51
|
-
const match = text.match(/^<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/);
|
|
52
|
-
if (!match)
|
|
53
|
-
return null;
|
|
54
|
-
return {
|
|
55
|
-
name: match[1],
|
|
56
|
-
location: match[2],
|
|
57
|
-
content: match[3],
|
|
58
|
-
userMessage: match[4]?.trim() || undefined,
|
|
59
|
-
};
|
|
60
|
-
}
|
|
40
|
+
export { parseSkillBlock } from "./agent-session-skills.js";
|
|
61
41
|
// ============================================================================
|
|
62
42
|
// Constants
|
|
63
43
|
// ============================================================================
|
|
@@ -82,16 +62,11 @@ export class AgentSession {
|
|
|
82
62
|
/** Messages queued to be included with the next user prompt as context ("asides"). */
|
|
83
63
|
_pendingNextTurnMessages = [];
|
|
84
64
|
// Compaction state
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
// Branch summarization state
|
|
89
|
-
_branchSummaryAbortController = undefined;
|
|
65
|
+
_compaction;
|
|
66
|
+
// Branch summarization / tree navigation
|
|
67
|
+
_tree;
|
|
90
68
|
// Retry state
|
|
91
|
-
|
|
92
|
-
_retryAttempt = 0;
|
|
93
|
-
_retryPromise = undefined;
|
|
94
|
-
_retryResolve = undefined;
|
|
69
|
+
_retry;
|
|
95
70
|
// Bash execution state
|
|
96
71
|
_bashAbortController = undefined;
|
|
97
72
|
_pendingBashMessages = [];
|
|
@@ -143,6 +118,52 @@ export class AgentSession {
|
|
|
143
118
|
: undefined;
|
|
144
119
|
this._baseToolsOverride = config.baseToolsOverride;
|
|
145
120
|
this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" };
|
|
121
|
+
this._retry = new AutoRetryController({
|
|
122
|
+
getRetrySettings: () => this.settingsManager.getRetrySettings(),
|
|
123
|
+
getModel: () => this.model,
|
|
124
|
+
getAgentMessages: () => this.agent.state.messages,
|
|
125
|
+
setAgentMessages: (messages) => {
|
|
126
|
+
this.agent.state.messages = messages;
|
|
127
|
+
},
|
|
128
|
+
continueAgent: () => {
|
|
129
|
+
this.agent.continue().catch(() => {
|
|
130
|
+
// Retry failed - will be caught by next agent_end
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
waitForAgentIdle: () => this.agent.waitForIdle(),
|
|
134
|
+
emit: (event) => this._emit(event),
|
|
135
|
+
});
|
|
136
|
+
this._compaction = new CompactionController({
|
|
137
|
+
sessionManager: this.sessionManager,
|
|
138
|
+
settingsManager: this.settingsManager,
|
|
139
|
+
modelRegistry: this._modelRegistry,
|
|
140
|
+
getModel: () => this.model,
|
|
141
|
+
getThinkingLevel: () => this.thinkingLevel,
|
|
142
|
+
getExtensionRunner: () => this._extensionRunner,
|
|
143
|
+
getAgentMessages: () => this.agent.state.messages,
|
|
144
|
+
setAgentMessages: (messages) => {
|
|
145
|
+
this.agent.state.messages = messages;
|
|
146
|
+
},
|
|
147
|
+
getRequiredRequestAuth: (model) => this._getRequiredRequestAuth(model),
|
|
148
|
+
emit: (event) => this._emit(event),
|
|
149
|
+
disconnectFromAgent: () => this._disconnectFromAgent(),
|
|
150
|
+
reconnectToAgent: () => this._reconnectToAgent(),
|
|
151
|
+
abortSession: () => this.abort(),
|
|
152
|
+
continueAgent: () => {
|
|
153
|
+
this.agent.continue().catch(() => { });
|
|
154
|
+
},
|
|
155
|
+
hasQueuedMessages: () => this.agent.hasQueuedMessages(),
|
|
156
|
+
});
|
|
157
|
+
this._tree = new TreeNavigationController({
|
|
158
|
+
sessionManager: this.sessionManager,
|
|
159
|
+
settingsManager: this.settingsManager,
|
|
160
|
+
getModel: () => this.model,
|
|
161
|
+
getExtensionRunner: () => this._extensionRunner,
|
|
162
|
+
getRequiredRequestAuth: (model) => this._getRequiredRequestAuth(model),
|
|
163
|
+
setAgentMessages: (messages) => {
|
|
164
|
+
this.agent.state.messages = messages;
|
|
165
|
+
},
|
|
166
|
+
});
|
|
146
167
|
// Always subscribe to agent events for internal handling
|
|
147
168
|
// (session persistence, extensions, auto-compaction, retry logic)
|
|
148
169
|
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
|
|
@@ -256,44 +277,19 @@ export class AgentSession {
|
|
|
256
277
|
_handleAgentEvent = (event) => {
|
|
257
278
|
// Create retry promise synchronously before queueing async processing.
|
|
258
279
|
// Agent.emit() calls this handler synchronously, and prompt() calls waitForRetry()
|
|
259
|
-
// as soon as agent.prompt() resolves. If
|
|
280
|
+
// as soon as agent.prompt() resolves. If the retry promise is created only inside
|
|
260
281
|
// _processAgentEvent, slow earlier queued events can delay agent_end processing
|
|
261
282
|
// and waitForRetry() can miss the in-flight retry.
|
|
262
|
-
this.
|
|
283
|
+
this._retry.createPromiseForAgentEnd(event);
|
|
263
284
|
this._agentEventQueue = this._agentEventQueue.then(() => this._processAgentEvent(event), () => this._processAgentEvent(event));
|
|
264
285
|
// Keep queue alive if an event handler fails
|
|
265
286
|
this._agentEventQueue.catch(() => { });
|
|
266
287
|
};
|
|
267
|
-
_createRetryPromiseForAgentEnd(event) {
|
|
268
|
-
if (event.type !== "agent_end" || this._retryPromise) {
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
const settings = this.settingsManager.getRetrySettings();
|
|
272
|
-
if (!settings.enabled) {
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
const lastAssistant = this._findLastAssistantInMessages(event.messages);
|
|
276
|
-
if (!lastAssistant || !this._isRetryableError(lastAssistant)) {
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
this._retryPromise = new Promise((resolve) => {
|
|
280
|
-
this._retryResolve = resolve;
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
_findLastAssistantInMessages(messages) {
|
|
284
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
285
|
-
const message = messages[i];
|
|
286
|
-
if (message.role === "assistant") {
|
|
287
|
-
return message;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
return undefined;
|
|
291
|
-
}
|
|
292
288
|
async _processAgentEvent(event) {
|
|
293
289
|
// When a user message starts, check if it's from either queue and remove it BEFORE emitting
|
|
294
290
|
// This ensures the UI sees the updated queue state
|
|
295
291
|
if (event.type === "message_start" && event.message.role === "user") {
|
|
296
|
-
this.
|
|
292
|
+
this._compaction.resetOverflowRecovery();
|
|
297
293
|
const messageText = this._getUserMessageText(event.message);
|
|
298
294
|
if (messageText) {
|
|
299
295
|
// Check steering queue first
|
|
@@ -335,7 +331,7 @@ export class AgentSession {
|
|
|
335
331
|
this._lastAssistantMessage = event.message;
|
|
336
332
|
const assistantMsg = event.message;
|
|
337
333
|
if (assistantMsg.stopReason !== "error") {
|
|
338
|
-
this.
|
|
334
|
+
this._compaction.resetOverflowRecovery();
|
|
339
335
|
// A successful response clears any prior provider-exhaustion flag so
|
|
340
336
|
// subagent dispatch is unblocked as soon as the provider recovers.
|
|
341
337
|
const provider = this.model?.provider;
|
|
@@ -344,13 +340,8 @@ export class AgentSession {
|
|
|
344
340
|
}
|
|
345
341
|
// Reset retry counter immediately on successful assistant response
|
|
346
342
|
// This prevents accumulation across multiple LLM calls within a turn
|
|
347
|
-
if (assistantMsg.stopReason !== "error"
|
|
348
|
-
this.
|
|
349
|
-
type: "auto_retry_end",
|
|
350
|
-
success: true,
|
|
351
|
-
attempt: this._retryAttempt,
|
|
352
|
-
});
|
|
353
|
-
this._retryAttempt = 0;
|
|
343
|
+
if (assistantMsg.stopReason !== "error") {
|
|
344
|
+
this._retry.onSuccessfulAssistantResponse();
|
|
354
345
|
}
|
|
355
346
|
}
|
|
356
347
|
}
|
|
@@ -359,8 +350,8 @@ export class AgentSession {
|
|
|
359
350
|
const msg = this._lastAssistantMessage;
|
|
360
351
|
this._lastAssistantMessage = undefined;
|
|
361
352
|
// Check for retryable errors first (overloaded, rate limit, server errors)
|
|
362
|
-
if (this.
|
|
363
|
-
const didRetry = await this.
|
|
353
|
+
if (this._retry.isRetryableError(msg)) {
|
|
354
|
+
const didRetry = await this._retry.handleRetryableError(msg);
|
|
364
355
|
if (didRetry)
|
|
365
356
|
return; // Retry was initiated, don't proceed to compaction
|
|
366
357
|
// Retries are exhausted/disabled and a quota or rate-limit error
|
|
@@ -372,16 +363,8 @@ export class AgentSession {
|
|
|
372
363
|
markProviderExhausted(provider, msg.errorMessage ?? "provider error");
|
|
373
364
|
}
|
|
374
365
|
}
|
|
375
|
-
this.
|
|
376
|
-
await this.
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
/** Resolve the pending retry promise */
|
|
380
|
-
_resolveRetry() {
|
|
381
|
-
if (this._retryResolve) {
|
|
382
|
-
this._retryResolve();
|
|
383
|
-
this._retryResolve = undefined;
|
|
384
|
-
this._retryPromise = undefined;
|
|
366
|
+
this._retry.resolve();
|
|
367
|
+
await this._compaction.checkCompaction(msg);
|
|
385
368
|
}
|
|
386
369
|
}
|
|
387
370
|
/** Extract text content from a message */
|
|
@@ -571,7 +554,7 @@ export class AgentSession {
|
|
|
571
554
|
}
|
|
572
555
|
/** Current retry attempt (0 if not retrying) */
|
|
573
556
|
get retryAttempt() {
|
|
574
|
-
return this.
|
|
557
|
+
return this._retry.attempt;
|
|
575
558
|
}
|
|
576
559
|
/**
|
|
577
560
|
* Get the names of currently active tools.
|
|
@@ -617,9 +600,7 @@ export class AgentSession {
|
|
|
617
600
|
}
|
|
618
601
|
/** Whether compaction or branch summarization is currently running */
|
|
619
602
|
get isCompacting() {
|
|
620
|
-
return
|
|
621
|
-
this._compactionAbortController !== undefined ||
|
|
622
|
-
this._branchSummaryAbortController !== undefined);
|
|
603
|
+
return this._compaction.isCompacting || this._tree.isSummarizing;
|
|
623
604
|
}
|
|
624
605
|
/** All messages including custom types like BashExecutionMessage */
|
|
625
606
|
get messages() {
|
|
@@ -798,7 +779,7 @@ export class AgentSession {
|
|
|
798
779
|
// Check if we need to compact before sending (catches aborted responses)
|
|
799
780
|
const lastAssistant = this._findLastAssistantMessage();
|
|
800
781
|
if (lastAssistant) {
|
|
801
|
-
await this.
|
|
782
|
+
await this._compaction.checkCompaction(lastAssistant, false);
|
|
802
783
|
}
|
|
803
784
|
// Build messages array (custom message if any, then user message)
|
|
804
785
|
messages = [];
|
|
@@ -869,7 +850,7 @@ export class AgentSession {
|
|
|
869
850
|
}
|
|
870
851
|
preflightResult?.(true);
|
|
871
852
|
await this.agent.prompt(messages);
|
|
872
|
-
await this.waitForRetry();
|
|
853
|
+
await this._retry.waitForRetry();
|
|
873
854
|
}
|
|
874
855
|
/**
|
|
875
856
|
* Try to execute an extension command. Returns true if command was found and executed.
|
|
@@ -904,29 +885,13 @@ export class AgentSession {
|
|
|
904
885
|
* Emits errors via extension runner if file read fails.
|
|
905
886
|
*/
|
|
906
887
|
_expandSkillCommand(text) {
|
|
907
|
-
|
|
908
|
-
return text;
|
|
909
|
-
const spaceIndex = text.indexOf(" ");
|
|
910
|
-
const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
|
|
911
|
-
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
|
|
912
|
-
const skill = this.resourceLoader.getSkills().skills.find((s) => s.name === skillName);
|
|
913
|
-
if (!skill)
|
|
914
|
-
return text; // Unknown skill, pass through
|
|
915
|
-
try {
|
|
916
|
-
const content = readFileSync(skill.filePath, "utf-8");
|
|
917
|
-
const body = stripFrontmatter(content).trim();
|
|
918
|
-
const skillBlock = `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${skill.baseDir}.\n\n${body}\n</skill>`;
|
|
919
|
-
return args ? `${skillBlock}\n\n${args}` : skillBlock;
|
|
920
|
-
}
|
|
921
|
-
catch (err) {
|
|
922
|
-
// Emit error like extension commands do
|
|
888
|
+
return expandSkillCommand(text, this.resourceLoader.getSkills().skills, ({ filePath, error }) => {
|
|
923
889
|
this._extensionRunner.emitError({
|
|
924
|
-
extensionPath:
|
|
890
|
+
extensionPath: filePath,
|
|
925
891
|
event: "skill_expansion",
|
|
926
|
-
error
|
|
892
|
+
error,
|
|
927
893
|
});
|
|
928
|
-
|
|
929
|
-
}
|
|
894
|
+
});
|
|
930
895
|
}
|
|
931
896
|
/**
|
|
932
897
|
* Queue a steering message while the agent is running.
|
|
@@ -1118,7 +1083,7 @@ export class AgentSession {
|
|
|
1118
1083
|
* Abort current operation and wait for agent to become idle.
|
|
1119
1084
|
*/
|
|
1120
1085
|
async abort() {
|
|
1121
|
-
this.
|
|
1086
|
+
this._retry.abort();
|
|
1122
1087
|
this.agent.abort();
|
|
1123
1088
|
await this.agent.waitForIdle();
|
|
1124
1089
|
}
|
|
@@ -1300,325 +1265,35 @@ export class AgentSession {
|
|
|
1300
1265
|
// =========================================================================
|
|
1301
1266
|
// Compaction
|
|
1302
1267
|
// =========================================================================
|
|
1303
|
-
/**
|
|
1304
|
-
* Shared core for manual and auto compaction.
|
|
1305
|
-
*
|
|
1306
|
-
* Runs the `session_before_compact` extension hook, produces the compaction
|
|
1307
|
-
* (from an extension or by summarizing), persists it, updates agent context,
|
|
1308
|
-
* and emits `session_compact`. Returns `{ status: "cancelled" }` if an
|
|
1309
|
-
* extension cancels or the signal aborts; callers map that to their own
|
|
1310
|
-
* cancel handling (manual throws, auto emits).
|
|
1311
|
-
*/
|
|
1312
|
-
async _applyCompaction(params) {
|
|
1313
|
-
const { preparation, branchEntries, model, apiKey, headers, customInstructions, signal } = params;
|
|
1314
|
-
let extensionCompaction;
|
|
1315
|
-
let fromExtension = false;
|
|
1316
|
-
if (this._extensionRunner.hasHandlers("session_before_compact")) {
|
|
1317
|
-
const result = (await this._extensionRunner.emit({
|
|
1318
|
-
type: "session_before_compact",
|
|
1319
|
-
preparation,
|
|
1320
|
-
branchEntries,
|
|
1321
|
-
customInstructions,
|
|
1322
|
-
signal,
|
|
1323
|
-
}));
|
|
1324
|
-
if (result?.cancel) {
|
|
1325
|
-
return { status: "cancelled" };
|
|
1326
|
-
}
|
|
1327
|
-
if (result?.compaction) {
|
|
1328
|
-
extensionCompaction = result.compaction;
|
|
1329
|
-
fromExtension = true;
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
const generated = extensionCompaction ??
|
|
1333
|
-
(await compact(preparation, model, apiKey, headers, customInstructions, signal, this.thinkingLevel));
|
|
1334
|
-
if (signal.aborted) {
|
|
1335
|
-
return { status: "cancelled" };
|
|
1336
|
-
}
|
|
1337
|
-
const { summary, firstKeptEntryId, tokensBefore, tokensAfter, details } = generated;
|
|
1338
|
-
this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, tokensAfter);
|
|
1339
|
-
const newEntries = this.sessionManager.getEntries();
|
|
1340
|
-
this.agent.state.messages = this.sessionManager.buildSessionContext().messages;
|
|
1341
|
-
const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary);
|
|
1342
|
-
if (this._extensionRunner && savedCompactionEntry) {
|
|
1343
|
-
await this._extensionRunner.emit({
|
|
1344
|
-
type: "session_compact",
|
|
1345
|
-
compactionEntry: savedCompactionEntry,
|
|
1346
|
-
fromExtension,
|
|
1347
|
-
});
|
|
1348
|
-
}
|
|
1349
|
-
return {
|
|
1350
|
-
status: "ok",
|
|
1351
|
-
result: { summary, firstKeptEntryId, tokensBefore, tokensAfter: tokensAfter ?? tokensBefore, details },
|
|
1352
|
-
};
|
|
1353
|
-
}
|
|
1354
1268
|
/**
|
|
1355
1269
|
* Manually compact the session context.
|
|
1356
1270
|
* Aborts current agent operation first.
|
|
1357
1271
|
* @param customInstructions Optional instructions for the compaction summary
|
|
1358
1272
|
*/
|
|
1359
1273
|
async compact(customInstructions) {
|
|
1360
|
-
this.
|
|
1361
|
-
await this.abort();
|
|
1362
|
-
this._compactionAbortController = new AbortController();
|
|
1363
|
-
this._emit({ type: "compaction_start", reason: "manual" });
|
|
1364
|
-
try {
|
|
1365
|
-
if (!this.model) {
|
|
1366
|
-
throw new Error(formatNoModelSelectedMessage());
|
|
1367
|
-
}
|
|
1368
|
-
const { apiKey, headers } = await this._getRequiredRequestAuth(this.model);
|
|
1369
|
-
const pathEntries = this.sessionManager.getBranch();
|
|
1370
|
-
const settings = this.settingsManager.getCompactionSettings();
|
|
1371
|
-
const preparation = prepareCompaction(pathEntries, settings);
|
|
1372
|
-
if (!preparation) {
|
|
1373
|
-
// Check why we can't compact
|
|
1374
|
-
const lastEntry = pathEntries[pathEntries.length - 1];
|
|
1375
|
-
if (lastEntry?.type === "compaction") {
|
|
1376
|
-
throw new Error("Already compacted");
|
|
1377
|
-
}
|
|
1378
|
-
throw new Error("Nothing to compact (session too small)");
|
|
1379
|
-
}
|
|
1380
|
-
const applied = await this._applyCompaction({
|
|
1381
|
-
preparation,
|
|
1382
|
-
branchEntries: pathEntries,
|
|
1383
|
-
model: this.model,
|
|
1384
|
-
apiKey,
|
|
1385
|
-
headers,
|
|
1386
|
-
customInstructions,
|
|
1387
|
-
signal: this._compactionAbortController.signal,
|
|
1388
|
-
});
|
|
1389
|
-
if (applied.status === "cancelled") {
|
|
1390
|
-
throw new Error("Compaction cancelled");
|
|
1391
|
-
}
|
|
1392
|
-
const compactionResult = applied.result;
|
|
1393
|
-
this._emit({
|
|
1394
|
-
type: "compaction_end",
|
|
1395
|
-
reason: "manual",
|
|
1396
|
-
result: compactionResult,
|
|
1397
|
-
aborted: false,
|
|
1398
|
-
willRetry: false,
|
|
1399
|
-
});
|
|
1400
|
-
return compactionResult;
|
|
1401
|
-
}
|
|
1402
|
-
catch (error) {
|
|
1403
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1404
|
-
const aborted = message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError");
|
|
1405
|
-
this._emit({
|
|
1406
|
-
type: "compaction_end",
|
|
1407
|
-
reason: "manual",
|
|
1408
|
-
result: undefined,
|
|
1409
|
-
aborted,
|
|
1410
|
-
willRetry: false,
|
|
1411
|
-
errorMessage: aborted ? undefined : `Compaction failed: ${message}`,
|
|
1412
|
-
});
|
|
1413
|
-
throw error;
|
|
1414
|
-
}
|
|
1415
|
-
finally {
|
|
1416
|
-
this._compactionAbortController = undefined;
|
|
1417
|
-
this._reconnectToAgent();
|
|
1418
|
-
}
|
|
1274
|
+
return this._compaction.compact(customInstructions);
|
|
1419
1275
|
}
|
|
1420
1276
|
/**
|
|
1421
1277
|
* Cancel in-progress compaction (manual or auto).
|
|
1422
1278
|
*/
|
|
1423
1279
|
abortCompaction() {
|
|
1424
|
-
this.
|
|
1425
|
-
this._autoCompactionAbortController?.abort();
|
|
1280
|
+
this._compaction.abortCompaction();
|
|
1426
1281
|
}
|
|
1427
1282
|
/**
|
|
1428
1283
|
* Cancel in-progress branch summarization.
|
|
1429
1284
|
*/
|
|
1430
1285
|
abortBranchSummary() {
|
|
1431
|
-
this.
|
|
1432
|
-
}
|
|
1433
|
-
/**
|
|
1434
|
-
* Check if compaction is needed and run it.
|
|
1435
|
-
* Called after agent_end and before prompt submission.
|
|
1436
|
-
*
|
|
1437
|
-
* Two cases:
|
|
1438
|
-
* 1. Overflow: LLM returned context overflow error, remove error message from agent state, compact, auto-retry
|
|
1439
|
-
* 2. Threshold: Context over threshold, compact, NO auto-retry (user continues manually)
|
|
1440
|
-
*
|
|
1441
|
-
* @param assistantMessage The assistant message to check
|
|
1442
|
-
* @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
|
|
1443
|
-
*/
|
|
1444
|
-
async _checkCompaction(assistantMessage, skipAbortedCheck = true) {
|
|
1445
|
-
const settings = this.settingsManager.getCompactionSettings();
|
|
1446
|
-
if (!settings.enabled)
|
|
1447
|
-
return;
|
|
1448
|
-
// Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
|
|
1449
|
-
if (skipAbortedCheck && assistantMessage.stopReason === "aborted")
|
|
1450
|
-
return;
|
|
1451
|
-
const contextWindow = this.model?.contextWindow ?? 0;
|
|
1452
|
-
// Skip overflow check if the message came from a different model.
|
|
1453
|
-
// This handles the case where user switched from a smaller-context model (e.g. opus)
|
|
1454
|
-
// to a larger-context model (e.g. codex) - the overflow error from the old model
|
|
1455
|
-
// shouldn't trigger compaction for the new model.
|
|
1456
|
-
const sameModel = this.model && assistantMessage.provider === this.model.provider && assistantMessage.model === this.model.id;
|
|
1457
|
-
// Skip compaction checks if this assistant message is older than the latest
|
|
1458
|
-
// compaction boundary. This prevents a stale pre-compaction usage/error
|
|
1459
|
-
// from retriggering compaction on the first prompt after compaction.
|
|
1460
|
-
const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());
|
|
1461
|
-
const assistantIsFromBeforeCompaction = compactionEntry !== null && assistantMessage.timestamp <= new Date(compactionEntry.timestamp).getTime();
|
|
1462
|
-
if (assistantIsFromBeforeCompaction) {
|
|
1463
|
-
return;
|
|
1464
|
-
}
|
|
1465
|
-
// Case 1: Overflow - LLM returned context overflow error
|
|
1466
|
-
if (sameModel && isContextOverflow(assistantMessage, contextWindow)) {
|
|
1467
|
-
if (this._overflowRecoveryAttempted) {
|
|
1468
|
-
this._emit({
|
|
1469
|
-
type: "compaction_end",
|
|
1470
|
-
reason: "overflow",
|
|
1471
|
-
result: undefined,
|
|
1472
|
-
aborted: false,
|
|
1473
|
-
willRetry: false,
|
|
1474
|
-
errorMessage: "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.",
|
|
1475
|
-
});
|
|
1476
|
-
return;
|
|
1477
|
-
}
|
|
1478
|
-
this._overflowRecoveryAttempted = true;
|
|
1479
|
-
// Remove the error message from agent state (it IS saved to session for history,
|
|
1480
|
-
// but we don't want it in context for the retry)
|
|
1481
|
-
const messages = this.agent.state.messages;
|
|
1482
|
-
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
|
|
1483
|
-
this.agent.state.messages = messages.slice(0, -1);
|
|
1484
|
-
}
|
|
1485
|
-
await this._runAutoCompaction("overflow", true);
|
|
1486
|
-
return;
|
|
1487
|
-
}
|
|
1488
|
-
// Case 2: Threshold - context is getting large
|
|
1489
|
-
// For error messages (no usage data), estimate from last successful response.
|
|
1490
|
-
// This ensures sessions that hit persistent API errors (e.g. 529) can still compact.
|
|
1491
|
-
let contextTokens;
|
|
1492
|
-
if (assistantMessage.stopReason === "error") {
|
|
1493
|
-
const messages = this.agent.state.messages;
|
|
1494
|
-
const estimate = estimateContextTokens(messages);
|
|
1495
|
-
if (estimate.lastUsageIndex === null)
|
|
1496
|
-
return; // No usage data at all
|
|
1497
|
-
// Verify the usage source is post-compaction. Kept pre-compaction messages
|
|
1498
|
-
// have stale usage reflecting the old (larger) context and would falsely
|
|
1499
|
-
// trigger compaction right after one just finished.
|
|
1500
|
-
const usageMsg = messages[estimate.lastUsageIndex];
|
|
1501
|
-
if (compactionEntry &&
|
|
1502
|
-
usageMsg.role === "assistant" &&
|
|
1503
|
-
usageMsg.timestamp <= new Date(compactionEntry.timestamp).getTime()) {
|
|
1504
|
-
return;
|
|
1505
|
-
}
|
|
1506
|
-
contextTokens = estimate.tokens;
|
|
1507
|
-
}
|
|
1508
|
-
else {
|
|
1509
|
-
contextTokens = calculateContextTokens(assistantMessage.usage);
|
|
1510
|
-
}
|
|
1511
|
-
if (shouldCompact(contextTokens, contextWindow, settings)) {
|
|
1512
|
-
await this._runAutoCompaction("threshold", false);
|
|
1513
|
-
}
|
|
1514
|
-
}
|
|
1515
|
-
/**
|
|
1516
|
-
* Internal: Run auto-compaction with events.
|
|
1517
|
-
*/
|
|
1518
|
-
async _runAutoCompaction(reason, willRetry) {
|
|
1519
|
-
const settings = this.settingsManager.getCompactionSettings();
|
|
1520
|
-
this._emit({ type: "compaction_start", reason });
|
|
1521
|
-
this._autoCompactionAbortController = new AbortController();
|
|
1522
|
-
try {
|
|
1523
|
-
if (!this.model) {
|
|
1524
|
-
this._emit({
|
|
1525
|
-
type: "compaction_end",
|
|
1526
|
-
reason,
|
|
1527
|
-
result: undefined,
|
|
1528
|
-
aborted: false,
|
|
1529
|
-
willRetry: false,
|
|
1530
|
-
});
|
|
1531
|
-
return;
|
|
1532
|
-
}
|
|
1533
|
-
const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model);
|
|
1534
|
-
if (!authResult.ok || !authResult.apiKey) {
|
|
1535
|
-
this._emit({
|
|
1536
|
-
type: "compaction_end",
|
|
1537
|
-
reason,
|
|
1538
|
-
result: undefined,
|
|
1539
|
-
aborted: false,
|
|
1540
|
-
willRetry: false,
|
|
1541
|
-
});
|
|
1542
|
-
return;
|
|
1543
|
-
}
|
|
1544
|
-
const { apiKey, headers } = authResult;
|
|
1545
|
-
const pathEntries = this.sessionManager.getBranch();
|
|
1546
|
-
const preparation = prepareCompaction(pathEntries, settings);
|
|
1547
|
-
if (!preparation) {
|
|
1548
|
-
this._emit({
|
|
1549
|
-
type: "compaction_end",
|
|
1550
|
-
reason,
|
|
1551
|
-
result: undefined,
|
|
1552
|
-
aborted: false,
|
|
1553
|
-
willRetry: false,
|
|
1554
|
-
});
|
|
1555
|
-
return;
|
|
1556
|
-
}
|
|
1557
|
-
const applied = await this._applyCompaction({
|
|
1558
|
-
preparation,
|
|
1559
|
-
branchEntries: pathEntries,
|
|
1560
|
-
model: this.model,
|
|
1561
|
-
apiKey,
|
|
1562
|
-
headers,
|
|
1563
|
-
customInstructions: undefined,
|
|
1564
|
-
signal: this._autoCompactionAbortController.signal,
|
|
1565
|
-
});
|
|
1566
|
-
if (applied.status === "cancelled") {
|
|
1567
|
-
this._emit({
|
|
1568
|
-
type: "compaction_end",
|
|
1569
|
-
reason,
|
|
1570
|
-
result: undefined,
|
|
1571
|
-
aborted: true,
|
|
1572
|
-
willRetry: false,
|
|
1573
|
-
});
|
|
1574
|
-
return;
|
|
1575
|
-
}
|
|
1576
|
-
const result = applied.result;
|
|
1577
|
-
this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry });
|
|
1578
|
-
if (willRetry) {
|
|
1579
|
-
const messages = this.agent.state.messages;
|
|
1580
|
-
const lastMsg = messages[messages.length - 1];
|
|
1581
|
-
if (lastMsg?.role === "assistant" && lastMsg.stopReason === "error") {
|
|
1582
|
-
this.agent.state.messages = messages.slice(0, -1);
|
|
1583
|
-
}
|
|
1584
|
-
setTimeout(() => {
|
|
1585
|
-
this.agent.continue().catch(() => { });
|
|
1586
|
-
}, 100);
|
|
1587
|
-
}
|
|
1588
|
-
else if (this.agent.hasQueuedMessages()) {
|
|
1589
|
-
// Auto-compaction can complete while follow-up/steering/custom messages are waiting.
|
|
1590
|
-
// Kick the loop so queued messages are actually delivered.
|
|
1591
|
-
setTimeout(() => {
|
|
1592
|
-
this.agent.continue().catch(() => { });
|
|
1593
|
-
}, 100);
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
catch (error) {
|
|
1597
|
-
const errorMessage = error instanceof Error ? error.message : "compaction failed";
|
|
1598
|
-
this._emit({
|
|
1599
|
-
type: "compaction_end",
|
|
1600
|
-
reason,
|
|
1601
|
-
result: undefined,
|
|
1602
|
-
aborted: false,
|
|
1603
|
-
willRetry: false,
|
|
1604
|
-
errorMessage: reason === "overflow"
|
|
1605
|
-
? `Context overflow recovery failed: ${errorMessage}`
|
|
1606
|
-
: `Auto-compaction failed: ${errorMessage}`,
|
|
1607
|
-
});
|
|
1608
|
-
}
|
|
1609
|
-
finally {
|
|
1610
|
-
this._autoCompactionAbortController = undefined;
|
|
1611
|
-
}
|
|
1286
|
+
this._tree.abortBranchSummary();
|
|
1612
1287
|
}
|
|
1613
1288
|
/**
|
|
1614
1289
|
* Toggle auto-compaction setting.
|
|
1615
1290
|
*/
|
|
1616
1291
|
setAutoCompactionEnabled(enabled) {
|
|
1617
|
-
this.
|
|
1292
|
+
this._compaction.setAutoCompactionEnabled(enabled);
|
|
1618
1293
|
}
|
|
1619
1294
|
/** Whether auto-compaction is enabled */
|
|
1620
1295
|
get autoCompactionEnabled() {
|
|
1621
|
-
return this.
|
|
1296
|
+
return this._compaction.autoCompactionEnabled;
|
|
1622
1297
|
}
|
|
1623
1298
|
async bindExtensions(bindings) {
|
|
1624
1299
|
if (bindings.uiContext !== undefined) {
|
|
@@ -1943,114 +1618,15 @@ export class AgentSession {
|
|
|
1943
1618
|
// =========================================================================
|
|
1944
1619
|
// Auto-Retry
|
|
1945
1620
|
// =========================================================================
|
|
1946
|
-
/**
|
|
1947
|
-
* Check if an error is retryable (overloaded, rate limit, server errors).
|
|
1948
|
-
* Context overflow errors are NOT retryable (handled by compaction instead).
|
|
1949
|
-
*/
|
|
1950
|
-
_isRetryableError(message) {
|
|
1951
|
-
if (message.stopReason !== "error" || !message.errorMessage)
|
|
1952
|
-
return false;
|
|
1953
|
-
// Context overflow is handled by compaction, not retry
|
|
1954
|
-
const contextWindow = this.model?.contextWindow ?? 0;
|
|
1955
|
-
if (isContextOverflow(message, contextWindow))
|
|
1956
|
-
return false;
|
|
1957
|
-
const err = message.errorMessage;
|
|
1958
|
-
// Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, network/connection errors (including connection lost), WebSocket transport closes/errors, fetch failed, request ended without sending chunks, HTTP/2 closed before response, terminated, retry delay exceeded
|
|
1959
|
-
return RETRYABLE_ERROR_PATTERN.test(err);
|
|
1960
|
-
}
|
|
1961
|
-
/**
|
|
1962
|
-
* Handle retryable errors with exponential backoff.
|
|
1963
|
-
* @returns true if retry was initiated, false if max retries exceeded or disabled
|
|
1964
|
-
*/
|
|
1965
|
-
async _handleRetryableError(message) {
|
|
1966
|
-
const settings = this.settingsManager.getRetrySettings();
|
|
1967
|
-
if (!settings.enabled) {
|
|
1968
|
-
this._resolveRetry();
|
|
1969
|
-
return false;
|
|
1970
|
-
}
|
|
1971
|
-
// Retry promise is created synchronously in _handleAgentEvent for agent_end.
|
|
1972
|
-
// Keep a defensive fallback here in case a future refactor bypasses that path.
|
|
1973
|
-
if (!this._retryPromise) {
|
|
1974
|
-
this._retryPromise = new Promise((resolve) => {
|
|
1975
|
-
this._retryResolve = resolve;
|
|
1976
|
-
});
|
|
1977
|
-
}
|
|
1978
|
-
this._retryAttempt++;
|
|
1979
|
-
if (this._retryAttempt > settings.maxRetries) {
|
|
1980
|
-
// Max retries exceeded, emit final failure and reset
|
|
1981
|
-
this._emit({
|
|
1982
|
-
type: "auto_retry_end",
|
|
1983
|
-
success: false,
|
|
1984
|
-
attempt: this._retryAttempt - 1,
|
|
1985
|
-
finalError: message.errorMessage,
|
|
1986
|
-
});
|
|
1987
|
-
this._retryAttempt = 0;
|
|
1988
|
-
this._resolveRetry(); // Resolve so waitForRetry() completes
|
|
1989
|
-
return false;
|
|
1990
|
-
}
|
|
1991
|
-
const delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1);
|
|
1992
|
-
this._emit({
|
|
1993
|
-
type: "auto_retry_start",
|
|
1994
|
-
attempt: this._retryAttempt,
|
|
1995
|
-
maxAttempts: settings.maxRetries,
|
|
1996
|
-
delayMs,
|
|
1997
|
-
errorMessage: message.errorMessage || "Unknown error",
|
|
1998
|
-
});
|
|
1999
|
-
// Remove error message from agent state (keep in session for history)
|
|
2000
|
-
const messages = this.agent.state.messages;
|
|
2001
|
-
if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
|
|
2002
|
-
this.agent.state.messages = messages.slice(0, -1);
|
|
2003
|
-
}
|
|
2004
|
-
// Wait with exponential backoff (abortable)
|
|
2005
|
-
this._retryAbortController = new AbortController();
|
|
2006
|
-
try {
|
|
2007
|
-
await sleep(delayMs, this._retryAbortController.signal);
|
|
2008
|
-
}
|
|
2009
|
-
catch {
|
|
2010
|
-
// Aborted during sleep - emit end event so UI can clean up
|
|
2011
|
-
const attempt = this._retryAttempt;
|
|
2012
|
-
this._retryAttempt = 0;
|
|
2013
|
-
this._retryAbortController = undefined;
|
|
2014
|
-
this._emit({
|
|
2015
|
-
type: "auto_retry_end",
|
|
2016
|
-
success: false,
|
|
2017
|
-
attempt,
|
|
2018
|
-
finalError: "Retry cancelled",
|
|
2019
|
-
});
|
|
2020
|
-
this._resolveRetry();
|
|
2021
|
-
return false;
|
|
2022
|
-
}
|
|
2023
|
-
this._retryAbortController = undefined;
|
|
2024
|
-
// Retry via continue() - use setTimeout to break out of event handler chain
|
|
2025
|
-
setTimeout(() => {
|
|
2026
|
-
this.agent.continue().catch(() => {
|
|
2027
|
-
// Retry failed - will be caught by next agent_end
|
|
2028
|
-
});
|
|
2029
|
-
}, 0);
|
|
2030
|
-
return true;
|
|
2031
|
-
}
|
|
2032
1621
|
/**
|
|
2033
1622
|
* Cancel in-progress retry.
|
|
2034
1623
|
*/
|
|
2035
1624
|
abortRetry() {
|
|
2036
|
-
this.
|
|
2037
|
-
// Note: _retryAttempt is reset in the catch block of _autoRetry
|
|
2038
|
-
this._resolveRetry();
|
|
2039
|
-
}
|
|
2040
|
-
/**
|
|
2041
|
-
* Wait for any in-progress retry to complete.
|
|
2042
|
-
* Returns immediately if no retry is in progress.
|
|
2043
|
-
*/
|
|
2044
|
-
async waitForRetry() {
|
|
2045
|
-
if (!this._retryPromise) {
|
|
2046
|
-
return;
|
|
2047
|
-
}
|
|
2048
|
-
await this._retryPromise;
|
|
2049
|
-
await this.agent.waitForIdle();
|
|
1625
|
+
this._retry.abort();
|
|
2050
1626
|
}
|
|
2051
1627
|
/** Whether auto-retry is currently in progress */
|
|
2052
1628
|
get isRetrying() {
|
|
2053
|
-
return this.
|
|
1629
|
+
return this._retry.isRetrying;
|
|
2054
1630
|
}
|
|
2055
1631
|
/** Whether auto-retry is enabled */
|
|
2056
1632
|
get autoRetryEnabled() {
|
|
@@ -2173,275 +1749,31 @@ export class AgentSession {
|
|
|
2173
1749
|
* @returns Result with editorText (if user message) and cancelled status
|
|
2174
1750
|
*/
|
|
2175
1751
|
async navigateTree(targetId, options = {}) {
|
|
2176
|
-
|
|
2177
|
-
// No-op if already at target
|
|
2178
|
-
if (targetId === oldLeafId) {
|
|
2179
|
-
return { cancelled: false };
|
|
2180
|
-
}
|
|
2181
|
-
// Model required for summarization
|
|
2182
|
-
if (options.summarize && !this.model) {
|
|
2183
|
-
throw new Error("No model available for summarization");
|
|
2184
|
-
}
|
|
2185
|
-
const targetEntry = this.sessionManager.getEntry(targetId);
|
|
2186
|
-
if (!targetEntry) {
|
|
2187
|
-
throw new Error(`Entry ${targetId} not found`);
|
|
2188
|
-
}
|
|
2189
|
-
// Collect entries to summarize (from old leaf to common ancestor)
|
|
2190
|
-
const { entries: entriesToSummarize, commonAncestorId } = await collectEntriesForBranchSummary(this.sessionManager, oldLeafId, targetId);
|
|
2191
|
-
// Prepare event data - mutable so extensions can override
|
|
2192
|
-
let customInstructions = options.customInstructions;
|
|
2193
|
-
let replaceInstructions = options.replaceInstructions;
|
|
2194
|
-
let label = options.label;
|
|
2195
|
-
const preparation = {
|
|
2196
|
-
targetId,
|
|
2197
|
-
oldLeafId,
|
|
2198
|
-
commonAncestorId,
|
|
2199
|
-
entriesToSummarize,
|
|
2200
|
-
userWantsSummary: options.summarize ?? false,
|
|
2201
|
-
customInstructions,
|
|
2202
|
-
replaceInstructions,
|
|
2203
|
-
label,
|
|
2204
|
-
};
|
|
2205
|
-
// Set up abort controller for summarization
|
|
2206
|
-
this._branchSummaryAbortController = new AbortController();
|
|
2207
|
-
try {
|
|
2208
|
-
let extensionSummary;
|
|
2209
|
-
let fromExtension = false;
|
|
2210
|
-
// Emit session_before_tree event
|
|
2211
|
-
if (this._extensionRunner.hasHandlers("session_before_tree")) {
|
|
2212
|
-
const result = (await this._extensionRunner.emit({
|
|
2213
|
-
type: "session_before_tree",
|
|
2214
|
-
preparation,
|
|
2215
|
-
signal: this._branchSummaryAbortController.signal,
|
|
2216
|
-
}));
|
|
2217
|
-
if (result?.cancel) {
|
|
2218
|
-
return { cancelled: true };
|
|
2219
|
-
}
|
|
2220
|
-
if (result?.summary && options.summarize) {
|
|
2221
|
-
extensionSummary = result.summary;
|
|
2222
|
-
fromExtension = true;
|
|
2223
|
-
}
|
|
2224
|
-
// Allow extensions to override instructions and label
|
|
2225
|
-
if (result?.customInstructions !== undefined) {
|
|
2226
|
-
customInstructions = result.customInstructions;
|
|
2227
|
-
}
|
|
2228
|
-
if (result?.replaceInstructions !== undefined) {
|
|
2229
|
-
replaceInstructions = result.replaceInstructions;
|
|
2230
|
-
}
|
|
2231
|
-
if (result?.label !== undefined) {
|
|
2232
|
-
label = result.label;
|
|
2233
|
-
}
|
|
2234
|
-
}
|
|
2235
|
-
// Run default summarizer if needed
|
|
2236
|
-
let summaryText;
|
|
2237
|
-
let summaryDetails;
|
|
2238
|
-
if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) {
|
|
2239
|
-
const model = this.model;
|
|
2240
|
-
const { apiKey, headers } = await this._getRequiredRequestAuth(model);
|
|
2241
|
-
const branchSummarySettings = this.settingsManager.getBranchSummarySettings();
|
|
2242
|
-
const result = await generateBranchSummary(entriesToSummarize, {
|
|
2243
|
-
model,
|
|
2244
|
-
apiKey,
|
|
2245
|
-
headers,
|
|
2246
|
-
signal: this._branchSummaryAbortController.signal,
|
|
2247
|
-
customInstructions,
|
|
2248
|
-
replaceInstructions,
|
|
2249
|
-
reserveTokens: branchSummarySettings.reserveTokens,
|
|
2250
|
-
});
|
|
2251
|
-
if (result.aborted) {
|
|
2252
|
-
return { cancelled: true, aborted: true };
|
|
2253
|
-
}
|
|
2254
|
-
if (result.error) {
|
|
2255
|
-
throw new Error(result.error);
|
|
2256
|
-
}
|
|
2257
|
-
summaryText = result.summary;
|
|
2258
|
-
summaryDetails = {
|
|
2259
|
-
readFiles: result.readFiles || [],
|
|
2260
|
-
modifiedFiles: result.modifiedFiles || [],
|
|
2261
|
-
};
|
|
2262
|
-
}
|
|
2263
|
-
else if (extensionSummary) {
|
|
2264
|
-
summaryText = extensionSummary.summary;
|
|
2265
|
-
summaryDetails = extensionSummary.details;
|
|
2266
|
-
}
|
|
2267
|
-
// Determine the new leaf position based on target type
|
|
2268
|
-
let newLeafId;
|
|
2269
|
-
let editorText;
|
|
2270
|
-
if (targetEntry.type === "message" && targetEntry.message.role === "user") {
|
|
2271
|
-
// User message: leaf = parent (null if root), text goes to editor
|
|
2272
|
-
newLeafId = targetEntry.parentId;
|
|
2273
|
-
editorText = this._extractUserMessageText(targetEntry.message.content);
|
|
2274
|
-
}
|
|
2275
|
-
else if (targetEntry.type === "custom_message") {
|
|
2276
|
-
// Custom message: leaf = parent (null if root), text goes to editor
|
|
2277
|
-
newLeafId = targetEntry.parentId;
|
|
2278
|
-
editorText =
|
|
2279
|
-
typeof targetEntry.content === "string"
|
|
2280
|
-
? targetEntry.content
|
|
2281
|
-
: targetEntry.content
|
|
2282
|
-
.filter((c) => c.type === "text")
|
|
2283
|
-
.map((c) => c.text)
|
|
2284
|
-
.join("");
|
|
2285
|
-
}
|
|
2286
|
-
else {
|
|
2287
|
-
// Non-user message: leaf = selected node
|
|
2288
|
-
newLeafId = targetId;
|
|
2289
|
-
}
|
|
2290
|
-
// Switch leaf (with or without summary)
|
|
2291
|
-
// Summary is attached at the navigation target position (newLeafId), not the old branch
|
|
2292
|
-
let summaryEntry;
|
|
2293
|
-
if (summaryText) {
|
|
2294
|
-
// Create summary at target position (can be null for root)
|
|
2295
|
-
const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension);
|
|
2296
|
-
summaryEntry = this.sessionManager.getEntry(summaryId);
|
|
2297
|
-
// Attach label to the summary entry
|
|
2298
|
-
if (label) {
|
|
2299
|
-
this.sessionManager.appendLabelChange(summaryId, label);
|
|
2300
|
-
}
|
|
2301
|
-
}
|
|
2302
|
-
else if (newLeafId === null) {
|
|
2303
|
-
// No summary, navigating to root - reset leaf
|
|
2304
|
-
this.sessionManager.resetLeaf();
|
|
2305
|
-
}
|
|
2306
|
-
else {
|
|
2307
|
-
// No summary, navigating to non-root
|
|
2308
|
-
this.sessionManager.branch(newLeafId);
|
|
2309
|
-
}
|
|
2310
|
-
// Attach label to target entry when not summarizing (no summary entry to label)
|
|
2311
|
-
if (label && !summaryText) {
|
|
2312
|
-
this.sessionManager.appendLabelChange(targetId, label);
|
|
2313
|
-
}
|
|
2314
|
-
// Update agent state
|
|
2315
|
-
const sessionContext = this.sessionManager.buildSessionContext();
|
|
2316
|
-
this.agent.state.messages = sessionContext.messages;
|
|
2317
|
-
// Emit session_tree event
|
|
2318
|
-
await this._extensionRunner.emit({
|
|
2319
|
-
type: "session_tree",
|
|
2320
|
-
newLeafId: this.sessionManager.getLeafId(),
|
|
2321
|
-
oldLeafId,
|
|
2322
|
-
summaryEntry,
|
|
2323
|
-
fromExtension: summaryText ? fromExtension : undefined,
|
|
2324
|
-
});
|
|
2325
|
-
// Emit to custom tools
|
|
2326
|
-
return { editorText, cancelled: false, summaryEntry };
|
|
2327
|
-
}
|
|
2328
|
-
finally {
|
|
2329
|
-
this._branchSummaryAbortController = undefined;
|
|
2330
|
-
}
|
|
1752
|
+
return this._tree.navigateTree(targetId, options);
|
|
2331
1753
|
}
|
|
2332
1754
|
/**
|
|
2333
1755
|
* Get all user messages from session for fork selector.
|
|
2334
1756
|
*/
|
|
2335
1757
|
getUserMessagesForForking() {
|
|
2336
|
-
|
|
2337
|
-
const result = [];
|
|
2338
|
-
for (const entry of entries) {
|
|
2339
|
-
if (entry.type !== "message")
|
|
2340
|
-
continue;
|
|
2341
|
-
if (entry.message.role !== "user")
|
|
2342
|
-
continue;
|
|
2343
|
-
const text = this._extractUserMessageText(entry.message.content);
|
|
2344
|
-
if (text) {
|
|
2345
|
-
result.push({ entryId: entry.id, text });
|
|
2346
|
-
}
|
|
2347
|
-
}
|
|
2348
|
-
return result;
|
|
2349
|
-
}
|
|
2350
|
-
_extractUserMessageText(content) {
|
|
2351
|
-
if (typeof content === "string")
|
|
2352
|
-
return content;
|
|
2353
|
-
if (Array.isArray(content)) {
|
|
2354
|
-
return content
|
|
2355
|
-
.filter((c) => c.type === "text")
|
|
2356
|
-
.map((c) => c.text)
|
|
2357
|
-
.join("");
|
|
2358
|
-
}
|
|
2359
|
-
return "";
|
|
1758
|
+
return collectUserMessagesForForking(this.sessionManager);
|
|
2360
1759
|
}
|
|
2361
1760
|
/**
|
|
2362
1761
|
* Get session statistics.
|
|
2363
1762
|
*/
|
|
2364
1763
|
getSessionStats() {
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
const assistantMessages = state.messages.filter((m) => m.role === "assistant").length;
|
|
2368
|
-
const toolResults = state.messages.filter((m) => m.role === "toolResult").length;
|
|
2369
|
-
let toolCalls = 0;
|
|
2370
|
-
let totalInput = 0;
|
|
2371
|
-
let totalOutput = 0;
|
|
2372
|
-
let totalCacheRead = 0;
|
|
2373
|
-
let totalCacheWrite = 0;
|
|
2374
|
-
let totalCost = 0;
|
|
2375
|
-
for (const message of state.messages) {
|
|
2376
|
-
if (message.role === "assistant") {
|
|
2377
|
-
const assistantMsg = message;
|
|
2378
|
-
toolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length;
|
|
2379
|
-
totalInput += assistantMsg.usage.input;
|
|
2380
|
-
totalOutput += assistantMsg.usage.output;
|
|
2381
|
-
totalCacheRead += assistantMsg.usage.cacheRead;
|
|
2382
|
-
totalCacheWrite += assistantMsg.usage.cacheWrite;
|
|
2383
|
-
totalCost += assistantMsg.usage.cost.total;
|
|
2384
|
-
}
|
|
2385
|
-
}
|
|
2386
|
-
return {
|
|
1764
|
+
return computeSessionStats({
|
|
1765
|
+
messages: this.state.messages,
|
|
2387
1766
|
sessionFile: this.sessionFile,
|
|
2388
1767
|
sessionId: this.sessionId,
|
|
2389
|
-
userMessages,
|
|
2390
|
-
assistantMessages,
|
|
2391
|
-
toolCalls,
|
|
2392
|
-
toolResults,
|
|
2393
|
-
totalMessages: state.messages.length,
|
|
2394
|
-
tokens: {
|
|
2395
|
-
input: totalInput,
|
|
2396
|
-
output: totalOutput,
|
|
2397
|
-
cacheRead: totalCacheRead,
|
|
2398
|
-
cacheWrite: totalCacheWrite,
|
|
2399
|
-
total: totalInput + totalOutput + totalCacheRead + totalCacheWrite,
|
|
2400
|
-
},
|
|
2401
|
-
cost: totalCost,
|
|
2402
1768
|
contextUsage: this.getContextUsage(),
|
|
2403
|
-
};
|
|
1769
|
+
});
|
|
2404
1770
|
}
|
|
2405
1771
|
getContextUsage() {
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
return undefined;
|
|
2412
|
-
// After compaction, the last assistant usage reflects pre-compaction context size.
|
|
2413
|
-
// We can only trust usage from an assistant that responded after the latest compaction.
|
|
2414
|
-
// If no such assistant exists, context token count is unknown until the next LLM response.
|
|
2415
|
-
const branchEntries = this.sessionManager.getBranch();
|
|
2416
|
-
const latestCompaction = getLatestCompactionEntry(branchEntries);
|
|
2417
|
-
if (latestCompaction) {
|
|
2418
|
-
// Check if there's a valid assistant usage after the compaction boundary
|
|
2419
|
-
const compactionIndex = branchEntries.lastIndexOf(latestCompaction);
|
|
2420
|
-
let hasPostCompactionUsage = false;
|
|
2421
|
-
for (let i = branchEntries.length - 1; i > compactionIndex; i--) {
|
|
2422
|
-
const entry = branchEntries[i];
|
|
2423
|
-
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
2424
|
-
const assistant = entry.message;
|
|
2425
|
-
if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error") {
|
|
2426
|
-
const contextTokens = calculateContextTokens(assistant.usage);
|
|
2427
|
-
if (contextTokens > 0) {
|
|
2428
|
-
hasPostCompactionUsage = true;
|
|
2429
|
-
}
|
|
2430
|
-
break;
|
|
2431
|
-
}
|
|
2432
|
-
}
|
|
2433
|
-
}
|
|
2434
|
-
if (!hasPostCompactionUsage) {
|
|
2435
|
-
return { tokens: null, contextWindow, percent: null };
|
|
2436
|
-
}
|
|
2437
|
-
}
|
|
2438
|
-
const estimate = estimateContextTokens(this.messages);
|
|
2439
|
-
const percent = (estimate.tokens / contextWindow) * 100;
|
|
2440
|
-
return {
|
|
2441
|
-
tokens: estimate.tokens,
|
|
2442
|
-
contextWindow,
|
|
2443
|
-
percent,
|
|
2444
|
-
};
|
|
1772
|
+
return computeContextUsage({
|
|
1773
|
+
model: this.model,
|
|
1774
|
+
sessionManager: this.sessionManager,
|
|
1775
|
+
messages: this.messages,
|
|
1776
|
+
});
|
|
2445
1777
|
}
|
|
2446
1778
|
/**
|
|
2447
1779
|
* Export session to HTML.
|
|
@@ -2469,29 +1801,7 @@ export class AgentSession {
|
|
|
2469
1801
|
* @returns The resolved output file path.
|
|
2470
1802
|
*/
|
|
2471
1803
|
exportToJsonl(outputPath) {
|
|
2472
|
-
|
|
2473
|
-
const dir = dirname(filePath);
|
|
2474
|
-
if (!existsSync(dir)) {
|
|
2475
|
-
mkdirSync(dir, { recursive: true });
|
|
2476
|
-
}
|
|
2477
|
-
const header = {
|
|
2478
|
-
type: "session",
|
|
2479
|
-
version: CURRENT_SESSION_VERSION,
|
|
2480
|
-
id: this.sessionManager.getSessionId(),
|
|
2481
|
-
timestamp: new Date().toISOString(),
|
|
2482
|
-
cwd: this.sessionManager.getCwd(),
|
|
2483
|
-
};
|
|
2484
|
-
const branchEntries = this.sessionManager.getBranch();
|
|
2485
|
-
const lines = [JSON.stringify(header)];
|
|
2486
|
-
// Re-chain parentIds to form a linear sequence
|
|
2487
|
-
let prevId = null;
|
|
2488
|
-
for (const entry of branchEntries) {
|
|
2489
|
-
const linear = { ...entry, parentId: prevId };
|
|
2490
|
-
lines.push(JSON.stringify(linear));
|
|
2491
|
-
prevId = entry.id;
|
|
2492
|
-
}
|
|
2493
|
-
writeFileSync(filePath, `${lines.join("\n")}\n`);
|
|
2494
|
-
return filePath;
|
|
1804
|
+
return exportSessionBranchToJsonl(this.sessionManager, outputPath);
|
|
2495
1805
|
}
|
|
2496
1806
|
// =========================================================================
|
|
2497
1807
|
// Utilities
|
|
@@ -2502,27 +1812,7 @@ export class AgentSession {
|
|
|
2502
1812
|
* @returns Text content, or undefined if no assistant message exists
|
|
2503
1813
|
*/
|
|
2504
1814
|
getLastAssistantText() {
|
|
2505
|
-
|
|
2506
|
-
.slice()
|
|
2507
|
-
.reverse()
|
|
2508
|
-
.find((m) => {
|
|
2509
|
-
if (m.role !== "assistant")
|
|
2510
|
-
return false;
|
|
2511
|
-
const msg = m;
|
|
2512
|
-
// Skip aborted messages with no content
|
|
2513
|
-
if (msg.stopReason === "aborted" && msg.content.length === 0)
|
|
2514
|
-
return false;
|
|
2515
|
-
return true;
|
|
2516
|
-
});
|
|
2517
|
-
if (!lastAssistant)
|
|
2518
|
-
return undefined;
|
|
2519
|
-
let text = "";
|
|
2520
|
-
for (const content of lastAssistant.content) {
|
|
2521
|
-
if (content.type === "text") {
|
|
2522
|
-
text += content.text;
|
|
2523
|
-
}
|
|
2524
|
-
}
|
|
2525
|
-
return text.trim() || undefined;
|
|
1815
|
+
return getLastAssistantText(this.messages);
|
|
2526
1816
|
}
|
|
2527
1817
|
// =========================================================================
|
|
2528
1818
|
// Extension System
|