Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 };
Expand All @@ -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);
Expand All @@ -394,15 +400,25 @@ 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':
return `${base}\n⚠️ Gemini 服务端容量不足(Google 服务器繁忙),非 Clowder AI 系统故障。`;
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':
Expand Down
9 changes: 8 additions & 1 deletion packages/api/test/acp/acp-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);

Expand Down Expand Up @@ -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();

Expand Down
2 changes: 2 additions & 0 deletions packages/api/test/acp/gemini-acp-adapter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading