diff --git a/docs/specs/agent-tooling-v2/spec.md b/docs/specs/agent-tooling-v2/spec.md index b07a71863..39290552c 100644 --- a/docs/specs/agent-tooling-v2/spec.md +++ b/docs/specs/agent-tooling-v2/spec.md @@ -75,7 +75,7 @@ | 工具 | 必填参数 | 可选参数 | 说明 | |---|---|---|---| -| `exec` | `command: string` | `cwd?: string`, `timeoutMs?: number`, `background?: boolean`, `yieldMs?: number` | 命令执行;长任务建议后台。 | +| `exec` | `command: string` | `cwd?: string`, `timeoutMs?: number`, `background?: boolean`, `yieldMs?: number` | 命令执行;前台仅等待 yield 窗口,超时后自动转后台并返回 `sessionId`。 | | `process` | `action: enum` | `sessionId?: string`, `offset?: number`, `limit?: number`, `data?: string`, `eof?: boolean` | 后台会话管理(list/poll/log/write/kill/clear/remove)。 | 约束: diff --git a/docs/specs/process-tool/spec.md b/docs/specs/process-tool/spec.md index c4895d1f2..db4e7d2ce 100644 --- a/docs/specs/process-tool/spec.md +++ b/docs/specs/process-tool/spec.md @@ -14,6 +14,15 @@ As an AI agent, I want to start a command in the background so that I can run lo - Command returns immediately with a `sessionId` and `status: "running"` - Process continues running after tool returns +### US-1.1: Foreground Yield To Background +As an AI agent, I want a foreground `exec` call to yield into a background session when it runs too long, so that the loop can continue without restarting the command. + +**Acceptance Criteria:** +- Foreground `exec` waits only until `yieldMs` (or the default yield window) +- If the command finishes within that window, it returns the normal foreground result +- If the command is still running after that window, the same process is kept alive and `exec` returns `status: "running"` with a `sessionId` +- The yielded session is manageable through `process` + ### US-2: Monitor Background Output As an AI agent, I want to poll the output of a background command so that I can monitor its progress. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 4dee57b91..d8955694c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -56,6 +56,7 @@ export default defineConfig({ ) }, optimizeDeps: { + exclude: ['markstream-vue', 'stream-monaco'], include: [ 'monaco-editor', 'axios' diff --git a/src/main/events.ts b/src/main/events.ts index fdf231ba1..130c392e2 100644 --- a/src/main/events.ts +++ b/src/main/events.ts @@ -78,7 +78,8 @@ export const SESSION_EVENTS = { ACTIVATED: 'session:activated', DEACTIVATED: 'session:deactivated', STATUS_CHANGED: 'session:status-changed', - COMPACTION_UPDATED: 'session:compaction-updated' + COMPACTION_UPDATED: 'session:compaction-updated', + PENDING_INPUTS_UPDATED: 'session:pending-inputs-updated' } // 系统相关事件 diff --git a/src/main/lib/agentRuntime/backgroundExecSessionManager.ts b/src/main/lib/agentRuntime/backgroundExecSessionManager.ts index 9c8eec4e6..dc3c99227 100644 --- a/src/main/lib/agentRuntime/backgroundExecSessionManager.ts +++ b/src/main/lib/agentRuntime/backgroundExecSessionManager.ts @@ -4,10 +4,13 @@ import path from 'path' import { nanoid } from 'nanoid' import logger from '@shared/logger' import { getShellEnvironment, getUserShell } from './shellEnvHelper' +import { terminateProcessTree } from './processTree' import { resolveSessionDir } from './sessionPaths' // Configuration with environment variable support -const getConfig = () => ({ +const FOREGROUND_PREVIEW_CHARS = 12000 + +export const getBackgroundExecConfig = () => ({ backgroundMs: parseInt(process.env.PI_BASH_YIELD_MS || '10000', 10), timeoutSec: parseInt(process.env.PI_BASH_TIMEOUT_SEC || '1800', 10), cleanupMs: parseInt(process.env.PI_BASH_JOB_TTL_MS || '1800000', 10), @@ -21,6 +24,8 @@ const getConfig = () => ({ offloadThresholdChars: 10000 // Offload to file when output exceeds this }) +const getConfig = getBackgroundExecConfig + export interface SessionMeta { sessionId: string command: string @@ -31,8 +36,22 @@ export interface SessionMeta { exitCode?: number outputLength: number offloaded: boolean + timedOut?: boolean +} + +export interface SessionCompletionResult { + status: 'done' | 'error' | 'killed' + output: string + exitCode: number | null + offloaded: boolean + outputFilePath?: string + timedOut: boolean } +export type WaitForCompletionOrYieldResult = + | { kind: 'running'; sessionId: string } + | { kind: 'completed'; result: SessionCompletionResult } + interface BackgroundSession { sessionId: string conversationId: string @@ -54,6 +73,7 @@ interface BackgroundSession { resolveClose: () => void closeSettled: boolean killTimeoutId?: NodeJS.Timeout + timedOut: boolean } interface StartSessionResult { @@ -67,6 +87,7 @@ interface PollResult { exitCode?: number offloaded?: boolean outputFilePath?: string + timedOut?: boolean } interface LogResult { @@ -76,6 +97,7 @@ interface LogResult { exitCode?: number offloaded?: boolean outputFilePath?: string + timedOut?: boolean } export class BackgroundExecSessionManager { @@ -93,6 +115,7 @@ export class BackgroundExecSessionManager { options?: { timeout?: number env?: Record + outputPrefix?: string } ): Promise { const config = getConfig() @@ -105,7 +128,9 @@ export class BackgroundExecSessionManager { fs.mkdirSync(sessionDir, { recursive: true }) } - const outputFilePath = sessionDir ? path.join(sessionDir, `bgexec_${sessionId}.log`) : null + const outputFilePath = sessionDir + ? this.createOutputFilePath(sessionDir, sessionId, options?.outputPrefix) + : null const child = spawn(shell, [...args, command], { cwd, @@ -114,6 +139,7 @@ export class BackgroundExecSessionManager { ...shellEnv, ...options?.env }, + detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] }) @@ -140,7 +166,8 @@ export class BackgroundExecSessionManager { stderrEof: false, closePromise, resolveClose, - closeSettled: false + closeSettled: false, + timedOut: false } this.setupOutputHandling(session, config) @@ -176,7 +203,8 @@ export class BackgroundExecSessionManager { pid: session.child.pid, exitCode: session.exitCode, outputLength: session.totalOutputLength, - offloaded: this.hasPersistedOutput(session, getConfig()) + offloaded: this.hasPersistedOutput(session, getConfig()), + timedOut: session.timedOut })) } @@ -195,7 +223,8 @@ export class BackgroundExecSessionManager { output, exitCode: session.exitCode, offloaded: true, - outputFilePath: session.outputFilePath + outputFilePath: session.outputFilePath, + timedOut: session.timedOut } } @@ -204,7 +233,8 @@ export class BackgroundExecSessionManager { status: session.status, output, exitCode: session.exitCode, - offloaded: false + offloaded: false, + timedOut: session.timedOut } } @@ -234,10 +264,65 @@ export class BackgroundExecSessionManager { totalLength: session.totalOutputLength, exitCode: session.exitCode, offloaded: isOffloaded, - outputFilePath: session.outputFilePath || undefined + outputFilePath: session.outputFilePath || undefined, + timedOut: session.timedOut + } + } + + async waitForCompletionOrYield( + conversationId: string, + sessionId: string, + yieldMs = getConfig().backgroundMs + ): Promise { + const session = this.getSession(conversationId, sessionId) + session.lastAccessedAt = Date.now() + + if (session.status !== 'running') { + return { + kind: 'completed', + result: await this.getCompletionResult(conversationId, sessionId) + } + } + + let yieldTimer: NodeJS.Timeout | null = null + + try { + await Promise.race([ + session.closePromise, + new Promise((resolve) => { + yieldTimer = setTimeout(resolve, Math.max(0, yieldMs)) + }) + ]) + } finally { + if (yieldTimer) { + clearTimeout(yieldTimer) + } + } + + if (session.status !== 'running') { + return { + kind: 'completed', + result: await this.getCompletionResult(conversationId, sessionId) + } + } + + return { + kind: 'running', + sessionId } } + async getCompletionResult( + conversationId: string, + sessionId: string, + previewChars = FOREGROUND_PREVIEW_CHARS + ): Promise { + const session = this.getSession(conversationId, sessionId) + session.lastAccessedAt = Date.now() + await this.waitForSessionDrain(session) + return this.buildCompletionResult(session, previewChars) + } + write(conversationId: string, sessionId: string, data: string, eof = false): void { const session = this.getSession(conversationId, sessionId) @@ -446,31 +531,15 @@ export class BackgroundExecSessionManager { clearTimeout(session.killTimeoutId) } - const gracefulKill = new Promise((resolve) => { - const timeout = setTimeout(() => { - resolve() - }, 2000) - - session.child.once('close', () => { - clearTimeout(timeout) - resolve() - }) - - try { - session.child.kill('SIGTERM') - } catch { - resolve() - } - }) - - await gracefulKill + if (reason === 'timeout') { + session.timedOut = true + } + session.status = 'killed' - if (session.status === 'running') { - try { - session.child.kill('SIGKILL') - } catch (error) { - logger.warn(`[BackgroundExec] Failed to force kill session ${session.sessionId}:`, error) - } + const closed = await terminateProcessTree(session.child, { graceMs: 2000 }) + if (!closed && !session.closeSettled) { + session.exitCode = undefined + await this.finalizeSession(session, null, 'SIGKILL') } await session.closePromise @@ -682,6 +751,37 @@ export class BackgroundExecSessionManager { ) } + private buildCompletionResult( + session: BackgroundSession, + previewChars: number + ): SessionCompletionResult { + const config = getConfig() + const offloaded = this.hasPersistedOutput(session, config) + const output = + offloaded && session.outputFilePath + ? this.getRecentOutputFromSession(session, previewChars) + : this.getRecentOutput(session.outputBuffer, previewChars) + + return { + status: session.status === 'running' ? 'killed' : session.status, + output, + exitCode: session.exitCode ?? null, + offloaded, + outputFilePath: session.outputFilePath || undefined, + timedOut: session.timedOut + } + } + + private createOutputFilePath( + sessionDir: string, + sessionId: string, + outputPrefix?: string + ): string { + const rawPrefix = outputPrefix?.trim() || 'bgexec' + const safePrefix = rawPrefix.replace(/[^a-zA-Z0-9_-]/g, '_') + return path.join(sessionDir, `${safePrefix}_${sessionId}.log`) + } + private resolveUtf8ByteRange( fd: number, fileSize: number, diff --git a/src/main/lib/agentRuntime/processTree.ts b/src/main/lib/agentRuntime/processTree.ts new file mode 100644 index 000000000..df9716041 --- /dev/null +++ b/src/main/lib/agentRuntime/processTree.ts @@ -0,0 +1,165 @@ +import { spawn, type ChildProcess } from 'child_process' + +const FORCE_KILL_SETTLE_MS = 500 + +function hasExited(child: ChildProcess): boolean { + return child.exitCode !== null || child.signalCode !== null +} + +function waitForClose(child: ChildProcess, timeoutMs: number): Promise { + if (hasExited(child)) { + return Promise.resolve(true) + } + + return new Promise((resolve) => { + let settled = false + let timeoutId: NodeJS.Timeout | null = null + + const cleanup = () => { + child.removeListener('close', onClose) + if (timeoutId) { + clearTimeout(timeoutId) + timeoutId = null + } + } + + const onClose = () => { + if (settled) return + settled = true + cleanup() + resolve(true) + } + + child.once('close', onClose) + timeoutId = setTimeout(() => { + if (settled) return + settled = true + cleanup() + resolve(false) + }, timeoutMs) + }) +} + +async function spawnAndWait(command: string, args: string[]): Promise { + await new Promise((resolve) => { + try { + const child = spawn(command, args, { stdio: 'ignore' }) + child.on('error', () => resolve()) + child.on('close', () => resolve()) + } catch { + resolve() + } + }) +} + +async function spawnAndCapture(command: string, args: string[]): Promise { + return await new Promise((resolve) => { + let output = '' + + try { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'ignore'] }) + child.stdout?.on('data', (chunk: Buffer | string) => { + output += chunk.toString() + }) + child.on('error', () => resolve('')) + child.on('close', () => resolve(output)) + } catch { + resolve('') + } + }) +} + +async function listChildPids(pid: number): Promise { + const output = await spawnAndCapture('pgrep', ['-P', `${pid}`]) + return output + .split(/\r?\n/) + .map((line) => Number.parseInt(line.trim(), 10)) + .filter((childPid) => Number.isInteger(childPid) && childPid > 0) +} + +async function collectDescendantPids(pid: number): Promise { + const descendants: number[] = [] + const pending = [pid] + const seen = new Set() + + while (pending.length > 0) { + const currentPid = pending.pop() + if (!currentPid) { + continue + } + + const childPids = await listChildPids(currentPid) + for (const childPid of childPids) { + if (seen.has(childPid)) { + continue + } + + seen.add(childPid) + descendants.push(childPid) + pending.push(childPid) + } + } + + return descendants +} + +async function signalDescendantsRecursively( + pid: number, + childSignal: '-TERM' | '-KILL' +): Promise { + const descendants = await collectDescendantPids(pid) + for (const descendantPid of descendants.reverse()) { + await spawnAndWait('kill', [childSignal, `${descendantPid}`]) + } +} + +async function signalProcessTree(pid: number, signal: 'SIGTERM' | 'SIGKILL'): Promise { + if (process.platform === 'win32') { + const args = ['/PID', `${pid}`, '/T', '/F'] + await spawnAndWait('taskkill', args) + return + } + + const childSignal = signal === 'SIGKILL' ? '-KILL' : '-TERM' + try { + process.kill(-pid, signal) + } catch { + await signalDescendantsRecursively(pid, childSignal) + try { + process.kill(pid, signal) + } catch { + // Process may have already exited. + } + } +} + +export async function terminateProcessTree( + child: ChildProcess, + options: { + graceMs?: number + } = {} +): Promise { + const graceMs = Math.max(0, options.graceMs ?? 2000) + + if (hasExited(child)) { + return true + } + + const pid = child.pid + if (!pid) { + try { + child.kill('SIGTERM') + } catch { + // Ignore missing pid failures. + } + return await waitForClose(child, graceMs) + } + + await signalProcessTree(pid, 'SIGTERM') + if (await waitForClose(child, graceMs)) { + return true + } + + await signalProcessTree(pid, 'SIGKILL') + return await waitForClose(child, FORCE_KILL_SETTLE_MS) +} diff --git a/src/main/lib/agentRuntime/systemEnvPromptBuilder.ts b/src/main/lib/agentRuntime/systemEnvPromptBuilder.ts index 8d27af6ce..1579b1196 100644 --- a/src/main/lib/agentRuntime/systemEnvPromptBuilder.ts +++ b/src/main/lib/agentRuntime/systemEnvPromptBuilder.ts @@ -105,7 +105,9 @@ export function buildRuntimeCapabilitiesPrompt( lines.push('- YoBrowser tools are available for browser automation when needed.') } if (options.hasExec) { - lines.push('- Use exec(background: true) to start long-running terminal commands.') + lines.push( + '- Use exec(background: true) to explicitly detach long-running terminal commands; foreground exec may also return a running session after its yield window.' + ) } if (options.hasProcess) { lines.push( diff --git a/src/main/presenter/agentPresenter/acp/agentBashHandler.ts b/src/main/presenter/agentPresenter/acp/agentBashHandler.ts index 883306cee..22c37b210 100644 --- a/src/main/presenter/agentPresenter/acp/agentBashHandler.ts +++ b/src/main/presenter/agentPresenter/acp/agentBashHandler.ts @@ -5,6 +5,8 @@ import os from 'os' import { z } from 'zod' import logger from '@shared/logger' import type { IConfigPresenter } from '@shared/presenter' +import { getBackgroundExecConfig } from '@/lib/agentRuntime/backgroundExecSessionManager' +import { terminateProcessTree } from '@/lib/agentRuntime/processTree' import { rtkRuntimeService } from '@/lib/agentRuntime/rtkRuntimeService' // Consider moving to a shared handlers location in future refactoring @@ -47,6 +49,22 @@ interface PreparedCommand { rtkFallbackReason?: string } +interface CompletedShellProcessResult { + kind: 'completed' + output: string + exitCode: number | null + timedOut: boolean + offloaded: boolean + outputFilePath?: string +} + +interface RunningShellProcessResult { + kind: 'running' + sessionId: string +} + +type ShellProcessResult = CompletedShellProcessResult | RunningShellProcessResult + export class AgentBashHandler { private allowedDirectories: string[] private readonly commandPermissionHandler?: CommandPermissionService @@ -81,7 +99,7 @@ export class AgentBashHandler { throw new Error(`Invalid arguments: ${parsed.error}`) } - const { command, timeout, background, cwd: requestedCwd } = parsed.data + const { command, timeout, background, cwd: requestedCwd, yieldMs } = parsed.data const cwd = this.resolveWorkingDirectory(requestedCwd) // Handle background execution @@ -111,13 +129,7 @@ export class AgentBashHandler { } } - let result: { - output: string - exitCode: number | null - timedOut: boolean - offloaded: boolean - outputFilePath?: string - } + let result: ShellProcessResult const prepared = await this.prepareCommand(command, options.env) @@ -127,10 +139,20 @@ export class AgentBashHandler { timeout ?? COMMAND_DEFAULT_TIMEOUT_MS, { ...options, - env: prepared.env + env: prepared.env, + yieldMs } ) + if (result.kind === 'running') { + return { + output: { status: 'running', sessionId: result.sessionId }, + rtkApplied: prepared.rtkApplied, + rtkMode: prepared.rtkMode, + rtkFallbackReason: prepared.rtkFallbackReason + } + } + const fallbackReason = this.getRtkCapabilityFallbackReason(result.output) if ( prepared.rewritten && @@ -155,28 +177,27 @@ export class AgentBashHandler { timeout ?? COMMAND_DEFAULT_TIMEOUT_MS, { ...options, - env: prepared.env + env: prepared.env, + yieldMs } ) prepared.rtkApplied = false prepared.rtkMode = 'bypass' prepared.rtkFallbackReason = fallbackReason - } - const responseLines: string[] = [] - if (result.output) { - responseLines.push(result.output.trimEnd()) - } - responseLines.push(`Exit Code: ${result.exitCode ?? 'null'}`) - if (result.timedOut) { - responseLines.push('Timed out') - } - if (result.offloaded && result.outputFilePath) { - responseLines.push(`Output offloaded: ${result.outputFilePath}`) + if (result.kind === 'running') { + return { + output: { status: 'running', sessionId: result.sessionId }, + rtkApplied: prepared.rtkApplied, + rtkMode: prepared.rtkMode, + rtkFallbackReason: prepared.rtkFallbackReason + } + } } + return { - output: responseLines.join('\n'), + output: this.formatCompletedResult(result), rtkApplied: prepared.rtkApplied, rtkMode: prepared.rtkMode, rtkFallbackReason: prepared.rtkFallbackReason @@ -228,17 +249,79 @@ export class AgentBashHandler { } private async runShellProcess( + command: string, + cwd: string, + timeout: number, + options: ExecuteCommandOptions & { yieldMs?: number } + ): Promise { + if (options.conversationId) { + return await this.runManagedShellProcess(command, cwd, timeout, options) + } + + return await this.runDetachedShellProcess(command, cwd, timeout, options) + } + + private async runManagedShellProcess( + command: string, + cwd: string, + timeout: number, + options: ExecuteCommandOptions & { yieldMs?: number } + ): Promise { + const conversationId = options.conversationId + if (!conversationId) { + throw new Error('Managed shell process requires a conversation ID') + } + + const session = await backgroundExecSessionManager.start(conversationId, command, cwd, { + timeout, + env: options.env, + outputPrefix: options.outputPrefix + }) + + backgroundExecSessionManager.write(conversationId, session.sessionId, options.stdin ?? '', true) + + const yielded = await backgroundExecSessionManager.waitForCompletionOrYield( + conversationId, + session.sessionId, + options.yieldMs ?? getBackgroundExecConfig().backgroundMs + ) + + if (yielded.kind === 'running') { + return yielded + } + + const shouldCleanupSession = !yielded.result.offloaded + + try { + return { + kind: 'completed', + output: yielded.result.output, + exitCode: yielded.result.exitCode, + timedOut: yielded.result.timedOut, + offloaded: yielded.result.offloaded, + outputFilePath: yielded.result.outputFilePath + } + } finally { + if (shouldCleanupSession) { + await backgroundExecSessionManager + .remove(conversationId, session.sessionId) + .catch((error) => { + logger.warn('[AgentBashHandler] Failed to cleanup completed foreground exec session', { + conversationId, + sessionId: session.sessionId, + error + }) + }) + } + } + } + + private async runDetachedShellProcess( command: string, cwd: string, timeout: number, options: ExecuteCommandOptions - ): Promise<{ - output: string - exitCode: number | null - timedOut: boolean - offloaded: boolean - outputFilePath?: string - }> { + ): Promise { const { shell, args } = getUserShell() const shellEnv = await getShellEnvironment() const outputFilePath = this.createOutputFilePath(options.conversationId, options.outputPrefix) @@ -251,17 +334,38 @@ export class AgentBashHandler { ...shellEnv, ...options.env }, + detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] }) + let settled = false let output = '' let totalOutputLength = 0 let offloaded = false let timedOut = false - let exitCode: number | null = null let outputWriteQueue = Promise.resolve() let timeoutId: NodeJS.Timeout | null = null - let killTimeoutId: NodeJS.Timeout | null = null + + const cleanupTimeout = () => { + if (timeoutId) { + clearTimeout(timeoutId) + timeoutId = null + } + } + + const settle = async (payload: CompletedShellProcessResult) => { + if (settled) return + settled = true + cleanupTimeout() + + try { + await outputWriteQueue + } catch { + // Already logged when flushing output. + } + + resolve(payload) + } const appendOutput = (chunk: string) => { totalOutputLength += chunk.length @@ -308,50 +412,42 @@ export class AgentBashHandler { timeoutId = setTimeout(() => { timedOut = true - try { - child.kill('SIGTERM') - } catch { - // ignore kill errors - } - killTimeoutId = setTimeout(() => { - try { - child.kill('SIGKILL') - } catch { - // ignore kill errors + void terminateProcessTree(child, { graceMs: COMMAND_KILL_GRACE_MS }).then((closed) => { + if (closed || settled) { + return } - }, COMMAND_KILL_GRACE_MS) + + const preview = + offloaded && outputFilePath + ? this.readLastCharsFromFile(outputFilePath, COMMAND_PREVIEW_CHARS) + : output + + void settle({ + kind: 'completed', + output: preview, + exitCode: null, + timedOut: true, + offloaded, + outputFilePath: outputFilePath ?? undefined + }) + }) }, timeout) child.on('error', (error) => { - if (timeoutId) clearTimeout(timeoutId) - if (killTimeoutId) clearTimeout(killTimeoutId) + cleanupTimeout() reject(error) }) child.on('close', async (code, signal) => { - if (timeoutId) clearTimeout(timeoutId) - if (killTimeoutId) clearTimeout(killTimeoutId) - - try { - await outputWriteQueue - } catch { - // Already logged when flushing output. - } - - if (signal && timedOut) { - exitCode = null - } else { - exitCode = code ?? null - } - const preview = offloaded && outputFilePath ? this.readLastCharsFromFile(outputFilePath, COMMAND_PREVIEW_CHARS) : output - resolve({ + void settle({ + kind: 'completed', output: preview, - exitCode, + exitCode: signal && timedOut ? null : (code ?? null), timedOut, offloaded, outputFilePath: outputFilePath ?? undefined @@ -360,6 +456,21 @@ export class AgentBashHandler { }) } + private formatCompletedResult(result: CompletedShellProcessResult): string { + const responseLines: string[] = [] + if (result.output) { + responseLines.push(result.output.trimEnd()) + } + responseLines.push(`Exit Code: ${result.exitCode ?? 'null'}`) + if (result.timedOut) { + responseLines.push('Timed out') + } + if (result.offloaded && result.outputFilePath) { + responseLines.push(`Output offloaded: ${result.outputFilePath}`) + } + return responseLines.join('\n') + } + private createOutputFilePath( conversationId?: string, outputPrefix: string = 'exec' @@ -458,9 +569,14 @@ export class AgentBashHandler { const result = await backgroundExecSessionManager.start(conversationId, prepared.command, cwd, { timeout: timeout ?? COMMAND_DEFAULT_TIMEOUT_MS, - env: prepared.env + env: prepared.env, + outputPrefix: options.outputPrefix }) + if (options.stdin !== undefined) { + backgroundExecSessionManager.write(conversationId, result.sessionId, options.stdin, true) + } + return { output: { status: 'running', sessionId: result.sessionId }, rtkApplied: prepared.rtkApplied, diff --git a/src/main/presenter/agentPresenter/acp/agentToolManager.ts b/src/main/presenter/agentPresenter/acp/agentToolManager.ts index ee2b20704..082dbfb14 100644 --- a/src/main/presenter/agentPresenter/acp/agentToolManager.ts +++ b/src/main/presenter/agentPresenter/acp/agentToolManager.ts @@ -178,7 +178,7 @@ export class AgentToolManager { .min(100) .optional() .describe( - 'Maximum time in milliseconds to wait for command output in foreground mode (default 120s). Ignored when background is true.' + 'Foreground grace window in milliseconds before auto-backgrounding the command and returning a sessionId (defaults to PI_BASH_YIELD_MS or 10000). Ignored when background is true.' ) }), process: z.object({ @@ -487,7 +487,7 @@ export class AgentToolManager { function: { name: 'exec', description: - 'Execute a shell command in the workspace directory. For long-running commands (builds, tests, servers, installations), use background: true to run asynchronously and get a session ID. Then use the process tool to poll output, send input, or manage the session.', + 'Execute a shell command in the workspace directory. Use background: true when you know the command should detach immediately. Otherwise foreground exec waits briefly, and long-running commands may auto-background and return a session ID for use with the process tool.', parameters: zodToJsonSchema(schemas.exec) as { type: string properties: Record @@ -505,7 +505,7 @@ export class AgentToolManager { function: { name: 'process', description: - 'Manage background exec sessions created by exec with background: true. Use poll to check output and status, log to get full output with pagination, write to send input to stdin, kill to terminate, and remove to clean up completed sessions.', + 'Manage background exec sessions created by explicit background exec calls or by long-running foreground exec calls that yielded a sessionId. Use poll to check output and status, log to get full output with pagination, write to send input to stdin, kill to terminate, and remove to clean up completed sessions.', parameters: zodToJsonSchema(schemas.process) as { type: string properties: Record diff --git a/src/main/presenter/deepchatAgentPresenter/contextBuilder.ts b/src/main/presenter/deepchatAgentPresenter/contextBuilder.ts index c75b7a651..e5ffce3f0 100644 --- a/src/main/presenter/deepchatAgentPresenter/contextBuilder.ts +++ b/src/main/presenter/deepchatAgentPresenter/contextBuilder.ts @@ -17,6 +17,11 @@ export type ContextBuildOptions = { fallbackProtectedTurnCount?: number } +type TokenizedTurn = { + messages: ChatMessage[] + tokens: number +} + export type HistoryTurn = { records: ChatMessageRecord[] messages: ChatMessage[] @@ -314,10 +319,39 @@ export function buildHistoryTurns( }) } -function flattenTurns(turns: HistoryTurn[]): ChatMessage[] { +function flattenTurns(turns: TokenizedTurn[]): ChatMessage[] { return turns.flatMap((turn) => turn.messages) } +function buildChatMessageTurns(messages: ChatMessage[]): TokenizedTurn[] { + const turns: ChatMessage[][] = [] + let currentTurn: ChatMessage[] = [] + + for (const message of messages) { + if (message.role === 'user' && currentTurn.length > 0) { + turns.push(currentTurn) + currentTurn = [message] + continue + } + + if (currentTurn.length === 0) { + currentTurn = [message] + continue + } + + currentTurn.push(message) + } + + if (currentTurn.length > 0) { + turns.push(currentTurn) + } + + return turns.map((turnMessages) => ({ + messages: turnMessages, + tokens: estimateMessagesTokens(turnMessages) + })) +} + /** * Emergency fallback that drops full turns first and only then falls back to * message-level truncation to keep the prompt valid. @@ -355,7 +389,7 @@ export function truncateContext(history: ChatMessage[], availableTokens: number) } function selectTurnHistory( - turns: HistoryTurn[], + turns: TokenizedTurn[], availableTokens: number, fallbackProtectedTurnCount: number ): ChatMessage[] { @@ -427,6 +461,47 @@ export function buildContext( return messages } +export function fitMessagesToContextWindow( + messages: ChatMessage[], + contextLength: number, + reserveTokens: number, + protectedTailCount: number = 0 +): ChatMessage[] { + if (messages.length === 0) { + return [] + } + + const leadingSystemMessage = messages[0]?.role === 'system' ? messages[0] : null + const conversationMessages = leadingSystemMessage ? messages.slice(1) : [...messages] + const clampedProtectedTailCount = Math.max( + 0, + Math.min(protectedTailCount, conversationMessages.length) + ) + const protectedTail = + clampedProtectedTailCount > 0 ? conversationMessages.slice(-clampedProtectedTailCount) : [] + const historyPrefix = + clampedProtectedTailCount > 0 + ? conversationMessages.slice(0, -clampedProtectedTailCount) + : conversationMessages + + const systemTokens = leadingSystemMessage ? estimateMessagesTokens([leadingSystemMessage]) : 0 + const protectedTailTokens = protectedTail.length > 0 ? estimateMessagesTokens(protectedTail) : 0 + const availableHistoryTokens = contextLength - systemTokens - protectedTailTokens - reserveTokens + const selectedHistory = selectTurnHistory( + buildChatMessageTurns(historyPrefix), + availableHistoryTokens, + 0 + ) + + const result: ChatMessage[] = [] + if (leadingSystemMessage) { + result.push(leadingSystemMessage) + } + result.push(...selectedHistory) + result.push(...protectedTail) + return result +} + export function buildResumeContext( sessionId: string, assistantMessageId: string, diff --git a/src/main/presenter/deepchatAgentPresenter/index.ts b/src/main/presenter/deepchatAgentPresenter/index.ts index 903adc205..21e1fd233 100644 --- a/src/main/presenter/deepchatAgentPresenter/index.ts +++ b/src/main/presenter/deepchatAgentPresenter/index.ts @@ -4,6 +4,7 @@ import type { DeepChatSessionState, IAgentImplementation, MessageFile, + PendingSessionInputRecord, PermissionMode, SendMessageInput, SessionCompactionState, @@ -18,6 +19,12 @@ import type { IConfigPresenter, ILlmProviderPresenter, ModelConfig } from '@shar import type { MCPToolDefinition } from '@shared/types/core/mcp' import type { IToolPresenter } from '@shared/types/presenters/tool.presenter' import type { ReasoningPortrait } from '@shared/types/model-db' +import { + normalizeLegacyThinkingBudgetValue, + parseFiniteNumericValue, + toValidNonNegativeInteger, + validateGenerationNumericField +} from '@shared/utils/generationSettingsValidation' import { nanoid } from 'nanoid' import type { SQLitePresenter } from '../sqlitePresenter' import { eventBus, SendTarget } from '@/eventbus' @@ -27,10 +34,17 @@ import { buildSystemEnvPrompt } from '@/lib/agentRuntime/systemEnvPromptBuilder' import { presenter } from '@/presenter' -import { buildContext, buildResumeContext } from './contextBuilder' +import { + buildContext, + buildResumeContext, + createUserChatMessage, + fitMessagesToContextWindow +} from './contextBuilder' import { appendSummarySection, CompactionService, type CompactionIntent } from './compactionService' import { buildPersistableMessageTracePayload } from './messageTracePayload' import { DeepChatMessageStore } from './messageStore' +import { PendingInputCoordinator } from './pendingInputCoordinator' +import { DeepChatPendingInputStore } from './pendingInputStore' import { processStream } from './process' import { DeepChatSessionStore, type SessionSummaryState } from './sessionStore' import type { PendingToolInteraction, ProcessResult } from './types' @@ -79,11 +93,6 @@ type SystemPromptCacheEntry = { fingerprint: string } -const TEMPERATURE_MIN = 0 -const TEMPERATURE_MAX = 2 -const CONTEXT_LENGTH_MIN = 2048 -const MAX_TOKENS_MIN = 128 - const isReasoningEffort = (value: unknown): value is 'minimal' | 'low' | 'medium' | 'high' => value === 'minimal' || value === 'low' || value === 'medium' || value === 'high' @@ -97,6 +106,8 @@ export class DeepChatAgentPresenter implements IAgentImplementation { private readonly toolPresenter: IToolPresenter | null private readonly sessionStore: DeepChatSessionStore private readonly messageStore: DeepChatMessageStore + private readonly pendingInputStore: DeepChatPendingInputStore + private readonly pendingInputCoordinator: PendingInputCoordinator private readonly runtimeState: Map = new Map() private readonly sessionGenerationSettings: Map = new Map() private readonly abortControllers: Map = new Map() @@ -106,6 +117,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { private readonly sessionCompactionStates: Map = new Map() private readonly interactionLocks: Set = new Set() private readonly resumingMessages: Set = new Set() + private readonly drainingPendingQueues: Set = new Set() private readonly compactionService: CompactionService private readonly toolOutputGuard: ToolOutputGuard private readonly hooksBridge?: NewSessionHooksBridge @@ -123,6 +135,8 @@ export class DeepChatAgentPresenter implements IAgentImplementation { this.toolPresenter = toolPresenter ?? null this.sessionStore = new DeepChatSessionStore(sqlitePresenter) this.messageStore = new DeepChatMessageStore(sqlitePresenter) + this.pendingInputStore = new DeepChatPendingInputStore(sqlitePresenter) + this.pendingInputCoordinator = new PendingInputCoordinator(this.pendingInputStore) this.compactionService = new CompactionService( this.sessionStore, this.messageStore, @@ -136,6 +150,13 @@ export class DeepChatAgentPresenter implements IAgentImplementation { if (recovered > 0) { console.log(`DeepChatAgent: recovered ${recovered} pending messages to error status`) } + + const recoveredPendingInputs = this.pendingInputCoordinator.recoverClaimedInputsAfterRestart() + if (recoveredPendingInputs > 0) { + console.log( + `DeepChatAgent: recovered ${recoveredPendingInputs} sessions with claimed pending inputs` + ) + } } async initSession( @@ -190,6 +211,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { this.abortControllers.delete(sessionId) } + this.pendingInputCoordinator.deleteBySession(sessionId) this.messageStore.deleteBySession(sessionId) this.sessionStore.delete(sessionId) this.runtimeState.delete(sessionId) @@ -198,6 +220,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { this.sessionProjectDirs.delete(sessionId) this.systemPromptCache.delete(sessionId) this.sessionCompactionStates.delete(sessionId) + this.drainingPendingQueues.delete(sessionId) } async getSessionState(sessionId: string): Promise { @@ -226,10 +249,84 @@ export class DeepChatAgentPresenter implements IAgentImplementation { return { ...rebuilt } } + async listPendingInputs(sessionId: string): Promise { + return this.pendingInputCoordinator.listPendingInputs(sessionId) + } + + async queuePendingInput( + sessionId: string, + content: string | SendMessageInput + ): Promise { + const state = await this.getSessionState(sessionId) + if (!state) { + throw new Error(`Session ${sessionId} not found`) + } + + const record = this.pendingInputCoordinator.queuePendingInput(sessionId, content) + if (this.isAwaitingToolQuestionFollowUp(sessionId)) { + const claimedFollowUp = this.pendingInputCoordinator.claimQueuedInput(sessionId, record.id) + void this.processMessage(sessionId, claimedFollowUp.payload, { + projectDir: this.resolveProjectDir(sessionId), + pendingQueueItemId: claimedFollowUp.id + }) + return claimedFollowUp + } + + void this.drainPendingQueueIfPossible(sessionId, 'enqueue') + return record + } + + async updateQueuedInput( + sessionId: string, + itemId: string, + content: string | SendMessageInput + ): Promise { + await this.ensureSessionReadyForPendingInputMutation(sessionId) + return this.pendingInputCoordinator.updateQueuedInput(sessionId, itemId, content) + } + + async moveQueuedInput( + sessionId: string, + itemId: string, + toIndex: number + ): Promise { + await this.ensureSessionReadyForPendingInputMutation(sessionId) + return this.pendingInputCoordinator.moveQueuedInput(sessionId, itemId, toIndex) + } + + async convertPendingInputToSteer( + sessionId: string, + itemId: string + ): Promise { + await this.ensureSessionReadyForPendingInputMutation(sessionId) + return this.pendingInputCoordinator.convertPendingInputToSteer(sessionId, itemId) + } + + async deletePendingInput(sessionId: string, itemId: string): Promise { + await this.ensureSessionReadyForPendingInputMutation(sessionId) + this.pendingInputCoordinator.deletePendingInput(sessionId, itemId) + } + + async resumePendingQueue(sessionId: string): Promise { + const state = await this.getSessionState(sessionId) + if (!state) { + throw new Error(`Session ${sessionId} not found`) + } + if (this.isAwaitingToolQuestionFollowUp(sessionId)) { + return + } + + void this.drainPendingQueueIfPossible(sessionId, 'resume') + } + async processMessage( sessionId: string, content: string | SendMessageInput, - context?: { projectDir?: string | null; emitRefreshBeforeStream?: boolean } + context?: { + projectDir?: string | null + emitRefreshBeforeStream?: boolean + pendingQueueItemId?: string + } ): Promise { const state = this.runtimeState.get(sessionId) if (!state) throw new Error(`Session ${sessionId} not found`) @@ -245,6 +342,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { ) this.setSessionStatus(sessionId, 'generating') + let consumedPendingQueueItem = false try { const generationSettings = await this.getEffectiveSessionGenerationSettings(sessionId) @@ -340,6 +438,11 @@ export class DeepChatAgentPresenter implements IAgentImplementation { assistantOrderSeq ) + if (context?.pendingQueueItemId) { + this.pendingInputCoordinator.consumeQueuedInput(sessionId, context.pendingQueueItemId) + consumedPendingQueueItem = true + } + if (context?.emitRefreshBeforeStream) { this.emitMessageRefresh(sessionId, assistantMessageId || userMessageId) } @@ -353,8 +456,21 @@ export class DeepChatAgentPresenter implements IAgentImplementation { tools }) this.applyProcessResultStatus(sessionId, result) + if (result?.status === 'completed') { + void this.drainPendingQueueIfPossible(sessionId, 'completed') + } } catch (err) { console.error('[DeepChatAgent] processMessage error:', err) + if (context?.pendingQueueItemId && !consumedPendingQueueItem) { + try { + this.pendingInputCoordinator.releaseClaimedQueueInput( + sessionId, + context.pendingQueueItemId + ) + } catch (releaseError) { + console.warn('[DeepChatAgent] failed to release claimed queue input:', releaseError) + } + } const errorMessage = err instanceof Error ? err.message : String(err) this.dispatchHook('Stop', { sessionId, @@ -884,6 +1000,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { } await this.cancelGeneration(sessionId) + this.pendingInputCoordinator.deleteBySession(sessionId) this.messageStore.deleteBySession(sessionId) this.resetSummaryState(sessionId) this.setSessionStatus(sessionId, 'idle') @@ -900,6 +1017,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { if (this.hasPendingInteractions(sessionId)) { throw new Error('Please resolve pending tool interactions before retrying.') } + this.assertNoActivePendingInputs(sessionId) const target = await this.messageStore.getMessage(messageId) if (!target) { @@ -931,6 +1049,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { } async deleteMessage(sessionId: string, messageId: string): Promise { + this.assertNoActivePendingInputs(sessionId) const target = await this.messageStore.getMessage(messageId) if (!target) { throw new Error(`Message ${messageId} not found`) @@ -950,6 +1069,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { messageId: string, text: string ): Promise { + this.assertNoActivePendingInputs(sessionId) const target = await this.messageStore.getMessage(messageId) if (!target) { throw new Error(`Message ${messageId} not found`) @@ -1045,6 +1165,9 @@ export class DeepChatAgentPresenter implements IAgentImplementation { } const traceEnabled = this.configPresenter.getSetting('traceDebugEnabled') === true + const pendingInputCoordinator = this.pendingInputCoordinator + const injectSteerInputsIntoRequest = this.injectSteerInputsIntoRequest.bind(this) + const persistMessageTrace = this.persistMessageTrace.bind(this) if (traceEnabled) { const traceAwareConfig = modelConfig as ModelConfig & { requestTraceContext?: { @@ -1055,7 +1178,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { traceAwareConfig.requestTraceContext = { enabled: true, persist: async (payload: ProviderRequestTracePayload) => { - this.persistMessageTrace({ + persistMessageTrace({ sessionId, messageId, providerId: state.providerId, @@ -1070,6 +1193,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { const maxTokens = generationSettings.maxTokens const tools = providedTools ?? (await this.loadToolDefinitionsForSession(sessionId, projectDir)) + const supportsVision = this.supportsVision(state.providerId, state.modelId) const abortController = new AbortController() this.abortControllers.set(sessionId, abortController) @@ -1088,7 +1212,51 @@ export class DeepChatAgentPresenter implements IAgentImplementation { messages, tools, toolPresenter: this.toolPresenter, - coreStream: provider.coreStream.bind(provider), + coreStream: async function* ( + requestMessages, + requestModelId, + requestModelConfig, + requestTemperature, + requestMaxTokens, + requestTools + ) { + const claimedSteerBatch = pendingInputCoordinator.claimSteerBatchForNextLoop(sessionId) + const injectedMessages = injectSteerInputsIntoRequest( + requestMessages, + claimedSteerBatch, + supportsVision, + requestModelConfig.contextLength, + requestMaxTokens + ) + + let didConsumeSteerBatch = false + + try { + for await (const event of provider.coreStream( + injectedMessages, + requestModelId, + requestModelConfig, + requestTemperature, + requestMaxTokens, + requestTools + )) { + if (!didConsumeSteerBatch && claimedSteerBatch.length > 0) { + pendingInputCoordinator.consumeClaimedSteerBatch(sessionId) + didConsumeSteerBatch = true + } + yield event + } + + if (!didConsumeSteerBatch && claimedSteerBatch.length > 0) { + pendingInputCoordinator.consumeClaimedSteerBatch(sessionId) + } + } catch (error) { + if (!didConsumeSteerBatch && claimedSteerBatch.length > 0) { + pendingInputCoordinator.releaseClaimedInputs(sessionId) + } + throw error + } + }, providerId: state.providerId, modelId: state.modelId, modelConfig, @@ -1155,6 +1323,97 @@ export class DeepChatAgentPresenter implements IAgentImplementation { } } + private injectSteerInputsIntoRequest( + messages: ChatMessage[], + steerInputs: PendingSessionInputRecord[], + supportsVision: boolean, + contextLength: number, + reserveTokens: number + ): ChatMessage[] { + if (steerInputs.length === 0) { + return messages + } + + const steerMessages = steerInputs.map((input) => + createUserChatMessage(input.payload, supportsVision) + ) + const clonedMessages = [...messages] + const lastMessage = clonedMessages[clonedMessages.length - 1] + const trailingUserCount = lastMessage?.role === 'user' ? 1 : 0 + const injectedMessages = + trailingUserCount > 0 + ? [...clonedMessages.slice(0, -1), ...steerMessages, lastMessage] + : [...clonedMessages, ...steerMessages] + + return fitMessagesToContextWindow( + injectedMessages, + contextLength, + reserveTokens, + steerMessages.length + trailingUserCount + ) + } + + private async drainPendingQueueIfPossible( + sessionId: string, + reason: 'enqueue' | 'resume' | 'completed' + ): Promise { + if (this.drainingPendingQueues.has(sessionId)) { + return false + } + + const state = await this.getSessionState(sessionId) + if (!state || !this.canDrainPendingQueueFromStatus(state.status, reason)) { + return false + } + if (this.isAwaitingToolQuestionFollowUp(sessionId)) { + return false + } + if (this.hasPendingInteractions(sessionId)) { + return false + } + + const nextQueuedInput = this.pendingInputCoordinator.getNextQueuedInput(sessionId) + if (!nextQueuedInput) { + return false + } + + this.drainingPendingQueues.add(sessionId) + try { + const claimedInput = this.pendingInputCoordinator.claimQueuedInput( + sessionId, + nextQueuedInput.id + ) + await this.processMessage(sessionId, claimedInput.payload, { + projectDir: this.resolveProjectDir(sessionId), + pendingQueueItemId: claimedInput.id + }) + return true + } catch (error) { + console.error('[DeepChatAgent] drainPendingQueueIfPossible error:', error) + return false + } finally { + this.drainingPendingQueues.delete(sessionId) + if ( + this.pendingInputCoordinator.getNextQueuedInput(sessionId) && + (await this.getSessionState(sessionId))?.status === 'idle' && + !this.hasPendingInteractions(sessionId) + ) { + void this.drainPendingQueueIfPossible(sessionId, 'completed') + } + } + } + + private canDrainPendingQueueFromStatus( + status: DeepChatSessionState['status'], + reason: 'enqueue' | 'resume' | 'completed' + ): boolean { + if (status === 'idle') { + return true + } + + return (reason === 'enqueue' || reason === 'resume') && status === 'error' + } + private applyProcessResultStatus( sessionId: string, result: ProcessResult | null | undefined @@ -1278,6 +1537,9 @@ export class DeepChatAgentPresenter implements IAgentImplementation { initialBlocks }) this.applyProcessResultStatus(sessionId, result) + if (result?.status === 'completed') { + void this.drainPendingQueueIfPossible(sessionId, 'completed') + } return true } catch (error) { console.error('[DeepChatAgent] resumeAssistantMessage error:', error) @@ -1633,7 +1895,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { patch.maxTokens = sessionRow.max_tokens } if (sessionRow.thinking_budget !== null) { - patch.thinkingBudget = sessionRow.thinking_budget + patch.thinkingBudget = normalizeLegacyThinkingBudgetValue(sessionRow.thinking_budget) } if (sessionRow.reasoning_effort !== null) { patch.reasoningEffort = sessionRow.reasoning_effort @@ -1651,45 +1913,29 @@ export class DeepChatAgentPresenter implements IAgentImplementation { ): Promise { const modelConfig = this.configPresenter.getModelConfig(modelId, providerId) const defaultSystemPrompt = await this.configPresenter.getDefaultSystemPrompt() - const contextLengthLimit = this.getContextLengthLimit(modelConfig) - const maxTokensLimit = this.getMaxTokensLimit(modelConfig) + const contextLengthDefault = toValidNonNegativeInteger(modelConfig.contextLength) ?? 32000 + const maxTokensDefault = + toValidNonNegativeInteger(modelConfig.maxTokens) ?? Math.min(4096, contextLengthDefault) const defaults: SessionGenerationSettings = { systemPrompt: defaultSystemPrompt ?? '', - temperature: this.clampNumber( - modelConfig.temperature ?? 0.7, - TEMPERATURE_MIN, - TEMPERATURE_MAX - ), - contextLength: this.clampInteger( - modelConfig.contextLength ?? contextLengthLimit, - CONTEXT_LENGTH_MIN, - contextLengthLimit - ), - maxTokens: this.clampInteger( - modelConfig.maxTokens ?? Math.min(4096, maxTokensLimit), - MAX_TOKENS_MIN, - maxTokensLimit - ) + temperature: parseFiniteNumericValue(modelConfig.temperature) ?? 0.7, + contextLength: contextLengthDefault, + maxTokens: + maxTokensDefault <= contextLengthDefault + ? maxTokensDefault + : Math.min(4096, contextLengthDefault) } - defaults.maxTokens = Math.min(defaults.maxTokens, defaults.contextLength) - const supportsReasoning = this.configPresenter.supportsReasoningCapability?.(providerId, modelId) === true if (supportsReasoning) { - const budgetRange = this.configPresenter.getThinkingBudgetRange?.(providerId, modelId) ?? {} - const defaultBudget = this.toFiniteNumber( - modelConfig.thinkingBudget ?? budgetRange.default ?? undefined + const defaultBudget = normalizeLegacyThinkingBudgetValue( + modelConfig.thinkingBudget ?? + this.configPresenter.getThinkingBudgetRange?.(providerId, modelId)?.default ) if (defaultBudget !== undefined) { - defaults.thinkingBudget = this.normalizeThinkingBudget( - providerId, - modelId, - Math.round(defaultBudget), - budgetRange.min, - budgetRange.max - ) + defaults.thinkingBudget = defaultBudget } } @@ -1728,10 +1974,6 @@ export class DeepChatAgentPresenter implements IAgentImplementation { const base = baseSettings ? { ...baseSettings } : await this.buildDefaultGenerationSettings(providerId, modelId) - const modelConfig = this.configPresenter.getModelConfig(modelId, providerId) - const contextLengthLimit = this.getContextLengthLimit(modelConfig) - const maxTokensLimit = this.getMaxTokensLimit(modelConfig) - const next: SessionGenerationSettings = { ...base } if (Object.prototype.hasOwnProperty.call(patch, 'systemPrompt')) { @@ -1740,67 +1982,58 @@ export class DeepChatAgentPresenter implements IAgentImplementation { } if (Object.prototype.hasOwnProperty.call(patch, 'temperature')) { - const numeric = this.toFiniteNumber(patch.temperature) - next.temperature = this.clampNumber( - numeric ?? base.temperature, - TEMPERATURE_MIN, - TEMPERATURE_MAX - ) + const numeric = parseFiniteNumericValue(patch.temperature) + if (numeric !== undefined) { + next.temperature = numeric + } } + const parsedContextLength = parseFiniteNumericValue(patch.contextLength) + const parsedMaxTokens = parseFiniteNumericValue(patch.maxTokens) + const nextContextReference = + Object.prototype.hasOwnProperty.call(patch, 'contextLength') && + toValidNonNegativeInteger(parsedContextLength) !== undefined + ? toValidNonNegativeInteger(parsedContextLength) + : next.contextLength + const nextMaxTokensReference = + Object.prototype.hasOwnProperty.call(patch, 'maxTokens') && + toValidNonNegativeInteger(parsedMaxTokens) !== undefined + ? toValidNonNegativeInteger(parsedMaxTokens) + : next.maxTokens + if (Object.prototype.hasOwnProperty.call(patch, 'contextLength')) { - const numeric = this.toFiniteNumber(patch.contextLength) - next.contextLength = this.clampInteger( - Math.round(numeric ?? base.contextLength), - CONTEXT_LENGTH_MIN, - contextLengthLimit - ) - } else { - next.contextLength = this.clampInteger( - next.contextLength, - CONTEXT_LENGTH_MIN, - contextLengthLimit - ) + const error = validateGenerationNumericField('contextLength', patch.contextLength, { + maxTokens: nextMaxTokensReference + }) + const numeric = toValidNonNegativeInteger(parsedContextLength) + if (!error && numeric !== undefined) { + next.contextLength = numeric + } } if (Object.prototype.hasOwnProperty.call(patch, 'maxTokens')) { - const numeric = this.toFiniteNumber(patch.maxTokens) - next.maxTokens = this.clampInteger( - Math.round(numeric ?? base.maxTokens), - MAX_TOKENS_MIN, - maxTokensLimit - ) - } else { - next.maxTokens = this.clampInteger(next.maxTokens, MAX_TOKENS_MIN, maxTokensLimit) + const error = validateGenerationNumericField('maxTokens', patch.maxTokens, { + contextLength: nextContextReference + }) + const numeric = toValidNonNegativeInteger(parsedMaxTokens) + if (!error && numeric !== undefined) { + next.maxTokens = numeric + } } - next.maxTokens = Math.min(next.maxTokens, next.contextLength) const supportsReasoning = this.configPresenter.supportsReasoningCapability?.(providerId, modelId) === true if (supportsReasoning) { - const budgetRange = this.configPresenter.getThinkingBudgetRange?.(providerId, modelId) ?? {} if (Object.prototype.hasOwnProperty.call(patch, 'thinkingBudget')) { const raw = patch.thinkingBudget - const numeric = this.toFiniteNumber(raw) - if (numeric === undefined) { + if (raw === undefined) { delete next.thinkingBudget - } else { - next.thinkingBudget = this.normalizeThinkingBudget( - providerId, - modelId, - Math.round(numeric), - budgetRange.min, - budgetRange.max - ) + } else if (!validateGenerationNumericField('thinkingBudget', raw)) { + const numeric = toValidNonNegativeInteger(raw) + if (numeric !== undefined) { + next.thinkingBudget = numeric + } } - } else if (next.thinkingBudget !== undefined) { - next.thinkingBudget = this.normalizeThinkingBudget( - providerId, - modelId, - Math.round(next.thinkingBudget), - budgetRange.min, - budgetRange.max - ) } } else { delete next.thinkingBudget @@ -1911,70 +2144,18 @@ export class DeepChatAgentPresenter implements IAgentImplementation { return this.configPresenter.getReasoningPortrait?.(providerId, modelId) ?? null } - private normalizeThinkingBudget( - providerId: string, - modelId: string, - value: number, - min?: number, - max?: number - ): number { - const roundedValue = Math.round(value) - const budget = this.getReasoningPortrait(providerId, modelId)?.budget - const sentinelValues = new Set() - - if (typeof budget?.default === 'number') sentinelValues.add(Math.round(budget.default)) - if (typeof budget?.auto === 'number') sentinelValues.add(Math.round(budget.auto)) - if (typeof budget?.off === 'number') sentinelValues.add(Math.round(budget.off)) - - if (sentinelValues.has(roundedValue)) { - return roundedValue - } - - return this.clampNumberWithOptionalRange(roundedValue, min, max) - } - - private toFiniteNumber(value: unknown): number | undefined { - if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) { - return undefined - } - return value - } - - private clampNumber(value: number, min: number, max: number): number { - if (value < min) return min - if (value > max) return max - return value - } - - private clampInteger(value: number, min: number, max: number): number { - return Math.round(this.clampNumber(value, min, max)) - } - - private clampNumberWithOptionalRange(value: number, min?: number, max?: number): number { - let next = value - if (typeof min === 'number' && Number.isFinite(min)) { - next = Math.max(next, Math.round(min)) - } - if (typeof max === 'number' && Number.isFinite(max)) { - next = Math.min(next, Math.round(max)) - } - return next - } - - private getContextLengthLimit(modelConfig: ModelConfig): number { - const configured = this.toFiniteNumber(modelConfig.contextLength) - if (configured === undefined) { - return 32000 + private async ensureSessionReadyForPendingInputMutation(sessionId: string): Promise { + const state = await this.getSessionState(sessionId) + if (!state) { + throw new Error(`Session ${sessionId} not found`) } - return Math.max(CONTEXT_LENGTH_MIN, Math.round(configured)) } - private getMaxTokensLimit(modelConfig: ModelConfig): number { - const configured = this.toFiniteNumber(modelConfig.maxTokens) - if (configured === undefined) { - return 4096 + private assertNoActivePendingInputs(sessionId: string): void { + if (!this.pendingInputCoordinator.hasActiveInputs(sessionId)) { + return } - return Math.max(MAX_TOKENS_MIN, Math.round(configured)) + throw new Error('Please clear the waiting lane before mutating chat history.') } private parseAssistantBlocks(rawContent: string): AssistantMessageBlock[] { @@ -2520,6 +2701,33 @@ export class DeepChatAgentPresenter implements IAgentImplementation { return false } + private isAwaitingToolQuestionFollowUp(sessionId: string): boolean { + const messages = this.messageStore.getMessages(sessionId) + let latestUserOrderSeq = 0 + + for (const message of messages) { + if (message.role === 'user') { + latestUserOrderSeq = Math.max(latestUserOrderSeq, message.orderSeq) + } + } + + return messages.some((message) => { + if (message.role !== 'assistant' || message.orderSeq <= latestUserOrderSeq) { + return false + } + + return this.parseAssistantBlocks(message.content).some( + (block) => + block.type === 'action' && + block.action_type === 'question_request' && + block.status === 'success' && + block.extra?.needsUserAction === false && + block.extra?.questionResolution === 'replied' && + typeof block.extra?.answerText !== 'string' + ) + }) + } + private async resolveCompactionStateForResumeTurn(params: { sessionId: string messageId: string diff --git a/src/main/presenter/deepchatAgentPresenter/pendingInputCoordinator.ts b/src/main/presenter/deepchatAgentPresenter/pendingInputCoordinator.ts new file mode 100644 index 000000000..29ec7a56e --- /dev/null +++ b/src/main/presenter/deepchatAgentPresenter/pendingInputCoordinator.ts @@ -0,0 +1,159 @@ +import { eventBus, SendTarget } from '@/eventbus' +import { SESSION_EVENTS } from '@/events' +import type { PendingSessionInputRecord, SendMessageInput } from '@shared/types/agent-interface' +import { DeepChatPendingInputStore } from './pendingInputStore' + +const MAX_ACTIVE_PENDING_INPUTS = 5 + +function normalizeInput(input: string | SendMessageInput): SendMessageInput { + if (typeof input === 'string') { + return { text: input, files: [] } + } + + return { + text: typeof input?.text === 'string' ? input.text : '', + files: Array.isArray(input?.files) ? input.files.filter(Boolean) : [] + } +} + +export class PendingInputCoordinator { + private readonly store: DeepChatPendingInputStore + + constructor(store: DeepChatPendingInputStore) { + this.store = store + } + + listPendingInputs(sessionId: string): PendingSessionInputRecord[] { + return this.store.listPendingInputs(sessionId) + } + + queuePendingInput( + sessionId: string, + input: string | SendMessageInput + ): PendingSessionInputRecord { + this.ensureWithinLimit(sessionId) + const record = this.store.createQueueInput(sessionId, normalizeInput(input)) + this.emitUpdated(sessionId) + return record + } + + updateQueuedInput( + sessionId: string, + itemId: string, + input: string | SendMessageInput + ): PendingSessionInputRecord { + this.assertQueueInput(sessionId, itemId) + const record = this.store.updateQueueInput(itemId, normalizeInput(input)) + this.emitUpdated(sessionId) + return record + } + + moveQueuedInput(sessionId: string, itemId: string, toIndex: number): PendingSessionInputRecord[] { + this.assertQueueInput(sessionId, itemId) + const records = this.store.moveQueueInput(sessionId, itemId, toIndex) + this.emitUpdated(sessionId) + return records + } + + convertPendingInputToSteer(sessionId: string, itemId: string): PendingSessionInputRecord { + this.assertQueueInput(sessionId, itemId) + const record = this.store.convertQueueInputToSteer(itemId) + this.emitUpdated(sessionId) + return record + } + + deletePendingInput(sessionId: string, itemId: string): void { + this.assertQueueInput(sessionId, itemId) + this.store.deleteInput(itemId) + this.emitUpdated(sessionId) + } + + getNextQueuedInput(sessionId: string): PendingSessionInputRecord | null { + return this.store.getNextPendingQueueInput(sessionId) + } + + claimQueuedInput(sessionId: string, itemId: string): PendingSessionInputRecord { + this.assertQueueInput(sessionId, itemId) + const record = this.store.claimQueueInput(itemId) + this.emitUpdated(sessionId) + return record + } + + releaseClaimedQueueInput(sessionId: string, itemId: string): PendingSessionInputRecord { + const record = this.store.releaseClaimedQueueInput(itemId) + this.emitUpdated(sessionId) + return record + } + + consumeQueuedInput(sessionId: string, itemId: string): void { + this.store.consumeQueueInput(itemId) + this.emitUpdated(sessionId) + } + + claimSteerBatchForNextLoop(sessionId: string): PendingSessionInputRecord[] { + const claimed = this.store.claimSteerBatch(sessionId) + if (claimed.length > 0) { + this.emitUpdated(sessionId) + } + return claimed + } + + releaseClaimedInputs(sessionId: string): number { + const released = this.store.releaseClaimedInputs(sessionId) + if (released > 0) { + this.emitUpdated(sessionId) + } + return released + } + + consumeClaimedSteerBatch(sessionId: string): number { + const consumed = this.store.consumeClaimedSteerBatch(sessionId) + if (consumed > 0) { + this.emitUpdated(sessionId) + } + return consumed + } + + recoverClaimedInputsAfterRestart(): number { + const sessionIds = this.store.recoverClaimedInputs() + for (const sessionId of sessionIds) { + this.emitUpdated(sessionId) + } + return sessionIds.length + } + + hasActiveInputs(sessionId: string): boolean { + return this.store.countActive(sessionId) > 0 + } + + isAtCapacity(sessionId: string): boolean { + return this.store.countActive(sessionId) >= MAX_ACTIVE_PENDING_INPUTS + } + + deleteBySession(sessionId: string): void { + this.store.deleteBySession(sessionId) + this.emitUpdated(sessionId) + } + + private ensureWithinLimit(sessionId: string): void { + if (this.store.countActive(sessionId) >= MAX_ACTIVE_PENDING_INPUTS) { + throw new Error('Pending input limit reached for this session.') + } + } + + private assertQueueInput(sessionId: string, itemId: string): void { + const record = this.store.listPendingInputs(sessionId).find((item) => item.id === itemId) + if (!record) { + throw new Error(`Pending input not found: ${itemId}`) + } + if (record.mode !== 'queue') { + throw new Error('Steer inputs are locked and cannot be modified.') + } + } + + private emitUpdated(sessionId: string): void { + eventBus.sendToRenderer(SESSION_EVENTS.PENDING_INPUTS_UPDATED, SendTarget.ALL_WINDOWS, { + sessionId + }) + } +} diff --git a/src/main/presenter/deepchatAgentPresenter/pendingInputStore.ts b/src/main/presenter/deepchatAgentPresenter/pendingInputStore.ts new file mode 100644 index 000000000..b4d2d114d --- /dev/null +++ b/src/main/presenter/deepchatAgentPresenter/pendingInputStore.ts @@ -0,0 +1,286 @@ +import { nanoid } from 'nanoid' +import type { + PendingSessionInputRecord, + PendingSessionInputState, + SendMessageInput +} from '@shared/types/agent-interface' +import type { SQLitePresenter } from '../sqlitePresenter' +import type { DeepChatPendingInputRow } from '../sqlitePresenter/tables/deepchatPendingInputs' + +function normalizeInput(input: string | SendMessageInput): SendMessageInput { + if (typeof input === 'string') { + return { text: input, files: [] } + } + + return { + text: typeof input?.text === 'string' ? input.text : '', + files: Array.isArray(input?.files) ? input.files.filter(Boolean) : [] + } +} + +export class DeepChatPendingInputStore { + private readonly sqlitePresenter: SQLitePresenter + + constructor(sqlitePresenter: SQLitePresenter) { + this.sqlitePresenter = sqlitePresenter + } + + listPendingInputs(sessionId: string): PendingSessionInputRecord[] { + return this.sqlitePresenter.deepchatPendingInputsTable + .listActiveBySession(sessionId) + .filter((row) => !(row.mode === 'queue' && row.state === 'claimed')) + .map((row) => this.toRecord(row)) + } + + countActive(sessionId: string): number { + return this.sqlitePresenter.deepchatPendingInputsTable.countActiveBySession(sessionId) + } + + createQueueInput(sessionId: string, input: string | SendMessageInput): PendingSessionInputRecord { + const normalized = normalizeInput(input) + const id = nanoid() + const nextQueueOrder = this.getNextQueueOrder(sessionId) + this.sqlitePresenter.deepchatPendingInputsTable.insert({ + id, + sessionId, + mode: 'queue', + state: 'pending', + payloadJson: JSON.stringify(normalized), + queueOrder: nextQueueOrder + }) + const row = this.sqlitePresenter.deepchatPendingInputsTable.get(id) + if (!row) { + throw new Error(`Failed to create pending input ${id}`) + } + return this.toRecord(row) + } + + updateQueueInput(itemId: string, input: string | SendMessageInput): PendingSessionInputRecord { + const row = this.requireRow(itemId) + this.sqlitePresenter.deepchatPendingInputsTable.update(itemId, { + payload_json: JSON.stringify(normalizeInput(input)) + }) + return this.toRecord(this.requireRow(itemId, row.session_id)) + } + + moveQueueInput(sessionId: string, itemId: string, toIndex: number): PendingSessionInputRecord[] { + const queueRows = this.getPendingQueueRows(sessionId) + const fromIndex = queueRows.findIndex((row) => row.id === itemId) + if (fromIndex === -1) { + throw new Error(`Pending queue item not found: ${itemId}`) + } + + const clampedIndex = Math.max(0, Math.min(toIndex, queueRows.length - 1)) + if (fromIndex === clampedIndex) { + return this.listPendingInputs(sessionId) + } + + const [moved] = queueRows.splice(fromIndex, 1) + queueRows.splice(clampedIndex, 0, moved) + this.resequenceQueueRows(queueRows) + + return this.listPendingInputs(sessionId) + } + + convertQueueInputToSteer(itemId: string): PendingSessionInputRecord { + const row = this.requireRow(itemId) + this.sqlitePresenter.deepchatPendingInputsTable.update(itemId, { + mode: 'steer', + queue_order: null + }) + this.resequenceQueue(row.session_id) + return this.toRecord(this.requireRow(itemId, row.session_id)) + } + + deleteInput(itemId: string): void { + const row = this.requireRow(itemId) + this.sqlitePresenter.deepchatPendingInputsTable.delete(itemId) + if (row.mode === 'queue') { + this.resequenceQueue(row.session_id) + } + } + + getNextPendingQueueInput(sessionId: string): PendingSessionInputRecord | null { + const row = this.getPendingQueueRows(sessionId)[0] + return row ? this.toRecord(row) : null + } + + claimQueueInput(itemId: string): PendingSessionInputRecord { + const row = this.requireRow(itemId) + if (row.mode !== 'queue') { + throw new Error(`Pending input ${itemId} is not a queue item.`) + } + if (row.state !== 'pending') { + throw new Error(`Pending queue item ${itemId} is not claimable.`) + } + + this.sqlitePresenter.deepchatPendingInputsTable.update(itemId, { + state: 'claimed', + claimed_at: Date.now() + }) + return this.toRecord(this.requireRow(itemId, row.session_id)) + } + + releaseClaimedQueueInput(itemId: string): PendingSessionInputRecord { + const row = this.requireRow(itemId) + if (row.mode !== 'queue') { + throw new Error(`Pending input ${itemId} is not a queue item.`) + } + if (row.state !== 'claimed') { + return this.toRecord(row) + } + + this.sqlitePresenter.deepchatPendingInputsTable.update(itemId, { + state: 'pending', + claimed_at: null + }) + return this.toRecord(this.requireRow(itemId, row.session_id)) + } + + consumeQueueInput(itemId: string): void { + this.deleteInput(itemId) + } + + claimSteerBatch(sessionId: string): PendingSessionInputRecord[] { + const now = Date.now() + const steerRows = this.getSteerRows(sessionId).filter((row) => row.state === 'pending') + if (steerRows.length === 0) { + return [] + } + + for (const row of steerRows) { + this.sqlitePresenter.deepchatPendingInputsTable.update(row.id, { + state: 'claimed', + claimed_at: now + }) + } + + return this.getSteerRows(sessionId) + .filter((row) => row.state === 'claimed') + .map((row) => this.toRecord(row)) + } + + releaseClaimedInputs(sessionId: string): number { + const claimedRows = this.sqlitePresenter.deepchatPendingInputsTable + .listActiveBySession(sessionId) + .filter((row) => row.state === 'claimed') + for (const row of claimedRows) { + this.sqlitePresenter.deepchatPendingInputsTable.update(row.id, { + state: 'pending', + claimed_at: null + }) + } + return claimedRows.length + } + + recoverClaimedInputs(): string[] { + const rows = this.listClaimedRows() + const recoveredSessionIds = new Set() + + for (const row of rows) { + if (!this.sqlitePresenter.deepchatSessionsTable.get(row.session_id)) { + continue + } + + this.sqlitePresenter.deepchatPendingInputsTable.update(row.id, { + state: 'pending', + claimed_at: null + }) + recoveredSessionIds.add(row.session_id) + } + + return Array.from(recoveredSessionIds) + } + + consumeClaimedSteerBatch(sessionId: string): number { + const claimedSteerRows = this.getSteerRows(sessionId).filter((row) => row.state === 'claimed') + if (claimedSteerRows.length === 0) { + return 0 + } + + const now = Date.now() + for (const row of claimedSteerRows) { + this.sqlitePresenter.deepchatPendingInputsTable.update(row.id, { + state: 'consumed', + consumed_at: now + }) + } + return claimedSteerRows.length + } + + deleteBySession(sessionId: string): void { + this.sqlitePresenter.deepchatPendingInputsTable.deleteBySession(sessionId) + } + + private getNextQueueOrder(sessionId: string): number { + const queueRows = this.getPendingQueueRows(sessionId) + if (queueRows.length === 0) { + return 1 + } + return (queueRows[queueRows.length - 1].queue_order ?? 0) + 1 + } + + private getPendingQueueRows(sessionId: string): DeepChatPendingInputRow[] { + return this.sqlitePresenter.deepchatPendingInputsTable + .listActiveBySession(sessionId) + .filter((row) => row.mode === 'queue' && row.state === 'pending') + .sort((left, right) => (left.queue_order ?? 0) - (right.queue_order ?? 0)) + } + + private getSteerRows(sessionId: string): DeepChatPendingInputRow[] { + return this.sqlitePresenter.deepchatPendingInputsTable + .listActiveBySession(sessionId) + .filter((row) => row.mode === 'steer') + .sort((left, right) => left.created_at - right.created_at) + } + + private listClaimedRows(): DeepChatPendingInputRow[] { + return this.sqlitePresenter.deepchatPendingInputsTable.listClaimed() + } + + private resequenceQueue(sessionId: string): void { + this.resequenceQueueRows(this.getPendingQueueRows(sessionId)) + } + + private resequenceQueueRows(rows: DeepChatPendingInputRow[]): void { + rows.forEach((row, index) => { + this.sqlitePresenter.deepchatPendingInputsTable.update(row.id, { + queue_order: index + 1 + }) + }) + } + + private requireRow(itemId: string, expectedSessionId?: string): DeepChatPendingInputRow { + const row = this.sqlitePresenter.deepchatPendingInputsTable.get(itemId) + if (!row) { + throw new Error(`Pending input not found: ${itemId}`) + } + if (expectedSessionId && row.session_id !== expectedSessionId) { + throw new Error(`Pending input ${itemId} does not belong to session ${expectedSessionId}`) + } + return row + } + + private toRecord(row: DeepChatPendingInputRow): PendingSessionInputRecord { + return { + id: row.id, + sessionId: row.session_id, + mode: row.mode, + state: row.state as PendingSessionInputState, + payload: this.parsePayload(row.payload_json), + queueOrder: row.queue_order, + claimedAt: row.claimed_at, + consumedAt: row.consumed_at, + createdAt: row.created_at, + updatedAt: row.updated_at + } + } + + private parsePayload(raw: string): SendMessageInput { + try { + return normalizeInput(JSON.parse(raw) as SendMessageInput) + } catch { + return normalizeInput(raw) + } + } +} diff --git a/src/main/presenter/newAgentPresenter/index.ts b/src/main/presenter/newAgentPresenter/index.ts index 8afefb149..cecdadf74 100644 --- a/src/main/presenter/newAgentPresenter/index.ts +++ b/src/main/presenter/newAgentPresenter/index.ts @@ -181,11 +181,19 @@ export class NewAgentPresenter { modelId: state?.modelId ?? modelId } - // Process the first message (non-blocking) after returning session ID - console.log(`[NewAgentPresenter] firing processMessage (non-blocking)`) - agent.processMessage(sessionId, normalizedInput, { projectDir }).catch((err) => { - console.error('[NewAgentPresenter] processMessage failed:', err) - }) + // Queue the first message (non-blocking) after returning session ID + if (normalizedInput.text.trim() || (normalizedInput.files?.length ?? 0) > 0) { + console.log(`[NewAgentPresenter] firing queuePendingInput (non-blocking)`) + if (agent.queuePendingInput) { + agent.queuePendingInput(sessionId, normalizedInput).catch((err) => { + console.error('[NewAgentPresenter] queuePendingInput failed:', err) + }) + } else { + agent.processMessage(sessionId, normalizedInput, { projectDir }).catch((err) => { + console.error('[NewAgentPresenter] processMessage failed:', err) + }) + } + } void this.generateSessionTitle(sessionId, title, providerId, modelId) return sessionResult @@ -272,11 +280,118 @@ export class NewAgentPresenter { } } this.assertAcpSessionHasWorkdir(providerId, session.projectDir ?? null) + if (agent.queuePendingInput) { + await agent.queuePendingInput(sessionId, normalizedInput) + return + } await agent.processMessage(sessionId, normalizedInput, { projectDir: session.projectDir ?? null }) } + async listPendingInputs(sessionId: string) { + const session = this.sessionManager.get(sessionId) + if (!session) { + throw new Error(`Session not found: ${sessionId}`) + } + const agent = await this.resolveAgentImplementation(session.agentId) + if (!agent.listPendingInputs) { + return [] + } + return await agent.listPendingInputs(sessionId) + } + + async queuePendingInput(sessionId: string, content: string | SendMessageInput) { + const session = this.sessionManager.get(sessionId) + if (!session) { + throw new Error(`Session not found: ${sessionId}`) + } + + let currentSession = session + const normalizedInput = this.normalizeSendMessageInput(content) + if (currentSession.isDraft) { + const title = normalizedInput.text.trim().slice(0, 50) || 'New Chat' + this.sessionManager.update(sessionId, { isDraft: false, title }) + this.emitSessionListUpdated() + currentSession = this.sessionManager.get(sessionId) ?? currentSession + } + + const agent = await this.resolveAgentImplementation(currentSession.agentId) + if (!agent.queuePendingInput) { + throw new Error(`Agent ${currentSession.agentId} does not support pending inputs.`) + } + + let providerId = (await agent.getSessionState(sessionId))?.providerId ?? '' + if (!providerId) { + const acpAgents = await this.configPresenter.getAcpAgents() + if (acpAgents.some((item) => item.id === currentSession.agentId)) { + providerId = 'acp' + } + } + this.assertAcpSessionHasWorkdir(providerId, currentSession.projectDir ?? null) + return await agent.queuePendingInput(sessionId, normalizedInput) + } + + async updateQueuedInput(sessionId: string, itemId: string, content: string | SendMessageInput) { + const session = this.sessionManager.get(sessionId) + if (!session) { + throw new Error(`Session not found: ${sessionId}`) + } + const agent = await this.resolveAgentImplementation(session.agentId) + if (!agent.updateQueuedInput) { + throw new Error(`Agent ${session.agentId} does not support pending input edits.`) + } + return await agent.updateQueuedInput(sessionId, itemId, this.normalizeSendMessageInput(content)) + } + + async moveQueuedInput(sessionId: string, itemId: string, toIndex: number) { + const session = this.sessionManager.get(sessionId) + if (!session) { + throw new Error(`Session not found: ${sessionId}`) + } + const agent = await this.resolveAgentImplementation(session.agentId) + if (!agent.moveQueuedInput) { + throw new Error(`Agent ${session.agentId} does not support pending input sorting.`) + } + return await agent.moveQueuedInput(sessionId, itemId, toIndex) + } + + async convertPendingInputToSteer(sessionId: string, itemId: string) { + const session = this.sessionManager.get(sessionId) + if (!session) { + throw new Error(`Session not found: ${sessionId}`) + } + const agent = await this.resolveAgentImplementation(session.agentId) + if (!agent.convertPendingInputToSteer) { + throw new Error(`Agent ${session.agentId} does not support steer conversion.`) + } + return await agent.convertPendingInputToSteer(sessionId, itemId) + } + + async deletePendingInput(sessionId: string, itemId: string): Promise { + const session = this.sessionManager.get(sessionId) + if (!session) { + throw new Error(`Session not found: ${sessionId}`) + } + const agent = await this.resolveAgentImplementation(session.agentId) + if (!agent.deletePendingInput) { + throw new Error(`Agent ${session.agentId} does not support pending input deletion.`) + } + await agent.deletePendingInput(sessionId, itemId) + } + + async resumePendingQueue(sessionId: string): Promise { + const session = this.sessionManager.get(sessionId) + if (!session) { + throw new Error(`Session not found: ${sessionId}`) + } + const agent = await this.resolveAgentImplementation(session.agentId) + if (!agent.resumePendingQueue) { + throw new Error(`Agent ${session.agentId} does not support pending queue resume.`) + } + await agent.resumePendingQueue(sessionId) + } + async retryMessage(sessionId: string, messageId: string): Promise { const session = this.sessionManager.get(sessionId) if (!session) { diff --git a/src/main/presenter/sqlitePresenter/index.ts b/src/main/presenter/sqlitePresenter/index.ts index 2e36219d5..3591c8b14 100644 --- a/src/main/presenter/sqlitePresenter/index.ts +++ b/src/main/presenter/sqlitePresenter/index.ts @@ -20,6 +20,7 @@ import { DeepChatSessionsTable } from './tables/deepchatSessions' import { DeepChatMessagesTable } from './tables/deepchatMessages' import { DeepChatMessageTracesTable } from './tables/deepchatMessageTraces' import { DeepChatMessageSearchResultsTable } from './tables/deepchatMessageSearchResults' +import { DeepChatPendingInputsTable } from './tables/deepchatPendingInputs' import { DeepChatUsageStatsTable } from './tables/deepchatUsageStats' import { LegacyImportStatusTable } from './tables/legacyImportStatus' @@ -44,6 +45,7 @@ export class SQLitePresenter implements ISQLitePresenter { public deepchatMessagesTable!: DeepChatMessagesTable public deepchatMessageTracesTable!: DeepChatMessageTracesTable public deepchatMessageSearchResultsTable!: DeepChatMessageSearchResultsTable + public deepchatPendingInputsTable!: DeepChatPendingInputsTable public deepchatUsageStatsTable!: DeepChatUsageStatsTable public legacyImportStatusTable!: LegacyImportStatusTable private currentVersion: number = 0 @@ -163,6 +165,7 @@ export class SQLitePresenter implements ISQLitePresenter { this.deepchatMessagesTable = new DeepChatMessagesTable(this.db) this.deepchatMessageTracesTable = new DeepChatMessageTracesTable(this.db) this.deepchatMessageSearchResultsTable = new DeepChatMessageSearchResultsTable(this.db) + this.deepchatPendingInputsTable = new DeepChatPendingInputsTable(this.db) this.deepchatUsageStatsTable = new DeepChatUsageStatsTable(this.db) this.legacyImportStatusTable = new LegacyImportStatusTable(this.db) @@ -175,6 +178,7 @@ export class SQLitePresenter implements ISQLitePresenter { this.deepchatMessagesTable.createTable() this.deepchatMessageTracesTable.createTable() this.deepchatMessageSearchResultsTable.createTable() + this.deepchatPendingInputsTable.createTable() this.deepchatUsageStatsTable.createTable() this.legacyImportStatusTable.createTable() } @@ -206,6 +210,7 @@ export class SQLitePresenter implements ISQLitePresenter { this.deepchatMessagesTable, this.deepchatMessageTracesTable, this.deepchatMessageSearchResultsTable, + this.deepchatPendingInputsTable, this.deepchatUsageStatsTable, this.legacyImportStatusTable ] diff --git a/src/main/presenter/sqlitePresenter/tables/deepchatPendingInputs.ts b/src/main/presenter/sqlitePresenter/tables/deepchatPendingInputs.ts new file mode 100644 index 000000000..336c337d6 --- /dev/null +++ b/src/main/presenter/sqlitePresenter/tables/deepchatPendingInputs.ts @@ -0,0 +1,218 @@ +import Database from 'better-sqlite3-multiple-ciphers' +import { BaseTable } from './baseTable' + +export interface DeepChatPendingInputRow { + id: string + session_id: string + mode: 'queue' | 'steer' + state: 'pending' | 'claimed' | 'consumed' + payload_json: string + queue_order: number | null + claimed_at: number | null + consumed_at: number | null + created_at: number + updated_at: number +} + +export class DeepChatPendingInputsTable extends BaseTable { + constructor(db: Database.Database) { + super(db, 'deepchat_pending_inputs') + } + + getCreateTableSQL(): string { + return ` + CREATE TABLE IF NOT EXISTS deepchat_pending_inputs ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + mode TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'pending', + payload_json TEXT NOT NULL, + queue_order INTEGER, + claimed_at INTEGER, + consumed_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_deepchat_pending_inputs_session + ON deepchat_pending_inputs(session_id, state, mode, queue_order, created_at); + ` + } + + getMigrationSQL(version: number): string | null { + if (version === 17) { + return this.getCreateTableSQL() + } + return null + } + + getLatestVersion(): number { + return 17 + } + + insert(row: { + id: string + sessionId: string + mode: 'queue' | 'steer' + state?: 'pending' | 'claimed' | 'consumed' + payloadJson: string + queueOrder?: number | null + claimedAt?: number | null + consumedAt?: number | null + createdAt?: number + updatedAt?: number + }): void { + const now = Date.now() + const createdAt = row.createdAt ?? now + const updatedAt = row.updatedAt ?? createdAt + this.db + .prepare( + `INSERT INTO deepchat_pending_inputs ( + id, + session_id, + mode, + state, + payload_json, + queue_order, + claimed_at, + consumed_at, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + row.id, + row.sessionId, + row.mode, + row.state ?? 'pending', + row.payloadJson, + row.queueOrder ?? null, + row.claimedAt ?? null, + row.consumedAt ?? null, + createdAt, + updatedAt + ) + } + + get(id: string): DeepChatPendingInputRow | undefined { + return this.db.prepare('SELECT * FROM deepchat_pending_inputs WHERE id = ?').get(id) as + | DeepChatPendingInputRow + | undefined + } + + listBySession(sessionId: string): DeepChatPendingInputRow[] { + return this.db + .prepare( + `SELECT * + FROM deepchat_pending_inputs + WHERE session_id = ? + ORDER BY + CASE mode WHEN 'steer' THEN 0 ELSE 1 END ASC, + CASE + WHEN mode = 'queue' THEN COALESCE(queue_order, 2147483647) + ELSE created_at + END ASC, + created_at ASC` + ) + .all(sessionId) as DeepChatPendingInputRow[] + } + + listClaimed(): DeepChatPendingInputRow[] { + return this.db + .prepare( + `SELECT * + FROM deepchat_pending_inputs + WHERE state = 'claimed' + ORDER BY session_id ASC, created_at ASC` + ) + .all() as DeepChatPendingInputRow[] + } + + listActiveBySession(sessionId: string): DeepChatPendingInputRow[] { + return this.db + .prepare( + `SELECT * + FROM deepchat_pending_inputs + WHERE session_id = ? + AND state != 'consumed' + ORDER BY + CASE mode WHEN 'steer' THEN 0 ELSE 1 END ASC, + CASE + WHEN mode = 'queue' THEN COALESCE(queue_order, 2147483647) + ELSE created_at + END ASC, + created_at ASC` + ) + .all(sessionId) as DeepChatPendingInputRow[] + } + + countActiveBySession(sessionId: string): number { + const row = this.db + .prepare( + `SELECT COUNT(*) AS total + FROM deepchat_pending_inputs + WHERE session_id = ? + AND state != 'consumed' + AND NOT (mode = 'queue' AND state = 'claimed')` + ) + .get(sessionId) as { total: number } + return row.total + } + + update( + id: string, + fields: Partial< + Pick< + DeepChatPendingInputRow, + 'mode' | 'state' | 'payload_json' | 'queue_order' | 'claimed_at' | 'consumed_at' + > + > + ): void { + const setClauses: string[] = [] + const params: unknown[] = [] + + if (fields.mode !== undefined) { + setClauses.push('mode = ?') + params.push(fields.mode) + } + if (fields.state !== undefined) { + setClauses.push('state = ?') + params.push(fields.state) + } + if (fields.payload_json !== undefined) { + setClauses.push('payload_json = ?') + params.push(fields.payload_json) + } + if (fields.queue_order !== undefined) { + setClauses.push('queue_order = ?') + params.push(fields.queue_order) + } + if (fields.claimed_at !== undefined) { + setClauses.push('claimed_at = ?') + params.push(fields.claimed_at) + } + if (fields.consumed_at !== undefined) { + setClauses.push('consumed_at = ?') + params.push(fields.consumed_at) + } + + if (setClauses.length === 0) { + return + } + + setClauses.push('updated_at = ?') + params.push(Date.now()) + params.push(id) + + this.db + .prepare(`UPDATE deepchat_pending_inputs SET ${setClauses.join(', ')} WHERE id = ?`) + .run(...params) + } + + delete(id: string): void { + this.db.prepare('DELETE FROM deepchat_pending_inputs WHERE id = ?').run(id) + } + + deleteBySession(sessionId: string): void { + this.db.prepare('DELETE FROM deepchat_pending_inputs WHERE session_id = ?').run(sessionId) + } +} diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts index 09c99bdc6..6fb77b450 100644 --- a/src/main/presenter/toolPresenter/index.ts +++ b/src/main/presenter/toolPresenter/index.ts @@ -373,6 +373,9 @@ export class ToolPresenter implements IToolPresenter { lines.push( 'Prefer shell patterns like `rg -n`, `rg --files`, `find . -name ...`, `ls`, and `tree` inside `exec`.' ) + lines.push( + 'Use `background: true` when you know a command should detach immediately; otherwise a foreground `exec` may yield a running `sessionId` after `yieldMs`.' + ) } if (toolNames.has('exec') && toolNames.has('read') && toolNames.has('edit')) { lines.push( @@ -381,7 +384,7 @@ export class ToolPresenter implements IToolPresenter { } if (toolNames.has('process')) { lines.push( - 'Use `process` to monitor, write to, or terminate long-running background `exec` tasks.' + 'Use `process` to monitor, write to, or terminate long-running `exec` tasks that returned a running `sessionId`.' ) } diff --git a/src/renderer/src/components/chat/ChatInputToolbar.vue b/src/renderer/src/components/chat/ChatInputToolbar.vue index fd9bf38e2..c1dc241f2 100644 --- a/src/renderer/src/components/chat/ChatInputToolbar.vue +++ b/src/renderer/src/components/chat/ChatInputToolbar.vue @@ -37,42 +37,54 @@ - - + + + + + +

{{ t('chat.input.stop') }}

+
+
+ + + + + +

{{ t('chat.input.queue') }}

+
+
diff --git a/src/renderer/src/components/chat/ChatStatusBar.vue b/src/renderer/src/components/chat/ChatStatusBar.vue index d277c046c..6a30b2f37 100644 --- a/src/renderer/src/components/chat/ChatStatusBar.vue +++ b/src/renderer/src/components/chat/ChatStatusBar.vue @@ -221,75 +221,191 @@
-
- + +
+ +
- +

+ {{ getNumericInputErrorMessage('temperature') }} +

-
- + +
+ +
- +

+ {{ getNumericInputErrorMessage('contextLength') }} +

-
- + +
+ +
- +

+ {{ getNumericInputErrorMessage('maxTokens') }} +

@@ -349,19 +465,80 @@ - - {{ thinkingBudgetHint }} - +
+ + {{ thinkingBudgetHint }} + + +
- +
+ + + +
+

+ {{ getNumericInputErrorMessage('thinkingBudget') }} +

@@ -522,7 +699,7 @@ import { SelectTrigger, SelectValue } from '@shadcn/components/ui/select' -import { Slider } from '@shadcn/components/ui/slider' +import { Switch } from '@shadcn/components/ui/switch' import type { AcpConfigOption, AcpConfigState, @@ -531,6 +708,14 @@ import type { } from '@shared/presenter' import type { PermissionMode, SessionGenerationSettings } from '@shared/types/agent-interface' import type { ReasoningPortrait } from '@shared/types/model-db' +import { + normalizeLegacyThinkingBudgetValue, + parseFiniteNumericValue, + toValidNonNegativeInteger, + type GenerationNumericField, + type GenerationNumericValidationCode, + validateGenerationNumericField +} from '@shared/utils/generationSettingsValidation' import McpIndicator from '@/components/chat-input/McpIndicator.vue' import ModelIcon from '@/components/icons/ModelIcon.vue' import { usePresenter } from '@/composables/usePresenter' @@ -572,10 +757,10 @@ type GroupedModelList = { models: RENDERER_MODEL_META[] } -const TEMPERATURE_MIN = 0 -const TEMPERATURE_MAX = 2 -const CONTEXT_LENGTH_MIN = 2048 -const MAX_TOKENS_MIN = 128 +const TEMPERATURE_STEP = 0.1 +const CONTEXT_LENGTH_STEP = 1024 +const MAX_TOKENS_STEP = 128 +const THINKING_BUDGET_STEP = 128 const ACP_INLINE_OPTION_LIMIT = 3 const DEFAULT_REASONING_EFFORT_OPTIONS: SessionGenerationSettings['reasoningEffort'][] = [ 'minimal', @@ -616,12 +801,24 @@ const acpConfigLoadingRequestKey = ref(null) const acpInlineOpenOptionId = ref(null) const acpOptionSavingIds = ref([]) const acpConfigCacheByAgent = new Map() +const activeNumericInput = ref(null) +const numericInputDrafts = ref>({ + temperature: '', + contextLength: '', + maxTokens: '', + thinkingBudget: '' +}) +const numericInputErrors = ref< + Record +>({ + temperature: null, + contextLength: null, + maxTokens: null, + thinkingBudget: null +}) const capabilitySupportsReasoning = ref(null) const capabilityReasoningPortrait = ref(null) -const capabilityBudgetRange = ref<{ min?: number; max?: number; default?: number } | null>(null) -const capabilitySupportsEffort = ref(null) -const capabilitySupportsVerbosity = ref(null) let draftModelSyncToken = 0 let permissionSyncToken = 0 @@ -630,6 +827,7 @@ let acpConfigSyncToken = 0 let generationPersistTimer: ReturnType | null = null let pendingGenerationPatch: Partial = {} let generationPersistRequestToken = 0 +let generationLocalRevision = 0 const hasActiveSession = computed(() => sessionStore.hasActiveSession) @@ -924,29 +1122,104 @@ const isReasoningEffort = (value: unknown): value is 'minimal' | 'low' | 'medium const isVerbosity = (value: unknown): value is 'low' | 'medium' | 'high' => value === 'low' || value === 'medium' || value === 'high' -const clamp = (value: number, min: number, max: number): number => { - if (value < min) return min - if (value > max) return max - return value +const getCommittedNumericInputValue = (field: GenerationNumericField): string => { + if (!localSettings.value) { + return '' + } + + switch (field) { + case 'temperature': + return String(localSettings.value.temperature) + case 'contextLength': + return String(localSettings.value.contextLength) + case 'maxTokens': + return String(localSettings.value.maxTokens) + case 'thinkingBudget': { + const value = localSettings.value.thinkingBudget + return value === undefined ? '' : String(value) + } + } } -const toFiniteNumber = (value: unknown): number | undefined => { - if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) { - return undefined +const syncNumericInputDraft = (field: GenerationNumericField): void => { + numericInputDrafts.value[field] = getCommittedNumericInputValue(field) +} + +const clearNumericInputError = (field: GenerationNumericField): void => { + numericInputErrors.value[field] = null +} + +const setNumericInputError = ( + field: GenerationNumericField, + code: GenerationNumericValidationCode +): void => { + numericInputErrors.value[field] = code +} + +const resetNumericInputFieldState = (field: GenerationNumericField): void => { + clearNumericInputError(field) + syncNumericInputDraft(field) +} + +const resetNumericInputState = (): void => { + activeNumericInput.value = null + resetNumericInputFieldState('temperature') + resetNumericInputFieldState('contextLength') + resetNumericInputFieldState('maxTokens') + resetNumericInputFieldState('thinkingBudget') +} + +const hasNumericInputError = (field: GenerationNumericField): boolean => + numericInputErrors.value[field] !== null + +const startNumericInputEdit = (field: GenerationNumericField): void => { + activeNumericInput.value = field + if (!hasNumericInputError(field)) { + syncNumericInputDraft(field) } - return value } -const parseNumericInput = (value: string | number): number | undefined => { - const normalized = typeof value === 'string' ? value.trim() : String(value) - if (!normalized) { - return undefined +const setNumericInputDraft = (field: GenerationNumericField, value: string | number): void => { + if (activeNumericInput.value !== field) { + activeNumericInput.value = field } - const numeric = Number(normalized) - if (!Number.isFinite(numeric)) { - return undefined + const nextValue = typeof value === 'string' ? value : String(value) + if (numericInputDrafts.value[field] !== nextValue) { + generationLocalRevision += 1 + } + numericInputDrafts.value[field] = nextValue + clearNumericInputError(field) +} + +const stopNumericInputEdit = (field: GenerationNumericField): void => { + if (activeNumericInput.value === field) { + activeNumericInput.value = null + } +} + +const getNumericInputValue = (field: GenerationNumericField): string => { + if (activeNumericInput.value === field || hasNumericInputError(field)) { + return numericInputDrafts.value[field] + } + return getCommittedNumericInputValue(field) +} + +const getNumericInputErrorMessage = (field: GenerationNumericField): string => { + const code = numericInputErrors.value[field] + if (!code) { + return '' + } + + switch (code) { + case 'finite_number': + return t('chat.advancedSettings.validation.finiteNumber') + case 'non_negative_integer': + return t('chat.advancedSettings.validation.nonNegativeInteger') + case 'context_length_below_max_tokens': + return t('chat.advancedSettings.validation.contextLengthAtLeastMaxTokens') + case 'max_tokens_exceed_context_length': + return t('chat.advancedSettings.validation.maxTokensWithinContextLength') } - return numeric } const isAcpConfigOptionValue = ( @@ -1118,35 +1391,6 @@ const normalizeVerbosity = ( : undefined } -const normalizeThinkingBudget = ( - portrait: ReasoningPortrait | null | undefined, - value: number, - min?: number, - max?: number -): number => { - const roundedValue = Math.round(value) - const sentinelValues = new Set() - - if (typeof portrait?.budget?.default === 'number') - sentinelValues.add(Math.round(portrait.budget.default)) - if (typeof portrait?.budget?.auto === 'number') - sentinelValues.add(Math.round(portrait.budget.auto)) - if (typeof portrait?.budget?.off === 'number') sentinelValues.add(Math.round(portrait.budget.off)) - - if (sentinelValues.has(roundedValue)) { - return roundedValue - } - - let nextValue = roundedValue - if (typeof min === 'number') { - nextValue = Math.max(nextValue, Math.round(min)) - } - if (typeof max === 'number') { - nextValue = Math.min(nextValue, Math.round(max)) - } - return nextValue -} - const findEnabledModel = (providerId: string, modelId: string): ModelSelection | null => { const hit = findEnabledModelMeta(providerId, modelId) if (!hit) { @@ -1202,43 +1446,21 @@ const clearPendingGenerationPersist = () => { pendingGenerationPatch = {} } -const getCurrentLimits = () => { - const selection = effectiveModelSelection.value - if (!selection) { - return { - contextLengthLimit: 32000, - maxTokensLimit: 8192 - } - } - - const modelConfig = configPresenter.getModelConfig(selection.modelId, selection.providerId) - const contextLengthLimit = Math.max( - CONTEXT_LENGTH_MIN, - Math.round(toFiniteNumber(modelConfig.contextLength) ?? 32000) - ) - const maxTokensLimit = Math.max( - MAX_TOKENS_MIN, - Math.round(toFiniteNumber(modelConfig.maxTokens) ?? 4096) - ) - return { contextLengthLimit, maxTokensLimit } +const invalidateGenerationPersistResponses = () => { + generationPersistRequestToken += 1 } -const contextLengthLimit = computed(() => getCurrentLimits().contextLengthLimit) - -const maxTokensSliderLimit = computed(() => { - const baseLimit = getCurrentLimits().maxTokensLimit - const contextLimit = localSettings.value?.contextLength ?? contextLengthLimit.value - return Math.max(MAX_TOKENS_MIN, Math.min(baseLimit, contextLimit)) -}) - -const budgetRange = computed(() => capabilityBudgetRange.value) +const temperatureInputValue = computed(() => getNumericInputValue('temperature')) +const contextLengthInputValue = computed(() => getNumericInputValue('contextLength')) +const maxTokensInputValue = computed(() => getNumericInputValue('maxTokens')) +const thinkingBudgetInputValue = computed(() => getNumericInputValue('thinkingBudget')) +const isThinkingBudgetEnabled = computed(() => localSettings.value?.thinkingBudget !== undefined) const thinkingBudgetHint = computed(() => { - const value = localSettings.value?.thinkingBudget - if (value === undefined) { - return t('chat.advancedSettings.useDefault') + if (!isThinkingBudgetEnabled.value) { + return t('common.disabled') } - return String(value) + return '' }) const showThinkingBudget = computed(() => { @@ -1470,38 +1692,26 @@ const resolveDefaultGenerationSettings = async ( const modelConfig = configPresenter.getModelConfig(modelId, providerId) const defaultSystemPrompt = await configPresenter.getDefaultSystemPrompt() const portrait = (await configPresenter.getReasoningPortrait?.(providerId, modelId)) ?? null - const limits = getCurrentLimits() + const contextLengthDefault = toValidNonNegativeInteger(modelConfig.contextLength) ?? 32000 + const maxTokensDefault = + toValidNonNegativeInteger(modelConfig.maxTokens) ?? Math.min(4096, contextLengthDefault) const defaults: SessionGenerationSettings = { systemPrompt: defaultSystemPrompt ?? '', - temperature: clamp( - toFiniteNumber(modelConfig.temperature) ?? 0.7, - TEMPERATURE_MIN, - TEMPERATURE_MAX - ), - contextLength: clamp( - Math.round(toFiniteNumber(modelConfig.contextLength) ?? limits.contextLengthLimit), - CONTEXT_LENGTH_MIN, - limits.contextLengthLimit - ), - maxTokens: clamp( - Math.round(toFiniteNumber(modelConfig.maxTokens) ?? Math.min(4096, limits.maxTokensLimit)), - MAX_TOKENS_MIN, - limits.maxTokensLimit - ) + temperature: parseFiniteNumericValue(modelConfig.temperature) ?? 0.7, + contextLength: contextLengthDefault, + maxTokens: + maxTokensDefault <= contextLengthDefault + ? maxTokensDefault + : Math.min(4096, contextLengthDefault) } - defaults.maxTokens = Math.min(defaults.maxTokens, defaults.contextLength) if (portrait?.supported === true && hasThinkingBudgetSupport(portrait)) { - const range = portrait.budget ?? {} - const defaultBudget = toFiniteNumber(modelConfig.thinkingBudget ?? range.default) + const defaultBudget = normalizeLegacyThinkingBudgetValue( + modelConfig.thinkingBudget ?? portrait.budget?.default + ) if (defaultBudget !== undefined) { - defaults.thinkingBudget = normalizeThinkingBudget( - portrait, - Math.round(defaultBudget), - range.min, - range.max - ) + defaults.thinkingBudget = defaultBudget } } @@ -1525,60 +1735,6 @@ const resolveDefaultGenerationSettings = async ( return defaults } -const mergeDraftOverrides = ( - defaults: SessionGenerationSettings, - portrait: ReasoningPortrait | null -): SessionGenerationSettings => { - const next: SessionGenerationSettings = { - ...defaults, - ...(draftStore.systemPrompt !== undefined ? { systemPrompt: draftStore.systemPrompt } : {}), - ...(draftStore.temperature !== undefined ? { temperature: draftStore.temperature } : {}), - ...(draftStore.contextLength !== undefined ? { contextLength: draftStore.contextLength } : {}), - ...(draftStore.maxTokens !== undefined ? { maxTokens: draftStore.maxTokens } : {}), - ...(draftStore.thinkingBudget !== undefined - ? { thinkingBudget: draftStore.thinkingBudget } - : {}), - ...(draftStore.reasoningEffort !== undefined - ? { - reasoningEffort: normalizeReasoningEffort(portrait, draftStore.reasoningEffort) - } - : {}), - ...(draftStore.verbosity !== undefined ? { verbosity: draftStore.verbosity } : {}) - } - - const limits = getCurrentLimits() - next.temperature = clamp(next.temperature, TEMPERATURE_MIN, TEMPERATURE_MAX) - next.contextLength = clamp( - Math.round(next.contextLength), - CONTEXT_LENGTH_MIN, - limits.contextLengthLimit - ) - next.maxTokens = clamp( - Math.round(next.maxTokens), - MAX_TOKENS_MIN, - Math.min(limits.maxTokensLimit, next.contextLength) - ) - - if (next.thinkingBudget !== undefined) { - next.thinkingBudget = normalizeThinkingBudget( - portrait, - next.thinkingBudget, - portrait?.budget?.min, - portrait?.budget?.max - ) - } - - if (next.reasoningEffort !== undefined) { - next.reasoningEffort = normalizeReasoningEffort(portrait, next.reasoningEffort) - } - - if (next.verbosity !== undefined) { - next.verbosity = normalizeVerbosity(portrait, next.verbosity) - } - - return next -} - const fetchCapabilities = async (providerId: string, modelId: string): Promise => { try { const portrait = (await configPresenter.getReasoningPortrait?.(providerId, modelId)) ?? null @@ -1586,24 +1742,10 @@ const fetchCapabilities = async (providerId: string, modelId: string): Promise { } const requestToken = ++generationPersistRequestToken + const localRevisionAtRequest = generationLocalRevision try { const updated = await newAgentPresenter.updateSessionGenerationSettings(sessionId, patch) if (requestToken !== generationPersistRequestToken) { return } - if (!localSettings.value) { - localSettings.value = { ...updated } + if (localRevisionAtRequest !== generationLocalRevision) { return } - localSettings.value = { - ...localSettings.value, - ...updated - } + localSettings.value = { ...updated } + resetNumericInputState() } catch (error) { console.warn('[ChatStatusBar] Failed to update generation settings:', error) } } const scheduleGenerationPersist = (patch: Partial) => { + if (!sessionStore.activeSessionId) { + clearPendingGenerationPersist() + draftStore.updateGenerationSettings(patch) + return + } + pendingGenerationPatch = { ...pendingGenerationPatch, ...patch } if (generationPersistTimer) { clearTimeout(generationPersistTimer) @@ -1656,25 +1802,13 @@ const updateLocalGenerationSettings = (patch: Partial return } generationSyncToken += 1 + generationLocalRevision += 1 - const limits = getCurrentLimits() const next: SessionGenerationSettings = { ...localSettings.value, ...patch } - next.temperature = clamp(next.temperature, TEMPERATURE_MIN, TEMPERATURE_MAX) - next.contextLength = clamp( - Math.round(next.contextLength), - CONTEXT_LENGTH_MIN, - limits.contextLengthLimit - ) - next.maxTokens = clamp( - Math.round(next.maxTokens), - MAX_TOKENS_MIN, - Math.min(limits.maxTokensLimit, next.contextLength) - ) - localSettings.value = next const normalizedPatch: Partial = {} @@ -1706,6 +1840,8 @@ const updateLocalGenerationSettings = (patch: Partial const syncGenerationSettings = async () => { const token = ++generationSyncToken clearPendingGenerationPersist() + invalidateGenerationPersistResponses() + resetNumericInputState() loadedSettingsSelection.value = null if (isAcpAgent.value) { @@ -1713,9 +1849,6 @@ const syncGenerationSettings = async () => { loadedSettingsSelection.value = null capabilitySupportsReasoning.value = null capabilityReasoningPortrait.value = null - capabilityBudgetRange.value = null - capabilitySupportsEffort.value = null - capabilitySupportsVerbosity.value = null return } @@ -1725,9 +1858,6 @@ const syncGenerationSettings = async () => { loadedSettingsSelection.value = null capabilityReasoningPortrait.value = null capabilitySupportsReasoning.value = null - capabilityBudgetRange.value = null - capabilitySupportsEffort.value = null - capabilitySupportsVerbosity.value = null return } @@ -1767,7 +1897,7 @@ const syncGenerationSettings = async () => { if (token !== generationSyncToken) { return } - localSettings.value = mergeDraftOverrides(defaults, capabilityReasoningPortrait.value) + localSettings.value = defaults loadedSettingsSelection.value = { ...selection } } @@ -2040,6 +2170,7 @@ watch(isModelPanelOpen, (open) => { onBeforeUnmount(() => { clearPendingGenerationPersist() + invalidateGenerationPersistResponses() window.electron?.ipcRenderer?.removeListener?.( ACP_WORKSPACE_EVENTS.SESSION_CONFIG_OPTIONS_READY, handleAcpConfigOptionsReady @@ -2089,8 +2220,27 @@ async function changeModelSelection(providerId: string, modelId: string): Promis const previousDraftSelection = draftModelSelection.value ? { ...draftModelSelection.value } : null const previousDraftProviderId = draftStore.providerId const previousDraftModelId = draftStore.modelId + const previousDraftGenerationSettings = { + systemPrompt: draftStore.systemPrompt, + temperature: draftStore.temperature, + contextLength: draftStore.contextLength, + maxTokens: draftStore.maxTokens, + thinkingBudget: draftStore.thinkingBudget, + reasoningEffort: draftStore.reasoningEffort, + verbosity: draftStore.verbosity + } as Partial + const clearedDraftModelOverrides = { + temperature: undefined, + contextLength: undefined, + maxTokens: undefined, + thinkingBudget: undefined, + reasoningEffort: undefined, + verbosity: undefined + } as Partial try { + clearPendingGenerationPersist() + draftStore.updateGenerationSettings(clearedDraftModelOverrides) draftModelSelection.value = { providerId, modelId } draftStore.providerId = providerId draftStore.modelId = modelId @@ -2100,6 +2250,7 @@ async function changeModelSelection(providerId: string, modelId: string): Promis draftModelSelection.value = previousDraftSelection draftStore.providerId = previousDraftProviderId draftStore.modelId = previousDraftModelId + draftStore.updateGenerationSettings(previousDraftGenerationSettings) console.warn('[ChatStatusBar] Failed to switch draft model:', error) return false } @@ -2150,89 +2301,179 @@ function onSystemPromptSelect(optionId: string) { updateLocalGenerationSettings({ systemPrompt: option.content }) } -function onTemperatureSlider(values: number[]) { - const next = values[0] - if (!localSettings.value || typeof next !== 'number') { - return +const getNumericValidationContext = ( + field: GenerationNumericField +): Pick => ({ + contextLength: + field === 'contextLength' + ? (localSettings.value?.contextLength ?? 0) + : (localSettings.value?.contextLength ?? 0), + maxTokens: + field === 'maxTokens' + ? (localSettings.value?.maxTokens ?? 0) + : (localSettings.value?.maxTokens ?? 0) +}) + +const commitNumericField = ( + field: GenerationNumericField, + rawValue: string | number +): number | undefined => { + if (!localSettings.value) { + stopNumericInputEdit(field) + resetNumericInputFieldState(field) + return undefined + } + + const error = validateGenerationNumericField(field, rawValue, getNumericValidationContext(field)) + if (error) { + stopNumericInputEdit(field) + setNumericInputError(field, error) + return undefined } - updateLocalGenerationSettings({ temperature: Number(next.toFixed(1)) }) + + const numeric = parseFiniteNumericValue(rawValue) + if (numeric === undefined) { + stopNumericInputEdit(field) + setNumericInputError(field, field === 'temperature' ? 'finite_number' : 'non_negative_integer') + return undefined + } + + stopNumericInputEdit(field) + clearNumericInputError(field) + return numeric } -function onTemperatureInput(value: string | number) { +const roundTemperatureStepValue = (value: number): number => Number(value.toFixed(10)) + +function stepTemperature(direction: -1 | 1) { if (!localSettings.value) { return } - const numeric = parseNumericInput(value) - if (numeric === undefined) { + if (hasNumericInputError('temperature')) { return } - const next = clamp(numeric, TEMPERATURE_MIN, TEMPERATURE_MAX) - updateLocalGenerationSettings({ temperature: Number(next.toFixed(1)) }) + const next = roundTemperatureStepValue( + localSettings.value.temperature + direction * TEMPERATURE_STEP + ) + updateLocalGenerationSettings({ temperature: next }) + resetNumericInputFieldState('temperature') } -function onContextLengthSlider(values: number[]) { - const next = values[0] - if (!localSettings.value || typeof next !== 'number') { +function onTemperatureInput(value: string | number) { + setNumericInputDraft('temperature', value) +} + +function commitTemperatureInput() { + const next = commitNumericField('temperature', numericInputDrafts.value.temperature) + if (next === undefined) { return } - updateLocalGenerationSettings({ contextLength: Math.round(next) }) + updateLocalGenerationSettings({ temperature: next }) + resetNumericInputFieldState('temperature') } -function onContextLengthInput(value: string | number) { +function stepContextLength(direction: -1 | 1) { if (!localSettings.value) { return } - const numeric = parseNumericInput(value) - if (numeric === undefined) { + if (hasNumericInputError('contextLength')) { return } - const next = clamp(Math.round(numeric), CONTEXT_LENGTH_MIN, contextLengthLimit.value) - updateLocalGenerationSettings({ contextLength: next }) + const next = Math.max(0, localSettings.value.contextLength + direction * CONTEXT_LENGTH_STEP) + const committed = commitNumericField('contextLength', next) + if (committed === undefined) { + return + } + updateLocalGenerationSettings({ contextLength: committed }) + resetNumericInputFieldState('contextLength') +} + +function onContextLengthInput(value: string | number) { + setNumericInputDraft('contextLength', value) } -function onMaxTokensSlider(values: number[]) { - const next = values[0] - if (!localSettings.value || typeof next !== 'number') { +function commitContextLengthInput() { + const next = commitNumericField('contextLength', numericInputDrafts.value.contextLength) + if (next === undefined) { return } - updateLocalGenerationSettings({ maxTokens: Math.round(next) }) + updateLocalGenerationSettings({ contextLength: next }) + resetNumericInputFieldState('contextLength') } -function onMaxTokensInput(value: string | number) { +function stepMaxTokens(direction: -1 | 1) { if (!localSettings.value) { return } - const numeric = parseNumericInput(value) - if (numeric === undefined) { + if (hasNumericInputError('maxTokens')) { + return + } + const next = Math.max(0, localSettings.value.maxTokens + direction * MAX_TOKENS_STEP) + const committed = commitNumericField('maxTokens', next) + if (committed === undefined) { + return + } + updateLocalGenerationSettings({ maxTokens: committed }) + resetNumericInputFieldState('maxTokens') +} + +function onMaxTokensInput(value: string | number) { + setNumericInputDraft('maxTokens', value) +} + +function commitMaxTokensInput() { + const next = commitNumericField('maxTokens', numericInputDrafts.value.maxTokens) + if (next === undefined) { return } - const next = clamp(Math.round(numeric), MAX_TOKENS_MIN, maxTokensSliderLimit.value) updateLocalGenerationSettings({ maxTokens: next }) + resetNumericInputFieldState('maxTokens') } -function onThinkingBudgetInput(value: string | number) { +function onThinkingBudgetToggle(enabled: boolean) { if (!localSettings.value) { return } - const normalized = typeof value === 'string' ? value.trim() : String(value) - if (!normalized) { + if (!enabled) { + stopNumericInputEdit('thinkingBudget') + resetNumericInputFieldState('thinkingBudget') updateLocalGenerationSettings({ thinkingBudget: undefined }) return } - const numeric = Number(normalized) - if (!Number.isFinite(numeric)) { + const preferred = normalizeLegacyThinkingBudgetValue(localSettings.value.thinkingBudget) ?? 0 + updateLocalGenerationSettings({ thinkingBudget: preferred }) + resetNumericInputFieldState('thinkingBudget') +} + +function stepThinkingBudget(direction: -1 | 1) { + if (!localSettings.value) { return } + if (hasNumericInputError('thinkingBudget')) { + return + } + const current = localSettings.value.thinkingBudget ?? 0 + const next = Math.max(0, current + direction * THINKING_BUDGET_STEP) + const committed = commitNumericField('thinkingBudget', next) + if (committed === undefined) { + return + } + updateLocalGenerationSettings({ thinkingBudget: committed }) + resetNumericInputFieldState('thinkingBudget') +} - const range = budgetRange.value - const budget = normalizeThinkingBudget( - capabilityReasoningPortrait.value, - Math.round(numeric), - range?.min, - range?.max - ) - updateLocalGenerationSettings({ thinkingBudget: budget }) +function onThinkingBudgetInput(value: string | number) { + setNumericInputDraft('thinkingBudget', value) +} + +function commitThinkingBudgetInput() { + const next = commitNumericField('thinkingBudget', numericInputDrafts.value.thinkingBudget) + if (next === undefined) { + return + } + updateLocalGenerationSettings({ thinkingBudget: next }) + resetNumericInputFieldState('thinkingBudget') } function onReasoningEffortSelect(value: string) { @@ -2304,7 +2545,19 @@ defineExpose({ permissionMode, showSystemPromptSection, showReasoningEffort, - onTemperatureSlider, + onTemperatureInput, + commitTemperatureInput, + onContextLengthInput, + commitContextLengthInput, + onMaxTokensInput, + commitMaxTokensInput, + onThinkingBudgetInput, + commitThinkingBudgetInput, + onThinkingBudgetToggle, + stepTemperature, + stepContextLength, + stepMaxTokens, + stepThinkingBudget, selectModel: changeModelSelection, openModelSettings, isModelSettingsExpanded, diff --git a/src/renderer/src/components/chat/PendingInputLane.vue b/src/renderer/src/components/chat/PendingInputLane.vue new file mode 100644 index 000000000..00e8cc45d --- /dev/null +++ b/src/renderer/src/components/chat/PendingInputLane.vue @@ -0,0 +1,328 @@ +