@gajae-code/coding-agent 0.11.4 → 0.11.6
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 +23 -0
- package/dist/types/cli/notify-cli.d.ts +5 -1
- package/dist/types/config/settings-schema.d.ts +31 -1
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
- package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
- package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
- package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/harness-control-plane/owner.d.ts +3 -1
- package/dist/types/internal-urls/local-protocol.d.ts +18 -0
- package/dist/types/main.d.ts +2 -2
- package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-transcript-format.d.ts +58 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
- package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
- package/dist/types/plan-mode/approved-plan.d.ts +1 -0
- package/dist/types/runtime-mcp/manager.d.ts +2 -0
- package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +42 -4
- package/dist/types/sdk/bus/config-commands.d.ts +8 -0
- package/dist/types/sdk/bus/config.d.ts +22 -0
- package/dist/types/sdk/bus/index.d.ts +162 -2
- package/dist/types/sdk/bus/notification-service.d.ts +71 -8
- package/dist/types/sdk/bus/session-control.d.ts +11 -8
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -3
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
- package/dist/types/sdk/session.d.ts +16 -1
- package/dist/types/secrets/index.d.ts +4 -2
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +31 -2
- package/dist/types/session/artifacts.d.ts +30 -8
- package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
- package/dist/types/session/session-manager.d.ts +98 -18
- package/dist/types/session/session-storage.d.ts +27 -0
- package/dist/types/session/streaming-output.d.ts +4 -0
- package/dist/types/session/yield-queue.d.ts +8 -0
- package/dist/types/task/executor.d.ts +9 -0
- package/dist/types/tool-discovery/tool-index.d.ts +2 -0
- package/dist/types/tools/ask.d.ts +48 -0
- package/dist/types/tools/bash-skill-urls.d.ts +2 -2
- package/dist/types/tools/index.d.ts +4 -0
- package/package.json +9 -9
- package/scripts/generate-sdk-operation-inventory.ts +3 -0
- package/src/cli/notify-cli.ts +40 -18
- package/src/commands/sdk.ts +7 -2
- package/src/config/settings-schema.ts +31 -1
- package/src/daemon/runtime.ts +2 -2
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
- package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
- package/src/defaults/gjc/skills/team/SKILL.md +7 -9
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
- package/src/eval/py/index.ts +2 -2
- package/src/export/html/template.generated.ts +1 -1
- package/src/extensibility/plugins/marketplace/manager.ts +94 -10
- package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
- package/src/gjc-runtime/deep-interview-state.ts +127 -3
- package/src/gjc-runtime/launch-tmux.ts +61 -6
- package/src/gjc-runtime/managed-owner-admission.ts +352 -0
- package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
- package/src/gjc-runtime/state-runtime.ts +38 -12
- package/src/gjc-runtime/team-runtime.ts +1 -1
- package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
- package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
- package/src/harness-control-plane/owner.ts +59 -3
- package/src/hindsight/client.ts +65 -2
- package/src/internal-urls/agent-protocol.ts +76 -5
- package/src/internal-urls/docs-index.generated.ts +8 -7
- package/src/internal-urls/local-protocol.ts +416 -18
- package/src/lsp/index.ts +4 -88
- package/src/main.ts +36 -12
- package/src/modes/acp/acp-agent.ts +8 -0
- package/src/modes/components/ansi-display-validator.ts +54 -0
- package/src/modes/components/notifications-settings-editor.ts +26 -2
- package/src/modes/components/session-observer-overlay.ts +40 -24
- package/src/modes/components/settings-selector.ts +3 -1
- package/src/modes/components/tool-transcript-format.ts +321 -0
- package/src/modes/components/transcript-viewer-overlay.ts +39 -17
- package/src/modes/controllers/command-controller.ts +15 -0
- package/src/modes/controllers/extension-ui-controller.ts +14 -2
- package/src/modes/controllers/goal-mode-controller.ts +20 -1
- package/src/modes/controllers/plan-mode-controller.ts +2 -0
- package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +70 -8
- package/src/modes/interactive-mode.ts +1 -0
- package/src/modes/runtime-init.ts +16 -2
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
- package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
- package/src/plan-mode/approved-plan.ts +3 -1
- package/src/prompts/agents/architect.md +1 -0
- package/src/prompts/agents/critic.md +1 -0
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/system-prompt.md +2 -1
- package/src/prompts/tools/lsp.md +3 -3
- package/src/runtime-mcp/manager.ts +16 -1
- package/src/runtime-mcp/oauth-discovery.ts +116 -26
- package/src/sdk/broker/session-index.ts +122 -64
- package/src/sdk/bus/chat-daemon-cli.ts +72 -10
- package/src/sdk/bus/chat-daemon-control.ts +538 -130
- package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
- package/src/sdk/bus/config-commands.ts +22 -0
- package/src/sdk/bus/config.ts +37 -0
- package/src/sdk/bus/index.ts +388 -94
- package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
- package/src/sdk/bus/notification-service.ts +284 -62
- package/src/sdk/bus/recent-activity.ts +14 -5
- package/src/sdk/bus/session-control.ts +150 -44
- package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
- package/src/sdk/bus/telegram-daemon-contract.ts +11 -3
- package/src/sdk/bus/telegram-daemon-control.ts +312 -76
- package/src/sdk/bus/telegram-daemon.ts +1916 -285
- package/src/sdk/host/control/dispatch.ts +14 -5
- package/src/sdk/protocol/operation-inventory.generated.json +29 -0
- package/src/sdk/session.ts +155 -27
- package/src/secrets/index.ts +53 -10
- package/src/secrets/obfuscator.ts +21 -18
- package/src/secrets/regex.ts +2 -0
- package/src/session/agent-session.ts +1447 -852
- package/src/session/artifacts.ts +188 -23
- package/src/session/context-estimation.ts +7 -2
- package/src/session/internal/managed-session-scope.ts +200 -38
- package/src/session/internal/managed-session-storage.ts +1152 -46
- package/src/session/session-manager.ts +1121 -148
- package/src/session/session-storage.ts +109 -11
- package/src/session/streaming-output.ts +6 -0
- package/src/session/yield-queue.ts +20 -0
- package/src/slash-commands/builtin-registry.ts +36 -0
- package/src/task/executor.ts +71 -26
- package/src/task/index.ts +63 -20
- package/src/tool-discovery/tool-index.ts +18 -0
- package/src/tools/ask-answer-registry.ts +15 -1
- package/src/tools/ask.ts +129 -32
- package/src/tools/bash-skill-urls.ts +14 -3
- package/src/tools/bash.ts +2 -2
- package/src/tools/index.ts +4 -0
- package/src/tools/plan-mode-guard.ts +1 -0
- package/src/tools/sqlite-reader.ts +3 -2
- package/src/tools/subagent.ts +2 -0
|
@@ -245,7 +245,6 @@ import {
|
|
|
245
245
|
persistCoordinatorRuntimeStateFromEvent,
|
|
246
246
|
registerCoordinatorRuntimeStateFinalizer,
|
|
247
247
|
} from "../gjc-runtime/session-state-sidecar";
|
|
248
|
-
import { writeArtifact } from "../gjc-runtime/state-writer";
|
|
249
248
|
import { requestGjcWorkerIntegrationAttempt } from "../gjc-runtime/team-runtime";
|
|
250
249
|
import { GoalRuntime } from "../goals/runtime";
|
|
251
250
|
import type { Goal, GoalModeState } from "../goals/state";
|
|
@@ -295,8 +294,10 @@ import {
|
|
|
295
294
|
isMCPBridgeTool,
|
|
296
295
|
isMCPToolName,
|
|
297
296
|
selectDiscoverableToolNamesByServer,
|
|
297
|
+
selectRestorableDiscoveredBuiltinToolNames,
|
|
298
298
|
} from "../tool-discovery/tool-index";
|
|
299
299
|
import type { AskAnswerSource, ToolSession } from "../tools";
|
|
300
|
+
import { computeEssentialBuiltinNames } from "../tools";
|
|
300
301
|
import { AskTool } from "../tools/ask";
|
|
301
302
|
import {
|
|
302
303
|
getAskAnswerSource as getAskAnswerSourceFromRegistry,
|
|
@@ -366,6 +367,8 @@ import type {
|
|
|
366
367
|
CompactionEntry,
|
|
367
368
|
DefaultModelSelectionStage,
|
|
368
369
|
NewSessionOptions,
|
|
370
|
+
RecoveryHydrationContext,
|
|
371
|
+
RecoveryHydrationPromotionFence,
|
|
369
372
|
SessionContext,
|
|
370
373
|
SessionEntry,
|
|
371
374
|
SessionManager,
|
|
@@ -432,11 +435,6 @@ export type AgentSessionEvent =
|
|
|
432
435
|
| { type: "thinking_level_changed"; thinkingLevel: ThinkingLevel | undefined }
|
|
433
436
|
| { type: "goal_updated"; goal: Goal | null; state?: GoalModeState };
|
|
434
437
|
|
|
435
|
-
function isUnderProjectGjc(cwd: string, targetPath: string): boolean {
|
|
436
|
-
const relative = path.relative(path.join(path.resolve(cwd), ".gjc"), path.resolve(targetPath));
|
|
437
|
-
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
438
|
-
}
|
|
439
|
-
|
|
440
438
|
/** Listener function for agent session events */
|
|
441
439
|
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
|
|
442
440
|
export type AsyncJobSnapshotItem = Pick<
|
|
@@ -529,12 +527,31 @@ export interface AgentSessionConfig {
|
|
|
529
527
|
discoveryMode?: "off" | "mcp-only" | "all";
|
|
530
528
|
/** MCP tool names to activate for the current session when discovery mode is enabled. */
|
|
531
529
|
initialSelectedMCPToolNames?: string[];
|
|
532
|
-
/**
|
|
530
|
+
/** Built-in discoverable tool names restored for the current all-discovery session. */
|
|
531
|
+
initialSelectedDiscoveredBuiltinToolNames?: string[];
|
|
532
|
+
/** Discoverable built-ins active for configured or explicit reasons independently of persisted discovery selection. */
|
|
533
|
+
initialBaselineDiscoveredBuiltinToolNames?: string[];
|
|
534
|
+
/** Whether an MCP selection was explicitly supplied to the constructor, including an empty selection. */
|
|
535
|
+
initialMCPToolSelectionIsExplicit?: boolean;
|
|
536
|
+
/** Whether a discoverable built-in selection was explicitly supplied to the constructor, including an empty selection. */
|
|
537
|
+
initialDiscoveredBuiltinToolSelectionIsExplicit?: boolean;
|
|
538
|
+
|
|
539
|
+
/** Whether constructor-provided MCP selections should be persisted immediately. */
|
|
533
540
|
persistInitialMCPToolSelection?: boolean;
|
|
541
|
+
/** Whether constructor-provided discovered-built-in selections should be persisted immediately. */
|
|
542
|
+
persistInitialDiscoveredBuiltinToolSelection?: boolean;
|
|
543
|
+
/** Explicit MCP authority to write for a new session; distinct from active fallback tools. */
|
|
544
|
+
initialPersistedMCPToolNames?: string[];
|
|
545
|
+
/** Explicit discovered built-in authority to write for a new session; distinct from active fallback tools. */
|
|
546
|
+
initialPersistedDiscoveredBuiltinToolNames?: string[];
|
|
547
|
+
/** Immutable predecessor authority while a recovery host is read-only. */
|
|
548
|
+
recoveryHydrationContext?: RecoveryHydrationContext;
|
|
534
549
|
/** MCP server names whose tools should seed discovery-mode sessions whenever those servers are connected. */
|
|
535
550
|
defaultSelectedMCPServerNames?: string[];
|
|
536
551
|
/** MCP tool names that should seed brand-new sessions created from this AgentSession. */
|
|
537
552
|
defaultSelectedMCPToolNames?: string[];
|
|
553
|
+
/** MCP capabilities that are always active and never part of persisted user selection. */
|
|
554
|
+
mandatoryMCPToolNames?: string[];
|
|
538
555
|
/** TTSR manager for time-traveling stream rules */
|
|
539
556
|
ttsrManager?: TtsrManager;
|
|
540
557
|
/** Secret obfuscator for deobfuscating streaming edit content */
|
|
@@ -754,6 +771,30 @@ type RetryErrorClassification =
|
|
|
754
771
|
| "local_unavailable"
|
|
755
772
|
| "unknown";
|
|
756
773
|
|
|
774
|
+
const BARE_DEFAULT_WATCHDOG_ERROR =
|
|
775
|
+
/^(?:[A-Za-z][A-Za-z0-9-]*(?: [A-Za-z][A-Za-z0-9-]*){0,3} )stream (?:timed out while waiting for the first event|stalled while waiting for the next event)$/;
|
|
776
|
+
|
|
777
|
+
function hasBareDefaultRetryDisqualifyingFacts(message: AssistantMessage): boolean {
|
|
778
|
+
if (message.errorKind !== undefined || message.errorStatus !== undefined) return true;
|
|
779
|
+
const facts = message.transportFailure;
|
|
780
|
+
if (!facts) return false;
|
|
781
|
+
if (classifyFallbackTrigger(facts).class !== "other") return true;
|
|
782
|
+
return (
|
|
783
|
+
facts.status !== undefined ||
|
|
784
|
+
facts.providerCode !== undefined ||
|
|
785
|
+
facts.anthropicErrorType !== undefined ||
|
|
786
|
+
facts.openaiErrorCode !== undefined ||
|
|
787
|
+
(facts.headers !== undefined && Object.keys(facts.headers).length > 0)
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function assistantMessageHasVisibleOrToolContent(message: AssistantMessage): boolean {
|
|
792
|
+
return message.content.some(content => {
|
|
793
|
+
if (content.type === "text") return content.text.length > 0;
|
|
794
|
+
return content.type === "thinking" || content.type === "redactedThinking" || content.type === "toolCall";
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
757
798
|
function isLocalModelEndpoint(model: Model | undefined): boolean {
|
|
758
799
|
if (!model) return false;
|
|
759
800
|
if (model.provider === "ollama" || model.provider === "lm-studio" || model.provider === "llama.cpp") {
|
|
@@ -949,11 +990,6 @@ function createHandoffContext(document: string): string {
|
|
|
949
990
|
return `<handoff-context>\n${document}\n</handoff-context>\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`;
|
|
950
991
|
}
|
|
951
992
|
|
|
952
|
-
function createHandoffFileName(date = new Date()): string {
|
|
953
|
-
const fileTimestamp = date.toISOString().replace(/[:.]/g, "-");
|
|
954
|
-
return `handoff-${fileTimestamp}.md`;
|
|
955
|
-
}
|
|
956
|
-
|
|
957
993
|
// ============================================================================
|
|
958
994
|
// ACP Permission Gate
|
|
959
995
|
// ============================================================================
|
|
@@ -1384,6 +1420,11 @@ export class AgentSession {
|
|
|
1384
1420
|
readonly notificationSessionController: NotificationSessionController | undefined;
|
|
1385
1421
|
readonly taskDepth: number;
|
|
1386
1422
|
readonly yieldQueue: YieldQueue;
|
|
1423
|
+
// True from the start of a handoff transition through commit/rollback. While
|
|
1424
|
+
// set, the yield queue treats the session as busy so background async-job
|
|
1425
|
+
// completions cannot start a new idle turn against the session being handed
|
|
1426
|
+
// off (or, on rollback, the restored predecessor mid-transition).
|
|
1427
|
+
#handoffTransitionActive = false;
|
|
1387
1428
|
|
|
1388
1429
|
#powerAssertion: MacOSPowerAssertion | undefined;
|
|
1389
1430
|
|
|
@@ -1474,6 +1515,30 @@ export class AgentSession {
|
|
|
1474
1515
|
#lastMidRunMaintenanceAnchorSignature: string | undefined = undefined;
|
|
1475
1516
|
#resourceSampler: () => EmergencyCompactionSample = () => this.#defaultResourceSample();
|
|
1476
1517
|
#retainedMemorySampler: (() => RetainedMemorySample) | undefined;
|
|
1518
|
+
|
|
1519
|
+
/** Replay safety for the currently admitted top-level prompt/custom-message run. */
|
|
1520
|
+
#retryReplayEpoch = 0;
|
|
1521
|
+
#retryReplayUnsafeEpoch: number | undefined;
|
|
1522
|
+
|
|
1523
|
+
#resetRetryReplaySafety(): void {
|
|
1524
|
+
this.#retryReplayEpoch++;
|
|
1525
|
+
this.#retryReplayUnsafeEpoch = undefined;
|
|
1526
|
+
if (
|
|
1527
|
+
this.#extensionRunner?.hasHandlers("context") ||
|
|
1528
|
+
this.#extensionRunner?.hasHandlers("before_provider_request") ||
|
|
1529
|
+
this.#extensionRunner?.hasHandlers("after_provider_response")
|
|
1530
|
+
) {
|
|
1531
|
+
this.#markRetryReplayUnsafe();
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
#markRetryReplayUnsafe(): void {
|
|
1536
|
+
if (this.#retryReplayEpoch > 0) this.#retryReplayUnsafeEpoch = this.#retryReplayEpoch;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
get #hasCleanRetryReplaySafety(): boolean {
|
|
1540
|
+
return this.#retryReplayEpoch > 0 && this.#retryReplayUnsafeEpoch !== this.#retryReplayEpoch;
|
|
1541
|
+
}
|
|
1477
1542
|
#prePromptContextCheckPromise: Promise<void> | undefined = undefined;
|
|
1478
1543
|
/** Display-only context snapshot; pre-prompt compaction estimates deliberately remain uncached. */
|
|
1479
1544
|
#contextUsageCache: { key: string; value: ContextUsage } | undefined;
|
|
@@ -1612,12 +1677,17 @@ export class AgentSession {
|
|
|
1612
1677
|
// Generic tool discovery (covers built-in + MCP + extension when tools.discoveryMode === "all")
|
|
1613
1678
|
#discoverableToolSearchIndex: DiscoverableToolSearchIndex | null = null;
|
|
1614
1679
|
#selectedDiscoveredToolNames = new Set<string>();
|
|
1680
|
+
#baselineDiscoveredBuiltinToolNames = new Set<string>();
|
|
1615
1681
|
#discoverableToolAllowedNames: ReadonlySet<string> | undefined;
|
|
1616
1682
|
#gjcSubskillToolNames = new Set<string>();
|
|
1617
1683
|
#gjcSubskillToolSignature: string | undefined;
|
|
1618
1684
|
#defaultSelectedMCPServerNames = new Set<string>();
|
|
1619
1685
|
#defaultSelectedMCPToolNames = new Set<string>();
|
|
1620
|
-
#
|
|
1686
|
+
#mandatoryMCPToolNames = new Set<string>();
|
|
1687
|
+
/** Constructor authority applies only while this AgentSession instance remains alive. */
|
|
1688
|
+
#constructorMCPToolSelection: string[] | undefined;
|
|
1689
|
+
#constructorDiscoveredBuiltinToolSelection: string[] | undefined;
|
|
1690
|
+
#recoveryHydrationContext: RecoveryHydrationContext | undefined;
|
|
1621
1691
|
|
|
1622
1692
|
// TTSR manager for time-traveling stream rules
|
|
1623
1693
|
#ttsrManager: TtsrManager | undefined = undefined;
|
|
@@ -1774,6 +1844,46 @@ export class AgentSession {
|
|
|
1774
1844
|
});
|
|
1775
1845
|
}
|
|
1776
1846
|
|
|
1847
|
+
/**
|
|
1848
|
+
* Reject a turn start while a handoff transition owns the session. Handoff never
|
|
1849
|
+
* routes its own generation/injection through these turn-start chokepoints, and
|
|
1850
|
+
* auto-maintenance runs before the fence, so this fences only external entrants
|
|
1851
|
+
* (steer/follow-up/sendUserMessage/custom/hidden-next-turn/continuation) that
|
|
1852
|
+
* bypass prompt admission.
|
|
1853
|
+
*/
|
|
1854
|
+
#assertNoHandoffTransition(): void {
|
|
1855
|
+
if (this.#handoffTransitionActive) {
|
|
1856
|
+
throw Object.assign(new AgentBusyError("Cannot start a turn while a handoff is in progress."), {
|
|
1857
|
+
code: "busy",
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/**
|
|
1863
|
+
* Single, synchronously-acquired mutex for session-identity transitions
|
|
1864
|
+
* (handoff, compact, new/switch/branch/clear, fork, tree navigation). Acquired
|
|
1865
|
+
* BEFORE any await at each transition's entry and released in its finally, so
|
|
1866
|
+
* exclusion is symmetric regardless of which transition starts first — an
|
|
1867
|
+
* operation that began earlier and yielded still owns the lease when a peer
|
|
1868
|
+
* tries to start. Auto-handoff acquires it via handoff() (the maintenance
|
|
1869
|
+
* orchestrator does not hold it), so there is no self-deadlock.
|
|
1870
|
+
*/
|
|
1871
|
+
#sessionTransitionKind: string | undefined;
|
|
1872
|
+
|
|
1873
|
+
#beginSessionTransition(kind: string): void {
|
|
1874
|
+
if (this.#sessionTransitionKind !== undefined) {
|
|
1875
|
+
throw Object.assign(
|
|
1876
|
+
new Error(`Cannot start ${kind} while a ${this.#sessionTransitionKind} transition is in progress.`),
|
|
1877
|
+
{ code: "busy" },
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
1880
|
+
this.#sessionTransitionKind = kind;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
#endSessionTransition(): void {
|
|
1884
|
+
this.#sessionTransitionKind = undefined;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1777
1887
|
#activateNextSessionAdmission(): void {
|
|
1778
1888
|
if (this.#activeSessionAdmission || this.#sessionAdmissionClosed) return;
|
|
1779
1889
|
const next = this.#sessionAdmissionQueue.shift();
|
|
@@ -1789,6 +1899,16 @@ export class AgentSession {
|
|
|
1789
1899
|
const owner = this.#sessionAdmissionContext.getStore();
|
|
1790
1900
|
if (owner && !owner.released) throw this.#sessionAdmissionBusyError();
|
|
1791
1901
|
if (this.#sessionAdmissionClosed || this.#isDisposed) throw this.#sessionAdmissionBusyError();
|
|
1902
|
+
// Reject new external turns for the whole handoff transition. The handoff
|
|
1903
|
+
// itself never acquires prompt admission (it generates via generateHandoff and
|
|
1904
|
+
// injects via appendCustomMessageEntry), so this fences external entrants —
|
|
1905
|
+
// prompt/sendUserMessage/steer/follow-up/triggerTurn all funnel here — without
|
|
1906
|
+
// blocking the handoff's own work or the exempt auto-maintenance owner.
|
|
1907
|
+
if (kind === "prompt" && this.#handoffTransitionActive) {
|
|
1908
|
+
throw Object.assign(new AgentBusyError("Cannot start a turn while a handoff is in progress."), {
|
|
1909
|
+
code: "busy",
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1792
1912
|
|
|
1793
1913
|
const entry: SessionAdmissionEntry = {
|
|
1794
1914
|
kind,
|
|
@@ -1806,6 +1926,17 @@ export class AgentSession {
|
|
|
1806
1926
|
this.#activateNextSessionAdmission();
|
|
1807
1927
|
throw this.#sessionAdmissionBusyError();
|
|
1808
1928
|
}
|
|
1929
|
+
// Re-check the handoff fence after activation: a prompt queued before the
|
|
1930
|
+
// transition began must not start once the fence is up.
|
|
1931
|
+
if (kind === "prompt" && this.#handoffTransitionActive) {
|
|
1932
|
+
entry.released = true;
|
|
1933
|
+
entry.settled.resolve();
|
|
1934
|
+
if (this.#activeSessionAdmission === entry) this.#activeSessionAdmission = undefined;
|
|
1935
|
+
this.#activateNextSessionAdmission();
|
|
1936
|
+
throw Object.assign(new AgentBusyError("Cannot start a turn while a handoff is in progress."), {
|
|
1937
|
+
code: "busy",
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1809
1940
|
|
|
1810
1941
|
const release = () => {
|
|
1811
1942
|
if (entry.released) return;
|
|
@@ -2021,6 +2152,7 @@ export class AgentSession {
|
|
|
2021
2152
|
});
|
|
2022
2153
|
// Power assertions are taken per turn (see #beginInFlight); nothing acquired here.
|
|
2023
2154
|
this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
|
|
2155
|
+
this.#recoveryHydrationContext = config.recoveryHydrationContext;
|
|
2024
2156
|
this.#ownedAsyncJobManager = config.ownedAsyncJobManager;
|
|
2025
2157
|
this.#retainedMemorySampler = config.retainedMemorySampler;
|
|
2026
2158
|
this.#ownedMcpManager = config.ownedMcpManager;
|
|
@@ -2070,7 +2202,7 @@ export class AgentSession {
|
|
|
2070
2202
|
this.#setGuardedAgentTools(this.agent.state.tools);
|
|
2071
2203
|
this.#bindWorkflowGateEmitter();
|
|
2072
2204
|
this.yieldQueue = new YieldQueue({
|
|
2073
|
-
isStreaming: () => this.isStreaming,
|
|
2205
|
+
isStreaming: () => this.isStreaming || this.#handoffTransitionActive,
|
|
2074
2206
|
injectStreaming: message => this.agent.followUp(message),
|
|
2075
2207
|
injectIdle: async messages => {
|
|
2076
2208
|
const first = messages[0];
|
|
@@ -2112,24 +2244,53 @@ export class AgentSession {
|
|
|
2112
2244
|
: undefined;
|
|
2113
2245
|
this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry());
|
|
2114
2246
|
this.#selectedMCPToolNames = new Set(config.initialSelectedMCPToolNames ?? []);
|
|
2247
|
+
this.#selectedDiscoveredToolNames = new Set(
|
|
2248
|
+
this.#selectRestorableDiscoveredBuiltinToolNames(config.initialSelectedDiscoveredBuiltinToolNames ?? []),
|
|
2249
|
+
);
|
|
2250
|
+
this.#baselineDiscoveredBuiltinToolNames = new Set(
|
|
2251
|
+
selectRestorableDiscoveredBuiltinToolNames(
|
|
2252
|
+
config.initialBaselineDiscoveredBuiltinToolNames ?? [],
|
|
2253
|
+
this.#toolRegistry,
|
|
2254
|
+
this.#discoverableToolAllowedNames,
|
|
2255
|
+
),
|
|
2256
|
+
);
|
|
2115
2257
|
this.#defaultSelectedMCPServerNames = new Set(config.defaultSelectedMCPServerNames ?? []);
|
|
2116
2258
|
this.#defaultSelectedMCPToolNames = new Set(config.defaultSelectedMCPToolNames ?? []);
|
|
2259
|
+
this.#mandatoryMCPToolNames = new Set(
|
|
2260
|
+
(config.mandatoryMCPToolNames ?? [])
|
|
2261
|
+
.map(name => name.toLowerCase())
|
|
2262
|
+
.filter(name => this.#toolRegistry.has(name)),
|
|
2263
|
+
);
|
|
2264
|
+
this.#constructorMCPToolSelection =
|
|
2265
|
+
config.initialMCPToolSelectionIsExplicit === true
|
|
2266
|
+
? this.#filterSelectableMCPToolNames(config.initialPersistedMCPToolNames ?? [])
|
|
2267
|
+
: undefined;
|
|
2268
|
+
this.#constructorDiscoveredBuiltinToolSelection =
|
|
2269
|
+
config.initialDiscoveredBuiltinToolSelectionIsExplicit === true
|
|
2270
|
+
? this.#selectRestorableDiscoveredBuiltinToolNames(config.initialPersistedDiscoveredBuiltinToolNames ?? [])
|
|
2271
|
+
: undefined;
|
|
2117
2272
|
this.#pruneSelectedMCPToolNames();
|
|
2118
|
-
const persistedSelectedMCPToolNames = this.buildDisplaySessionContext().selectedMCPToolNames;
|
|
2119
|
-
const currentSelectedMCPToolNames = this.getSelectedMCPToolNames();
|
|
2120
2273
|
const persistInitialMCPToolSelection =
|
|
2121
|
-
config.persistInitialMCPToolSelection
|
|
2274
|
+
config.persistInitialMCPToolSelection === true && config.initialMCPToolSelectionIsExplicit !== false;
|
|
2275
|
+
const persistInitialDiscoveredBuiltinToolSelection =
|
|
2276
|
+
config.persistInitialDiscoveredBuiltinToolSelection === true &&
|
|
2277
|
+
config.initialDiscoveredBuiltinToolSelectionIsExplicit !== false;
|
|
2122
2278
|
if (
|
|
2123
|
-
this.#
|
|
2124
|
-
|
|
2125
|
-
|
|
2279
|
+
!this.#recoveryHydrationContext &&
|
|
2280
|
+
(this.#mcpDiscoveryEnabled || this.#resolveEffectiveDiscoveryMode() === "all") &&
|
|
2281
|
+
persistInitialMCPToolSelection
|
|
2126
2282
|
) {
|
|
2127
|
-
this.sessionManager.appendMCPToolSelection(
|
|
2283
|
+
this.sessionManager.appendMCPToolSelection(config.initialPersistedMCPToolNames ?? []);
|
|
2284
|
+
}
|
|
2285
|
+
if (
|
|
2286
|
+
!this.#recoveryHydrationContext &&
|
|
2287
|
+
this.#resolveEffectiveDiscoveryMode() === "all" &&
|
|
2288
|
+
persistInitialDiscoveredBuiltinToolSelection
|
|
2289
|
+
) {
|
|
2290
|
+
this.sessionManager.appendDiscoveredBuiltinToolSelection(
|
|
2291
|
+
config.initialPersistedDiscoveredBuiltinToolNames ?? [],
|
|
2292
|
+
);
|
|
2128
2293
|
}
|
|
2129
|
-
this.#rememberSessionDefaultSelectedMCPToolNames(
|
|
2130
|
-
this.sessionManager.getSessionFile(),
|
|
2131
|
-
this.#getConfiguredDefaultSelectedMCPToolNames(),
|
|
2132
|
-
);
|
|
2133
2294
|
this.#ttsrManager = config.ttsrManager;
|
|
2134
2295
|
this.#obfuscator = config.obfuscator;
|
|
2135
2296
|
this.#agentId = config.agentId;
|
|
@@ -2187,11 +2348,37 @@ export class AgentSession {
|
|
|
2187
2348
|
// Re-evaluate append-only context mode when the setting changes at runtime.
|
|
2188
2349
|
this.#unsubscribeAppendOnly = onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model));
|
|
2189
2350
|
// SDK ToolSession callbacks capture the just-constructed session. Defer the
|
|
2190
|
-
// initial ask-tool
|
|
2351
|
+
// initial ask-tool registration until that capture has been assigned by the
|
|
2191
2352
|
// session factory, while retaining the durable emitter created above.
|
|
2192
|
-
|
|
2193
|
-
|
|
2353
|
+
this.#workflowGateToolRestoration = new Promise<void>((resolve, reject) => {
|
|
2354
|
+
queueMicrotask(() => {
|
|
2355
|
+
if (this.#isDisposed) {
|
|
2356
|
+
resolve();
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
try {
|
|
2360
|
+
this.#registerWorkflowGateAskTool();
|
|
2361
|
+
this.#attachAskToolIfWorkflowActive().then(resolve, reject);
|
|
2362
|
+
} catch (error) {
|
|
2363
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
2364
|
+
}
|
|
2365
|
+
});
|
|
2194
2366
|
});
|
|
2367
|
+
// Non-SDK embedders may never observe the getter; a swallowed handler
|
|
2368
|
+
// prevents an unhandled rejection while later awaits still reject.
|
|
2369
|
+
this.#workflowGateToolRestoration.catch(() => {});
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
#workflowGateToolRestoration: Promise<void> = Promise.resolve();
|
|
2373
|
+
|
|
2374
|
+
/**
|
|
2375
|
+
* Resolves when constructor-time workflow-gate tool restoration (ask
|
|
2376
|
+
* registration plus durable active-workflow attachment) has settled. The
|
|
2377
|
+
* SDK factory awaits this so a resumed canonical workflow session is
|
|
2378
|
+
* returned with `ask` already resident.
|
|
2379
|
+
*/
|
|
2380
|
+
get workflowGateToolRestoration(): Promise<void> {
|
|
2381
|
+
return this.#workflowGateToolRestoration;
|
|
2195
2382
|
}
|
|
2196
2383
|
|
|
2197
2384
|
/** Model registry for API key resolution and model discovery */
|
|
@@ -2900,17 +3087,16 @@ export class AgentSession {
|
|
|
2900
3087
|
if (thresholdTokens <= 0 || estimateTextTokensHeuristic(fullText) <= thresholdTokens) return;
|
|
2901
3088
|
|
|
2902
3089
|
try {
|
|
2903
|
-
const
|
|
2904
|
-
if (!
|
|
2905
|
-
await Bun.write(artifact.path, fullText);
|
|
3090
|
+
const artifactId = await this.sessionManager.saveArtifact(fullText, "tool-result");
|
|
3091
|
+
if (!artifactId) return;
|
|
2906
3092
|
const digest = crypto.createHash("sha256").update(fullText).digest("hex");
|
|
2907
|
-
const preview = createPreAdmissionArtifactSpillPreview(fullText,
|
|
3093
|
+
const preview = createPreAdmissionArtifactSpillPreview(fullText, artifactId, digest);
|
|
2908
3094
|
const spillMeta = outputMeta()
|
|
2909
3095
|
.truncationFromText(preview, {
|
|
2910
3096
|
direction: "middle",
|
|
2911
3097
|
totalLines: fullText.split("\n").length,
|
|
2912
3098
|
totalBytes: Buffer.byteLength(fullText, "utf-8"),
|
|
2913
|
-
artifactId
|
|
3099
|
+
artifactId,
|
|
2914
3100
|
})
|
|
2915
3101
|
.get();
|
|
2916
3102
|
const existingDetails = message.details;
|
|
@@ -2950,6 +3136,38 @@ export class AgentSession {
|
|
|
2950
3136
|
|
|
2951
3137
|
/** Internal handler for agent events - shared by subscribe and reconnect */
|
|
2952
3138
|
#handleAgentEvent = async (event: AgentEvent): Promise<void> => {
|
|
3139
|
+
if (this.#extensionRunner?.hasHandlers(event.type)) this.#markRetryReplayUnsafe();
|
|
3140
|
+
if (
|
|
3141
|
+
event.type === "tool_execution_start" ||
|
|
3142
|
+
event.type === "tool_execution_update" ||
|
|
3143
|
+
event.type === "tool_execution_end"
|
|
3144
|
+
) {
|
|
3145
|
+
this.#markRetryReplayUnsafe();
|
|
3146
|
+
} else if (event.type === "message_end") {
|
|
3147
|
+
if (
|
|
3148
|
+
event.message.role === "toolResult" ||
|
|
3149
|
+
(event.message.role === "assistant" && assistantMessageHasVisibleOrToolContent(event.message))
|
|
3150
|
+
) {
|
|
3151
|
+
this.#markRetryReplayUnsafe();
|
|
3152
|
+
}
|
|
3153
|
+
} else if (event.type === "message_update") {
|
|
3154
|
+
const update = event.assistantMessageEvent;
|
|
3155
|
+
if (
|
|
3156
|
+
update.type === "toolcall_start" ||
|
|
3157
|
+
update.type === "toolcall_delta" ||
|
|
3158
|
+
update.type === "toolcall_end" ||
|
|
3159
|
+
((update.type === "text_delta" ||
|
|
3160
|
+
update.type === "thinking_delta" ||
|
|
3161
|
+
update.type === "reasoning_summary_delta") &&
|
|
3162
|
+
update.delta.length > 0) ||
|
|
3163
|
+
((update.type === "text_end" ||
|
|
3164
|
+
update.type === "thinking_end" ||
|
|
3165
|
+
update.type === "reasoning_summary_end") &&
|
|
3166
|
+
update.content.length > 0)
|
|
3167
|
+
) {
|
|
3168
|
+
this.#markRetryReplayUnsafe();
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
2953
3171
|
// Record a successful final yield before any asynchronous extension work so a
|
|
2954
3172
|
// concurrently delivered agent_end cannot start post-turn maintenance first.
|
|
2955
3173
|
if (event.type === "tool_execution_end" && event.toolName === "yield" && !event.isError) {
|
|
@@ -3096,7 +3314,7 @@ export class AgentSession {
|
|
|
3096
3314
|
// Deobfuscate assistant message content for display emission — the LLM echoes back
|
|
3097
3315
|
// obfuscated placeholders, but listeners (TUI, extensions, exporters) must see real
|
|
3098
3316
|
// values. The original event.message stays obfuscated so the canonical persistence path above
|
|
3099
|
-
// writes
|
|
3317
|
+
// writes authenticated placeholder tokens to the session file; convertToLlm re-obfuscates outbound
|
|
3100
3318
|
// traffic on the next turn. Walks text, thinking, and toolCall arguments/intent.
|
|
3101
3319
|
let displayEvent: AgentEvent = event;
|
|
3102
3320
|
const obfuscator = this.#obfuscator;
|
|
@@ -3620,14 +3838,15 @@ export class AgentSession {
|
|
|
3620
3838
|
skipCompactionCheck?: boolean;
|
|
3621
3839
|
suppressPredecessorAgentEnd?: boolean;
|
|
3622
3840
|
shouldContinue?: () => boolean;
|
|
3623
|
-
onSkip?: (reason: "generation_changed" | "aborted_signal" | "queue_drained") => void;
|
|
3841
|
+
onSkip?: (reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress") => void;
|
|
3842
|
+
allowDuringCancelAndSubmit?: boolean;
|
|
3624
3843
|
onError?: (error: unknown) => void;
|
|
3625
3844
|
}): Promise<void> {
|
|
3626
3845
|
const predecessorAgentEndHold = options?.suppressPredecessorAgentEnd
|
|
3627
3846
|
? this.#reserveDeferredAgentEndForContinuation()
|
|
3628
3847
|
: undefined;
|
|
3629
3848
|
let terminalized = false;
|
|
3630
|
-
const skip = (reason: "generation_changed" | "aborted_signal" | "queue_drained") => {
|
|
3849
|
+
const skip = (reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress") => {
|
|
3631
3850
|
if (terminalized) return;
|
|
3632
3851
|
terminalized = true;
|
|
3633
3852
|
this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
|
|
@@ -3655,7 +3874,7 @@ export class AgentSession {
|
|
|
3655
3874
|
skip("generation_changed");
|
|
3656
3875
|
return false;
|
|
3657
3876
|
}
|
|
3658
|
-
if (this.#cancelAndSubmitInProgress) {
|
|
3877
|
+
if (this.#cancelAndSubmitInProgress && !options?.allowDuringCancelAndSubmit) {
|
|
3659
3878
|
skip("queue_drained");
|
|
3660
3879
|
return false;
|
|
3661
3880
|
}
|
|
@@ -3683,6 +3902,13 @@ export class AgentSession {
|
|
|
3683
3902
|
skip("queue_drained");
|
|
3684
3903
|
return;
|
|
3685
3904
|
}
|
|
3905
|
+
// A continuation scheduled before a handoff engaged must not start a
|
|
3906
|
+
// turn against the session being handed off (or the restored
|
|
3907
|
+
// predecessor). rearmIdle / normal delivery resumes after the fence.
|
|
3908
|
+
if (this.#handoffTransitionActive) {
|
|
3909
|
+
skip("handoff_in_progress");
|
|
3910
|
+
return;
|
|
3911
|
+
}
|
|
3686
3912
|
const predecessorAgentEnd = this.#claimDeferredAgentEndForContinuation(predecessorAgentEndHold);
|
|
3687
3913
|
try {
|
|
3688
3914
|
await this.agent.continue(this.#managedFallbackPromptOptions());
|
|
@@ -4291,6 +4517,7 @@ export class AgentSession {
|
|
|
4291
4517
|
#localProtocolOptions(): LocalProtocolOptions {
|
|
4292
4518
|
return {
|
|
4293
4519
|
getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
|
|
4520
|
+
isManagedDestination: () => this.sessionManager.isManagedDestination(),
|
|
4294
4521
|
getSessionId: () => this.sessionManager.getSessionId(),
|
|
4295
4522
|
};
|
|
4296
4523
|
}
|
|
@@ -4583,6 +4810,7 @@ export class AgentSession {
|
|
|
4583
4810
|
message: event.message,
|
|
4584
4811
|
contentIndex: event.assistantMessageEvent.contentIndex,
|
|
4585
4812
|
};
|
|
4813
|
+
if (this.#extensionRunner.hasHandlers("reasoning_summary_start")) this.#markRetryReplayUnsafe();
|
|
4586
4814
|
await this.#extensionRunner.emit(reasoningEvent, continueWhile);
|
|
4587
4815
|
} else if (event.assistantMessageEvent.type === "reasoning_summary_delta") {
|
|
4588
4816
|
const reasoningEvent: ReasoningSummaryDeltaEvent = {
|
|
@@ -4591,6 +4819,7 @@ export class AgentSession {
|
|
|
4591
4819
|
contentIndex: event.assistantMessageEvent.contentIndex,
|
|
4592
4820
|
delta: event.assistantMessageEvent.delta,
|
|
4593
4821
|
};
|
|
4822
|
+
if (this.#extensionRunner.hasHandlers("reasoning_summary_delta")) this.#markRetryReplayUnsafe();
|
|
4594
4823
|
await this.#extensionRunner.emit(reasoningEvent, continueWhile);
|
|
4595
4824
|
} else if (event.assistantMessageEvent.type === "reasoning_summary_end") {
|
|
4596
4825
|
const reasoningEvent: ReasoningSummaryEndEvent = {
|
|
@@ -4599,6 +4828,7 @@ export class AgentSession {
|
|
|
4599
4828
|
contentIndex: event.assistantMessageEvent.contentIndex,
|
|
4600
4829
|
content: event.assistantMessageEvent.content,
|
|
4601
4830
|
};
|
|
4831
|
+
if (this.#extensionRunner.hasHandlers("reasoning_summary_end")) this.#markRetryReplayUnsafe();
|
|
4602
4832
|
await this.#extensionRunner.emit(reasoningEvent, continueWhile);
|
|
4603
4833
|
}
|
|
4604
4834
|
} else if (event.type === "message_end") {
|
|
@@ -4652,6 +4882,7 @@ export class AgentSession {
|
|
|
4652
4882
|
continuationSkipReason: event.continuationSkipReason,
|
|
4653
4883
|
});
|
|
4654
4884
|
} else if (event.type === "auto_retry_start") {
|
|
4885
|
+
if (this.#extensionRunner.hasHandlers("auto_retry_start")) this.#markRetryReplayUnsafe();
|
|
4655
4886
|
await this.#extensionRunner.emit({
|
|
4656
4887
|
type: "auto_retry_start",
|
|
4657
4888
|
attempt: event.attempt,
|
|
@@ -5090,29 +5321,66 @@ export class AgentSession {
|
|
|
5090
5321
|
return left.length === right.length && left.every((name, index) => name === right[index]);
|
|
5091
5322
|
}
|
|
5092
5323
|
|
|
5093
|
-
#
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5324
|
+
#resolveConstructorMCPToolSelection(): string[] | undefined {
|
|
5325
|
+
return this.#constructorMCPToolSelection
|
|
5326
|
+
? this.#filterSelectableMCPToolNames(this.#constructorMCPToolSelection)
|
|
5327
|
+
: undefined;
|
|
5328
|
+
}
|
|
5329
|
+
|
|
5330
|
+
#selectRestorableDiscoveredBuiltinToolNames(toolNames: Iterable<string>): string[] {
|
|
5331
|
+
return selectRestorableDiscoveredBuiltinToolNames(
|
|
5332
|
+
toolNames,
|
|
5333
|
+
this.#toolRegistry,
|
|
5334
|
+
this.#discoverableToolAllowedNames,
|
|
5335
|
+
new Set(computeEssentialBuiltinNames(this.settings)),
|
|
5101
5336
|
);
|
|
5102
5337
|
}
|
|
5103
5338
|
|
|
5104
|
-
#
|
|
5105
|
-
|
|
5106
|
-
|
|
5339
|
+
#resolveConstructorDiscoveredBuiltinToolSelection(): string[] | undefined {
|
|
5340
|
+
return this.#constructorDiscoveredBuiltinToolSelection
|
|
5341
|
+
? this.#selectRestorableDiscoveredBuiltinToolNames(this.#constructorDiscoveredBuiltinToolSelection)
|
|
5342
|
+
: undefined;
|
|
5343
|
+
}
|
|
5344
|
+
|
|
5345
|
+
#clearConstructorToolSelectionAuthority(): void {
|
|
5346
|
+
this.#constructorMCPToolSelection = undefined;
|
|
5347
|
+
this.#constructorDiscoveredBuiltinToolSelection = undefined;
|
|
5348
|
+
}
|
|
5349
|
+
|
|
5350
|
+
#getSelectedDiscoveredBuiltinToolNames(): string[] {
|
|
5351
|
+
return this.#selectRestorableDiscoveredBuiltinToolNames(this.#selectedDiscoveredToolNames).filter(name =>
|
|
5352
|
+
this.getActiveToolNames().includes(name),
|
|
5353
|
+
);
|
|
5107
5354
|
}
|
|
5108
5355
|
|
|
5109
|
-
#persistSelectedMCPToolNamesIfChanged(
|
|
5110
|
-
|
|
5356
|
+
#persistSelectedMCPToolNamesIfChanged(
|
|
5357
|
+
previousSelectedMCPToolNames: string[],
|
|
5358
|
+
previousSelectedDiscoveredBuiltinToolNames: string[],
|
|
5359
|
+
): void {
|
|
5360
|
+
if (!this.#mcpDiscoveryEnabled && this.#resolveEffectiveDiscoveryMode() !== "all") return;
|
|
5111
5361
|
const nextSelectedMCPToolNames = this.getSelectedMCPToolNames();
|
|
5112
|
-
|
|
5113
|
-
|
|
5362
|
+
const nextSelectedDiscoveredBuiltinToolNames = this.#getSelectedDiscoveredBuiltinToolNames();
|
|
5363
|
+
const mcpSelectionChanged = !this.#selectedMCPToolNamesMatch(
|
|
5364
|
+
previousSelectedMCPToolNames,
|
|
5365
|
+
nextSelectedMCPToolNames,
|
|
5366
|
+
);
|
|
5367
|
+
const discoveredBuiltinSelectionChanged =
|
|
5368
|
+
this.#resolveEffectiveDiscoveryMode() === "all" &&
|
|
5369
|
+
!this.#selectedMCPToolNamesMatch(
|
|
5370
|
+
previousSelectedDiscoveredBuiltinToolNames,
|
|
5371
|
+
nextSelectedDiscoveredBuiltinToolNames,
|
|
5372
|
+
);
|
|
5373
|
+
const mutationCorrelationId =
|
|
5374
|
+
mcpSelectionChanged && discoveredBuiltinSelectionChanged ? crypto.randomUUID() : undefined;
|
|
5375
|
+
if (mcpSelectionChanged) {
|
|
5376
|
+
this.sessionManager.appendMCPToolSelection(nextSelectedMCPToolNames, mutationCorrelationId);
|
|
5377
|
+
}
|
|
5378
|
+
if (discoveredBuiltinSelectionChanged) {
|
|
5379
|
+
this.sessionManager.appendDiscoveredBuiltinToolSelection(
|
|
5380
|
+
nextSelectedDiscoveredBuiltinToolNames,
|
|
5381
|
+
mutationCorrelationId,
|
|
5382
|
+
);
|
|
5114
5383
|
}
|
|
5115
|
-
this.sessionManager.appendMCPToolSelection(nextSelectedMCPToolNames);
|
|
5116
5384
|
}
|
|
5117
5385
|
|
|
5118
5386
|
#getActiveNonMCPToolNames(): string[] {
|
|
@@ -5291,30 +5559,13 @@ export class AgentSession {
|
|
|
5291
5559
|
|
|
5292
5560
|
getSelectedMCPToolNames(): string[] {
|
|
5293
5561
|
if (!this.#mcpDiscoveryEnabled) {
|
|
5294
|
-
return this.getActiveToolNames().filter(
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
}
|
|
5298
|
-
|
|
5299
|
-
async #activateDiscoveredMCPTools(toolNames: string[]): Promise<string[]> {
|
|
5300
|
-
const nextSelectedMCPToolNames = new Set(this.#selectedMCPToolNames);
|
|
5301
|
-
const activated: string[] = [];
|
|
5302
|
-
for (const name of toolNames) {
|
|
5303
|
-
if (!this.#discoverableMCPTools.has(name) || !this.#toolRegistry.has(name)) {
|
|
5304
|
-
continue;
|
|
5305
|
-
}
|
|
5306
|
-
nextSelectedMCPToolNames.add(name);
|
|
5307
|
-
activated.push(name);
|
|
5308
|
-
}
|
|
5309
|
-
if (activated.length === 0) {
|
|
5310
|
-
return [];
|
|
5562
|
+
return this.getActiveToolNames().filter(
|
|
5563
|
+
name => isMCPToolName(name) && this.#toolRegistry.has(name) && !this.#mandatoryMCPToolNames.has(name),
|
|
5564
|
+
);
|
|
5311
5565
|
}
|
|
5312
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
];
|
|
5316
|
-
await this.setActiveToolsByName(nextActive);
|
|
5317
|
-
return [...new Set(activated)];
|
|
5566
|
+
return this.#filterSelectableMCPToolNames(this.#selectedMCPToolNames).filter(
|
|
5567
|
+
name => !this.#mandatoryMCPToolNames.has(name),
|
|
5568
|
+
);
|
|
5318
5569
|
}
|
|
5319
5570
|
|
|
5320
5571
|
// ── Generic tool discovery (covers built-in + MCP + extension) ────────────
|
|
@@ -5362,54 +5613,44 @@ export class AgentSession {
|
|
|
5362
5613
|
return this.#discoverableToolSearchIndex;
|
|
5363
5614
|
}
|
|
5364
5615
|
|
|
5365
|
-
/** Invalidate the generic search index cache (call after tool set changes).
|
|
5366
|
-
* Delegates to {@link #invalidateDiscoveryCaches} so all discovery-related caches stay in sync. */
|
|
5367
|
-
#invalidateDiscoverableToolSearchIndex(): void {
|
|
5368
|
-
this.#invalidateDiscoveryCaches();
|
|
5369
|
-
}
|
|
5370
|
-
|
|
5371
5616
|
getSelectedDiscoveredToolNames(): string[] {
|
|
5372
|
-
// Union of MCP-selected and generic non-MCP selected. Non-MCP selections are only
|
|
5373
|
-
// selected while they are still active; otherwise BM25 must be able to rediscover them.
|
|
5374
|
-
const activeNames = new Set(this.getActiveToolNames());
|
|
5375
5617
|
const mcpSelected = this.getSelectedMCPToolNames();
|
|
5376
|
-
|
|
5377
|
-
name => activeNames.has(name) && this.#toolRegistry.has(name) && !isMCPToolName(name),
|
|
5378
|
-
);
|
|
5379
|
-
return [...new Set([...mcpSelected, ...nonMcpSelected])];
|
|
5618
|
+
return [...new Set([...mcpSelected, ...this.#getSelectedDiscoveredBuiltinToolNames()])];
|
|
5380
5619
|
}
|
|
5381
5620
|
|
|
5382
5621
|
async activateDiscoveredTools(toolNames: string[]): Promise<string[]> {
|
|
5383
|
-
const
|
|
5384
|
-
const
|
|
5622
|
+
const previousSelectedMCPToolNames = this.getSelectedMCPToolNames();
|
|
5623
|
+
const previousSelectedDiscoveredBuiltinToolNames = this.#getSelectedDiscoveredBuiltinToolNames();
|
|
5624
|
+
const nextActiveToolNames = this.getActiveToolNames();
|
|
5625
|
+
const nextActiveNameSet = new Set(nextActiveToolNames);
|
|
5626
|
+
const nextSelectedDiscoveredBuiltinToolNames = new Set(this.#selectedDiscoveredToolNames);
|
|
5385
5627
|
const activated: string[] = [];
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
}
|
|
5392
|
-
|
|
5393
|
-
// Activate non-MCP tools (built-ins that are in the registry but not currently active)
|
|
5394
|
-
if (nonMcpNames.length > 0) {
|
|
5395
|
-
const currentActiveNames = new Set(this.getActiveToolNames());
|
|
5396
|
-
const newlyAdded: string[] = [];
|
|
5397
|
-
for (const name of nonMcpNames) {
|
|
5398
|
-
if (this.#discoverableToolAllowedNames && !this.#discoverableToolAllowedNames.has(name)) continue;
|
|
5399
|
-
if (this.#toolRegistry.has(name) && !currentActiveNames.has(name)) {
|
|
5400
|
-
newlyAdded.push(name);
|
|
5401
|
-
this.#selectedDiscoveredToolNames.add(name);
|
|
5628
|
+
for (const name of new Set(toolNames)) {
|
|
5629
|
+
if (this.#discoverableMCPTools.has(name) && this.#toolRegistry.has(name)) {
|
|
5630
|
+
if (!nextActiveNameSet.has(name)) {
|
|
5631
|
+
nextActiveToolNames.push(name);
|
|
5632
|
+
nextActiveNameSet.add(name);
|
|
5402
5633
|
activated.push(name);
|
|
5403
5634
|
}
|
|
5635
|
+
continue;
|
|
5404
5636
|
}
|
|
5405
|
-
if (
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5637
|
+
if (this.#discoverableToolAllowedNames && !this.#discoverableToolAllowedNames.has(name)) continue;
|
|
5638
|
+
const tool = this.#toolRegistry.get(name);
|
|
5639
|
+
if (tool?.loadMode === "discoverable" && !nextActiveNameSet.has(name)) {
|
|
5640
|
+
nextActiveToolNames.push(name);
|
|
5641
|
+
nextActiveNameSet.add(name);
|
|
5642
|
+
nextSelectedDiscoveredBuiltinToolNames.add(name);
|
|
5643
|
+
activated.push(name);
|
|
5644
|
+
}
|
|
5645
|
+
}
|
|
5646
|
+
if (activated.length > 0) {
|
|
5647
|
+
await this.#applyActiveToolsByName(nextActiveToolNames, {
|
|
5648
|
+
previousSelectedMCPToolNames,
|
|
5649
|
+
previousSelectedDiscoveredBuiltinToolNames,
|
|
5650
|
+
nextSelectedDiscoveredBuiltinToolNames: [...nextSelectedDiscoveredBuiltinToolNames],
|
|
5651
|
+
});
|
|
5410
5652
|
}
|
|
5411
|
-
|
|
5412
|
-
return [...new Set(activated)];
|
|
5653
|
+
return activated;
|
|
5413
5654
|
}
|
|
5414
5655
|
|
|
5415
5656
|
/** Wrap guarded tools so SDK permission modes remain fail-closed without a reverse provider. */
|
|
@@ -5597,10 +5838,17 @@ export class AgentSession {
|
|
|
5597
5838
|
|
|
5598
5839
|
async #applyActiveToolsByName(
|
|
5599
5840
|
toolNames: string[],
|
|
5600
|
-
options?: {
|
|
5841
|
+
options?: {
|
|
5842
|
+
persistMCPSelection?: boolean;
|
|
5843
|
+
previousSelectedMCPToolNames?: string[];
|
|
5844
|
+
previousSelectedDiscoveredBuiltinToolNames?: string[];
|
|
5845
|
+
nextSelectedDiscoveredBuiltinToolNames?: string[];
|
|
5846
|
+
},
|
|
5601
5847
|
): Promise<void> {
|
|
5602
|
-
toolNames = [...new Set(toolNames.map(name => name.toLowerCase()))];
|
|
5848
|
+
toolNames = [...new Set([...toolNames.map(name => name.toLowerCase()), ...this.#mandatoryMCPToolNames])];
|
|
5603
5849
|
const previousSelectedMCPToolNames = options?.previousSelectedMCPToolNames ?? this.getSelectedMCPToolNames();
|
|
5850
|
+
const previousSelectedDiscoveredBuiltinToolNames =
|
|
5851
|
+
options?.previousSelectedDiscoveredBuiltinToolNames ?? this.#getSelectedDiscoveredBuiltinToolNames();
|
|
5604
5852
|
const tools: AgentTool[] = [];
|
|
5605
5853
|
const validToolNames: string[] = [];
|
|
5606
5854
|
for (const name of toolNames) {
|
|
@@ -5610,37 +5858,29 @@ export class AgentSession {
|
|
|
5610
5858
|
validToolNames.push(name);
|
|
5611
5859
|
}
|
|
5612
5860
|
}
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5861
|
+
const nextSelectedMCPToolNames = this.#mcpDiscoveryEnabled
|
|
5862
|
+
? new Set(
|
|
5863
|
+
validToolNames.filter(
|
|
5864
|
+
name =>
|
|
5865
|
+
isMCPToolName(name) &&
|
|
5866
|
+
!this.#mandatoryMCPToolNames.has(name) &&
|
|
5867
|
+
this.#discoverableMCPTools.has(name) &&
|
|
5868
|
+
this.#toolRegistry.has(name),
|
|
5869
|
+
),
|
|
5870
|
+
)
|
|
5871
|
+
: this.#selectedMCPToolNames;
|
|
5620
5872
|
const activeNameSet = new Set(validToolNames);
|
|
5621
|
-
|
|
5873
|
+
const nextSelectedDiscoveredBuiltinToolNames = new Set(
|
|
5874
|
+
options?.nextSelectedDiscoveredBuiltinToolNames ?? this.#selectedDiscoveredToolNames,
|
|
5875
|
+
);
|
|
5876
|
+
for (const name of nextSelectedDiscoveredBuiltinToolNames) {
|
|
5622
5877
|
if (!activeNameSet.has(name) || this.#discoverableMCPTools.has(name) || !this.#toolRegistry.has(name)) {
|
|
5623
|
-
|
|
5878
|
+
nextSelectedDiscoveredBuiltinToolNames.delete(name);
|
|
5624
5879
|
}
|
|
5625
5880
|
}
|
|
5626
5881
|
const signature = this.#computeAppliedToolSignature(validToolNames, tools);
|
|
5627
5882
|
const promptRelevantToolsChanged =
|
|
5628
5883
|
signature !== (this.#pendingAppliedToolSignature ?? this.#lastAppliedToolSignature);
|
|
5629
|
-
if (promptRelevantToolsChanged) {
|
|
5630
|
-
this.#defaultModelSelectionMutationRevision++;
|
|
5631
|
-
this.#pendingAppliedToolSignature = signature;
|
|
5632
|
-
}
|
|
5633
|
-
this.#setGuardedAgentTools(tools);
|
|
5634
|
-
|
|
5635
|
-
// Active tool set changed → discoverable tool list (which excludes already-active tools)
|
|
5636
|
-
// is now stale. Invalidate before any prompt-template hook reads the discovery list.
|
|
5637
|
-
this.#invalidateDiscoveryCaches();
|
|
5638
|
-
|
|
5639
|
-
// Rebuild base system prompt with new tool set, but only when the tool set
|
|
5640
|
-
// actually changed. MCP servers can reconnect at arbitrary times and call
|
|
5641
|
-
// `refreshMCPTools` -> `#applyActiveToolsByName` even though the resulting
|
|
5642
|
-
// tool list is byte-identical. Skipping the rebuild keeps the system prompt
|
|
5643
|
-
// stable, which is required for Anthropic prompt caching to keep hitting.
|
|
5644
5884
|
if (promptRelevantToolsChanged && this.#rebuildSystemPrompt) {
|
|
5645
5885
|
const generation = this.#reserveBaseSystemPromptGeneration();
|
|
5646
5886
|
try {
|
|
@@ -5663,8 +5903,16 @@ export class AgentSession {
|
|
|
5663
5903
|
this.#lastAppliedToolSignature = signature;
|
|
5664
5904
|
this.#pendingAppliedToolSignature = undefined;
|
|
5665
5905
|
}
|
|
5906
|
+
if (promptRelevantToolsChanged) this.#defaultModelSelectionMutationRevision++;
|
|
5907
|
+
this.#selectedMCPToolNames = nextSelectedMCPToolNames;
|
|
5908
|
+
this.#selectedDiscoveredToolNames = nextSelectedDiscoveredBuiltinToolNames;
|
|
5909
|
+
this.#setGuardedAgentTools(tools);
|
|
5910
|
+
this.#invalidateDiscoveryCaches();
|
|
5666
5911
|
if (options?.persistMCPSelection !== false) {
|
|
5667
|
-
this.#persistSelectedMCPToolNamesIfChanged(
|
|
5912
|
+
this.#persistSelectedMCPToolNamesIfChanged(
|
|
5913
|
+
previousSelectedMCPToolNames,
|
|
5914
|
+
previousSelectedDiscoveredBuiltinToolNames,
|
|
5915
|
+
);
|
|
5668
5916
|
}
|
|
5669
5917
|
}
|
|
5670
5918
|
|
|
@@ -5717,24 +5965,33 @@ export class AgentSession {
|
|
|
5717
5965
|
await this.#applyActiveToolsByName(toolNames);
|
|
5718
5966
|
}
|
|
5719
5967
|
|
|
5720
|
-
async #restoreMCPSelectionsForSessionContext(
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
const
|
|
5726
|
-
|
|
5727
|
-
|
|
5968
|
+
async #restoreMCPSelectionsForSessionContext(sessionContext: SessionContext): Promise<void> {
|
|
5969
|
+
if (!this.#mcpDiscoveryEnabled && this.#resolveEffectiveDiscoveryMode() !== "all") {
|
|
5970
|
+
await this.#attachAskToolIfWorkflowActive();
|
|
5971
|
+
return;
|
|
5972
|
+
}
|
|
5973
|
+
const selectionOnlyDiscoveredBuiltinToolNames = new Set(
|
|
5974
|
+
this.#getSelectedDiscoveredBuiltinToolNames().filter(
|
|
5975
|
+
name => !this.#baselineDiscoveredBuiltinToolNames.has(name),
|
|
5976
|
+
),
|
|
5977
|
+
);
|
|
5978
|
+
const nextActiveNonMCPToolNames = this.#getActiveNonMCPToolNames().filter(
|
|
5979
|
+
name => name !== "ask" && !selectionOnlyDiscoveredBuiltinToolNames.has(name),
|
|
5980
|
+
);
|
|
5981
|
+
const constructorMCPToolNames = this.#resolveConstructorMCPToolSelection();
|
|
5728
5982
|
const restoredMCPToolNames = sessionContext.hasPersistedMCPToolSelection
|
|
5729
5983
|
? this.#filterSelectableMCPToolNames(sessionContext.selectedMCPToolNames)
|
|
5730
|
-
: this.#
|
|
5731
|
-
this.#
|
|
5732
|
-
|
|
5733
|
-
this.#
|
|
5984
|
+
: (constructorMCPToolNames ?? this.#getConfiguredDefaultSelectedMCPToolNames());
|
|
5985
|
+
const constructorDiscoveredBuiltinToolNames = this.#resolveConstructorDiscoveredBuiltinToolSelection();
|
|
5986
|
+
const restoredDiscoveredBuiltinToolNames = sessionContext.hasPersistedDiscoveredBuiltinToolSelection
|
|
5987
|
+
? this.#selectRestorableDiscoveredBuiltinToolNames(sessionContext.selectedDiscoveredBuiltinToolNames ?? [])
|
|
5988
|
+
: (constructorDiscoveredBuiltinToolNames ?? []);
|
|
5989
|
+
this.#selectedDiscoveredToolNames = new Set(restoredDiscoveredBuiltinToolNames);
|
|
5990
|
+
await this.#applyActiveToolsByName(
|
|
5991
|
+
[...nextActiveNonMCPToolNames, ...restoredMCPToolNames, ...restoredDiscoveredBuiltinToolNames],
|
|
5992
|
+
{ persistMCPSelection: false },
|
|
5734
5993
|
);
|
|
5735
|
-
await this.#
|
|
5736
|
-
persistMCPSelection: false,
|
|
5737
|
-
});
|
|
5994
|
+
await this.#attachAskToolIfWorkflowActive();
|
|
5738
5995
|
}
|
|
5739
5996
|
/** Rebuild the base system prompt using the current active tool set. */
|
|
5740
5997
|
async refreshBaseSystemPrompt(): Promise<void> {
|
|
@@ -5878,19 +6135,17 @@ export class AgentSession {
|
|
|
5878
6135
|
|
|
5879
6136
|
this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry());
|
|
5880
6137
|
this.#pruneSelectedMCPToolNames();
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
6138
|
+
const hasPersistedMCPToolSelection = this.buildDisplaySessionContext().hasPersistedMCPToolSelection;
|
|
6139
|
+
if (!hasPersistedMCPToolSelection) {
|
|
6140
|
+
this.#selectedMCPToolNames = new Set(
|
|
6141
|
+
this.#resolveConstructorMCPToolSelection() ?? this.#getConfiguredDefaultSelectedMCPToolNames(),
|
|
6142
|
+
);
|
|
5886
6143
|
}
|
|
5887
|
-
this.#rememberSessionDefaultSelectedMCPToolNames(
|
|
5888
|
-
this.sessionFile,
|
|
5889
|
-
this.#getConfiguredDefaultSelectedMCPToolNames(),
|
|
5890
|
-
);
|
|
5891
|
-
|
|
5892
6144
|
const nextActive = [...this.#getActiveNonMCPToolNames(), ...this.getSelectedMCPToolNames()];
|
|
5893
|
-
await this.#applyActiveToolsByName(nextActive, {
|
|
6145
|
+
await this.#applyActiveToolsByName(nextActive, {
|
|
6146
|
+
previousSelectedMCPToolNames,
|
|
6147
|
+
persistMCPSelection: hasPersistedMCPToolSelection,
|
|
6148
|
+
});
|
|
5894
6149
|
}
|
|
5895
6150
|
|
|
5896
6151
|
async #hasActiveGjcSubskillTools(parent: string, sessionId: string | undefined): Promise<boolean> {
|
|
@@ -6035,8 +6290,30 @@ export class AgentSession {
|
|
|
6035
6290
|
return this.#promptGeneration;
|
|
6036
6291
|
}
|
|
6037
6292
|
|
|
6293
|
+
/** The immutable recovery authority, present only before external ownership promotion. */
|
|
6294
|
+
get recoveryHydrationContext(): RecoveryHydrationContext | undefined {
|
|
6295
|
+
return this.#recoveryHydrationContext;
|
|
6296
|
+
}
|
|
6297
|
+
|
|
6298
|
+
/** Enables normal session mutations after the owner has published its durable fence and writer lease. */
|
|
6299
|
+
async promoteRecoveryHydrationAfterOwnershipReadyFence(fence: RecoveryHydrationPromotionFence): Promise<void> {
|
|
6300
|
+
const context = this.#recoveryHydrationContext;
|
|
6301
|
+
if (!context) throw new Error("Agent session is not awaiting recovery hydration promotion.");
|
|
6302
|
+
await this.sessionManager.promoteRecoveryHydrationAfterOwnershipReadyFence(context, fence);
|
|
6303
|
+
this.#recoveryHydrationContext = undefined;
|
|
6304
|
+
}
|
|
6305
|
+
|
|
6306
|
+
/** Recovery hydration must not start a continuation before ownership promotion. */
|
|
6307
|
+
#assertRecoveryHydrationPromoted(): void {
|
|
6308
|
+
if (this.#recoveryHydrationContext) {
|
|
6309
|
+
throw new Error("Recovery hydration has not been promoted to a writer-owning session.");
|
|
6310
|
+
}
|
|
6311
|
+
}
|
|
6312
|
+
|
|
6038
6313
|
/** Main startup calls this exactly once, after a strict open returned `kind: "opened"`. */
|
|
6039
6314
|
async continuePersistedHistory(): Promise<void> {
|
|
6315
|
+
this.#assertNoHandoffTransition();
|
|
6316
|
+
this.#assertRecoveryHydrationPromoted();
|
|
6040
6317
|
this.#removeEphemeralCustomMessages();
|
|
6041
6318
|
|
|
6042
6319
|
if (!canContinuePersistedHistory(this.agent.state.messages)) {
|
|
@@ -6072,6 +6349,10 @@ export class AgentSession {
|
|
|
6072
6349
|
hindsightRecall = undefined;
|
|
6073
6350
|
}
|
|
6074
6351
|
}
|
|
6352
|
+
// Re-check after the awaited preparation: a handoff can engage during the
|
|
6353
|
+
// volatile-context/hindsight awaits above and this would otherwise start a
|
|
6354
|
+
// turn against the session being handed off.
|
|
6355
|
+
this.#assertNoHandoffTransition();
|
|
6075
6356
|
await this.agent.continue({
|
|
6076
6357
|
...this.#managedFallbackPromptOptions(),
|
|
6077
6358
|
onRunAccepted: () => {
|
|
@@ -6358,27 +6639,61 @@ export class AgentSession {
|
|
|
6358
6639
|
return getAskAnswerSourceFromRegistry(this.sessionId);
|
|
6359
6640
|
}
|
|
6360
6641
|
|
|
6361
|
-
#
|
|
6642
|
+
#constructWorkflowGateEmitter(): WorkflowGateEmitter {
|
|
6362
6643
|
const sessionId = this.sessionManager.getSessionId();
|
|
6363
6644
|
assertNonEmptyGjcSessionId(sessionId, "AgentSession workflow-gate session");
|
|
6364
6645
|
const gateStore = this.sessionManager.isPersisted()
|
|
6365
6646
|
? new FileGateStore(path.join(sessionStateDir(this.sessionManager.getCwd(), sessionId), "workflow-gates.json"))
|
|
6366
6647
|
: new MemoryGateStore();
|
|
6367
|
-
|
|
6368
|
-
|
|
6369
|
-
|
|
6370
|
-
|
|
6648
|
+
return new BrokerWorkflowGateEmitter(sessionId, gateStore);
|
|
6649
|
+
}
|
|
6650
|
+
|
|
6651
|
+
/**
|
|
6652
|
+
* Publish an already-constructed successor emitter. This is no-throw from the
|
|
6653
|
+
* caller's perspective: predecessor fencing/quarantine is best-effort (a failure
|
|
6654
|
+
* must never leave the session with no emitter after a committed switch), and
|
|
6655
|
+
* listener notification is isolated in the registry.
|
|
6656
|
+
*/
|
|
6657
|
+
#publishWorkflowGateEmitter(
|
|
6658
|
+
successorEmitter: WorkflowGateEmitter,
|
|
6659
|
+
previousSessionId?: string,
|
|
6660
|
+
previousEmitter = this.#workflowGateEmitter,
|
|
6661
|
+
): void {
|
|
6662
|
+
try {
|
|
6663
|
+
previousEmitter?.fence?.();
|
|
6664
|
+
if (previousEmitter && !previousEmitter.fence) {
|
|
6665
|
+
for (const gate of previousEmitter.listPendingGates?.() ?? [])
|
|
6666
|
+
previousEmitter.quarantineGate?.(gate.gate_id);
|
|
6667
|
+
}
|
|
6668
|
+
} catch (error) {
|
|
6669
|
+
logger.warn("Workflow-gate predecessor fence failed during publish", {
|
|
6670
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6671
|
+
});
|
|
6371
6672
|
}
|
|
6372
6673
|
if (previousSessionId) notifyWorkflowGateEmitterChanged(previousSessionId, undefined);
|
|
6373
6674
|
this.setWorkflowGateEmitter(successorEmitter);
|
|
6374
6675
|
}
|
|
6375
6676
|
|
|
6677
|
+
#bindWorkflowGateEmitter(previousSessionId?: string, previousEmitter = this.#workflowGateEmitter): void {
|
|
6678
|
+
this.#publishWorkflowGateEmitter(this.#constructWorkflowGateEmitter(), previousSessionId, previousEmitter);
|
|
6679
|
+
}
|
|
6680
|
+
|
|
6376
6681
|
#suspendWorkflowGateEmitter(sessionId: string): WorkflowGateEmitter | undefined {
|
|
6377
6682
|
const emitter = this.#workflowGateEmitter;
|
|
6378
6683
|
if (!emitter) return undefined;
|
|
6379
|
-
|
|
6684
|
+
// Clear the field first, then run the (throwable) suspend + listener
|
|
6685
|
+
// notification inside a guard. This guarantees the caller always receives the
|
|
6686
|
+
// emitter token so a rollback can restore it, even if a registry listener
|
|
6687
|
+
// throws during notification.
|
|
6380
6688
|
this.#workflowGateEmitter = undefined;
|
|
6381
|
-
|
|
6689
|
+
try {
|
|
6690
|
+
emitter.suspend?.();
|
|
6691
|
+
notifyWorkflowGateEmitterChanged(sessionId, undefined);
|
|
6692
|
+
} catch (error) {
|
|
6693
|
+
logger.warn("Workflow-gate emitter suspension notification failed", {
|
|
6694
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6695
|
+
});
|
|
6696
|
+
}
|
|
6382
6697
|
return emitter;
|
|
6383
6698
|
}
|
|
6384
6699
|
|
|
@@ -6392,33 +6707,70 @@ export class AgentSession {
|
|
|
6392
6707
|
this.#workflowGateEmitter = emitter;
|
|
6393
6708
|
notifyWorkflowGateEmitterChanged(this.sessionId, emitter);
|
|
6394
6709
|
if (emitter) {
|
|
6395
|
-
this.#
|
|
6710
|
+
this.#registerWorkflowGateAskTool();
|
|
6396
6711
|
}
|
|
6397
6712
|
}
|
|
6398
6713
|
|
|
6399
|
-
#
|
|
6400
|
-
if (this.#toolRegistry.has("ask")) return;
|
|
6714
|
+
#registerWorkflowGateAskTool(): void {
|
|
6401
6715
|
if (!this.#workflowGateToolSession) return;
|
|
6402
6716
|
|
|
6403
|
-
|
|
6404
|
-
if (!askTool)
|
|
6717
|
+
let askTool = this.#toolRegistry.get("ask");
|
|
6718
|
+
if (!askTool) {
|
|
6719
|
+
const createdAskTool = AskTool.createIf(this.#workflowGateToolSession);
|
|
6720
|
+
if (!createdAskTool) return;
|
|
6721
|
+
const wrappedTool = wrapToolWithMetaNotice(createdAskTool as unknown as AgentTool);
|
|
6722
|
+
askTool = this.#extensionRunner ? new ExtensionToolWrapper(wrappedTool, this.#extensionRunner) : wrappedTool;
|
|
6723
|
+
this.#toolRegistry.set(askTool.name, askTool);
|
|
6724
|
+
}
|
|
6405
6725
|
|
|
6406
|
-
|
|
6407
|
-
|
|
6408
|
-
|
|
6409
|
-
|
|
6410
|
-
|
|
6726
|
+
try {
|
|
6727
|
+
if ((this.#workflowGateEmitter?.listPendingGates?.().length ?? 0) > 0) {
|
|
6728
|
+
this.#attachAskTool();
|
|
6729
|
+
}
|
|
6730
|
+
} catch (error) {
|
|
6731
|
+
logger.warn("Failed to inspect pending workflow gates; activating ask tool conservatively", {
|
|
6732
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6733
|
+
});
|
|
6734
|
+
this.#attachAskTool();
|
|
6735
|
+
}
|
|
6736
|
+
}
|
|
6411
6737
|
|
|
6412
|
-
|
|
6413
|
-
|
|
6414
|
-
|
|
6415
|
-
this.#
|
|
6416
|
-
|
|
6417
|
-
|
|
6738
|
+
async #attachAskToolIfWorkflowActive(): Promise<void> {
|
|
6739
|
+
const sessionId = this.sessionManager.getSessionId();
|
|
6740
|
+
const inMemoryActiveSkill =
|
|
6741
|
+
this.#activeSkillState && (!this.#activeSkillState.sessionId || this.#activeSkillState.sessionId === sessionId)
|
|
6742
|
+
? this.#activeSkillState.skill
|
|
6743
|
+
: undefined;
|
|
6744
|
+
let activeSkill = inMemoryActiveSkill;
|
|
6745
|
+
if (!activeSkill) {
|
|
6746
|
+
try {
|
|
6747
|
+
const activeState = await readVisibleSkillActiveState(this.sessionManager.getCwd(), sessionId);
|
|
6748
|
+
activeSkill =
|
|
6749
|
+
activeState?.skill ?? activeState?.active_skills?.find(entry => entry.active !== false)?.skill;
|
|
6750
|
+
} catch (error) {
|
|
6751
|
+
logger.warn("Failed to read durable workflow skill state while restoring ask tool", {
|
|
6418
6752
|
error: error instanceof Error ? error.message : String(error),
|
|
6419
6753
|
});
|
|
6420
|
-
|
|
6754
|
+
return;
|
|
6755
|
+
}
|
|
6756
|
+
// Identity fence: the durable read is async — the session may have been
|
|
6757
|
+
// disposed or switched to a different identity meanwhile. Never attach
|
|
6758
|
+
// a predecessor session's workflow state to the current identity.
|
|
6759
|
+
if (this.#isDisposed || this.sessionManager.getSessionId() !== sessionId) return;
|
|
6421
6760
|
}
|
|
6761
|
+
if (activeSkill && isCanonicalGjcWorkflowSkill(activeSkill.trim())) this.#attachAskTool();
|
|
6762
|
+
}
|
|
6763
|
+
|
|
6764
|
+
#attachAskTool(): void {
|
|
6765
|
+
const askTool = this.#toolRegistry.get("ask");
|
|
6766
|
+
if (!askTool || this.getActiveToolNames().includes(askTool.name)) return;
|
|
6767
|
+
this.#setGuardedAgentTools([...this.agent.state.tools, askTool]);
|
|
6768
|
+
this.#invalidateDiscoveryCaches();
|
|
6769
|
+
void this.refreshBaseSystemPrompt().catch(error => {
|
|
6770
|
+
logger.warn("Failed to refresh system prompt after workflow gate ask tool activation", {
|
|
6771
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6772
|
+
});
|
|
6773
|
+
});
|
|
6422
6774
|
}
|
|
6423
6775
|
|
|
6424
6776
|
get goalRuntime(): GoalRuntime {
|
|
@@ -6894,6 +7246,7 @@ export class AgentSession {
|
|
|
6894
7246
|
...options,
|
|
6895
7247
|
prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined,
|
|
6896
7248
|
admissionLease: admission,
|
|
7249
|
+
resetRetryReplaySafety: true,
|
|
6897
7250
|
});
|
|
6898
7251
|
} finally {
|
|
6899
7252
|
// Clean up residual eager-todo directive if the prompt never consumed it
|
|
@@ -6916,6 +7269,11 @@ export class AgentSession {
|
|
|
6916
7269
|
const name = (details as { name?: unknown }).name;
|
|
6917
7270
|
if (typeof name !== "string" || !name.trim()) return;
|
|
6918
7271
|
const skill = name.trim();
|
|
7272
|
+
// Functional tool availability must not depend on the best-effort
|
|
7273
|
+
// observational state-sync below (whose failures are swallowed by
|
|
7274
|
+
// #syncSkillPromptActiveStateSafely): attach ask first so canonical
|
|
7275
|
+
// workflow skills can always call it.
|
|
7276
|
+
if (active && isCanonicalGjcWorkflowSkill(skill)) this.#attachAskTool();
|
|
6919
7277
|
const sessionId = this.sessionManager.getSessionId();
|
|
6920
7278
|
// Canonical GJC workflow skills (deep-interview, ralplan, ultragoal, team)
|
|
6921
7279
|
// own their `.gjc/state/skill-active-state.json` row through the
|
|
@@ -7021,7 +7379,11 @@ export class AgentSession {
|
|
|
7021
7379
|
|
|
7022
7380
|
await this.#syncSkillPromptActiveStateSafely(customMessage, true);
|
|
7023
7381
|
try {
|
|
7024
|
-
await this.#promptWithMessage(customMessage, textContent, {
|
|
7382
|
+
await this.#promptWithMessage(customMessage, textContent, {
|
|
7383
|
+
...options,
|
|
7384
|
+
admissionLease: admission,
|
|
7385
|
+
resetRetryReplaySafety: true,
|
|
7386
|
+
});
|
|
7025
7387
|
} finally {
|
|
7026
7388
|
await this.#syncSkillPromptActiveStateSafely(customMessage, false);
|
|
7027
7389
|
}
|
|
@@ -7037,9 +7399,15 @@ export class AgentSession {
|
|
|
7037
7399
|
predecessorAgentEndHold?: symbol;
|
|
7038
7400
|
admissionLease?: SessionAdmissionLease;
|
|
7039
7401
|
onRunAccepted?: () => void;
|
|
7402
|
+
resetRetryReplaySafety?: boolean;
|
|
7040
7403
|
},
|
|
7041
7404
|
): Promise<void> {
|
|
7405
|
+
this.#assertNoHandoffTransition();
|
|
7042
7406
|
await this.#agentEndPublicationPromise;
|
|
7407
|
+
// Re-check after the publication await: a handoff can engage during that
|
|
7408
|
+
// window, and #beginInFlight below would otherwise start a turn against the
|
|
7409
|
+
// session being handed off.
|
|
7410
|
+
this.#assertNoHandoffTransition();
|
|
7043
7411
|
this.#beginInFlight();
|
|
7044
7412
|
const predecessorAgentEndHold =
|
|
7045
7413
|
options?.predecessorAgentEndHold ?? this.#reserveDeferredAgentEndForContinuation();
|
|
@@ -7049,6 +7417,7 @@ export class AgentSession {
|
|
|
7049
7417
|
let hindsightRecall: string | undefined;
|
|
7050
7418
|
try {
|
|
7051
7419
|
this.#throwIfPromptPreflightCancelled(generation, preflightSignal);
|
|
7420
|
+
if (options?.resetRetryReplaySafety) this.#resetRetryReplaySafety();
|
|
7052
7421
|
if (message.role === "user") {
|
|
7053
7422
|
await this.#resetDefaultFallbackForNewTurn();
|
|
7054
7423
|
await this.#ensureDefaultFallbackResolution();
|
|
@@ -7188,6 +7557,8 @@ export class AgentSession {
|
|
|
7188
7557
|
|
|
7189
7558
|
// Emit before_agent_start extension event. Race hook completion with prompt
|
|
7190
7559
|
// cancellation so a wedged hook cannot retain SDK prompt authority.
|
|
7560
|
+
if (this.#extensionRunner?.hasHandlers("before_agent_start")) this.#markRetryReplayUnsafe();
|
|
7561
|
+
|
|
7191
7562
|
if (this.#extensionRunner) {
|
|
7192
7563
|
const result = await this.#awaitPromptPreflight(
|
|
7193
7564
|
generation,
|
|
@@ -7458,6 +7829,8 @@ export class AgentSession {
|
|
|
7458
7829
|
} as unknown as HookCommandContext;
|
|
7459
7830
|
|
|
7460
7831
|
try {
|
|
7832
|
+
this.#markRetryReplayUnsafe();
|
|
7833
|
+
|
|
7461
7834
|
const args = parseCommandArgs(argsString);
|
|
7462
7835
|
const result = await loaded.command.execute(args, ctx);
|
|
7463
7836
|
// If result is a string, it's a prompt to send to LLM
|
|
@@ -7520,6 +7893,7 @@ export class AgentSession {
|
|
|
7520
7893
|
images?: ImageContent[],
|
|
7521
7894
|
options?: { claimsGenuineUserIntent?: boolean },
|
|
7522
7895
|
): Promise<void> {
|
|
7896
|
+
this.#assertNoHandoffTransition();
|
|
7523
7897
|
assertImagePlaceholdersHavePayload(text, images);
|
|
7524
7898
|
const displayText = text || (images && images.length > 0 ? "[Image]" : "");
|
|
7525
7899
|
this.#steeringMessages.push(this.#createQueuedDisplayEntry(displayText));
|
|
@@ -7554,6 +7928,7 @@ export class AgentSession {
|
|
|
7554
7928
|
images?: ImageContent[],
|
|
7555
7929
|
options?: { forceOneAtATime?: boolean; claimsGenuineUserIntent?: boolean },
|
|
7556
7930
|
): Promise<void> {
|
|
7931
|
+
this.#assertNoHandoffTransition();
|
|
7557
7932
|
assertImagePlaceholdersHavePayload(text, images);
|
|
7558
7933
|
const displayText = text || (images && images.length > 0 ? "[Image]" : "");
|
|
7559
7934
|
this.#followUpMessages.push(this.#createQueuedDisplayEntry(displayText));
|
|
@@ -7625,6 +8000,10 @@ export class AgentSession {
|
|
|
7625
8000
|
}
|
|
7626
8001
|
|
|
7627
8002
|
#queueHiddenNextTurnMessage(message: CustomMessage, triggerTurn: boolean): void {
|
|
8003
|
+
// A hidden next-turn message queued during a handoff transition would be
|
|
8004
|
+
// dropped when the successor clears predecessor queues; reject it as busy so
|
|
8005
|
+
// the caller can retry against the settled session.
|
|
8006
|
+
this.#assertNoHandoffTransition();
|
|
7628
8007
|
this.#pendingNextTurnMessages.push(message);
|
|
7629
8008
|
if (!triggerTurn) return;
|
|
7630
8009
|
const generation = this.#promptGeneration;
|
|
@@ -7744,6 +8123,10 @@ export class AgentSession {
|
|
|
7744
8123
|
this.#deepInterviewGenuineUserMessageEpochs.set(appMessage, epoch);
|
|
7745
8124
|
}
|
|
7746
8125
|
if (this.isStreaming) {
|
|
8126
|
+
// A handoff transition owns the session; a background/custom trigger (cron,
|
|
8127
|
+
// monitor, skill) must not steer/follow-up/queue against the outgoing turn
|
|
8128
|
+
// while an (auto-)handoff is unwinding it.
|
|
8129
|
+
this.#assertNoHandoffTransition();
|
|
7747
8130
|
if (options?.deliverAs === "nextTurn") {
|
|
7748
8131
|
this.#queueHiddenNextTurnMessage(appMessage, options?.triggerTurn ?? false);
|
|
7749
8132
|
return;
|
|
@@ -8188,6 +8571,7 @@ export class AgentSession {
|
|
|
8188
8571
|
: undefined;
|
|
8189
8572
|
this.#abortActiveMidRunBarriers();
|
|
8190
8573
|
this.#silentAbortPending = options?.silent === true;
|
|
8574
|
+
this.#markRetryReplayUnsafe();
|
|
8191
8575
|
this.abortRetry();
|
|
8192
8576
|
this.#promptGeneration++;
|
|
8193
8577
|
this.#promptPreflightAbortController.abort();
|
|
@@ -8426,6 +8810,7 @@ export class AgentSession {
|
|
|
8426
8810
|
try {
|
|
8427
8811
|
await this.#promptWithMessage(message, messageText, {
|
|
8428
8812
|
admissionLease: admission,
|
|
8813
|
+
resetRetryReplaySafety: true,
|
|
8429
8814
|
onRunAccepted: () => {
|
|
8430
8815
|
runAccepted = true;
|
|
8431
8816
|
if (selected) {
|
|
@@ -8467,12 +8852,15 @@ export class AgentSession {
|
|
|
8467
8852
|
*/
|
|
8468
8853
|
newSession(options?: NewSessionOptions): Promise<boolean> {
|
|
8469
8854
|
if (this.#newSessionTransition) return this.#newSessionTransition;
|
|
8470
|
-
|
|
8855
|
+
// Acquire the shared transition lease only when starting a fresh transition
|
|
8856
|
+
// (the dedup above returns the in-flight promise without re-acquiring).
|
|
8857
|
+
this.#beginSessionTransition("new-session");
|
|
8471
8858
|
const transition = this.#runNewSessionTransition(options);
|
|
8472
8859
|
this.#newSessionTransition = transition;
|
|
8473
8860
|
void transition
|
|
8474
8861
|
.finally(() => {
|
|
8475
8862
|
if (this.#newSessionTransition === transition) this.#newSessionTransition = undefined;
|
|
8863
|
+
this.#endSessionTransition();
|
|
8476
8864
|
})
|
|
8477
8865
|
.catch(() => {});
|
|
8478
8866
|
return transition;
|
|
@@ -8481,10 +8869,17 @@ export class AgentSession {
|
|
|
8481
8869
|
async #runNewSessionTransition(options?: NewSessionOptions): Promise<boolean> {
|
|
8482
8870
|
const previousSessionFile = this.sessionFile;
|
|
8483
8871
|
const previousWorkflowGateSessionId = this.sessionId;
|
|
8872
|
+
const selectionOnlyDiscoveredBuiltinToolNames = new Set(
|
|
8873
|
+
this.#getSelectedDiscoveredBuiltinToolNames().filter(
|
|
8874
|
+
name => !this.#baselineDiscoveredBuiltinToolNames.has(name),
|
|
8875
|
+
),
|
|
8876
|
+
);
|
|
8484
8877
|
const nextDiscoverySessionToolNames = this.#mcpDiscoveryEnabled
|
|
8485
8878
|
? [
|
|
8486
|
-
...this.#getActiveNonMCPToolNames()
|
|
8487
|
-
|
|
8879
|
+
...this.#getActiveNonMCPToolNames().filter(
|
|
8880
|
+
name => name !== "ask" && !selectionOnlyDiscoveredBuiltinToolNames.has(name),
|
|
8881
|
+
),
|
|
8882
|
+
...this.#getConfiguredDefaultSelectedMCPToolNames(),
|
|
8488
8883
|
]
|
|
8489
8884
|
: undefined;
|
|
8490
8885
|
|
|
@@ -8626,6 +9021,7 @@ export class AgentSession {
|
|
|
8626
9021
|
nextDiscoverySessionToolNames: string[] | undefined,
|
|
8627
9022
|
previousSessionFile: string | undefined,
|
|
8628
9023
|
): Promise<void> {
|
|
9024
|
+
this.#clearConstructorToolSelectionAuthority();
|
|
8629
9025
|
const inheritedThinkingLevel = resolveThinkingLevelForModel(this.model, this.#getInheritedThinkingLevel());
|
|
8630
9026
|
this.#thinkingLevel = inheritedThinkingLevel;
|
|
8631
9027
|
this.agent.setThinkingLevel(toReasoningEffort(inheritedThinkingLevel));
|
|
@@ -8635,15 +9031,11 @@ export class AgentSession {
|
|
|
8635
9031
|
}
|
|
8636
9032
|
this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
|
|
8637
9033
|
if (nextDiscoverySessionToolNames) {
|
|
8638
|
-
await this.#applyActiveToolsByName(nextDiscoverySessionToolNames, {
|
|
8639
|
-
|
|
8640
|
-
|
|
8641
|
-
}
|
|
9034
|
+
await this.#applyActiveToolsByName(nextDiscoverySessionToolNames, {
|
|
9035
|
+
persistMCPSelection: false,
|
|
9036
|
+
nextSelectedDiscoveredBuiltinToolNames: [],
|
|
9037
|
+
});
|
|
8642
9038
|
}
|
|
8643
|
-
this.#rememberSessionDefaultSelectedMCPToolNames(
|
|
8644
|
-
this.sessionFile,
|
|
8645
|
-
this.#getConfiguredDefaultSelectedMCPToolNames(),
|
|
8646
|
-
);
|
|
8647
9039
|
this.#todoReminderCount = 0;
|
|
8648
9040
|
this.#planReferenceSent = false;
|
|
8649
9041
|
this.#planReferencePath = "local://PLAN.md";
|
|
@@ -8663,34 +9055,39 @@ export class AgentSession {
|
|
|
8663
9055
|
* session identity and durable history trail.
|
|
8664
9056
|
*/
|
|
8665
9057
|
async clearContext(): Promise<boolean> {
|
|
8666
|
-
|
|
8667
|
-
|
|
8668
|
-
|
|
8669
|
-
|
|
8670
|
-
|
|
8671
|
-
|
|
8672
|
-
|
|
8673
|
-
|
|
8674
|
-
|
|
8675
|
-
|
|
8676
|
-
|
|
8677
|
-
|
|
8678
|
-
|
|
8679
|
-
|
|
8680
|
-
|
|
8681
|
-
|
|
8682
|
-
|
|
9058
|
+
this.#beginSessionTransition("clear-context");
|
|
9059
|
+
try {
|
|
9060
|
+
const sessionId = this.sessionId;
|
|
9061
|
+
this.#disconnectFromAgent();
|
|
9062
|
+
await this.abort();
|
|
9063
|
+
this.#cancelOwnAsyncJobs();
|
|
9064
|
+
this.#suppressOwnAsyncJobDeliveries();
|
|
9065
|
+
this.yieldQueue.clear();
|
|
9066
|
+
this.#pendingBackgroundExchanges = [];
|
|
9067
|
+
this.#closeAllProviderSessions("context clear");
|
|
9068
|
+
this.agent.reset();
|
|
9069
|
+
await this.sessionManager.flush();
|
|
9070
|
+
this.sessionManager.appendContextClearEntry({ sessionId });
|
|
9071
|
+
this.setTodoPhases([]);
|
|
9072
|
+
this.#syncAgentSessionId(sessionId);
|
|
9073
|
+
this.#steeringMessages = [];
|
|
9074
|
+
this.#followUpMessages = [];
|
|
9075
|
+
this.#pendingNextTurnMessages = [];
|
|
9076
|
+
this.#scheduledHiddenNextTurnGeneration = undefined;
|
|
8683
9077
|
|
|
8684
|
-
|
|
8685
|
-
|
|
8686
|
-
|
|
9078
|
+
this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
|
|
9079
|
+
if (this.model) {
|
|
9080
|
+
this.sessionManager.appendModelChange(`${this.model.provider}/${this.model.id}`);
|
|
9081
|
+
}
|
|
9082
|
+
this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
|
|
9083
|
+
this.#todoReminderCount = 0;
|
|
9084
|
+
this.#planReferenceSent = false;
|
|
9085
|
+
this.#planReferencePath = "local://PLAN.md";
|
|
9086
|
+
this.#reconnectToAgent();
|
|
9087
|
+
return true;
|
|
9088
|
+
} finally {
|
|
9089
|
+
this.#endSessionTransition();
|
|
8687
9090
|
}
|
|
8688
|
-
this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
|
|
8689
|
-
this.#todoReminderCount = 0;
|
|
8690
|
-
this.#planReferenceSent = false;
|
|
8691
|
-
this.#planReferencePath = "local://PLAN.md";
|
|
8692
|
-
this.#reconnectToAgent();
|
|
8693
|
-
return true;
|
|
8694
9091
|
}
|
|
8695
9092
|
|
|
8696
9093
|
/**
|
|
@@ -8707,66 +9104,54 @@ export class AgentSession {
|
|
|
8707
9104
|
* @returns true if completed, false if cancelled by hook or not persisting
|
|
8708
9105
|
*/
|
|
8709
9106
|
async fork(): Promise<boolean> {
|
|
8710
|
-
|
|
8711
|
-
|
|
9107
|
+
// Fork replaces session identity/file and publishes session_switch; serialize
|
|
9108
|
+
// it with handoff and the other transitions via the shared lease.
|
|
9109
|
+
this.#beginSessionTransition("fork");
|
|
9110
|
+
try {
|
|
9111
|
+
const previousSessionFile = this.sessionFile;
|
|
9112
|
+
const previousWorkflowGateSessionId = this.sessionId;
|
|
8712
9113
|
|
|
8713
|
-
|
|
8714
|
-
|
|
8715
|
-
|
|
8716
|
-
|
|
8717
|
-
|
|
8718
|
-
|
|
9114
|
+
// Emit session_before_switch event with reason "fork" (can be cancelled)
|
|
9115
|
+
if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
|
|
9116
|
+
const result = (await this.#extensionRunner.emit({
|
|
9117
|
+
type: "session_before_switch",
|
|
9118
|
+
reason: "fork",
|
|
9119
|
+
})) as SessionBeforeSwitchResult | undefined;
|
|
8719
9120
|
|
|
8720
|
-
|
|
8721
|
-
|
|
8722
|
-
|
|
8723
|
-
|
|
9121
|
+
if (result?.cancel) {
|
|
9122
|
+
return false;
|
|
9123
|
+
}
|
|
9124
|
+
}
|
|
8724
9125
|
|
|
8725
|
-
|
|
8726
|
-
|
|
9126
|
+
// Flush current session to ensure all entries are written
|
|
9127
|
+
await this.sessionManager.flush();
|
|
8727
9128
|
|
|
8728
|
-
|
|
8729
|
-
|
|
8730
|
-
|
|
8731
|
-
|
|
8732
|
-
|
|
9129
|
+
// Fork the session (creates new session file with same entries)
|
|
9130
|
+
const forkResult = await this.sessionManager.fork();
|
|
9131
|
+
if (!forkResult) {
|
|
9132
|
+
return false;
|
|
9133
|
+
}
|
|
8733
9134
|
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
9135
|
+
// Update agent session ID
|
|
9136
|
+
this.#syncAgentSessionId();
|
|
9137
|
+
this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
|
|
9138
|
+
this.#rekeyHindsightMemoryForCurrentSessionId();
|
|
8737
9139
|
|
|
8738
|
-
|
|
8739
|
-
|
|
8740
|
-
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
|
|
8744
|
-
|
|
8745
|
-
|
|
8746
|
-
oldArtifactDir,
|
|
8747
|
-
newArtifactDir,
|
|
8748
|
-
error: err instanceof Error ? err.message : String(err),
|
|
9140
|
+
this.#resetIrcRosterDeliveryState();
|
|
9141
|
+
|
|
9142
|
+
// Emit session_switch event with reason "fork" to hooks
|
|
9143
|
+
if (this.#extensionRunner) {
|
|
9144
|
+
await this.#extensionRunner.emit({
|
|
9145
|
+
type: "session_switch",
|
|
9146
|
+
reason: "fork",
|
|
9147
|
+
previousSessionFile,
|
|
8749
9148
|
});
|
|
8750
9149
|
}
|
|
8751
|
-
}
|
|
8752
|
-
|
|
8753
|
-
// Update agent session ID
|
|
8754
|
-
this.#syncAgentSessionId();
|
|
8755
|
-
this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
|
|
8756
|
-
this.#rekeyHindsightMemoryForCurrentSessionId();
|
|
8757
|
-
|
|
8758
|
-
this.#resetIrcRosterDeliveryState();
|
|
8759
9150
|
|
|
8760
|
-
|
|
8761
|
-
|
|
8762
|
-
|
|
8763
|
-
type: "session_switch",
|
|
8764
|
-
reason: "fork",
|
|
8765
|
-
previousSessionFile,
|
|
8766
|
-
});
|
|
9151
|
+
return true;
|
|
9152
|
+
} finally {
|
|
9153
|
+
this.#endSessionTransition();
|
|
8767
9154
|
}
|
|
8768
|
-
|
|
8769
|
-
return true;
|
|
8770
9155
|
}
|
|
8771
9156
|
|
|
8772
9157
|
// =========================================================================
|
|
@@ -9766,148 +10151,156 @@ export class AgentSession {
|
|
|
9766
10151
|
* @param options Optional callbacks for completion/error handling
|
|
9767
10152
|
*/
|
|
9768
10153
|
async compact(customInstructions?: string, options?: CompactOptions): Promise<CompactionResult> {
|
|
9769
|
-
|
|
9770
|
-
|
|
9771
|
-
|
|
9772
|
-
this.#
|
|
9773
|
-
await this.abort();
|
|
9774
|
-
const compactionAbortController = new AbortController();
|
|
9775
|
-
this.#compactionAbortController = compactionAbortController;
|
|
9776
|
-
|
|
10154
|
+
// Serialize with every other session-identity transition via the shared lease
|
|
10155
|
+
// (bidirectional mutual exclusion with handoff/new/switch/branch/clear/fork/
|
|
10156
|
+
// navigateTree). Released in the outer finally below.
|
|
10157
|
+
this.#beginSessionTransition("compact");
|
|
9777
10158
|
try {
|
|
9778
|
-
if (
|
|
9779
|
-
throw new Error("
|
|
10159
|
+
if (this.#compactionAbortController) {
|
|
10160
|
+
throw new Error("Compaction already in progress");
|
|
9780
10161
|
}
|
|
10162
|
+
this.#disconnectFromAgent();
|
|
10163
|
+
await this.abort();
|
|
10164
|
+
const compactionAbortController = new AbortController();
|
|
10165
|
+
this.#compactionAbortController = compactionAbortController;
|
|
9781
10166
|
|
|
9782
|
-
|
|
9783
|
-
|
|
9784
|
-
|
|
9785
|
-
const preparation = prepareCompaction(pathEntries, compactionSettings, {
|
|
9786
|
-
contextWindow: this.model.contextWindow,
|
|
9787
|
-
tokenCorrectionRatio: this.#computeCompactionTokenCorrectionRatio(),
|
|
9788
|
-
});
|
|
9789
|
-
if (!preparation) {
|
|
9790
|
-
// Check why we can't compact
|
|
9791
|
-
const lastEntry = pathEntries[pathEntries.length - 1];
|
|
9792
|
-
if (lastEntry?.type === "compaction") {
|
|
9793
|
-
throw new Error("Already compacted");
|
|
10167
|
+
try {
|
|
10168
|
+
if (!this.model) {
|
|
10169
|
+
throw new Error("No model selected");
|
|
9794
10170
|
}
|
|
9795
|
-
throw new Error("Nothing to compact (session too small)");
|
|
9796
|
-
}
|
|
9797
10171
|
|
|
9798
|
-
|
|
9799
|
-
|
|
9800
|
-
let preserveData: Record<string, unknown> | undefined;
|
|
10172
|
+
const compactionSettings = this.settings.getGroup("compaction");
|
|
10173
|
+
const pathEntries = this.#withoutEphemeralCustomMessageEntries(this.sessionManager.getBranch());
|
|
9801
10174
|
|
|
9802
|
-
|
|
9803
|
-
|
|
9804
|
-
|
|
9805
|
-
|
|
9806
|
-
|
|
9807
|
-
|
|
9808
|
-
|
|
9809
|
-
|
|
9810
|
-
|
|
9811
|
-
|
|
9812
|
-
throw new
|
|
9813
|
-
}
|
|
9814
|
-
|
|
9815
|
-
if (result?.compaction) {
|
|
9816
|
-
hookCompaction = result.compaction;
|
|
9817
|
-
fromExtension = true;
|
|
10175
|
+
const preparation = prepareCompaction(pathEntries, compactionSettings, {
|
|
10176
|
+
contextWindow: this.model.contextWindow,
|
|
10177
|
+
tokenCorrectionRatio: this.#computeCompactionTokenCorrectionRatio(),
|
|
10178
|
+
});
|
|
10179
|
+
if (!preparation) {
|
|
10180
|
+
// Check why we can't compact
|
|
10181
|
+
const lastEntry = pathEntries[pathEntries.length - 1];
|
|
10182
|
+
if (lastEntry?.type === "compaction") {
|
|
10183
|
+
throw new Error("Already compacted");
|
|
10184
|
+
}
|
|
10185
|
+
throw new Error("Nothing to compact (session too small)");
|
|
9818
10186
|
}
|
|
9819
|
-
}
|
|
9820
10187
|
|
|
9821
|
-
|
|
10188
|
+
let hookCompaction: CompactionResult | undefined;
|
|
10189
|
+
let fromExtension = false;
|
|
10190
|
+
let preserveData: Record<string, unknown> | undefined;
|
|
9822
10191
|
|
|
9823
|
-
|
|
9824
|
-
|
|
9825
|
-
|
|
9826
|
-
let tokensBefore: number;
|
|
9827
|
-
let details: unknown;
|
|
9828
|
-
|
|
9829
|
-
if (compactionPrep.kind === "fromHook") {
|
|
9830
|
-
summary = compactionPrep.summary;
|
|
9831
|
-
shortSummary = compactionPrep.shortSummary;
|
|
9832
|
-
firstKeptEntryId = compactionPrep.firstKeptEntryId;
|
|
9833
|
-
tokensBefore = compactionPrep.tokensBefore;
|
|
9834
|
-
details = compactionPrep.details;
|
|
9835
|
-
preserveData = compactionPrep.preserveData;
|
|
9836
|
-
} else {
|
|
9837
|
-
// Generate compaction result. Only convert known abort-shaped
|
|
9838
|
-
// rejections (AbortError raised while the abort signal is set,
|
|
9839
|
-
// or an already-typed sentinel) into `CompactionCancelledError`
|
|
9840
|
-
// so downstream callers can discriminate cancel from generic
|
|
9841
|
-
// failure via `instanceof` without inspecting message strings.
|
|
9842
|
-
// Real compaction bugs (network, server, parsing, etc.) keep
|
|
9843
|
-
// their original shape — they must not be silently relabeled
|
|
9844
|
-
// as cancellations even if the signal happens to be aborted
|
|
9845
|
-
// for an unrelated reason. Assignments live inside the try
|
|
9846
|
-
// block because every catch path throws — the post-try reads
|
|
9847
|
-
// of the result-derived locals are reachable only on success.
|
|
9848
|
-
try {
|
|
9849
|
-
const result = await this.#compactWithFallbackModel(
|
|
10192
|
+
if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
|
|
10193
|
+
const result = (await this.#extensionRunner.emit({
|
|
10194
|
+
type: "session_before_compact",
|
|
9850
10195
|
preparation,
|
|
10196
|
+
branchEntries: pathEntries,
|
|
9851
10197
|
customInstructions,
|
|
9852
|
-
compactionAbortController.signal,
|
|
9853
|
-
|
|
9854
|
-
|
|
9855
|
-
|
|
9856
|
-
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
|
|
9857
|
-
convertToLlm,
|
|
9858
|
-
},
|
|
9859
|
-
);
|
|
9860
|
-
summary = result.summary;
|
|
9861
|
-
shortSummary = result.shortSummary;
|
|
9862
|
-
firstKeptEntryId = result.firstKeptEntryId;
|
|
9863
|
-
tokensBefore = result.tokensBefore;
|
|
9864
|
-
details = result.details;
|
|
9865
|
-
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(result.preserveData ?? {}) };
|
|
9866
|
-
} catch (err) {
|
|
9867
|
-
if (err instanceof CompactionCancelledError) {
|
|
9868
|
-
throw err;
|
|
9869
|
-
}
|
|
9870
|
-
if (compactionAbortController.signal.aborted && err instanceof Error && err.name === "AbortError") {
|
|
10198
|
+
signal: compactionAbortController.signal,
|
|
10199
|
+
})) as SessionBeforeCompactResult | undefined;
|
|
10200
|
+
|
|
10201
|
+
if (result?.cancel) {
|
|
9871
10202
|
throw new CompactionCancelledError();
|
|
9872
10203
|
}
|
|
9873
|
-
|
|
10204
|
+
|
|
10205
|
+
if (result?.compaction) {
|
|
10206
|
+
hookCompaction = result.compaction;
|
|
10207
|
+
fromExtension = true;
|
|
10208
|
+
}
|
|
9874
10209
|
}
|
|
9875
|
-
}
|
|
9876
10210
|
|
|
9877
|
-
|
|
9878
|
-
|
|
9879
|
-
|
|
10211
|
+
const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
|
|
10212
|
+
|
|
10213
|
+
let summary: string;
|
|
10214
|
+
let shortSummary: string | undefined;
|
|
10215
|
+
let firstKeptEntryId: string;
|
|
10216
|
+
let tokensBefore: number;
|
|
10217
|
+
let details: unknown;
|
|
10218
|
+
|
|
10219
|
+
if (compactionPrep.kind === "fromHook") {
|
|
10220
|
+
summary = compactionPrep.summary;
|
|
10221
|
+
shortSummary = compactionPrep.shortSummary;
|
|
10222
|
+
firstKeptEntryId = compactionPrep.firstKeptEntryId;
|
|
10223
|
+
tokensBefore = compactionPrep.tokensBefore;
|
|
10224
|
+
details = compactionPrep.details;
|
|
10225
|
+
preserveData = compactionPrep.preserveData;
|
|
10226
|
+
} else {
|
|
10227
|
+
// Generate compaction result. Only convert known abort-shaped
|
|
10228
|
+
// rejections (AbortError raised while the abort signal is set,
|
|
10229
|
+
// or an already-typed sentinel) into `CompactionCancelledError`
|
|
10230
|
+
// so downstream callers can discriminate cancel from generic
|
|
10231
|
+
// failure via `instanceof` without inspecting message strings.
|
|
10232
|
+
// Real compaction bugs (network, server, parsing, etc.) keep
|
|
10233
|
+
// their original shape — they must not be silently relabeled
|
|
10234
|
+
// as cancellations even if the signal happens to be aborted
|
|
10235
|
+
// for an unrelated reason. Assignments live inside the try
|
|
10236
|
+
// block because every catch path throws — the post-try reads
|
|
10237
|
+
// of the result-derived locals are reachable only on success.
|
|
10238
|
+
try {
|
|
10239
|
+
const result = await this.#compactWithFallbackModel(
|
|
10240
|
+
preparation,
|
|
10241
|
+
customInstructions,
|
|
10242
|
+
compactionAbortController.signal,
|
|
10243
|
+
{
|
|
10244
|
+
promptOverride: compactionPrep.hookPrompt,
|
|
10245
|
+
extraContext: compactionPrep.hookContext,
|
|
10246
|
+
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
|
|
10247
|
+
convertToLlm,
|
|
10248
|
+
},
|
|
10249
|
+
);
|
|
10250
|
+
summary = result.summary;
|
|
10251
|
+
shortSummary = result.shortSummary;
|
|
10252
|
+
firstKeptEntryId = result.firstKeptEntryId;
|
|
10253
|
+
tokensBefore = result.tokensBefore;
|
|
10254
|
+
details = result.details;
|
|
10255
|
+
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(result.preserveData ?? {}) };
|
|
10256
|
+
} catch (err) {
|
|
10257
|
+
if (err instanceof CompactionCancelledError) {
|
|
10258
|
+
throw err;
|
|
10259
|
+
}
|
|
10260
|
+
if (compactionAbortController.signal.aborted && err instanceof Error && err.name === "AbortError") {
|
|
10261
|
+
throw new CompactionCancelledError();
|
|
10262
|
+
}
|
|
10263
|
+
throw err;
|
|
10264
|
+
}
|
|
10265
|
+
}
|
|
9880
10266
|
|
|
9881
|
-
|
|
9882
|
-
|
|
9883
|
-
|
|
9884
|
-
firstKeptEntryId,
|
|
9885
|
-
tokensBefore,
|
|
9886
|
-
details,
|
|
9887
|
-
fromExtension,
|
|
9888
|
-
preserveData,
|
|
9889
|
-
);
|
|
9890
|
-
await this.#applyCompactionPostAppend(compactionEntryId, firstKeptEntryId, fromExtension);
|
|
10267
|
+
if (compactionAbortController.signal.aborted) {
|
|
10268
|
+
throw new CompactionCancelledError();
|
|
10269
|
+
}
|
|
9891
10270
|
|
|
9892
|
-
|
|
9893
|
-
|
|
9894
|
-
|
|
9895
|
-
|
|
9896
|
-
|
|
9897
|
-
|
|
9898
|
-
|
|
9899
|
-
|
|
9900
|
-
|
|
9901
|
-
|
|
9902
|
-
|
|
9903
|
-
|
|
9904
|
-
|
|
9905
|
-
|
|
9906
|
-
|
|
9907
|
-
|
|
9908
|
-
|
|
10271
|
+
const compactionEntryId = this.sessionManager.appendCompaction(
|
|
10272
|
+
summary,
|
|
10273
|
+
shortSummary,
|
|
10274
|
+
firstKeptEntryId,
|
|
10275
|
+
tokensBefore,
|
|
10276
|
+
details,
|
|
10277
|
+
fromExtension,
|
|
10278
|
+
preserveData,
|
|
10279
|
+
);
|
|
10280
|
+
await this.#applyCompactionPostAppend(compactionEntryId, firstKeptEntryId, fromExtension);
|
|
10281
|
+
|
|
10282
|
+
const compactionResult: CompactionResult = {
|
|
10283
|
+
summary,
|
|
10284
|
+
shortSummary,
|
|
10285
|
+
firstKeptEntryId,
|
|
10286
|
+
tokensBefore,
|
|
10287
|
+
details,
|
|
10288
|
+
preserveData,
|
|
10289
|
+
};
|
|
10290
|
+
options?.onComplete?.(compactionResult);
|
|
10291
|
+
return compactionResult;
|
|
10292
|
+
} catch (error) {
|
|
10293
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
10294
|
+
options?.onError?.(err);
|
|
10295
|
+
throw error;
|
|
10296
|
+
} finally {
|
|
10297
|
+
if (this.#compactionAbortController === compactionAbortController) {
|
|
10298
|
+
this.#compactionAbortController = undefined;
|
|
10299
|
+
}
|
|
10300
|
+
this.#reconnectToAgent();
|
|
9909
10301
|
}
|
|
9910
|
-
|
|
10302
|
+
} finally {
|
|
10303
|
+
this.#endSessionTransition();
|
|
9911
10304
|
}
|
|
9912
10305
|
}
|
|
9913
10306
|
|
|
@@ -9971,6 +10364,8 @@ export class AgentSession {
|
|
|
9971
10364
|
/** Trigger idle compaction through the auto-compaction flow (with UI events). */
|
|
9972
10365
|
async runIdleCompaction(): Promise<void> {
|
|
9973
10366
|
if (this.isStreaming || this.isCompacting) return;
|
|
10367
|
+
// Do not start idle compaction while a handoff transition owns the session.
|
|
10368
|
+
if (this.isGeneratingHandoff || this.#handoffTransitionActive) return;
|
|
9974
10369
|
await this.#runAutoCompaction("idle", false, true);
|
|
9975
10370
|
}
|
|
9976
10371
|
|
|
@@ -10009,8 +10404,37 @@ export class AgentSession {
|
|
|
10009
10404
|
if (messageCount < 2) {
|
|
10010
10405
|
throw new Error("Nothing to hand off (no messages yet)");
|
|
10011
10406
|
}
|
|
10407
|
+
// Single-flight: a concurrent handoff would overwrite the abort controller
|
|
10408
|
+
// and the transition fence, letting one invocation's finally clear the
|
|
10409
|
+
// other's ownership and allowing overlapping newSession/restore transactions.
|
|
10410
|
+
if (this.isGeneratingHandoff || this.#handoffTransitionActive) {
|
|
10411
|
+
throw Object.assign(new Error("A handoff is already in progress."), { code: "busy" });
|
|
10412
|
+
}
|
|
10413
|
+
// A manual/external handoff must not race an active turn: replacing the
|
|
10414
|
+
// session and resetting the agent mid-stream can strand old-turn events
|
|
10415
|
+
// onto the successor session. Auto-triggered handoffs run during
|
|
10416
|
+
// post-turn maintenance (never mid-stream) and are exempt.
|
|
10417
|
+
if (this.isStreaming && !options?.autoTriggered) {
|
|
10418
|
+
throw Object.assign(
|
|
10419
|
+
new Error("Cannot hand off while a response is streaming; wait for it to finish or abort it first."),
|
|
10420
|
+
{ code: "busy" },
|
|
10421
|
+
);
|
|
10422
|
+
}
|
|
10423
|
+
// Acquire the shared session-transition lease so handoff is mutually exclusive
|
|
10424
|
+
// with compact/new/switch/branch/clear/fork/navigateTree in BOTH directions —
|
|
10425
|
+
// a transition that started first and yielded still owns the lease here, and a
|
|
10426
|
+
// peer that starts after us is rejected at its own entry. Auto-triggered
|
|
10427
|
+
// handoff still runs while auto-compaction owns its abort controller, but the
|
|
10428
|
+
// maintenance orchestrator does not hold this lease, so acquiring it here does
|
|
10429
|
+
// not self-deadlock. Released in the outer finally below.
|
|
10430
|
+
this.#beginSessionTransition("handoff");
|
|
10012
10431
|
|
|
10013
10432
|
this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
|
|
10433
|
+
// Fence background async-job delivery for the whole transition (generation
|
|
10434
|
+
// through commit/rollback): a completion that lands mid-handoff must not
|
|
10435
|
+
// start an idle turn against the session being replaced or the restored
|
|
10436
|
+
// predecessor. Cleared in the finally below.
|
|
10437
|
+
this.#handoffTransitionActive = true;
|
|
10014
10438
|
|
|
10015
10439
|
this.#handoffAbortController = new AbortController();
|
|
10016
10440
|
const handoffAbortController = this.#handoffAbortController;
|
|
@@ -10051,6 +10475,7 @@ export class AgentSession {
|
|
|
10051
10475
|
systemPrompt: this.#baseSystemPrompt,
|
|
10052
10476
|
tools: this.agent.state.tools,
|
|
10053
10477
|
customInstructions,
|
|
10478
|
+
promptExtension: this.settings.get("compaction.handoffPromptExtension") || undefined,
|
|
10054
10479
|
convertToLlm,
|
|
10055
10480
|
initiatorOverride: "agent",
|
|
10056
10481
|
metadata: this.agent.metadataForProvider(model.provider),
|
|
@@ -10066,82 +10491,207 @@ export class AgentSession {
|
|
|
10066
10491
|
return undefined;
|
|
10067
10492
|
}
|
|
10068
10493
|
|
|
10069
|
-
//
|
|
10070
|
-
|
|
10071
|
-
|
|
10072
|
-
|
|
10073
|
-
|
|
10074
|
-
|
|
10075
|
-
this
|
|
10076
|
-
|
|
10077
|
-
|
|
10078
|
-
|
|
10079
|
-
|
|
10080
|
-
|
|
10081
|
-
|
|
10082
|
-
this.#todoReminderCount = 0;
|
|
10083
|
-
if (model) {
|
|
10084
|
-
this.sessionManager.appendModelChange(`${model.provider}/${model.id}`);
|
|
10494
|
+
// Revalidate immediately before mutating: generation can take seconds,
|
|
10495
|
+
// during which a turn (e.g. a background completion that started before
|
|
10496
|
+
// the delivery fence) may have begun. A manual/external handoff must not
|
|
10497
|
+
// mutate under an active turn; auto-triggered handoff runs at post-turn
|
|
10498
|
+
// maintenance and stays exempt. Nothing has been mutated yet, so throwing
|
|
10499
|
+
// here is fully non-destructive.
|
|
10500
|
+
if (this.isStreaming && !options?.autoTriggered) {
|
|
10501
|
+
throw Object.assign(
|
|
10502
|
+
new Error("Cannot hand off while a response is streaming; wait for it to finish or abort it first."),
|
|
10503
|
+
// The document was already generated; retain it so public callers can
|
|
10504
|
+
// copy/retry even though this late race declines to mutate.
|
|
10505
|
+
{ code: "busy", handoffDocument: handoffText },
|
|
10506
|
+
);
|
|
10085
10507
|
}
|
|
10086
|
-
this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
|
|
10087
|
-
this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
|
|
10088
10508
|
|
|
10089
|
-
//
|
|
10090
|
-
|
|
10091
|
-
|
|
10092
|
-
|
|
10509
|
+
// Start a new session transactionally. Capture restore state before the
|
|
10510
|
+
// switch so a persistence, injection, display, or extension failure
|
|
10511
|
+
// after the switch is non-destructive: the current session stays active
|
|
10512
|
+
// and the generated handoff document is preserved for copy/retry.
|
|
10513
|
+
const previousSessionFile = this.sessionFile;
|
|
10514
|
+
await this.sessionManager.flush();
|
|
10515
|
+
const rollbackSessionState = this.sessionManager.captureState();
|
|
10516
|
+
const rollbackAgentMessages = [...this.agent.state.messages];
|
|
10517
|
+
const rollbackSteeringMessages = [...this.#steeringMessages];
|
|
10518
|
+
const rollbackFollowUpMessages = [...this.#followUpMessages];
|
|
10519
|
+
const rollbackPendingNextTurnMessages = [...this.#pendingNextTurnMessages];
|
|
10520
|
+
const rollbackScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration;
|
|
10521
|
+
const rollbackTodoReminderCount = this.#todoReminderCount;
|
|
10522
|
+
// Snapshot the agent's executable queues so a rollback restores queued
|
|
10523
|
+
// user work that agent.reset() would otherwise clear.
|
|
10524
|
+
const rollbackAgentSteeringQueue = this.agent.snapshotSteering();
|
|
10525
|
+
const rollbackAgentFollowUpQueue = this.agent.snapshotFollowUp();
|
|
10526
|
+
let successorSessionFile: string | undefined;
|
|
10093
10527
|
let savedPath: string | undefined;
|
|
10094
|
-
|
|
10095
|
-
|
|
10096
|
-
|
|
10097
|
-
|
|
10528
|
+
let committed = false;
|
|
10529
|
+
// Suspend (do not destroy) the workflow-gate emitter so it can be fenced on
|
|
10530
|
+
// commit or resumed on rollback. Suspension runs inside the transaction so a
|
|
10531
|
+
// listener fault during suspension is rolled back like any other prepare step.
|
|
10532
|
+
let suspendedWorkflowGateEmitter: WorkflowGateEmitter | undefined;
|
|
10533
|
+
try {
|
|
10534
|
+
suspendedWorkflowGateEmitter = this.#suspendWorkflowGateEmitter(rollbackSessionState.sessionId);
|
|
10535
|
+
// --- Prepare (reversible): build and persist the successor session
|
|
10536
|
+
// without touching predecessor authority. No irreversible predecessor
|
|
10537
|
+
// teardown or identity publication happens until the commit boundary.
|
|
10538
|
+
await this.sessionManager.newSession(
|
|
10539
|
+
previousSessionFile ? { parentSession: previousSessionFile } : undefined,
|
|
10540
|
+
);
|
|
10541
|
+
successorSessionFile = this.sessionFile;
|
|
10542
|
+
this.agent.reset();
|
|
10543
|
+
this.#syncAgentSessionId();
|
|
10544
|
+
this.#rekeyHindsightMemoryForCurrentSessionId();
|
|
10545
|
+
this.#steeringMessages = [];
|
|
10546
|
+
this.#followUpMessages = [];
|
|
10547
|
+
this.#pendingNextTurnMessages = [];
|
|
10548
|
+
this.#scheduledHiddenNextTurnGeneration = undefined;
|
|
10549
|
+
this.#todoReminderCount = 0;
|
|
10550
|
+
if (model) {
|
|
10551
|
+
this.sessionManager.appendModelChange(`${model.provider}/${model.id}`);
|
|
10552
|
+
}
|
|
10553
|
+
this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
|
|
10554
|
+
this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
|
|
10555
|
+
|
|
10556
|
+
// Inject the handoff document as a custom message
|
|
10557
|
+
const handoffContent = createHandoffContext(handoffText);
|
|
10558
|
+
this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true, undefined, "agent");
|
|
10559
|
+
await this.sessionManager.ensureOnDisk();
|
|
10560
|
+
if (options?.autoTriggered && this.settings.get("compaction.handoffSaveToDisk")) {
|
|
10098
10561
|
try {
|
|
10099
|
-
|
|
10100
|
-
|
|
10101
|
-
cwd: this.sessionManager.getCwd(),
|
|
10102
|
-
audit: { category: "artifact", verb: "write", owner: "gjc-runtime" },
|
|
10103
|
-
});
|
|
10104
|
-
} else {
|
|
10105
|
-
await Bun.write(handoffFilePath, `${handoffText}\n`);
|
|
10106
|
-
}
|
|
10107
|
-
savedPath = handoffFilePath;
|
|
10562
|
+
const artifactId = await this.sessionManager.saveArtifact(`${handoffText}\n`, "handoff");
|
|
10563
|
+
savedPath = `artifact://${artifactId}`;
|
|
10108
10564
|
} catch (error) {
|
|
10109
|
-
logger.warn("Failed to save handoff document
|
|
10110
|
-
path: handoffFilePath,
|
|
10565
|
+
logger.warn("Failed to save handoff document", {
|
|
10111
10566
|
error: error instanceof Error ? error.message : String(error),
|
|
10112
10567
|
});
|
|
10113
10568
|
}
|
|
10114
|
-
} else {
|
|
10115
|
-
logger.debug("Skipping handoff document save because session is not persisted");
|
|
10116
10569
|
}
|
|
10117
|
-
}
|
|
10118
10570
|
|
|
10119
|
-
|
|
10120
|
-
|
|
10121
|
-
|
|
10122
|
-
|
|
10123
|
-
|
|
10124
|
-
|
|
10125
|
-
|
|
10571
|
+
// Rebuild agent messages from session
|
|
10572
|
+
const sessionContext = this.buildDisplaySessionContext();
|
|
10573
|
+
this.agent.replaceMessages(sessionContext.messages);
|
|
10574
|
+
this.#syncTodoPhasesFromBranch();
|
|
10575
|
+
|
|
10576
|
+
// Construct the successor workflow-gate emitter in the reversible window:
|
|
10577
|
+
// FileGateStore load / broker init can throw, and a failure here must roll
|
|
10578
|
+
// back rather than corrupt an already-committed switch. Publication is
|
|
10579
|
+
// deferred to the (no-throw) commit step below.
|
|
10580
|
+
const successorGateEmitter = this.#constructWorkflowGateEmitter();
|
|
10581
|
+
|
|
10582
|
+
// --- Commit boundary: the successor is durably persisted and every
|
|
10583
|
+
// reversible, fallible step above has succeeded. Cross the irreversible
|
|
10584
|
+
// commit BEFORE any identity rotation. Fencing the predecessor gate
|
|
10585
|
+
// emitter and rotating provider sessions cannot be undone, so once we
|
|
10586
|
+
// begin them a failure must RETAIN the committed successor rather than
|
|
10587
|
+
// attempt a (now-impossible) rollback that would resume a fenced emitter.
|
|
10588
|
+
committed = true;
|
|
10589
|
+
this.#resetInjectedContextSignatures();
|
|
10590
|
+
this.#publishWorkflowGateEmitter(
|
|
10591
|
+
successorGateEmitter,
|
|
10592
|
+
rollbackSessionState.sessionId,
|
|
10593
|
+
suspendedWorkflowGateEmitter,
|
|
10594
|
+
);
|
|
10595
|
+
this.#closeAllProviderSessions("session handoff");
|
|
10596
|
+
this.#rebindProviderSessionState(new Map());
|
|
10597
|
+
this.#resetHindsightConversationTrackingIfHindsight();
|
|
10598
|
+
this.#resetIrcRosterDeliveryState();
|
|
10599
|
+
this.#planReferenceSent = false;
|
|
10600
|
+
this.#planReferencePath = "local://PLAN.md";
|
|
10601
|
+
this.#cancelOwnAsyncJobs();
|
|
10602
|
+
// The predecessor's fenced async-job results (queued while the
|
|
10603
|
+
// transition held the delivery fence) belong to the handed-off session,
|
|
10604
|
+
// not the successor. Suppress and drop ONLY the async-result kind so they
|
|
10605
|
+
// never flush into the new session; MCP resource notifications are
|
|
10606
|
+
// server-scoped and are preserved for the successor.
|
|
10607
|
+
this.#suppressOwnAsyncJobDeliveries();
|
|
10608
|
+
this.yieldQueue.clearKind("async-result");
|
|
10609
|
+
|
|
10610
|
+
// The successor identity/emitter/provider state is now fully live and
|
|
10611
|
+
// predecessor deliveries are suppressed, so release the turn-admission
|
|
10612
|
+
// fence BEFORE publishing session_switch. Successor turns (e.g. a
|
|
10613
|
+
// session_switch hook queuing steering) are legitimate and must not be
|
|
10614
|
+
// rejected; the finally below is only a backstop for early-exit paths.
|
|
10615
|
+
this.#handoffTransitionActive = false;
|
|
10616
|
+
// session_switch is a post-commit identity signal. Extension handler
|
|
10617
|
+
// errors are isolated by ExtensionRunner and must not roll back the
|
|
10618
|
+
// already-committed switch.
|
|
10619
|
+
if (this.#extensionRunner) {
|
|
10620
|
+
await this.#extensionRunner.emit({
|
|
10621
|
+
type: "session_switch",
|
|
10622
|
+
reason: "new",
|
|
10623
|
+
previousSessionFile,
|
|
10624
|
+
});
|
|
10625
|
+
}
|
|
10126
10626
|
|
|
10127
|
-
|
|
10128
|
-
|
|
10129
|
-
|
|
10130
|
-
|
|
10131
|
-
|
|
10132
|
-
|
|
10627
|
+
return { document: handoffText, savedPath };
|
|
10628
|
+
} catch (switchError) {
|
|
10629
|
+
if (committed) {
|
|
10630
|
+
// The switch already committed; a post-commit step failed. Do not
|
|
10631
|
+
// tear down the new session — surface success (the handoff exists).
|
|
10632
|
+
logger.warn("Handoff post-commit step failed after the session switch committed", {
|
|
10633
|
+
error: switchError instanceof Error ? switchError.message : String(switchError),
|
|
10634
|
+
});
|
|
10635
|
+
return { document: handoffText, savedPath };
|
|
10636
|
+
}
|
|
10637
|
+
// Reversible window: roll back to the pre-handoff session so the
|
|
10638
|
+
// failure is non-destructive. Predecessor gate emitter, provider
|
|
10639
|
+
// sessions, async jobs, IRC/plan bookkeeping, and injection signatures
|
|
10640
|
+
// were never mutated before commit, so they survive intact.
|
|
10641
|
+
this.sessionManager.restoreState(rollbackSessionState);
|
|
10642
|
+
this.#syncAgentSessionId(rollbackSessionState.sessionId);
|
|
10643
|
+
this.#restoreWorkflowGateEmitter(suspendedWorkflowGateEmitter);
|
|
10644
|
+
this.#rekeyHindsightMemoryForCurrentSessionId();
|
|
10645
|
+
this.agent.replaceMessages(rollbackAgentMessages);
|
|
10646
|
+
this.agent.clearAllQueues();
|
|
10647
|
+
this.agent.restoreSteering(rollbackAgentSteeringQueue);
|
|
10648
|
+
this.agent.restoreFollowUp(rollbackAgentFollowUpQueue);
|
|
10649
|
+
this.#steeringMessages = rollbackSteeringMessages;
|
|
10650
|
+
this.#followUpMessages = rollbackFollowUpMessages;
|
|
10651
|
+
this.#pendingNextTurnMessages = rollbackPendingNextTurnMessages;
|
|
10652
|
+
this.#scheduledHiddenNextTurnGeneration = rollbackScheduledHiddenNextTurnGeneration;
|
|
10653
|
+
this.#todoReminderCount = rollbackTodoReminderCount;
|
|
10654
|
+
this.#syncTodoPhasesFromBranch();
|
|
10655
|
+
// Remove the orphaned successor transcript/artifacts written before the
|
|
10656
|
+
// failure so a rolled-back handoff leaves nothing behind.
|
|
10657
|
+
if (successorSessionFile && successorSessionFile !== previousSessionFile) {
|
|
10658
|
+
try {
|
|
10659
|
+
await this.sessionManager.discardUncommittedSession(successorSessionFile);
|
|
10660
|
+
} catch (cleanupError) {
|
|
10661
|
+
logger.warn("Failed to remove orphaned handoff session after rollback", {
|
|
10662
|
+
path: successorSessionFile,
|
|
10663
|
+
error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
|
|
10664
|
+
});
|
|
10665
|
+
}
|
|
10666
|
+
}
|
|
10667
|
+
// Map to cancellation only for a genuine handoff-signal abort; a
|
|
10668
|
+
// downstream error keeps its cause and the generated document.
|
|
10669
|
+
if (handoffSignal.aborted) {
|
|
10670
|
+
throw new Error("Handoff cancelled");
|
|
10671
|
+
}
|
|
10672
|
+
// Preserve the generated handoff document for copy/retry.
|
|
10673
|
+
throw Object.assign(switchError instanceof Error ? switchError : new Error(String(switchError)), {
|
|
10674
|
+
handoffDocument: handoffText,
|
|
10133
10675
|
});
|
|
10134
10676
|
}
|
|
10135
|
-
|
|
10136
|
-
return { document: handoffText, savedPath };
|
|
10137
10677
|
} catch (error) {
|
|
10138
|
-
|
|
10678
|
+
// Genuine handoff-signal cancellation maps to a cancellation error.
|
|
10679
|
+
// Errors surfaced by the inner transaction (which already rolled back
|
|
10680
|
+
// and, for non-aborts, attached the generated document) pass through.
|
|
10681
|
+
if (handoffSignal.aborted) {
|
|
10139
10682
|
throw new Error("Handoff cancelled");
|
|
10140
10683
|
}
|
|
10141
10684
|
throw error;
|
|
10142
10685
|
} finally {
|
|
10143
10686
|
sourceSignal?.removeEventListener("abort", onSourceAbort);
|
|
10144
10687
|
this.#handoffAbortController = undefined;
|
|
10688
|
+
this.#handoffTransitionActive = false;
|
|
10689
|
+
// Releasing the fence: re-arm any idle delivery queued while the transition
|
|
10690
|
+
// held it (e.g. a predecessor async result retained through a rollback, or a
|
|
10691
|
+
// preserved MCP notification) so it is not stranded until an unrelated
|
|
10692
|
+
// enqueue or the next agent yield.
|
|
10693
|
+
this.yieldQueue.rearmIdle();
|
|
10694
|
+
this.#endSessionTransition();
|
|
10145
10695
|
}
|
|
10146
10696
|
}
|
|
10147
10697
|
|
|
@@ -10638,6 +11188,8 @@ export class AgentSession {
|
|
|
10638
11188
|
return;
|
|
10639
11189
|
}
|
|
10640
11190
|
|
|
11191
|
+
this.#attachAskTool();
|
|
11192
|
+
|
|
10641
11193
|
const reminder = prompt.render(planModeToolDecisionReminderPrompt, {
|
|
10642
11194
|
askToolName: "ask",
|
|
10643
11195
|
});
|
|
@@ -12489,23 +13041,33 @@ export class AgentSession {
|
|
|
12489
13041
|
): Promise<boolean | ManagedAttemptDecision> {
|
|
12490
13042
|
const controller = this.#defaultFallbackChain();
|
|
12491
13043
|
const managedFallback = controller.chain.entries.length > 1;
|
|
12492
|
-
const trigger = this.#fallbackTriggerFor(message, !managedFallback, transportFailure);
|
|
12493
|
-
if (!trigger) {
|
|
12494
|
-
return managedOutcome
|
|
12495
|
-
? this.#managedFallbackExhaustionDecision(message, message.errorMessage || "Model fallback attempt failed")
|
|
12496
|
-
: false;
|
|
12497
|
-
}
|
|
12498
13044
|
const retrySettings = this.settings.getGroup("retry");
|
|
12499
13045
|
const legacyRetryConfigured =
|
|
12500
13046
|
this.settings.has("retry.enabled") ||
|
|
12501
13047
|
this.settings.has("retry.maxRetries") ||
|
|
12502
13048
|
this.settings.has("retry.baseDelayMs") ||
|
|
12503
13049
|
this.settings.has("retry.maxDelayMs");
|
|
12504
|
-
//
|
|
12505
|
-
//
|
|
12506
|
-
|
|
12507
|
-
if (!managedFallback && (!retrySettings.enabled || !legacyRetryConfigured)) return false;
|
|
13050
|
+
// retry.enabled=false always surfaces immediately, matching the explicit
|
|
13051
|
+
// user opt-out.
|
|
13052
|
+
if (!managedFallback && !retrySettings.enabled) return false;
|
|
12508
13053
|
const classification = managedFallback ? undefined : this.#classifyErrorForRetry(message);
|
|
13054
|
+
// Bare defaults admit only clean, side-effect-free canonical stream watchdog failures.
|
|
13055
|
+
if (!managedFallback && !legacyRetryConfigured) {
|
|
13056
|
+
if (
|
|
13057
|
+
hasBareDefaultRetryDisqualifyingFacts(message) ||
|
|
13058
|
+
(classification !== "transient" && classification !== "first_event_timeout") ||
|
|
13059
|
+
!BARE_DEFAULT_WATCHDOG_ERROR.test(message.errorMessage ?? "") ||
|
|
13060
|
+
!this.#hasCleanRetryReplaySafety
|
|
13061
|
+
) {
|
|
13062
|
+
return false;
|
|
13063
|
+
}
|
|
13064
|
+
}
|
|
13065
|
+
const trigger = this.#fallbackTriggerFor(message, !managedFallback, transportFailure);
|
|
13066
|
+
if (!trigger) {
|
|
13067
|
+
return managedOutcome
|
|
13068
|
+
? this.#managedFallbackExhaustionDecision(message, message.errorMessage || "Model fallback attempt failed")
|
|
13069
|
+
: false;
|
|
13070
|
+
}
|
|
12509
13071
|
const legacyUnbounded = classification === "transient";
|
|
12510
13072
|
const attemptsUsed = managedFallback ? controller.attemptsUsed || 1 : this.#retryAttempt + 1;
|
|
12511
13073
|
const failedSelector = managedFallback ? controller.currentSelector() : undefined;
|
|
@@ -12678,6 +13240,7 @@ export class AgentSession {
|
|
|
12678
13240
|
this.#scheduleAgentContinue({
|
|
12679
13241
|
delayMs: 1,
|
|
12680
13242
|
generation,
|
|
13243
|
+
allowDuringCancelAndSubmit: true,
|
|
12681
13244
|
onError: () => this.#failRetryRecovery("Retry continuation failed to start"),
|
|
12682
13245
|
onSkip: () => this.#failRetryRecovery("Retry continuation was superseded"),
|
|
12683
13246
|
});
|
|
@@ -12804,6 +13367,9 @@ export class AgentSession {
|
|
|
12804
13367
|
*/
|
|
12805
13368
|
async retry(): Promise<boolean> {
|
|
12806
13369
|
if (this.isStreaming || this.isCompacting || this.isRetrying) return false;
|
|
13370
|
+
// A handoff transition owns the session; retrying would mutate the tail and
|
|
13371
|
+
// schedule a continuation against the session being handed off.
|
|
13372
|
+
if (this.isGeneratingHandoff || this.#handoffTransitionActive) return false;
|
|
12807
13373
|
|
|
12808
13374
|
const messages = this.agent.state.messages;
|
|
12809
13375
|
const lastMsg = messages[messages.length - 1];
|
|
@@ -12860,6 +13426,8 @@ export class AgentSession {
|
|
|
12860
13426
|
options?: { excludeFromContext?: boolean },
|
|
12861
13427
|
): Promise<BashResult> {
|
|
12862
13428
|
const excludeFromContext = options?.excludeFromContext === true;
|
|
13429
|
+
this.#markRetryReplayUnsafe();
|
|
13430
|
+
|
|
12863
13431
|
const cwd = this.sessionManager.getCwd();
|
|
12864
13432
|
|
|
12865
13433
|
if (this.#extensionRunner?.hasHandlers("user_bash")) {
|
|
@@ -12889,7 +13457,7 @@ export class AgentSession {
|
|
|
12889
13457
|
cwd,
|
|
12890
13458
|
timeout: clampTimeout("bash") * 1000,
|
|
12891
13459
|
env: buildGjcRuntimeSessionEnv({
|
|
12892
|
-
sessionFile:
|
|
13460
|
+
sessionFile: null,
|
|
12893
13461
|
sessionId: this.sessionId,
|
|
12894
13462
|
cwd,
|
|
12895
13463
|
}),
|
|
@@ -12991,6 +13559,7 @@ export class AgentSession {
|
|
|
12991
13559
|
options?: { excludeFromContext?: boolean },
|
|
12992
13560
|
): Promise<PythonResult> {
|
|
12993
13561
|
const excludeFromContext = options?.excludeFromContext === true;
|
|
13562
|
+
this.#markRetryReplayUnsafe();
|
|
12994
13563
|
const cwd = this.sessionManager.getCwd();
|
|
12995
13564
|
this.assertEvalExecutionAllowed();
|
|
12996
13565
|
|
|
@@ -13716,204 +14285,212 @@ export class AgentSession {
|
|
|
13716
14285
|
* @returns true if switch completed, false if cancelled by hook
|
|
13717
14286
|
*/
|
|
13718
14287
|
async switchSession(sessionPath: string): Promise<boolean> {
|
|
13719
|
-
|
|
13720
|
-
|
|
13721
|
-
|
|
13722
|
-
|
|
13723
|
-
|
|
13724
|
-
|
|
13725
|
-
|
|
13726
|
-
|
|
13727
|
-
|
|
13728
|
-
|
|
13729
|
-
|
|
14288
|
+
this.#beginSessionTransition("switch-session");
|
|
14289
|
+
try {
|
|
14290
|
+
const previousSessionFile = this.sessionManager.getSessionFile();
|
|
14291
|
+
const switchingToDifferentSession = previousSessionFile
|
|
14292
|
+
? path.resolve(previousSessionFile) !== path.resolve(sessionPath)
|
|
14293
|
+
: true;
|
|
14294
|
+
// Emit session_before_switch event (can be cancelled)
|
|
14295
|
+
if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
|
|
14296
|
+
const result = (await this.#extensionRunner.emit({
|
|
14297
|
+
type: "session_before_switch",
|
|
14298
|
+
reason: "resume",
|
|
14299
|
+
targetSessionFile: sessionPath,
|
|
14300
|
+
})) as SessionBeforeSwitchResult | undefined;
|
|
13730
14301
|
|
|
13731
|
-
|
|
13732
|
-
|
|
14302
|
+
if (result?.cancel) {
|
|
14303
|
+
return false;
|
|
14304
|
+
}
|
|
13733
14305
|
}
|
|
13734
|
-
}
|
|
13735
14306
|
|
|
13736
|
-
|
|
13737
|
-
|
|
13738
|
-
|
|
13739
|
-
// Flush pending writes before switching so restore snapshots reflect committed state.
|
|
13740
|
-
await this.sessionManager.flush();
|
|
13741
|
-
const previousSessionState = this.sessionManager.captureState();
|
|
13742
|
-
const previousSessionContext = this.buildDisplaySessionContext();
|
|
13743
|
-
// switchSession replaces these arrays wholesale during load/rollback, so retaining
|
|
13744
|
-
// the existing message objects is sufficient and avoids structured-clone failures for
|
|
13745
|
-
// extension/custom metadata that is valid to persist but not cloneable.
|
|
13746
|
-
const previousAgentMessages = [...this.agent.state.messages];
|
|
13747
|
-
const previousSteeringMessages = [...this.#steeringMessages];
|
|
13748
|
-
const previousFollowUpMessages = [...this.#followUpMessages];
|
|
13749
|
-
const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages];
|
|
13750
|
-
const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration;
|
|
13751
|
-
const previousModel = this.model;
|
|
13752
|
-
const previousThinkingLevel = this.#thinkingLevel;
|
|
13753
|
-
const previousServiceTier = this.agent.serviceTier;
|
|
13754
|
-
const previousSelectedMCPToolNames = new Set(this.#selectedMCPToolNames);
|
|
13755
|
-
const previousTools = [...this.agent.state.tools];
|
|
13756
|
-
const previousBaseSystemPrompt = this.#baseSystemPrompt;
|
|
13757
|
-
const previousSystemPrompt = this.agent.state.systemPrompt;
|
|
13758
|
-
const previousFallbackSelectedMCPToolNames = previousSessionFile
|
|
13759
|
-
? this.#getSessionDefaultSelectedMCPToolNames(previousSessionFile)
|
|
13760
|
-
: undefined;
|
|
13761
|
-
const previousAgentSteeringQueue = this.agent.snapshotSteering();
|
|
13762
|
-
const previousAgentFollowUpQueue = this.agent.snapshotFollowUp();
|
|
14307
|
+
this.#disconnectFromAgent();
|
|
14308
|
+
await this.abort();
|
|
13763
14309
|
|
|
13764
|
-
|
|
13765
|
-
|
|
13766
|
-
|
|
13767
|
-
|
|
13768
|
-
|
|
13769
|
-
|
|
13770
|
-
|
|
13771
|
-
|
|
14310
|
+
// Flush pending writes before switching so restore snapshots reflect committed state.
|
|
14311
|
+
await this.sessionManager.flush();
|
|
14312
|
+
const previousSessionState = this.sessionManager.captureState();
|
|
14313
|
+
const previousSessionContext = this.buildDisplaySessionContext();
|
|
14314
|
+
// switchSession replaces these arrays wholesale during load/rollback, so retaining
|
|
14315
|
+
// the existing message objects is sufficient and avoids structured-clone failures for
|
|
14316
|
+
// extension/custom metadata that is valid to persist but not cloneable.
|
|
14317
|
+
const previousAgentMessages = [...this.agent.state.messages];
|
|
14318
|
+
const previousSteeringMessages = [...this.#steeringMessages];
|
|
14319
|
+
const previousFollowUpMessages = [...this.#followUpMessages];
|
|
14320
|
+
const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages];
|
|
14321
|
+
const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration;
|
|
14322
|
+
const previousModel = this.model;
|
|
14323
|
+
const previousThinkingLevel = this.#thinkingLevel;
|
|
14324
|
+
const previousServiceTier = this.agent.serviceTier;
|
|
14325
|
+
const previousSelectedMCPToolNames = new Set(this.#selectedMCPToolNames);
|
|
14326
|
+
const previousTools = [...this.agent.state.tools];
|
|
14327
|
+
const previousBaseSystemPrompt = this.#baseSystemPrompt;
|
|
14328
|
+
const previousSystemPrompt = this.agent.state.systemPrompt;
|
|
14329
|
+
const previousAgentSteeringQueue = this.agent.snapshotSteering();
|
|
14330
|
+
const previousAgentFollowUpQueue = this.agent.snapshotFollowUp();
|
|
13772
14331
|
|
|
13773
|
-
|
|
13774
|
-
|
|
13775
|
-
this.#
|
|
13776
|
-
this.#
|
|
14332
|
+
this.#steeringMessages = [];
|
|
14333
|
+
this.#followUpMessages = [];
|
|
14334
|
+
this.#pendingNextTurnMessages = [];
|
|
14335
|
+
this.#scheduledHiddenNextTurnGeneration = undefined;
|
|
14336
|
+
const suspendedWorkflowGateEmitter = switchingToDifferentSession
|
|
14337
|
+
? this.#suspendWorkflowGateEmitter(previousSessionState.sessionId)
|
|
14338
|
+
: undefined;
|
|
14339
|
+
let unavailableDefaultChainMessage: string | undefined;
|
|
13777
14340
|
|
|
13778
|
-
|
|
13779
|
-
|
|
13780
|
-
|
|
13781
|
-
this.#
|
|
13782
|
-
const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath);
|
|
13783
|
-
await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames });
|
|
14341
|
+
try {
|
|
14342
|
+
await this.sessionManager.setSessionFile(sessionPath);
|
|
14343
|
+
this.#syncAgentSessionId();
|
|
14344
|
+
this.#rekeyHindsightMemoryForCurrentSessionId();
|
|
13784
14345
|
|
|
13785
|
-
|
|
13786
|
-
|
|
13787
|
-
|
|
14346
|
+
const sessionContext = this.buildDisplaySessionContext();
|
|
14347
|
+
const didReloadConversationChange =
|
|
14348
|
+
!switchingToDifferentSession &&
|
|
14349
|
+
this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages);
|
|
14350
|
+
await this.#restoreMCPSelectionsForSessionContext(sessionContext);
|
|
13788
14351
|
|
|
13789
|
-
|
|
13790
|
-
|
|
13791
|
-
|
|
13792
|
-
if (switchingToDifferentSession || didReloadConversationChange) {
|
|
13793
|
-
this.#closeAllProviderSessions(switchingToDifferentSession ? "session switch" : "session reload");
|
|
13794
|
-
this.#rebindProviderSessionState(new Map());
|
|
13795
|
-
}
|
|
14352
|
+
// The target session is loaded and MCP selections are restored: discard
|
|
14353
|
+
// pre-switch delivery queues before completing the restored agent state.
|
|
14354
|
+
this.agent.clearAllQueues();
|
|
13796
14355
|
|
|
13797
|
-
|
|
13798
|
-
|
|
13799
|
-
|
|
13800
|
-
|
|
13801
|
-
|
|
13802
|
-
|
|
13803
|
-
|
|
13804
|
-
this.#modelRegistry,
|
|
13805
|
-
this.settings,
|
|
13806
|
-
this.sessionId,
|
|
13807
|
-
{ managedFallback: true },
|
|
13808
|
-
);
|
|
13809
|
-
const controller = this.#defaultFallbackChain();
|
|
13810
|
-
this.seedDefaultFallbackResolution(resolution.activeIndex, resolution.skips);
|
|
13811
|
-
if (!resolution.model) {
|
|
13812
|
-
unavailableDefaultChainMessage = this.#fallbackExhaustionError(controller);
|
|
13813
|
-
throw new Error(unavailableDefaultChainMessage);
|
|
13814
|
-
}
|
|
13815
|
-
if (!this.model || !modelsAreEqual(this.model, resolution.model)) {
|
|
13816
|
-
this.#setModelAuthoritatively(resolution.model, "restore");
|
|
13817
|
-
}
|
|
13818
|
-
if (resolution.explicitThinkingLevel && resolution.thinkingLevel !== undefined) {
|
|
13819
|
-
this.setThinkingLevel(resolution.thinkingLevel);
|
|
13820
|
-
}
|
|
13821
|
-
}
|
|
13822
|
-
|
|
13823
|
-
const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
|
|
13824
|
-
const hasServiceTierEntry = this.sessionManager
|
|
13825
|
-
.getBranch()
|
|
13826
|
-
.some(entry => entry.type === "service_tier_change");
|
|
13827
|
-
const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
|
|
13828
|
-
const configuredServiceTier = this.settings.get("serviceTier");
|
|
13829
|
-
const persistedThinkingLevel = hasThinkingEntry
|
|
13830
|
-
? (sessionContext.thinkingLevel as ThinkingLevel | undefined)
|
|
13831
|
-
: defaultThinkingLevel;
|
|
13832
|
-
const nextThinkingLevel = resolveThinkingLevelForModel(
|
|
13833
|
-
this.model,
|
|
13834
|
-
persistedThinkingLevel === ThinkingLevel.Inherit
|
|
13835
|
-
? this.#getInheritedThinkingLevel()
|
|
13836
|
-
: persistedThinkingLevel,
|
|
13837
|
-
);
|
|
13838
|
-
this.#thinkingLevel = nextThinkingLevel;
|
|
13839
|
-
this.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
|
|
13840
|
-
this.agent.serviceTier = hasServiceTierEntry
|
|
13841
|
-
? sessionContext.serviceTier
|
|
13842
|
-
: configuredServiceTier === "none"
|
|
13843
|
-
? undefined
|
|
13844
|
-
: configuredServiceTier;
|
|
13845
|
-
// Establish the successor's durable session identity only after every
|
|
13846
|
-
// restored state facet is live. Identity-bound extension hooks run below.
|
|
13847
|
-
await this.sessionManager.ensureOnDisk();
|
|
14356
|
+
this.agent.replaceMessages(sessionContext.messages);
|
|
14357
|
+
this.#resetInjectedContextSignatures();
|
|
14358
|
+
this.#syncTodoPhasesFromBranch();
|
|
14359
|
+
if (switchingToDifferentSession || didReloadConversationChange) {
|
|
14360
|
+
this.#closeAllProviderSessions(switchingToDifferentSession ? "session switch" : "session reload");
|
|
14361
|
+
this.#rebindProviderSessionState(new Map());
|
|
14362
|
+
}
|
|
13848
14363
|
|
|
13849
|
-
|
|
13850
|
-
|
|
13851
|
-
|
|
13852
|
-
|
|
14364
|
+
const configuredDefaultChain = sessionContext.configuredModelChains.default?.entries;
|
|
14365
|
+
const defaultEntries =
|
|
14366
|
+
configuredDefaultChain ?? (sessionContext.models.default ? [sessionContext.models.default] : []);
|
|
14367
|
+
this.#defaultFallbackController = undefined;
|
|
14368
|
+
if (defaultEntries.length > 0) {
|
|
14369
|
+
const resolution = await resolveModelChainWithAuth(
|
|
14370
|
+
defaultEntries,
|
|
14371
|
+
this.#modelRegistry,
|
|
14372
|
+
this.settings,
|
|
14373
|
+
this.sessionId,
|
|
14374
|
+
{ managedFallback: true },
|
|
14375
|
+
);
|
|
14376
|
+
const controller = this.#defaultFallbackChain();
|
|
14377
|
+
this.seedDefaultFallbackResolution(resolution.activeIndex, resolution.skips);
|
|
14378
|
+
if (!resolution.model) {
|
|
14379
|
+
unavailableDefaultChainMessage = this.#fallbackExhaustionError(controller);
|
|
14380
|
+
throw new Error(unavailableDefaultChainMessage);
|
|
14381
|
+
}
|
|
14382
|
+
if (!this.model || !modelsAreEqual(this.model, resolution.model)) {
|
|
14383
|
+
this.#setModelAuthoritatively(resolution.model, "restore");
|
|
14384
|
+
}
|
|
14385
|
+
if (resolution.explicitThinkingLevel && resolution.thinkingLevel !== undefined) {
|
|
14386
|
+
this.setThinkingLevel(resolution.thinkingLevel);
|
|
14387
|
+
}
|
|
14388
|
+
}
|
|
13853
14389
|
|
|
13854
|
-
|
|
13855
|
-
|
|
13856
|
-
|
|
13857
|
-
|
|
13858
|
-
|
|
13859
|
-
|
|
13860
|
-
|
|
13861
|
-
|
|
13862
|
-
|
|
13863
|
-
|
|
13864
|
-
|
|
13865
|
-
|
|
13866
|
-
|
|
13867
|
-
|
|
13868
|
-
|
|
13869
|
-
|
|
13870
|
-
|
|
13871
|
-
|
|
13872
|
-
|
|
13873
|
-
|
|
13874
|
-
|
|
13875
|
-
|
|
13876
|
-
|
|
13877
|
-
|
|
13878
|
-
|
|
13879
|
-
|
|
13880
|
-
|
|
13881
|
-
|
|
13882
|
-
|
|
13883
|
-
|
|
13884
|
-
|
|
13885
|
-
}
|
|
13886
|
-
|
|
13887
|
-
this.#
|
|
14390
|
+
const hasThinkingEntry = this.sessionManager
|
|
14391
|
+
.getBranch()
|
|
14392
|
+
.some(entry => entry.type === "thinking_level_change");
|
|
14393
|
+
const hasServiceTierEntry = this.sessionManager
|
|
14394
|
+
.getBranch()
|
|
14395
|
+
.some(entry => entry.type === "service_tier_change");
|
|
14396
|
+
const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
|
|
14397
|
+
const configuredServiceTier = this.settings.get("serviceTier");
|
|
14398
|
+
const persistedThinkingLevel = hasThinkingEntry
|
|
14399
|
+
? (sessionContext.thinkingLevel as ThinkingLevel | undefined)
|
|
14400
|
+
: defaultThinkingLevel;
|
|
14401
|
+
const nextThinkingLevel = resolveThinkingLevelForModel(
|
|
14402
|
+
this.model,
|
|
14403
|
+
persistedThinkingLevel === ThinkingLevel.Inherit
|
|
14404
|
+
? this.#getInheritedThinkingLevel()
|
|
14405
|
+
: persistedThinkingLevel,
|
|
14406
|
+
);
|
|
14407
|
+
this.#thinkingLevel = nextThinkingLevel;
|
|
14408
|
+
this.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
|
|
14409
|
+
this.agent.serviceTier = hasServiceTierEntry
|
|
14410
|
+
? sessionContext.serviceTier
|
|
14411
|
+
: configuredServiceTier === "none"
|
|
14412
|
+
? undefined
|
|
14413
|
+
: configuredServiceTier;
|
|
14414
|
+
// Establish the successor's durable session identity only after every
|
|
14415
|
+
// restored state facet is live. Identity-bound extension hooks run below.
|
|
14416
|
+
await this.sessionManager.ensureOnDisk();
|
|
14417
|
+
|
|
14418
|
+
if (switchingToDifferentSession) {
|
|
14419
|
+
this.#resetHindsightConversationTrackingIfHindsight();
|
|
14420
|
+
this.#resetIrcRosterDeliveryState();
|
|
14421
|
+
}
|
|
14422
|
+
|
|
14423
|
+
this.#reconnectToAgent();
|
|
14424
|
+
// Fence predecessor continuations before session_switch starts SDK runtime
|
|
14425
|
+
// teardown. The previous runtime waits for those continuations to settle;
|
|
14426
|
+
// waiting to transfer authority until after hooks creates a circular wait.
|
|
14427
|
+
if (suspendedWorkflowGateEmitter)
|
|
14428
|
+
this.#bindWorkflowGateEmitter(previousSessionState.sessionId, suspendedWorkflowGateEmitter);
|
|
14429
|
+
// session_switch is the post-commit identity signal. SDK authority and
|
|
14430
|
+
// other identity-bound integrations must not observe the successor until
|
|
14431
|
+
// messages, model state, MCP selections, and the agent subscription are live.
|
|
14432
|
+
if (this.#extensionRunner) {
|
|
14433
|
+
await this.#extensionRunner.emit({
|
|
14434
|
+
type: "session_switch",
|
|
14435
|
+
reason: "resume",
|
|
14436
|
+
previousSessionFile,
|
|
14437
|
+
});
|
|
14438
|
+
}
|
|
14439
|
+
return true;
|
|
14440
|
+
} catch (error) {
|
|
14441
|
+
this.sessionManager.restoreState(previousSessionState);
|
|
14442
|
+
this.#defaultFallbackController = undefined;
|
|
14443
|
+
this.#syncAgentSessionId(previousSessionState.sessionId);
|
|
14444
|
+
this.#restoreWorkflowGateEmitter(suspendedWorkflowGateEmitter);
|
|
14445
|
+
this.#rekeyHindsightMemoryForCurrentSessionId();
|
|
14446
|
+
let restoreMcpError: unknown;
|
|
14447
|
+
try {
|
|
14448
|
+
await this.#restoreMCPSelectionsForSessionContext(previousSessionContext);
|
|
14449
|
+
} catch (mcpError) {
|
|
14450
|
+
restoreMcpError = mcpError;
|
|
14451
|
+
logger.warn("Failed to restore MCP selections after switch error", {
|
|
14452
|
+
previousSessionFile,
|
|
14453
|
+
targetSessionFile: sessionPath,
|
|
14454
|
+
error: String(mcpError),
|
|
14455
|
+
});
|
|
14456
|
+
this.#selectedMCPToolNames = new Set(previousSelectedMCPToolNames);
|
|
14457
|
+
this.#setGuardedAgentTools(previousTools);
|
|
14458
|
+
this.#baseSystemPrompt = previousBaseSystemPrompt;
|
|
14459
|
+
this.agent.setSystemPrompt(previousSystemPrompt);
|
|
14460
|
+
}
|
|
13888
14461
|
this.#baseSystemPrompt = previousBaseSystemPrompt;
|
|
13889
14462
|
this.agent.setSystemPrompt(previousSystemPrompt);
|
|
14463
|
+
this.agent.replaceMessages(previousAgentMessages);
|
|
14464
|
+
this.#steeringMessages = previousSteeringMessages;
|
|
14465
|
+
this.#followUpMessages = previousFollowUpMessages;
|
|
14466
|
+
this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
|
|
14467
|
+
this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
|
|
14468
|
+
this.agent.clearAllQueues();
|
|
14469
|
+
this.agent.restoreSteering(previousAgentSteeringQueue);
|
|
14470
|
+
this.agent.restoreFollowUp(previousAgentFollowUpQueue);
|
|
14471
|
+
if (previousModel) {
|
|
14472
|
+
this.agent.setModel(previousModel);
|
|
14473
|
+
}
|
|
14474
|
+
this.#thinkingLevel = previousThinkingLevel;
|
|
14475
|
+
this.agent.setThinkingLevel(toReasoningEffort(previousThinkingLevel));
|
|
14476
|
+
this.agent.serviceTier = previousServiceTier;
|
|
14477
|
+
this.#syncTodoPhasesFromBranch();
|
|
14478
|
+
this.#reconnectToAgent();
|
|
14479
|
+
if (restoreMcpError) {
|
|
14480
|
+
throw restoreMcpError;
|
|
14481
|
+
}
|
|
14482
|
+
if (unavailableDefaultChainMessage) {
|
|
14483
|
+
this.emitNotice(
|
|
14484
|
+
"error",
|
|
14485
|
+
`Could not restore session model: ${unavailableDefaultChainMessage}`,
|
|
14486
|
+
"fallback",
|
|
14487
|
+
);
|
|
14488
|
+
return false;
|
|
14489
|
+
}
|
|
14490
|
+
throw error;
|
|
13890
14491
|
}
|
|
13891
|
-
|
|
13892
|
-
this
|
|
13893
|
-
this.agent.replaceMessages(previousAgentMessages);
|
|
13894
|
-
this.#steeringMessages = previousSteeringMessages;
|
|
13895
|
-
this.#followUpMessages = previousFollowUpMessages;
|
|
13896
|
-
this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
|
|
13897
|
-
this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
|
|
13898
|
-
this.agent.clearAllQueues();
|
|
13899
|
-
this.agent.restoreSteering(previousAgentSteeringQueue);
|
|
13900
|
-
this.agent.restoreFollowUp(previousAgentFollowUpQueue);
|
|
13901
|
-
if (previousModel) {
|
|
13902
|
-
this.agent.setModel(previousModel);
|
|
13903
|
-
}
|
|
13904
|
-
this.#thinkingLevel = previousThinkingLevel;
|
|
13905
|
-
this.agent.setThinkingLevel(toReasoningEffort(previousThinkingLevel));
|
|
13906
|
-
this.agent.serviceTier = previousServiceTier;
|
|
13907
|
-
this.#syncTodoPhasesFromBranch();
|
|
13908
|
-
this.#reconnectToAgent();
|
|
13909
|
-
if (restoreMcpError) {
|
|
13910
|
-
throw restoreMcpError;
|
|
13911
|
-
}
|
|
13912
|
-
if (unavailableDefaultChainMessage) {
|
|
13913
|
-
this.emitNotice("error", `Could not restore session model: ${unavailableDefaultChainMessage}`, "fallback");
|
|
13914
|
-
return false;
|
|
13915
|
-
}
|
|
13916
|
-
throw error;
|
|
14492
|
+
} finally {
|
|
14493
|
+
this.#endSessionTransition();
|
|
13917
14494
|
}
|
|
13918
14495
|
}
|
|
13919
14496
|
|
|
@@ -13930,74 +14507,79 @@ export class AgentSession {
|
|
|
13930
14507
|
selectedText: string;
|
|
13931
14508
|
cancelled: boolean;
|
|
13932
14509
|
}> {
|
|
13933
|
-
|
|
13934
|
-
|
|
13935
|
-
|
|
14510
|
+
this.#beginSessionTransition("branch");
|
|
14511
|
+
try {
|
|
14512
|
+
const previousSessionFile = this.sessionFile;
|
|
14513
|
+
const previousWorkflowGateSessionId = this.sessionId;
|
|
14514
|
+
const selectedEntry = this.sessionManager.getEntryForFidelity(entryId);
|
|
13936
14515
|
|
|
13937
|
-
|
|
13938
|
-
|
|
13939
|
-
|
|
14516
|
+
if (selectedEntry?.type !== "message" || selectedEntry.message.role !== "user") {
|
|
14517
|
+
throw new Error("Invalid entry ID for branching");
|
|
14518
|
+
}
|
|
13940
14519
|
|
|
13941
|
-
|
|
14520
|
+
const selectedText = this.#extractUserMessageText(selectedEntry.message.content);
|
|
13942
14521
|
|
|
13943
|
-
|
|
14522
|
+
let skipConversationRestore = false;
|
|
13944
14523
|
|
|
13945
|
-
|
|
13946
|
-
|
|
13947
|
-
|
|
13948
|
-
|
|
13949
|
-
|
|
13950
|
-
|
|
14524
|
+
// Emit session_before_branch event (can be cancelled)
|
|
14525
|
+
if (this.#extensionRunner?.hasHandlers("session_before_branch")) {
|
|
14526
|
+
const result = (await this.#extensionRunner.emit({
|
|
14527
|
+
type: "session_before_branch",
|
|
14528
|
+
entryId,
|
|
14529
|
+
})) as SessionBeforeBranchResult | undefined;
|
|
13951
14530
|
|
|
13952
|
-
|
|
13953
|
-
|
|
14531
|
+
if (result?.cancel) {
|
|
14532
|
+
return { selectedText, cancelled: true };
|
|
14533
|
+
}
|
|
14534
|
+
skipConversationRestore = result?.skipConversationRestore ?? false;
|
|
13954
14535
|
}
|
|
13955
|
-
skipConversationRestore = result?.skipConversationRestore ?? false;
|
|
13956
|
-
}
|
|
13957
14536
|
|
|
13958
|
-
|
|
13959
|
-
|
|
13960
|
-
|
|
14537
|
+
// Clear pending messages (bound to old session state)
|
|
14538
|
+
this.#pendingNextTurnMessages = [];
|
|
14539
|
+
this.#scheduledHiddenNextTurnGeneration = undefined;
|
|
13961
14540
|
|
|
13962
|
-
|
|
13963
|
-
|
|
13964
|
-
|
|
14541
|
+
// Flush pending writes before branching
|
|
14542
|
+
await this.sessionManager.flush();
|
|
14543
|
+
this.#cancelOwnAsyncJobs();
|
|
13965
14544
|
|
|
13966
|
-
|
|
13967
|
-
|
|
13968
|
-
|
|
13969
|
-
|
|
13970
|
-
|
|
13971
|
-
|
|
13972
|
-
|
|
13973
|
-
|
|
13974
|
-
|
|
13975
|
-
|
|
13976
|
-
|
|
13977
|
-
|
|
14545
|
+
if (!selectedEntry.parentId) {
|
|
14546
|
+
await this.sessionManager.newSession({ parentSession: previousSessionFile });
|
|
14547
|
+
} else {
|
|
14548
|
+
this.sessionManager.createBranchedSession(selectedEntry.parentId);
|
|
14549
|
+
}
|
|
14550
|
+
this.#syncTodoPhasesFromBranch();
|
|
14551
|
+
this.#syncAgentSessionId();
|
|
14552
|
+
this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
|
|
14553
|
+
this.#rekeyHindsightMemoryForCurrentSessionId();
|
|
14554
|
+
this.#resetHindsightConversationTrackingIfHindsight();
|
|
14555
|
+
this.#closeAllProviderSessions("session branch");
|
|
14556
|
+
this.#rebindProviderSessionState(new Map());
|
|
13978
14557
|
|
|
13979
|
-
|
|
13980
|
-
|
|
14558
|
+
// Reload messages from entries (works for both file and in-memory mode)
|
|
14559
|
+
const sessionContext = this.buildDisplaySessionContext();
|
|
13981
14560
|
|
|
13982
|
-
|
|
14561
|
+
await this.#restoreMCPSelectionsForSessionContext(sessionContext);
|
|
13983
14562
|
|
|
13984
|
-
|
|
13985
|
-
|
|
13986
|
-
|
|
13987
|
-
|
|
13988
|
-
|
|
14563
|
+
if (!skipConversationRestore) {
|
|
14564
|
+
this.agent.replaceMessages(sessionContext.messages);
|
|
14565
|
+
this.#resetInjectedContextSignatures();
|
|
14566
|
+
this.#closeCodexProviderSessionsForHistoryRewrite();
|
|
14567
|
+
}
|
|
13989
14568
|
|
|
13990
|
-
|
|
13991
|
-
|
|
13992
|
-
|
|
13993
|
-
|
|
13994
|
-
|
|
13995
|
-
|
|
13996
|
-
|
|
13997
|
-
|
|
13998
|
-
|
|
14569
|
+
this.#resetIrcRosterDeliveryState();
|
|
14570
|
+
// session_branch is the post-commit identity signal. Publish it only after
|
|
14571
|
+
// the successor's messages and MCP selections are restored.
|
|
14572
|
+
if (this.#extensionRunner) {
|
|
14573
|
+
await this.#extensionRunner.emit({
|
|
14574
|
+
type: "session_branch",
|
|
14575
|
+
previousSessionFile,
|
|
14576
|
+
});
|
|
14577
|
+
}
|
|
13999
14578
|
|
|
14000
|
-
|
|
14579
|
+
return { selectedText, cancelled: false };
|
|
14580
|
+
} finally {
|
|
14581
|
+
this.#endSessionTransition();
|
|
14582
|
+
}
|
|
14001
14583
|
}
|
|
14002
14584
|
|
|
14003
14585
|
// =========================================================================
|
|
@@ -14024,165 +14606,178 @@ export class AgentSession {
|
|
|
14024
14606
|
/** Raw session context built during navigation — pass to renderInitialMessages to skip a second O(N) walk. */
|
|
14025
14607
|
sessionContext?: SessionContext;
|
|
14026
14608
|
}> {
|
|
14027
|
-
|
|
14609
|
+
// Serialize with every other session-identity transition via the shared
|
|
14610
|
+
// lease (handoff/compact/new/switch/branch/clear/fork). navigateTree rewrites
|
|
14611
|
+
// live history in place, so a concurrent transition would race the same state.
|
|
14612
|
+
this.#beginSessionTransition("navigate-tree");
|
|
14613
|
+
try {
|
|
14614
|
+
const oldLeafId = this.sessionManager.getLeafId();
|
|
14028
14615
|
|
|
14029
|
-
|
|
14030
|
-
|
|
14031
|
-
|
|
14032
|
-
|
|
14616
|
+
// No-op if already at target
|
|
14617
|
+
if (targetId === oldLeafId) {
|
|
14618
|
+
return { cancelled: false };
|
|
14619
|
+
}
|
|
14033
14620
|
|
|
14034
|
-
|
|
14035
|
-
|
|
14036
|
-
|
|
14037
|
-
|
|
14621
|
+
// Model required for summarization
|
|
14622
|
+
if (options.summarize && !this.model) {
|
|
14623
|
+
throw new Error("No model available for summarization");
|
|
14624
|
+
}
|
|
14038
14625
|
|
|
14039
|
-
|
|
14040
|
-
|
|
14041
|
-
|
|
14042
|
-
|
|
14626
|
+
const targetEntry = this.sessionManager.getEntryForFidelity(targetId);
|
|
14627
|
+
if (!targetEntry) {
|
|
14628
|
+
throw new Error(`Entry ${targetId} not found`);
|
|
14629
|
+
}
|
|
14043
14630
|
|
|
14044
|
-
|
|
14045
|
-
|
|
14046
|
-
|
|
14047
|
-
|
|
14048
|
-
|
|
14049
|
-
|
|
14050
|
-
|
|
14051
|
-
|
|
14052
|
-
|
|
14053
|
-
|
|
14054
|
-
|
|
14055
|
-
|
|
14056
|
-
|
|
14057
|
-
|
|
14058
|
-
|
|
14059
|
-
|
|
14631
|
+
// Collect entries to summarize (from old leaf to common ancestor).
|
|
14632
|
+
const { entries: collectedEntriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary(
|
|
14633
|
+
this.sessionManager,
|
|
14634
|
+
oldLeafId,
|
|
14635
|
+
targetId,
|
|
14636
|
+
);
|
|
14637
|
+
const entriesToSummarize = this.#withoutEphemeralCustomMessageEntries(collectedEntriesToSummarize);
|
|
14638
|
+
|
|
14639
|
+
// Prepare event data
|
|
14640
|
+
const preparation: TreePreparation = {
|
|
14641
|
+
targetId,
|
|
14642
|
+
oldLeafId,
|
|
14643
|
+
commonAncestorId,
|
|
14644
|
+
entriesToSummarize,
|
|
14645
|
+
userWantsSummary: options.summarize ?? false,
|
|
14646
|
+
};
|
|
14060
14647
|
|
|
14061
|
-
|
|
14062
|
-
|
|
14063
|
-
|
|
14064
|
-
|
|
14648
|
+
// Set up abort controller for summarization
|
|
14649
|
+
this.#branchSummaryAbortController = new AbortController();
|
|
14650
|
+
let hookSummary: { summary: string; details?: unknown } | undefined;
|
|
14651
|
+
let fromExtension = false;
|
|
14065
14652
|
|
|
14066
|
-
|
|
14067
|
-
|
|
14068
|
-
|
|
14069
|
-
|
|
14070
|
-
|
|
14071
|
-
|
|
14072
|
-
|
|
14653
|
+
// Emit session_before_tree event
|
|
14654
|
+
if (this.#extensionRunner?.hasHandlers("session_before_tree")) {
|
|
14655
|
+
const result = (await this.#extensionRunner.emit({
|
|
14656
|
+
type: "session_before_tree",
|
|
14657
|
+
preparation,
|
|
14658
|
+
signal: this.#branchSummaryAbortController.signal,
|
|
14659
|
+
})) as SessionBeforeTreeResult | undefined;
|
|
14073
14660
|
|
|
14074
|
-
|
|
14075
|
-
|
|
14076
|
-
|
|
14661
|
+
if (result?.cancel) {
|
|
14662
|
+
return { cancelled: true };
|
|
14663
|
+
}
|
|
14077
14664
|
|
|
14078
|
-
|
|
14079
|
-
|
|
14080
|
-
|
|
14665
|
+
if (result?.summary && options.summarize) {
|
|
14666
|
+
hookSummary = result.summary;
|
|
14667
|
+
fromExtension = true;
|
|
14668
|
+
}
|
|
14081
14669
|
}
|
|
14082
|
-
}
|
|
14083
14670
|
|
|
14084
|
-
|
|
14085
|
-
|
|
14086
|
-
|
|
14087
|
-
|
|
14088
|
-
|
|
14089
|
-
|
|
14090
|
-
|
|
14091
|
-
|
|
14092
|
-
|
|
14093
|
-
|
|
14094
|
-
|
|
14095
|
-
|
|
14096
|
-
|
|
14097
|
-
|
|
14098
|
-
|
|
14099
|
-
|
|
14100
|
-
|
|
14101
|
-
|
|
14102
|
-
|
|
14103
|
-
|
|
14104
|
-
|
|
14105
|
-
|
|
14106
|
-
|
|
14107
|
-
|
|
14108
|
-
|
|
14109
|
-
|
|
14110
|
-
|
|
14671
|
+
// Run default summarizer if needed
|
|
14672
|
+
let summaryText: string | undefined;
|
|
14673
|
+
let summaryDetails: unknown;
|
|
14674
|
+
if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) {
|
|
14675
|
+
const model = this.model!;
|
|
14676
|
+
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
|
|
14677
|
+
if (!apiKey) {
|
|
14678
|
+
throw new Error(`No API key for ${model.provider}`);
|
|
14679
|
+
}
|
|
14680
|
+
const branchSummarySettings = this.settings.getGroup("branchSummary");
|
|
14681
|
+
const result = await generateBranchSummary(entriesToSummarize, {
|
|
14682
|
+
...this.#maintenanceProviderTransport(),
|
|
14683
|
+
model,
|
|
14684
|
+
apiKey,
|
|
14685
|
+
signal: this.#branchSummaryAbortController.signal,
|
|
14686
|
+
customInstructions: options.customInstructions,
|
|
14687
|
+
reserveTokens: branchSummarySettings.reserveTokens,
|
|
14688
|
+
metadata: this.agent.metadataForProvider(model.provider),
|
|
14689
|
+
convertToLlm,
|
|
14690
|
+
telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
|
|
14691
|
+
});
|
|
14692
|
+
this.#branchSummaryAbortController = undefined;
|
|
14693
|
+
if (result.aborted) {
|
|
14694
|
+
return { cancelled: true, aborted: true };
|
|
14695
|
+
}
|
|
14696
|
+
if (result.error) {
|
|
14697
|
+
throw new Error(result.error);
|
|
14698
|
+
}
|
|
14699
|
+
summaryText = result.summary;
|
|
14700
|
+
summaryDetails = {
|
|
14701
|
+
readFiles: result.readFiles || [],
|
|
14702
|
+
modifiedFiles: result.modifiedFiles || [],
|
|
14703
|
+
};
|
|
14704
|
+
} else if (hookSummary) {
|
|
14705
|
+
summaryText = hookSummary.summary;
|
|
14706
|
+
summaryDetails = hookSummary.details;
|
|
14707
|
+
}
|
|
14708
|
+
|
|
14709
|
+
// Determine the new leaf position based on target type
|
|
14710
|
+
let newLeafId: string | null;
|
|
14711
|
+
let editorText: string | undefined;
|
|
14712
|
+
|
|
14713
|
+
if (targetEntry.type === "message" && targetEntry.message.role === "user") {
|
|
14714
|
+
// User message: leaf = parent (null if root), text goes to editor
|
|
14715
|
+
newLeafId = targetEntry.parentId;
|
|
14716
|
+
editorText = this.#extractUserMessageText(targetEntry.message.content);
|
|
14717
|
+
} else if (targetEntry.type === "custom_message") {
|
|
14718
|
+
// Custom message: leaf = parent (null if root), text goes to editor
|
|
14719
|
+
newLeafId = targetEntry.parentId;
|
|
14720
|
+
editorText =
|
|
14721
|
+
typeof targetEntry.content === "string"
|
|
14722
|
+
? targetEntry.content
|
|
14723
|
+
: targetEntry.content
|
|
14724
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
14725
|
+
.map(c => c.text)
|
|
14726
|
+
.join("");
|
|
14727
|
+
} else {
|
|
14728
|
+
// Non-user message: leaf = selected node
|
|
14729
|
+
newLeafId = targetId;
|
|
14730
|
+
}
|
|
14731
|
+
|
|
14732
|
+
// Switch leaf (with or without summary)
|
|
14733
|
+
// Summary is attached at the navigation target position (newLeafId), not the old branch
|
|
14734
|
+
let summaryEntry: BranchSummaryEntry | undefined;
|
|
14735
|
+
if (summaryText) {
|
|
14736
|
+
// Create summary at target position (can be null for root)
|
|
14737
|
+
const summaryId = this.sessionManager.branchWithSummary(
|
|
14738
|
+
newLeafId,
|
|
14739
|
+
summaryText,
|
|
14740
|
+
summaryDetails,
|
|
14741
|
+
fromExtension,
|
|
14742
|
+
);
|
|
14743
|
+
summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
|
|
14744
|
+
} else if (newLeafId === null) {
|
|
14745
|
+
// No summary, navigating to root - reset leaf
|
|
14746
|
+
this.sessionManager.resetLeaf();
|
|
14747
|
+
} else {
|
|
14748
|
+
// No summary, navigating to non-root
|
|
14749
|
+
this.sessionManager.branch(newLeafId);
|
|
14111
14750
|
}
|
|
14112
|
-
summaryText = result.summary;
|
|
14113
|
-
summaryDetails = {
|
|
14114
|
-
readFiles: result.readFiles || [],
|
|
14115
|
-
modifiedFiles: result.modifiedFiles || [],
|
|
14116
|
-
};
|
|
14117
|
-
} else if (hookSummary) {
|
|
14118
|
-
summaryText = hookSummary.summary;
|
|
14119
|
-
summaryDetails = hookSummary.details;
|
|
14120
|
-
}
|
|
14121
|
-
|
|
14122
|
-
// Determine the new leaf position based on target type
|
|
14123
|
-
let newLeafId: string | null;
|
|
14124
|
-
let editorText: string | undefined;
|
|
14125
|
-
|
|
14126
|
-
if (targetEntry.type === "message" && targetEntry.message.role === "user") {
|
|
14127
|
-
// User message: leaf = parent (null if root), text goes to editor
|
|
14128
|
-
newLeafId = targetEntry.parentId;
|
|
14129
|
-
editorText = this.#extractUserMessageText(targetEntry.message.content);
|
|
14130
|
-
} else if (targetEntry.type === "custom_message") {
|
|
14131
|
-
// Custom message: leaf = parent (null if root), text goes to editor
|
|
14132
|
-
newLeafId = targetEntry.parentId;
|
|
14133
|
-
editorText =
|
|
14134
|
-
typeof targetEntry.content === "string"
|
|
14135
|
-
? targetEntry.content
|
|
14136
|
-
: targetEntry.content
|
|
14137
|
-
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
14138
|
-
.map(c => c.text)
|
|
14139
|
-
.join("");
|
|
14140
|
-
} else {
|
|
14141
|
-
// Non-user message: leaf = selected node
|
|
14142
|
-
newLeafId = targetId;
|
|
14143
|
-
}
|
|
14144
|
-
|
|
14145
|
-
// Switch leaf (with or without summary)
|
|
14146
|
-
// Summary is attached at the navigation target position (newLeafId), not the old branch
|
|
14147
|
-
let summaryEntry: BranchSummaryEntry | undefined;
|
|
14148
|
-
if (summaryText) {
|
|
14149
|
-
// Create summary at target position (can be null for root)
|
|
14150
|
-
const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension);
|
|
14151
|
-
summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
|
|
14152
|
-
} else if (newLeafId === null) {
|
|
14153
|
-
// No summary, navigating to root - reset leaf
|
|
14154
|
-
this.sessionManager.resetLeaf();
|
|
14155
|
-
} else {
|
|
14156
|
-
// No summary, navigating to non-root
|
|
14157
|
-
this.sessionManager.branch(newLeafId);
|
|
14158
|
-
}
|
|
14159
14751
|
|
|
14160
|
-
|
|
14161
|
-
|
|
14162
|
-
|
|
14163
|
-
|
|
14164
|
-
|
|
14165
|
-
|
|
14166
|
-
|
|
14167
|
-
|
|
14752
|
+
// Update agent state through the canonical filtered display context so legacy
|
|
14753
|
+
// request-scoped entries cannot re-enter live history after tree navigation.
|
|
14754
|
+
const displayContext = this.buildDisplaySessionContext();
|
|
14755
|
+
await this.#restoreMCPSelectionsForSessionContext(displayContext);
|
|
14756
|
+
this.agent.replaceMessages(displayContext.messages);
|
|
14757
|
+
this.#resetInjectedContextSignatures();
|
|
14758
|
+
this.#syncTodoPhasesFromBranch();
|
|
14759
|
+
this.#closeCodexProviderSessionsForHistoryRewrite();
|
|
14168
14760
|
|
|
14169
|
-
|
|
14761
|
+
this.#branchSummaryAbortController = undefined;
|
|
14170
14762
|
|
|
14171
|
-
|
|
14172
|
-
|
|
14173
|
-
|
|
14174
|
-
|
|
14175
|
-
|
|
14176
|
-
|
|
14177
|
-
|
|
14178
|
-
|
|
14179
|
-
|
|
14180
|
-
|
|
14181
|
-
|
|
14182
|
-
|
|
14183
|
-
|
|
14763
|
+
// Emit session_tree event; only handlers can mutate session entries, so skip
|
|
14764
|
+
// the emit and the context rebuild when no handlers are registered (mirrors
|
|
14765
|
+
// the session_before_tree guard above).
|
|
14766
|
+
if (this.#extensionRunner?.hasHandlers("session_tree")) {
|
|
14767
|
+
await this.#extensionRunner.emit({
|
|
14768
|
+
type: "session_tree",
|
|
14769
|
+
newLeafId: this.sessionManager.getLeafId(),
|
|
14770
|
+
oldLeafId,
|
|
14771
|
+
summaryEntry,
|
|
14772
|
+
fromExtension: summaryText ? fromExtension : undefined,
|
|
14773
|
+
});
|
|
14774
|
+
const refreshedContext = this.buildDisplaySessionContext();
|
|
14775
|
+
return { editorText, cancelled: false, summaryEntry, sessionContext: refreshedContext };
|
|
14776
|
+
}
|
|
14777
|
+
return { editorText, cancelled: false, summaryEntry, sessionContext: displayContext };
|
|
14778
|
+
} finally {
|
|
14779
|
+
this.#endSessionTransition();
|
|
14184
14780
|
}
|
|
14185
|
-
return { editorText, cancelled: false, summaryEntry, sessionContext: displayContext };
|
|
14186
14781
|
}
|
|
14187
14782
|
|
|
14188
14783
|
/**
|