diff --git a/packages/api/src/domains/cats/services/agents/providers/acp/AcpClient.ts b/packages/api/src/domains/cats/services/agents/providers/acp/AcpClient.ts index d1cc03703f..330cdb5c20 100644 --- a/packages/api/src/domains/cats/services/agents/providers/acp/AcpClient.ts +++ b/packages/api/src/domains/cats/services/agents/providers/acp/AcpClient.ts @@ -18,6 +18,7 @@ import { createInterface, type Interface as ReadlineInterface } from 'node:readl import { createModuleLogger } from '../../../../../../infrastructure/logger.js'; import { resolveCliCommandOrBare } from '../../../../../../utils/cli-resolve.js'; import { resolveWindowsSpawnPlan } from '../../../../../../utils/cli-spawn-win.js'; +import { resolveCliTimeoutMs } from '../../../../../../utils/cli-timeout.js'; import type { AcpAgentRequest, AcpContentBlock, @@ -103,6 +104,10 @@ export interface AcpCapacitySignal { const CAPACITY_RE = /MODEL_CAPACITY_EXHAUSTED|No capacity available|status 429.*Retrying/i; +export function resolveAcpPromptTimeoutMs(overrideMs?: number, env: NodeJS.ProcessEnv = process.env): number { + return resolveCliTimeoutMs(overrideMs, env); +} + export class AcpClient { private child: ChildProcess | null = null; private rl: ReadlineInterface | null = null; @@ -260,12 +265,12 @@ export class AcpClient { // after timeoutMs of SILENCE (no events). Idle stall (90s) catches true hangs // faster; this is the wider safety net for slow-but-alive sessions. // sendRequest gets a hard ceiling (1h) as absolute last-resort guard. - const timeoutMs = options?.timeoutMs ?? 900_000; + const timeoutMs = resolveAcpPromptTimeoutMs(options?.timeoutMs); const idleWarningMs = options?.idleWarningMs ?? 20_000; // Idle stall catches true hangs. Gemini CLI doesn't emit tool_call for MCP // tools, so pendingTool never activates. 90s covers most MCP calls (10-30s). const idleStallMs = options?.idleStallMs ?? 90_000; - const HARD_CEILING_MS = 3_600_000; // 1h — absolute last-resort for sendRequest promise + const hardCeilingMs = Math.max(3_600_000, timeoutMs > 0 ? timeoutMs * 2 : 3_600_000); const queue: AcpSessionUpdate[] = []; let waitResolve: (() => void) | null = null; let done = false; @@ -284,7 +289,7 @@ export class AcpClient { * Called once at prompt start and again on every incoming event. */ const resetBudget = () => { if (budgetTimer) clearTimeout(budgetTimer); - if (done) return; + if (done || timeoutMs <= 0) return; budgetTimer = setTimeout(() => { if (done) return; log.error({ sessionId, eventCount, timeoutMs }, 'Turn budget exceeded — no activity for %dms', timeoutMs); @@ -416,12 +421,13 @@ export class AcpClient { }; this.capacityListeners.add(capacityInjector); - // Start activity-based budget timer — resets on each event from listener + // Start activity-based budget timer — resets on each event from listener. + // CLI_TIMEOUT_MS=0 disables the activity budget; the hard ceiling remains. resetBudget(); // Fire prompt request — don't await, we'll drain the queue concurrently. - // sendRequest uses hard ceiling (1h); actual budget is managed by resetBudget(). - this.sendRequest(ACP_METHODS.sessionPrompt, { sessionId, prompt: [{ type: 'text', text }] }, HARD_CEILING_MS) + // sendRequest uses a hard ceiling; actual budget is managed by resetBudget(). + this.sendRequest(ACP_METHODS.sessionPrompt, { sessionId, prompt: [{ type: 'text', text }] }, hardCeilingMs) .then((resp) => { const result = resp.result as unknown as AcpPromptResult; stopReason = result.stopReason; diff --git a/packages/api/src/domains/cats/services/agents/providers/acp/GeminiAcpAdapter.ts b/packages/api/src/domains/cats/services/agents/providers/acp/GeminiAcpAdapter.ts index 33e52311fe..aa50a4889c 100644 --- a/packages/api/src/domains/cats/services/agents/providers/acp/GeminiAcpAdapter.ts +++ b/packages/api/src/domains/cats/services/agents/providers/acp/GeminiAcpAdapter.ts @@ -269,12 +269,12 @@ export class GeminiAcpAdapter implements AgentService { log.info({ ...ctx }, 'ACP capacity warning yielded (catch path)'); yield makeCapacityWarning(this.catId, capacitySignal, metadata); } - const { errorCode, errorMsg } = classifyError(err, capacitySignal, client.recentCapacitySignal); + const { errorCode, errorMsg, timeoutMs } = classifyError(err, capacitySignal, client.recentCapacitySignal); log.error({ ...ctx, errorCode, err: errorMsg, sessionId, eventCount, waitedMs }, 'ACP prompt failure'); yield { type: 'error', catId: this.catId, - error: toUserFacingError(errorCode, errorMsg), + error: toUserFacingError(errorCode, errorMsg, timeoutMs), errorCode, metadata, timestamp: Date.now(), @@ -348,11 +348,17 @@ const RECENT_SIGNAL_MAX_AGE_MS = 10 * 60 * 1000; /** Pattern for stream idle stall errors thrown by AcpClient idle watchdog. */ const STREAM_IDLE_RE = /Stream idle|STREAM_IDLE_STALL/i; +interface ClassifiedError { + errorCode: string; + errorMsg: string; + timeoutMs?: number; +} + function classifyError( err: unknown, capacitySignal: AcpCapacitySignal | null | undefined, clientRecentSignal?: AcpCapacitySignal | null, -): { errorCode: string; errorMsg: string } { +): ClassifiedError { if (err instanceof AcpProtocolError) { if (err.code === -32000 || err.message.includes('capacity')) { return { errorCode: 'model_capacity', errorMsg: err.message }; @@ -378,7 +384,7 @@ function classifyError( errorMsg: `Provider capacity exhausted (upstream 429, evidence: recent_process_signal, ${ageS}s ago). ${clientRecentSignal.message}`, }; } - return { errorCode: 'turn_budget_exceeded', errorMsg: err.message }; + return { errorCode: 'turn_budget_exceeded', errorMsg: err.message, timeoutMs: err.timeoutMs }; } // F149: Stream idle stall — provider started responding then went silent const msg = err instanceof Error ? err.message : String(err); @@ -394,7 +400,17 @@ function classifyError( /** Map internal error codes to user-friendly messages that clarify the failure source. * Format: `{errorCode}: {errorMsg}\n{user-facing explanation}` * The errorCode prefix is preserved for machine grep-ability (tests + invoke-helpers). */ -function toUserFacingError(errorCode: string, errorMsg: string): string { +function formatTimeoutDuration(timeoutMs: number | undefined): string { + if (timeoutMs === undefined || !Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return '当前配置'; + } + const seconds = Math.max(1, Math.round(timeoutMs / 1000)); + if (seconds < 60) return `${seconds}秒`; + const minutes = Math.max(1, Math.round(seconds / 60)); + return `${minutes}分钟`; +} + +function toUserFacingError(errorCode: string, errorMsg: string, timeoutMs?: number): string { const base = `${errorCode}: ${errorMsg}`; switch (errorCode) { case 'model_capacity': @@ -402,7 +418,7 @@ function toUserFacingError(errorCode: string, errorMsg: string): string { case 'stream_idle_stall': return `${base}\n⚠️ Gemini 服务端响应中断(Google 服务器可能繁忙或不稳定),非 Clowder AI 系统故障。`; case 'turn_budget_exceeded': - return `${base}\n⚠️ 本轮对话时间预算用完(${Math.round(900 / 60)}分钟),烁烁可能在执行复杂工具链。非故障,可重试。`; + return `${base}\n⚠️ 本轮对话时间预算用完(${formatTimeoutDuration(timeoutMs)}),烁烁可能在执行复杂工具链。非故障,可重试。`; case 'mcp_pollution': return `${base}\n⚠️ Gemini 工具调用异常(MCP 服务端错误)。`; case 'init_failure': diff --git a/packages/api/test/acp/acp-client.test.js b/packages/api/test/acp/acp-client.test.js index 9c3620e00e..f46600f0e8 100644 --- a/packages/api/test/acp/acp-client.test.js +++ b/packages/api/test/acp/acp-client.test.js @@ -7,7 +7,7 @@ import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; import { afterEach, describe, it, mock } from 'node:test'; -const { AcpClient, AcpProtocolError } = await import( +const { AcpClient, AcpProtocolError, resolveAcpPromptTimeoutMs } = await import( '../../dist/domains/cats/services/agents/providers/acp/AcpClient.js' ); @@ -66,6 +66,13 @@ describe('AcpClient', () => { } }); + it('promptStream timeout follows shared CLI timeout configuration', () => { + assert.equal(resolveAcpPromptTimeoutMs(undefined, {}), 30 * 60 * 1000); + assert.equal(resolveAcpPromptTimeoutMs(undefined, { CLI_TIMEOUT_MS: '900000' }), 900000); + assert.equal(resolveAcpPromptTimeoutMs(undefined, { CLI_TIMEOUT_MS: '0' }), 0); + assert.equal(resolveAcpPromptTimeoutMs(120000, { CLI_TIMEOUT_MS: '1800000' }), 120000); + }); + it('initialize sends protocolVersion and parses response', async () => { const { child, clientStdin, agentStdout } = createMockChild(); diff --git a/packages/api/test/acp/gemini-acp-adapter.test.js b/packages/api/test/acp/gemini-acp-adapter.test.js index 7d74725a52..4657f83687 100644 --- a/packages/api/test/acp/gemini-acp-adapter.test.js +++ b/packages/api/test/acp/gemini-acp-adapter.test.js @@ -829,6 +829,8 @@ describe('GeminiAcpAdapter integration', () => { 'turn_budget_exceeded', `Expected turn_budget_exceeded when no capacity signal, got ${errorMsg.errorCode}`, ); + assert.match(errorMsg.error, /2分钟/, 'Timeout message should reflect the actual AcpTimeoutError duration'); + assert.doesNotMatch(errorMsg.error, /15分钟/, 'Timeout message must not be hard-coded to 15 minutes'); }); it('concurrent prompts on same client both capture provider-level capacity signal', async () => {