Merge remote-tracking branch 'can1357/main' into fix/empty-stop-guard-tooluse

This commit is contained in:
DarkPhilosophy
2026-06-07 15:04:44 +03:00
545 changed files with 26429 additions and 8342 deletions
@@ -91,6 +91,7 @@ import {
extractRetryHint,
getAgentDbPath,
getInstallId,
isBunTestRuntime,
isEnoent,
isUnexpectedSocketCloseMessage,
logger,
@@ -127,7 +128,6 @@ import {
} from "../eval/py/executor";
import { defaultEvalSessionId } from "../eval/session-id";
import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor";
import { exportSessionToHtml } from "../export/html";
import type { TtsrManager, TtsrMatchContext } from "../export/ttsr";
import type { LoadedCustomCommand } from "../extensibility/custom-commands";
import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
@@ -192,6 +192,7 @@ import {
toReasoningEffort,
} from "../thinking";
import { shutdownTinyTitleClient } from "../tiny/title-client";
import { countToolsForAutoDiscovery, resolveEffectiveToolDiscoveryMode } from "../tool-discovery/mode";
import {
buildDiscoverableToolSearchIndex,
collectDiscoverableTools,
@@ -470,6 +471,12 @@ export interface SessionStats {
cost: number;
}
export interface FreshSessionResult {
previousSessionId: string;
sessionId: string;
closedProviderSessions: number;
}
/** Internal marker for hook messages queued through the agent loop */
// ============================================================================
// Constants
@@ -921,6 +928,7 @@ export class AgentSession {
#agentId: string | undefined;
#agentRegistry: AgentRegistry | undefined;
#providerSessionId: string | undefined;
#freshProviderSessionId: string | undefined;
#isDisposed = false;
// Extension system
#extensionRunner: ExtensionRunner | undefined = undefined;
@@ -1035,6 +1043,7 @@ export class AgentSession {
#acquirePowerAssertion(): void {
if (process.platform !== "darwin") return;
if (isBunTestRuntime()) return;
if (this.#powerAssertion) return;
const idle = this.settings.get("power.preventIdleSleep");
const system = this.settings.get("power.preventSystemSleep");
@@ -1273,6 +1282,14 @@ export class AgentSession {
return this.#modelRegistry;
}
get asyncJobManager(): AsyncJobManager | undefined {
return this.#asyncJobManager;
}
getAgentId(): string | undefined {
return this.#agentId;
}
/** Advance the tool-choice queue and return the next directive for the upcoming LLM call. */
nextToolChoice(): ToolChoice | undefined {
return this.#toolChoiceQueue.nextToolChoice();
@@ -1679,7 +1696,7 @@ export class AgentSession {
// Abort the stream immediately — do not gate on extension callbacks
this.#ttsrAbortPending = true;
this.#ensureTtsrResumePromise();
this.agent.abort();
this.agent.abort(this.#formatTtsrAbortReason(matches));
// Notify extensions (fire-and-forget, does not block abort)
this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
// Schedule retry after a short delay
@@ -2160,6 +2177,12 @@ export class AgentSession {
}
}
#formatTtsrAbortReason(rules: Rule[]): string {
const label = rules.length === 1 ? "rule" : "rules";
const ruleNames = rules.map(rule => rule.name).join(", ");
return `TTSR matched ${label}: ${ruleNames}`;
}
/** Get TTSR injection payload and clear pending injections. */
#getTtsrInjectionContent(): { content: string; rules: Rule[] } | undefined {
if (this.#pendingTtsrInjections.length === 0) return undefined;
@@ -2183,13 +2206,20 @@ export class AgentSession {
* project, `~`-relative when it lives under home, else the raw path.
*/
#displayRulePath(rulePath: string): string {
const cwdRel = relativePathWithinRoot(this.sessionManager.getCwd(), rulePath);
const cwdRel =
relativePathWithinRoot(this.sessionManager.getCwd(), rulePath) ??
this.#displayPathWithinRoot(this.sessionManager.getCwd(), rulePath);
if (cwdRel) return cwdRel;
const homeRel = relativePathWithinRoot(os.homedir(), rulePath);
if (homeRel) return `~/${homeRel}`;
return rulePath;
}
#displayPathWithinRoot(root: string, candidate: string): string | null {
const relative = path.relative(path.resolve(root), path.resolve(candidate));
return relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : null;
}
#addPendingTtsrInjections(rules: Rule[]): void {
const seen = new Set(this.#pendingTtsrInjections.map(rule => rule.name));
for (const rule of rules) {
@@ -2944,6 +2974,10 @@ export class AgentSession {
this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
}
#activeProviderSessionId(sessionId?: string): string {
return this.#freshProviderSessionId ?? this.#providerSessionId ?? sessionId ?? this.sessionManager.getSessionId();
}
/**
* Set agent.sessionId from the session manager and install a dynamic
* metadata resolver so every Anthropic API request carries
@@ -2956,7 +2990,7 @@ export class AgentSession {
* `#syncAgentSessionId()` on every such event.
*/
#syncAgentSessionId(sessionId?: string): void {
const sid = this.#providerSessionId ?? sessionId ?? this.sessionManager.getSessionId();
const sid = this.#activeProviderSessionId(sessionId);
this.agent.sessionId = sid;
this.agent.setMetadataResolver((provider: string) =>
buildSessionMetadata(sid, provider, this.#modelRegistry.authStorage),
@@ -2964,14 +2998,14 @@ export class AgentSession {
}
#rekeyHindsightMemoryForCurrentSessionId(): void {
if (resolveMemoryBackend(this.settings).id !== "hindsight") return;
if (this.settings.get("memory.backend") !== "hindsight") return;
const sid = this.agent.sessionId;
if (!sid) return;
this.getHindsightSessionState()?.setSessionId(sid);
}
#rekeyMnemopiMemoryForCurrentSessionId(): void {
if (resolveMemoryBackend(this.settings).id !== "mnemopi") return;
if (this.settings.get("memory.backend") !== "mnemopi") return;
const sid = this.agent.sessionId;
if (!sid) return;
this.getMnemopiSessionState()?.setSessionId(sid);
@@ -2979,14 +3013,14 @@ export class AgentSession {
/** New session file: reset auto-recall / retain-threshold counters for the new transcript. */
#resetHindsightConversationTrackingIfHindsight(): void {
if (resolveMemoryBackend(this.settings).id !== "hindsight") return;
if (this.settings.get("memory.backend") !== "hindsight") return;
const state = this.getHindsightSessionState();
if (!state || state.aliasOf) return;
state.resetConversationTracking();
}
#resetMnemopiConversationTrackingIfMnemopi(): void {
if (resolveMemoryBackend(this.settings).id !== "mnemopi") return;
if (this.settings.get("memory.backend") !== "mnemopi") return;
const state = this.getMnemopiSessionState();
if (!state || state.aliasOf) return;
state.resetConversationTracking();
@@ -3086,6 +3120,23 @@ export class AgentSession {
this.#providerSessionState.clear();
}
freshSession(): FreshSessionResult | undefined {
if (this.isStreaming) return undefined;
const previousSessionId = this.sessionId;
const closedProviderSessions = this.#providerSessionState.size;
this.#closeAllProviderSessions("fresh session");
this.#freshProviderSessionId = Bun.randomUUIDv7();
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
this.#rekeyMnemopiMemoryForCurrentSessionId();
this.agent.appendOnlyContext?.invalidateForModelChange();
return {
previousSessionId,
sessionId: this.sessionId,
closedProviderSessions,
};
}
// =========================================================================
// Read-only State Access
// =========================================================================
@@ -3325,12 +3376,14 @@ export class AgentSession {
// ── Generic tool discovery (covers built-in + MCP + extension) ────────────
/** Resolve effective discovery mode: tools.discoveryMode wins; mcp.discoveryMode is back-compat alias. */
/** Resolve effective discovery mode from the current registry size. */
#resolveEffectiveDiscoveryMode(): "off" | "mcp-only" | "all" {
const toolsMode = this.settings.get("tools.discoveryMode");
if (toolsMode !== "off") return toolsMode as "off" | "mcp-only" | "all";
if (this.settings.get("mcp.discoveryMode")) return "mcp-only";
return "off";
const mode = resolveEffectiveToolDiscoveryMode(
this.settings,
countToolsForAutoDiscovery(this.#toolRegistry.keys()),
);
if (mode !== "off") return mode;
return this.#mcpDiscoveryEnabled ? "mcp-only" : "off";
}
isToolDiscoveryEnabled(): boolean {
@@ -3665,7 +3718,7 @@ export class AgentSession {
}
async #buildSystemPromptForAgentStart(promptText: string): Promise<string[]> {
const backend = resolveMemoryBackend(this.settings);
const backend = await resolveMemoryBackend(this.settings);
if (!backend.beforeAgentStartPrompt) return this.#baseSystemPrompt;
try {
@@ -3988,7 +4041,7 @@ export class AgentSession {
/** Current session ID */
get sessionId(): string {
return this.#providerSessionId ?? this.sessionManager.getSessionId();
return this.#activeProviderSessionId();
}
getEvalSessionId(): string | null {
if (this.#parentEvalSessionId !== undefined) return this.#parentEvalSessionId;
@@ -5087,8 +5140,13 @@ export class AgentSession {
/**
* Abort current operation and wait for agent to become idle.
*
* `reason` (e.g. `USER_INTERRUPT_LABEL`) rides the agent's `AbortController`
* and surfaces verbatim on the aborted assistant message's `errorMessage`, so
* the transcript can distinguish a deliberate user interrupt from an opaque
* abort. Omit it for internal/lifecycle aborts.
*/
async abort(options?: { goalReason?: "interrupted" | "internal" }): Promise<void> {
async abort(options?: { goalReason?: "interrupted" | "internal"; reason?: string }): Promise<void> {
this.abortRetry();
this.#promptGeneration++;
this.#scheduledHiddenNextTurnGeneration = undefined;
@@ -5097,7 +5155,7 @@ export class AgentSession {
this.abortBash();
this.abortEval();
const postPromptDrain = this.#cancelPostPromptTasks();
this.agent.abort();
this.agent.abort(options?.reason);
await postPromptDrain;
await this.agent.waitForIdle();
await this.#goalRuntime.onTaskAborted({ reason: options?.goalReason ?? "interrupted" });
@@ -5114,6 +5172,19 @@ export class AgentSession {
}
}
/**
* Abort active work, then immediately resume the agent so queued steer/follow-up
* messages drain instead of waiting for another natural turn boundary.
*/
async interruptAndFlushQueuedMessages(options?: { reason?: string }): Promise<void> {
if (!this.agent.hasQueuedMessages()) return;
await this.abort({ reason: options?.reason });
if (!this.agent.hasQueuedMessages()) return;
if (this.isCompacting || this.isGeneratingHandoff) return;
await this.#maybeRestoreRetryFallbackPrimary();
await this.agent.continue();
}
/**
* Start a new session, optionally with initial messages and parent tracking.
* Clears all messages and starts a new session.
@@ -5158,6 +5229,7 @@ export class AgentSession {
}
await this.sessionManager.newSession(options);
this.setTodoPhases([]);
this.#freshProviderSessionId = undefined;
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
this.#rekeyMnemopiMemoryForCurrentSessionId();
@@ -5255,6 +5327,7 @@ export class AgentSession {
}
// Update agent session ID
this.#freshProviderSessionId = undefined;
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
this.#rekeyMnemopiMemoryForCurrentSessionId();
@@ -6091,7 +6164,7 @@ export class AgentSession {
messagesToSummarize: AgentMessage[];
turnPrefixMessages: AgentMessage[];
}): Promise<string | undefined> {
const backend = resolveMemoryBackend(this.settings);
const backend = await resolveMemoryBackend(this.settings);
if (!backend.preCompactionContext) return undefined;
const messages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
try {
@@ -6222,6 +6295,7 @@ export class AgentSession {
this.#cancelOwnAsyncJobs();
await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
this.agent.reset();
this.#freshProviderSessionId = undefined;
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
this.#rekeyMnemopiMemoryForCurrentSessionId();
@@ -8150,8 +8224,10 @@ export class AgentSession {
const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
if (!switchedCredential && currentSelector) {
this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
switchedModel = await this.#tryRetryModelFallback(currentSelector);
if (retrySettings.modelFallback) {
this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
switchedModel = await this.#tryRetryModelFallback(currentSelector);
}
if (switchedModel) {
delayMs = 0;
} else if (parsedRetryAfterMs && parsedRetryAfterMs > delayMs) {
@@ -8952,6 +9028,7 @@ export class AgentSession {
const previousTools = [...this.agent.state.tools];
const previousBaseSystemPrompt = this.#baseSystemPrompt;
const previousSystemPrompt = this.agent.state.systemPrompt;
const previousFreshProviderSessionId = this.#freshProviderSessionId;
const previousFallbackSelectedMCPToolNames = previousSessionFile
? this.#getSessionDefaultSelectedMCPToolNames(previousSessionFile)
: undefined;
@@ -8963,6 +9040,9 @@ export class AgentSession {
try {
await this.sessionManager.setSessionFile(sessionPath);
if (switchingToDifferentSession) {
this.#freshProviderSessionId = undefined;
}
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
this.#rekeyMnemopiMemoryForCurrentSessionId();
@@ -9072,6 +9152,7 @@ export class AgentSession {
return true;
} catch (error) {
this.sessionManager.restoreState(previousSessionState);
this.#freshProviderSessionId = previousFreshProviderSessionId;
this.#syncAgentSessionId(previousSessionState.sessionId);
this.#rekeyHindsightMemoryForCurrentSessionId();
this.#rekeyMnemopiMemoryForCurrentSessionId();
@@ -9170,6 +9251,7 @@ export class AgentSession {
this.sessionManager.createBranchedSession(selectedEntry.parentId);
}
this.#syncTodoPhasesFromBranch();
this.#freshProviderSessionId = undefined;
this.#syncAgentSessionId();
this.#rekeyHindsightMemoryForCurrentSessionId();
this.#rekeyMnemopiMemoryForCurrentSessionId();
@@ -9598,6 +9680,7 @@ export class AgentSession {
*/
async exportToHtml(outputPath?: string): Promise<string> {
const themeName = getCurrentThemeName();
const { exportSessionToHtml } = await import("../export/html");
return exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName });
}
@@ -70,6 +70,32 @@ export function isSilentAbort(errorMessage: string | undefined): boolean {
return errorMessage === SILENT_ABORT_MARKER;
}
/** Reason threaded through `AbortController.abort(reason)` when the user aborts
* the turn with Esc (see `AgentSession.abort`). The agent surfaces it verbatim
* on the aborted assistant message's `errorMessage`, so the transcript reads as
* a deliberate user interrupt instead of an opaque failure. */
export const USER_INTERRUPT_LABEL = "Interrupted by user";
/** Sentinel `errorMessage` the agent stamps on any abort that carried no custom
* reason (bare `abort()`). Renderers treat it as "no specific reason given". */
const GENERIC_ABORT_SENTINEL = "Request was aborted";
/** Resolve the operator-facing label for an aborted assistant turn. A custom
* abort reason (e.g. `USER_INTERRUPT_LABEL`) threaded onto `errorMessage` is
* shown verbatim; aborts with no threaded reason fall back to the retry-aware
* generic label. Centralizes the live-stream (`EventController`), replay
* (`ui-helpers`), and component (`AssistantMessageComponent`) render paths so
* they stay in lockstep. */
export function resolveAbortLabel(errorMessage: string | undefined, retryAttempt = 0): string {
if (errorMessage && errorMessage !== GENERIC_ABORT_SENTINEL && !isSilentAbort(errorMessage)) {
return errorMessage;
}
if (retryAttempt > 0) {
return `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`;
}
return "Operation aborted";
}
/** Extract the optional `__pendingDisplayTag` field from a CustomMessage's
* `details` blob. Safe over `unknown`; returns undefined when the field is
* absent or non-string. */
@@ -1967,6 +1967,7 @@ export class SessionManager {
#inMemoryArtifacts: Map<string, string> | null = null;
#inMemoryArtifactCounter = 0;
readonly #blobStore: BlobStore;
#suppressBreadcrumb = false;
private constructor(
private cwd: string,
@@ -1981,6 +1982,11 @@ export class SessionManager {
// Note: call _initSession() or _initSessionFile() after construction
}
#maybeWriteBreadcrumb(cwd: string, sessionFile: string): void {
if (this.#suppressBreadcrumb) return;
writeTerminalBreadcrumb(cwd, sessionFile);
}
/** Puts a binary blob into the blob store and returns the blob reference */
async putBlob(data: Buffer, options?: BlobPutOptions): Promise<BlobPutResult> {
return this.#blobStore.put(data, options);
@@ -2027,7 +2033,7 @@ export class SessionManager {
this.#adoptedArtifactManager = null;
this.#buildIndex();
if (this.#sessionFile) {
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
this.#maybeWriteBreadcrumb(this.cwd, this.#sessionFile);
}
}
@@ -2047,7 +2053,7 @@ export class SessionManager {
this.#persistError = undefined;
this.#persistErrorReported = false;
this.#sessionFile = path.resolve(sessionFile);
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
this.#maybeWriteBreadcrumb(this.cwd, this.#sessionFile);
this.#fileEntries = await loadEntriesFromFile(this.#sessionFile, this.storage);
if (this.#fileEntries.length > 0) {
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
@@ -2064,7 +2070,7 @@ export class SessionManager {
if (headerCwd && headerCwd !== this.cwd) {
this.cwd = headerCwd;
this.sessionDir = path.resolve(this.#sessionFile, "..");
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
this.#maybeWriteBreadcrumb(this.cwd, this.#sessionFile);
}
this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
@@ -2245,7 +2251,7 @@ export class SessionManager {
// Update terminal breadcrumb
if (this.#sessionFile) {
writeTerminalBreadcrumb(resolvedCwd, this.#sessionFile);
this.#maybeWriteBreadcrumb(resolvedCwd, this.#sessionFile);
}
}
@@ -2280,7 +2286,7 @@ export class SessionManager {
if (this.persist) {
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`);
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
this.#maybeWriteBreadcrumb(this.cwd, this.#sessionFile);
}
return this.#sessionFile;
}
@@ -3429,9 +3435,11 @@ export class SessionManager {
cwd: string,
sessionDir?: string,
storage: SessionStorage = new FileSessionStorage(),
options?: { suppressBreadcrumb?: boolean },
): Promise<SessionManager> {
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
const manager = new SessionManager(cwd, dir, true, storage);
manager.#suppressBreadcrumb = options?.suppressBreadcrumb === true;
const forkEntries = structuredClone(await loadEntriesFromFile(sourcePath, storage)) as FileEntry[];
migrateToCurrentVersion(forkEntries);
await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);