UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

1,202 lines 116 kB
/** * AgentSession - Core abstraction for agent lifecycle and session management. * * This class is shared between all run modes (interactive, print, rpc). * It encapsulates: * - Agent state access * - Event subscription with automatic session persistence * - Model and thinking level management * - Compaction (manual and auto) * - Bash execution * - Session switching and branching * * Modes use this class and add their own I/O layer on top. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname } from "node:path"; import { contentText } from "@earendil-works/pi-ai"; import { clampThinkingLevel, cleanupSessionResources, getSupportedThinkingLevels, isContextOverflow, isRetryableAssistantError, modelsAreEqual, resetApiProviders, streamSimple, } from "@earendil-works/pi-ai/compat"; import { getThemeByName, theme } from "../modes/interactive/theme/theme.js"; import { stripFrontmatter } from "../utils/frontmatter.js"; import { resolvePath } from "../utils/paths.js"; import { sleep } from "../utils/sleep.js"; import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.js"; import { executeBashWithOperations } from "./bash-executor.js"; import { calculateContextTokens, collectEntriesForBranchSummary, compact, estimateContextTokens, estimateTokens, generateBranchSummary, prepareCompaction, shouldCompact, } from "./compaction/index.js"; import { DEFAULT_THINKING_LEVEL } from "./defaults.js"; import { exportSessionToHtml } from "./export-html/index.js"; import { createToolHtmlRenderer } from "./export-html/tool-renderer.js"; import { ExtensionRunner, wrapRegisteredTools, } from "./extensions/index.js"; import { emitSessionShutdownEvent } from "./extensions/runner.js"; import { ModelRegistry } from "./model-registry.js"; import { expandPromptTemplate } from "./prompt-templates.js"; import { CURRENT_SESSION_VERSION, getLatestCompactionEntry } from "./session-manager.js"; import { createSyntheticSourceInfo } from "./source-info.js"; import { buildSystemPrompt } from "./system-prompt.js"; import { createLocalBashOperations } from "./tools/bash.js"; import { createAllToolDefinitions } from "./tools/index.js"; import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.js"; import { addUsageToTotals, createUsageTotals } from "./usage-totals.js"; /** * Parse a skill block from message text. * Returns null if the text doesn't contain a skill block. */ export function parseSkillBlock(text) { const match = text.match(/^<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/); if (!match) return null; return { name: match[1], location: match[2], content: match[3], userMessage: match[4]?.trim() || undefined, }; } // ============================================================================ // Types // ============================================================================ function withoutDeletedHeaders(headers) { return headers ? Object.fromEntries(Object.entries(headers).filter((entry) => entry[1] !== null)) : undefined; } function estimateMessagesTokens(messages) { let tokens = 0; for (const message of messages) { tokens += estimateTokens(message); } return tokens; } // ============================================================================ // Constants // ============================================================================ /** Standard thinking levels */ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"]; // ============================================================================ // AgentSession Class // ============================================================================ export class AgentSession { agent; sessionManager; settingsManager; _scopedModels; // Event subscription state _unsubscribeAgent; _eventListeners = []; _isAgentRunActive = false; _idleWaitPromise; _resolveIdleWait; /** Tracks pending steering messages for UI display. Removed when delivered. */ _steeringMessages = []; /** Tracks pending follow-up messages for UI display. Removed when delivered. */ _followUpMessages = []; /** Messages queued to be included with the next user prompt as context ("asides"). */ _pendingNextTurnMessages = []; // Compaction state _compactionAbortController = undefined; _autoCompactionAbortController = undefined; _overflowRecoveryAttempted = false; // Branch summarization state _branchSummaryAbortController = undefined; // Retry state _retryAbortController = undefined; _retryAttempt = 0; // Bash execution state _bashAbortControllers = new Set(); _pendingBashMessages = []; // Extension system _extensionRunner; _turnIndex = 0; _resourceLoader; _customTools; _baseToolDefinitions = new Map(); _cwd; _extensionRunnerRef; _initialActiveToolNames; _allowedToolNames; _excludedToolNames; _baseToolsOverride; _sessionStartEvent; _extensionUIContext; _extensionMode = "print"; _extensionCommandContextActions; _extensionAbortHandler; _extensionShutdownHandler; _extensionErrorListener; _extensionErrorUnsubscriber; _modelRuntime; // Tool registry for extension getTools/setTools _toolRegistry = new Map(); _toolDefinitions = new Map(); _toolPromptSnippets = new Map(); _toolPromptGuidelines = new Map(); // Base system prompt (without extension appends) - used to apply fresh appends each turn _baseSystemPrompt = ""; _baseSystemPromptOptions; _systemPromptOverride; constructor(config) { this.agent = config.agent; this.sessionManager = config.sessionManager; this.settingsManager = config.settingsManager; this._scopedModels = config.scopedModels ?? []; this._resourceLoader = config.resourceLoader; this._customTools = config.customTools ?? []; this._cwd = config.cwd; this._modelRuntime = config.modelRuntime; this._extensionRunnerRef = config.extensionRunnerRef; this._initialActiveToolNames = config.initialActiveToolNames; this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined; this._excludedToolNames = config.excludedToolNames ? new Set(config.excludedToolNames) : undefined; this._baseToolsOverride = config.baseToolsOverride; this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" }; // Always subscribe to agent events for internal handling // (session persistence, extensions, auto-compaction, retry logic) this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); this._installAgentToolHooks(); this._installAgentNextTurnRefresh(); this._buildRuntime({ activeToolNames: this._initialActiveToolNames, includeAllExtensionTools: true, }); } get modelRuntime() { return this._modelRuntime; } async _getRequiredRequestAuth(model) { let result; try { result = await this._modelRuntime.getAuth(model); } catch (error) { const cause = error instanceof Error ? error.cause : undefined; if (cause instanceof Error && cause.message === "authHeader requires a resolved API key") { throw new Error(formatNoApiKeyFoundMessage(model.provider)); } throw error; } if (result && (result.auth.apiKey || result.auth.headers)) { return { apiKey: result.auth.apiKey, headers: withoutDeletedHeaders(result.auth.headers), env: result.env, }; } const isOAuth = this._modelRuntime.isUsingOAuth(model.provider); if (isOAuth) { throw new Error(`Authentication failed for "${model.provider}". ` + `Credentials may have expired or network is unavailable. ` + `Run '/login ${model.provider}' to re-authenticate.`); } throw new Error(formatNoApiKeyFoundMessage(model.provider)); } async _getSummarizationRequestAuth(model) { if (this.agent.streamFunction === streamSimple) { return this._getRequiredRequestAuth(model); } try { const result = await this._modelRuntime.getAuth(model); return result ? { apiKey: result.auth.apiKey, headers: withoutDeletedHeaders(result.auth.headers), env: result.env } : {}; } catch { return {}; } } /** * Install tool hooks once on the Agent instance. * * The callbacks read `this._extensionRunner` at execution time, so extension reload swaps in the * new runner without reinstalling hooks. Extension-specific tool wrappers are still used to adapt * registered tool execution to the extension context. Tool call and tool result interception now * happens here instead of in wrappers. */ _installAgentToolHooks() { this.agent.beforeToolCall = async ({ toolCall, args }) => { const runner = this._extensionRunner; if (!runner.hasHandlers("tool_call")) { return undefined; } try { return await runner.emitToolCall({ type: "tool_call", toolName: toolCall.name, toolCallId: toolCall.id, input: args, }); } catch (err) { if (err instanceof Error) { throw err; } throw new Error(`Extension failed, blocking execution: ${String(err)}`); } }; this.agent.afterToolCall = async ({ toolCall, args, result, isError }) => { const runner = this._extensionRunner; if (!runner.hasHandlers("tool_result")) { return undefined; } const hookResult = await runner.emitToolResult({ type: "tool_result", toolName: toolCall.name, toolCallId: toolCall.id, input: args, content: result.content, details: result.details, isError, usage: result.usage, }); if (!hookResult) { return undefined; } return { content: hookResult.content, details: hookResult.details, isError: hookResult.isError ?? isError, usage: hookResult.usage, }; }; } _installAgentNextTurnRefresh() { const previousPrepareNextTurnWithContext = this.agent.prepareNextTurnWithContext ?? (this.agent.prepareNextTurn ? async (_turn, signal) => await this.agent.prepareNextTurn?.(signal) : undefined); this.agent.prepareNextTurnWithContext = async (turn, signal) => { const previousSnapshot = await previousPrepareNextTurnWithContext?.(turn, signal); const previousContext = previousSnapshot?.context ?? turn.context; return { ...previousSnapshot, context: { ...previousContext, systemPrompt: this._systemPromptOverride ?? this._baseSystemPrompt, tools: this.agent.state.tools.slice(), }, model: this.agent.state.model, thinkingLevel: this.agent.state.thinkingLevel, }; }; } // ========================================================================= // Event Subscription // ========================================================================= /** Emit an event to all listeners */ _emit(event) { for (const l of this._eventListeners) { l(event); } } _emitQueueUpdate() { this._emit({ type: "queue_update", steering: [...this._steeringMessages], followUp: [...this._followUpMessages], }); } _getIdleWaitPromise() { if (!this._idleWaitPromise) { this._idleWaitPromise = new Promise((resolve) => { this._resolveIdleWait = resolve; }); } return this._idleWaitPromise; } _resolveIdleWaitIfIdle() { if (this._isAgentRunActive || !this._resolveIdleWait) { return; } const resolve = this._resolveIdleWait; this._idleWaitPromise = undefined; this._resolveIdleWait = undefined; resolve(); } async _emitAgentSettled() { this._isAgentRunActive = false; try { await this._extensionRunner.emit({ type: "agent_settled" }); this._emit({ type: "agent_settled" }); } finally { this._resolveIdleWaitIfIdle(); } } // Track last assistant message for auto-compaction check _lastAssistantMessage = undefined; /** Internal handler for agent events - shared by subscribe and reconnect */ _handleAgentEvent = async (event) => { // When a user message starts, check if it's from either queue and remove it BEFORE emitting // This ensures the UI sees the updated queue state if (event.type === "message_start" && event.message.role === "user") { this._overflowRecoveryAttempted = false; const messageText = contentText(event.message.content, ""); if (messageText) { // Check steering queue first const steeringIndex = this._steeringMessages.indexOf(messageText); if (steeringIndex !== -1) { this._steeringMessages.splice(steeringIndex, 1); this._emitQueueUpdate(); } else { // Check follow-up queue const followUpIndex = this._followUpMessages.indexOf(messageText); if (followUpIndex !== -1) { this._followUpMessages.splice(followUpIndex, 1); this._emitQueueUpdate(); } } } } // Emit to extensions first await this._emitExtensionEvent(event); // Notify all listeners this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event); // Handle session persistence if (event.type === "message_end") { // Check if this is a custom message from extensions if (event.message.role === "custom") { // Persist as CustomMessageEntry this.sessionManager.appendCustomMessageEntry(event.message.customType, event.message.content, event.message.display, event.message.details); } else if (event.message.role === "user" || event.message.role === "assistant" || event.message.role === "toolResult") { // Regular LLM message - persist as SessionMessageEntry this.sessionManager.appendMessage(event.message); } // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere // Track assistant message for auto-compaction (checked on agent_end) if (event.message.role === "assistant") { this._lastAssistantMessage = event.message; const assistantMsg = event.message; if (assistantMsg.stopReason !== "error") { this._overflowRecoveryAttempted = false; } // Reset retry counter immediately on successful assistant response // This prevents accumulation across multiple LLM calls within a turn if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) { this._emit({ type: "auto_retry_end", success: true, attempt: this._retryAttempt, }); this._retryAttempt = 0; } } } }; _willRetryAfterAgentEnd(event) { const settings = this.settingsManager.getRetrySettings(); if (!settings.enabled || this._retryAttempt >= settings.maxRetries) { return false; } for (let i = event.messages.length - 1; i >= 0; i--) { const message = event.messages[i]; if (message.role === "assistant") { return this._isRetryableError(message); } } return false; } /** Find the last assistant message in agent state (including aborted ones) */ _findLastAssistantMessage() { const messages = this.agent.state.messages; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role === "assistant") { return msg; } } return undefined; } _replaceMessageInPlace(target, replacement) { // Agent-core stores the finalized message object in its state before emitting message_end. // SessionManager persistence happens later in _handleAgentEvent() with event.message. // Mutating this object in place keeps agent state, later turn/agent events, listeners, // and the eventual SessionManager.appendMessage(event.message) persistence in sync. if (target === replacement) { return; } const targetRecord = target; for (const key of Object.keys(targetRecord)) { delete targetRecord[key]; } Object.assign(targetRecord, replacement); } /** Emit extension events based on agent events */ async _emitExtensionEvent(event) { if (event.type === "agent_start") { this._turnIndex = 0; await this._extensionRunner.emit({ type: "agent_start" }); } else if (event.type === "agent_end") { await this._extensionRunner.emit({ type: "agent_end", messages: event.messages }); } else if (event.type === "turn_start") { const extensionEvent = { type: "turn_start", turnIndex: this._turnIndex, timestamp: Date.now(), }; await this._extensionRunner.emit(extensionEvent); } else if (event.type === "turn_end") { const extensionEvent = { type: "turn_end", turnIndex: this._turnIndex, message: event.message, toolResults: event.toolResults, }; await this._extensionRunner.emit(extensionEvent); this._turnIndex++; } else if (event.type === "message_start") { const extensionEvent = { type: "message_start", message: event.message, }; await this._extensionRunner.emit(extensionEvent); } else if (event.type === "message_update") { const extensionEvent = { type: "message_update", message: event.message, assistantMessageEvent: event.assistantMessageEvent, }; await this._extensionRunner.emit(extensionEvent); } else if (event.type === "message_end") { const extensionEvent = { type: "message_end", message: event.message, }; const replacement = await this._extensionRunner.emitMessageEnd(extensionEvent); if (replacement) { // Untyped extension handlers can return messages with null/missing content; // normalize so it never enters agent state or session history. const normalized = (replacement.role === "user" || replacement.role === "assistant" || replacement.role === "toolResult" || replacement.role === "custom") && replacement.content == null ? { ...replacement, content: [] } : replacement; this._replaceMessageInPlace(event.message, normalized); } } else if (event.type === "tool_execution_start") { const extensionEvent = { type: "tool_execution_start", toolCallId: event.toolCallId, toolName: event.toolName, args: event.args, }; await this._extensionRunner.emit(extensionEvent); } else if (event.type === "tool_execution_update") { const extensionEvent = { type: "tool_execution_update", toolCallId: event.toolCallId, toolName: event.toolName, args: event.args, partialResult: event.partialResult, }; await this._extensionRunner.emit(extensionEvent); } else if (event.type === "tool_execution_end") { const extensionEvent = { type: "tool_execution_end", toolCallId: event.toolCallId, toolName: event.toolName, result: event.result, isError: event.isError, }; await this._extensionRunner.emit(extensionEvent); } } /** * Subscribe to agent events. * Session persistence is handled internally (saves messages on message_end). * Multiple listeners can be added. Returns unsubscribe function for this listener. */ subscribe(listener) { this._eventListeners.push(listener); // Return unsubscribe function for this specific listener return () => { const index = this._eventListeners.indexOf(listener); if (index !== -1) { this._eventListeners.splice(index, 1); } }; } /** * Temporarily disconnect from agent events. * User listeners are preserved and will receive events again after resubscribe(). * Used internally during operations that need to pause event processing. */ _disconnectFromAgent() { if (this._unsubscribeAgent) { this._unsubscribeAgent(); this._unsubscribeAgent = undefined; } } /** * Reconnect to agent events after _disconnectFromAgent(). * Preserves all existing listeners. */ _reconnectToAgent() { if (this._unsubscribeAgent) return; // Already connected this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); } /** * Remove all listeners and disconnect from agent. * Call this when completely done with the session. */ dispose() { try { this.abortRetry(); this.abortCompaction(); this.abortBranchSummary(); this.abortBash(); this.agent.abort(); } catch { // Dispose must succeed even if an abort hook throws. } this._extensionRunner.invalidate("This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload()."); this._disconnectFromAgent(); this._eventListeners = []; cleanupSessionResources(this.sessionId); } // ========================================================================= // Read-only State Access // ========================================================================= /** Full agent state */ get state() { return this.agent.state; } /** Current model (may be undefined if not yet selected) */ get model() { return this.agent.state.model; } /** Current thinking level */ get thinkingLevel() { return this.agent.state.thinkingLevel; } /** Whether the session is currently processing an agent run or post-run continuation. */ get isStreaming() { return this._isAgentRunActive; } /** Whether the session has no active agent run, retry, auto-compaction, or queued continuation. */ get isIdle() { return !this._isAgentRunActive; } /** Current effective system prompt (includes any per-turn extension modifications) */ get systemPrompt() { return this.agent.state.systemPrompt; } /** Current retry attempt (0 if not retrying) */ get retryAttempt() { return this._retryAttempt; } /** * Get the names of currently active tools. * Returns the names of tools currently set on the agent. */ getActiveToolNames() { return this.agent.state.tools.map((t) => t.name); } /** * Get all configured tools with name, description, parameter schema, prompt guidelines, and source metadata. */ getAllTools() { return Array.from(this._toolDefinitions.values()).map(({ definition, sourceInfo }) => ({ name: definition.name, description: definition.description, parameters: definition.parameters, promptGuidelines: definition.promptGuidelines, sourceInfo, })); } getToolDefinition(name) { return this._toolDefinitions.get(name)?.definition; } /** * Set active tools by name. * Only tools in the registry can be enabled. Unknown tool names are ignored. * Also rebuilds the system prompt to reflect the new tool set. * Changes take effect on the next agent turn. */ setActiveToolsByName(toolNames) { const tools = []; const validToolNames = []; for (const name of toolNames) { const tool = this._toolRegistry.get(name); if (tool) { tools.push(tool); validToolNames.push(name); } } this.agent.state.tools = tools; // Rebuild base system prompt with new tool set this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames); this.agent.state.systemPrompt = this._systemPromptOverride ?? this._baseSystemPrompt; } /** Whether compaction or branch summarization is currently running */ get isCompacting() { return (this._autoCompactionAbortController !== undefined || this._compactionAbortController !== undefined || this._branchSummaryAbortController !== undefined); } /** All messages including custom types like BashExecutionMessage */ get messages() { return this.agent.state.messages; } /** Current steering mode */ get steeringMode() { return this.agent.steeringMode; } /** Current follow-up mode */ get followUpMode() { return this.agent.followUpMode; } /** Current session file path, or undefined if sessions are disabled */ get sessionFile() { return this.sessionManager.getSessionFile(); } /** Current session ID */ get sessionId() { return this.sessionManager.getSessionId(); } /** Current session display name, if set */ get sessionName() { return this.sessionManager.getSessionName(); } /** Scoped models for cycling (from --models flag) */ get scopedModels() { return this._scopedModels; } /** Update scoped models for cycling */ setScopedModels(scopedModels) { this._scopedModels = scopedModels; } /** File-based prompt templates */ get promptTemplates() { return this._resourceLoader.getPrompts().prompts; } _normalizePromptSnippet(text) { if (!text) return undefined; const oneLine = text .replace(/[\r\n]+/g, " ") .replace(/\s+/g, " ") .trim(); return oneLine.length > 0 ? oneLine : undefined; } _normalizePromptGuidelines(guidelines) { if (!guidelines || guidelines.length === 0) { return []; } const unique = new Set(); for (const guideline of guidelines) { const normalized = guideline.trim(); if (normalized.length > 0) { unique.add(normalized); } } return Array.from(unique); } _rebuildSystemPrompt(toolNames) { const validToolNames = toolNames.filter((name) => this._toolRegistry.has(name)); const toolSnippets = {}; const promptGuidelines = []; for (const name of validToolNames) { const snippet = this._toolPromptSnippets.get(name); if (snippet) { toolSnippets[name] = snippet; } const toolGuidelines = this._toolPromptGuidelines.get(name); if (toolGuidelines) { promptGuidelines.push(...toolGuidelines); } } const loaderSystemPrompt = this._resourceLoader.getSystemPrompt(); const loaderAppendSystemPrompt = this._resourceLoader.getAppendSystemPrompt(); const appendSystemPrompt = loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : undefined; const loadedSkills = this._resourceLoader.getSkills().skills; const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles; this._baseSystemPromptOptions = { cwd: this._cwd, skills: loadedSkills, contextFiles: loadedContextFiles, customPrompt: loaderSystemPrompt, appendSystemPrompt, selectedTools: validToolNames, toolSnippets, promptGuidelines, }; return buildSystemPrompt(this._baseSystemPromptOptions); } // ========================================================================= // Prompting // ========================================================================= async _runAgentPrompt(messages) { this._isAgentRunActive = true; try { await this.agent.prompt(messages); while (await this._handlePostAgentRun()) { await this.agent.continue(); } } finally { this._systemPromptOverride = undefined; this._flushPendingBashMessages(); await this._emitAgentSettled(); } } async _handlePostAgentRun() { const msg = this._lastAssistantMessage; this._lastAssistantMessage = undefined; if (!msg) { return false; } if (this._isRetryableError(msg) && (await this._prepareRetry(msg))) { return true; } if (msg.stopReason === "error" && this._retryAttempt > 0) { this._emit({ type: "auto_retry_end", success: false, attempt: this._retryAttempt, finalError: msg.errorMessage, }); this._retryAttempt = 0; } if (await this._checkCompaction(msg)) { return true; } // The agent loop drains both queues before emitting agent_end. Any messages // here were queued by agent_end extension handlers and need a continuation. return this.agent.hasQueuedMessages(); } /** * Send a prompt to the agent. * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming * - Expands file-based prompt templates by default * - During streaming, queues via steer() or followUp() based on streamingBehavior option * - Validates model and API key before sending (when not streaming) * @throws Error if streaming and no streamingBehavior specified * @throws Error if no model selected or no API key available (when not streaming) */ async prompt(text, options) { const expandPromptTemplates = options?.expandPromptTemplates ?? true; const preflightResult = options?.preflightResult; let messages; try { // Handle extension commands first (execute immediately, even during streaming) // Extension commands manage their own LLM interaction via pi.sendMessage() if (expandPromptTemplates && text.startsWith("/")) { const handled = await this._tryExecuteExtensionCommand(text); if (handled) { // Extension command executed, no prompt to send preflightResult?.(true); return; } } // Emit input event for extension interception (before skill/template expansion) let currentText = text; let currentImages = options?.images; if (this._extensionRunner.hasHandlers("input")) { const inputResult = await this._extensionRunner.emitInput(currentText, currentImages, options?.source ?? "interactive", this.isStreaming ? options?.streamingBehavior : undefined); if (inputResult.action === "handled") { preflightResult?.(true); return; } if (inputResult.action === "transform") { currentText = inputResult.text; currentImages = inputResult.images ?? currentImages; } } // Expand skill commands (/skill:name args) and prompt templates (/template args) let expandedText = currentText; if (expandPromptTemplates) { expandedText = this._expandSkillCommand(expandedText); expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); } // If streaming, queue via steer() or followUp() based on option if (this.isStreaming) { if (!options?.streamingBehavior) { throw new Error("Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message."); } if (options.streamingBehavior === "followUp") { await this._queueFollowUp(expandedText, currentImages); } else { await this._queueSteer(expandedText, currentImages); } preflightResult?.(true); return; } // Flush any pending bash messages before the new prompt this._flushPendingBashMessages(); // Validate model if (!this.model) { throw new Error(formatNoModelSelectedMessage()); } const hasConfiguredAuth = this._modelRuntime.hasConfiguredAuth(this.model.provider) || (await this._modelRuntime.checkAuth(this.model.provider)) !== undefined; if (!hasConfiguredAuth) { const isOAuth = this._modelRuntime.isUsingOAuth(this.model.provider); if (isOAuth) { throw new Error(`Authentication failed for "${this.model.provider}". ` + `Credentials may have expired or network is unavailable. ` + `Run '/login ${this.model.provider}' to re-authenticate.`); } throw new Error(formatNoApiKeyFoundMessage(this.model.provider)); } // Check if we need to compact before sending (catches aborted responses). // The user's new prompt is sent below, so do not call agent.continue() here. const lastAssistant = this._findLastAssistantMessage(); if (lastAssistant) { await this._checkCompaction(lastAssistant, false); } // Build messages array (custom message if any, then user message) messages = []; // Add user message const userContent = [{ type: "text", text: expandedText }]; if (currentImages) { userContent.push(...currentImages); } messages.push({ role: "user", content: userContent, timestamp: Date.now(), }); // Inject any pending "nextTurn" messages as context alongside the user message for (const msg of this._pendingNextTurnMessages) { messages.push(msg); } this._pendingNextTurnMessages = []; // Emit before_agent_start extension event const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, currentImages, this._baseSystemPrompt, this._baseSystemPromptOptions); // Add all custom messages from extensions if (result?.messages) { for (const msg of result.messages) { messages.push({ role: "custom", customType: msg.customType, // Untyped extensions can pass null/missing content; normalize at ingestion. content: msg.content ?? [], display: msg.display, details: msg.details, timestamp: Date.now(), }); } } // Apply extension-modified system prompt, or reset to base if (result?.systemPrompt !== undefined) { this._systemPromptOverride = result.systemPrompt; this.agent.state.systemPrompt = result.systemPrompt; } else { // Ensure we're using the base prompt (in case previous turn had modifications) this._systemPromptOverride = undefined; this.agent.state.systemPrompt = this._baseSystemPrompt; } } catch (error) { preflightResult?.(false); throw error; } if (!messages) { return; } preflightResult?.(true); await this._runAgentPrompt(messages); } /** * Try to execute an extension command. Returns true if command was found and executed. */ async _tryExecuteExtensionCommand(text) { // Parse command name and args const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); const command = this._extensionRunner.getCommand(commandName); if (!command) return false; // Get command context from extension runner (includes session control methods) const ctx = this._extensionRunner.createCommandContext(); try { await command.handler(args, ctx); return true; } catch (err) { // Emit error via extension runner this._extensionRunner.emitError({ extensionPath: `command:${commandName}`, event: "command", error: err instanceof Error ? err.message : String(err), }); return true; } } /** * Expand skill commands (/skill:name args) to their full content. * Returns the expanded text, or the original text if not a skill command or skill not found. * Emits errors via extension runner if file read fails. */ _expandSkillCommand(text) { if (!text.startsWith("/skill:")) return text; const spaceIndex = text.indexOf(" "); const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex); const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim(); const skill = this.resourceLoader.getSkills().skills.find((s) => s.name === skillName); if (!skill) return text; // Unknown skill, pass through try { const content = readFileSync(skill.filePath, "utf-8"); const body = stripFrontmatter(content).trim(); const skillBlock = `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${skill.baseDir}.\n\n${body}\n</skill>`; return args ? `${skillBlock}\n\n${args}` : skillBlock; } catch (err) { // Emit error like extension commands do this._extensionRunner.emitError({ extensionPath: skill.filePath, event: "skill_expansion", error: err instanceof Error ? err.message : String(err), }); return text; // Return original on error } } /** * Queue a steering message while the agent is running. * Delivered after the current assistant turn finishes executing its tool calls, * before the next LLM call. * Expands skill commands and prompt templates. Errors on extension commands. * @param images Optional image attachments to include with the message * @throws Error if text is an extension command */ async steer(text, images) { // Check for extension commands (cannot be queued) if (text.startsWith("/")) { this._throwIfExtensionCommand(text); } // Expand skill commands and prompt templates let expandedText = this._expandSkillCommand(text); expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); await this._queueSteer(expandedText, images); } /** * Queue a follow-up message to be processed after the agent finishes. * Delivered only when agent has no more tool calls or steering messages. * Expands skill commands and prompt templates. Errors on extension commands. * @param images Optional image attachments to include with the message * @throws Error if text is an extension command */ async followUp(text, images) { // Check for extension commands (cannot be queued) if (text.startsWith("/")) { this._throwIfExtensionCommand(text); } // Expand skill commands and prompt templates let expandedText = this._expandSkillCommand(text); expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); await this._queueFollowUp(expandedText, images); } /** * Internal: Queue a steering message (already expanded, no extension command check). */ async _queueSteer(text, images) { this._steeringMessages.push(text); this._emitQueueUpdate(); const content = [{ type: "text", text }]; if (images) { content.push(...images); } this.agent.steer({ role: "user", content, timestamp: Date.now(), }); } /** * Internal: Queue a follow-up message (already expanded, no extension command check). */ async _queueFollowUp(text, images) { this._followUpMessages.push(text); this._emitQueueUpdate(); const content = [{ type: "text", text }]; if (images) { content.push(...images); } this.agent.followUp({ role: "user", content, timestamp: Date.now(), }); } /** * Throw an error if the text is an extension command. */ _throwIfExtensionCommand(text) { const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); const command = this._extensionRunner.getCommand(commandName); if (command) { throw new Error(`Extension command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`); } } /** * Send a custom message to the session. Creates a CustomMessageEntry. * * Handles three cases: * - Streaming: queues message, processed when loop pulls from queue * - Not streaming + triggerTurn: appends to state/session, starts new turn * - Not streaming + no trigger: appends to state/session, no turn * * @param message Custom message with customType, content, display, details * @param options.triggerTurn If true and not streaming, triggers a new LLM turn * @param options.deliverAs Delivery mode: "steer", "followUp", or "nextTurn" */ async sendCustomMessage(message, options) { const appMessage = { role: "custom", customType: message.customType, // Untyped extensions can pass null/missing content; normalize at ingestion. content: message.content ?? [], display: message.display, details: message.details, timestamp: Date.now(), }; if (options?.deliverAs === "nextTurn") { this._pendingNextTurnMessages.push(appMessage); } else if (this.isStreaming) { if (options?.deliverAs === "followUp") { this.agent.followUp(appMessage); } else { this.agent.steer(appMessage); } } else if (options?.triggerTurn) { await this._runAgentPrompt(appMessage); } else { this.agent.state.messages.push(appMessage); this.sessionManager.appendCustomMessageEntry(message.customType, message.content, message.display, message.details); this._emit({ type: "message_start", message: appMessage }); this._emit({ type: "message_end", message: appMessage }); } } /** * Send a user message to the agent. Always triggers a turn. * When the agent is streaming, use deliverAs to specify how to queue the message. * * @param content User message content (string or content array) * @param options.deliverAs Delivery mode when streaming: "steer" or "followUp" */ async sendUserMessage(content, options) { // Normalize content to text string + optional images let text; let images; if (typeof content === "string") { text = content; } else { const textParts = []; images = []; for (const part of content) { if (part.type === "text") { textParts.push(part.text); } else { images.push(part); } } text = textParts.join("\n"); if (images.length === 0) images = undefined; } // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion await this.prompt(text, { expandPromptTemplates: false, streamingBehavior: options?.deliverAs, images, source: "extension", }); } /** * Clear all queued messages and return them. * Useful for restoring to editor when user aborts. * @returns Object with steering and followUp arrays */ clearQueue() { const steering = [...this._steeringMessages]; const followUp = [...this._followUpMessages]; this._steeringMessages = []; this._followUpMessages = []; this.agent.clearAllQueues(); this._emitQueueUpdate(); return { steering, followUp }; } /** Number of pending messages (includes both steering and follow-up) */ get pendingMessageCount() { return this._steeringMessages.length + this._followUpMessages.length; } /** Get pending steering messages (read-only) */ getSteeringMessages() { return this._steeringMessages; } /** Get pending follow-up messages (read-only) */ getFollowUpMessages() { return this._followUpMessages; } get resourceLoader() { return this._resourceLoader; } /** * Abort current operation and wait for agent to become idle. */ async abort() { this.abortRetry(); this.agent.abort(); await this.waitForIdle(); } async waitForIdle() { if (this.isIdle) { return; } await this._getIdleWaitPromise(); } // ========================================================================= // Model Management // ========================================================================= async _emitModelSelect(nextModel, previousModel, source) { if (modelsAreEqual(previousModel, nextModel)) return; await this._extensionRunner.emit({ type: "model_select", model: nextModel, previousModel, source, }); } /** * Set model directly. * Validates that auth is configured, saves to session and settings. * @throws Error if no auth is configured for the model */ async setModel(model) { if (!(await this._modelRuntime.checkAuth(model.provider))) { throw new Error(`No API key for ${model.provider}/${model.id}`); } const previousModel = this.model; const thinkingLevel = this._getThinkingLevelForModelSwitch(); this.agent.state.model = model; this.sessionManager.appendModelChange(model.provider, model.id); this.