fix(acp): support configurable prompt timeout (#608)

This commit is contained in:
matevip 2026-08-20 02:58:09 -04:00
parent 5b3285c78f
commit 656a0b0436
12 changed files with 106 additions and 9 deletions

View File

@ -60,6 +60,9 @@ public class AcpEndpointEntity {
/** Stdio buffer ceiling in bytes; defaults to 50 MiB. */ /** Stdio buffer ceiling in bytes; defaults to 50 MiB. */
private Long stdioBufferLimitBytes; private Long stdioBufferLimitBytes;
/** Max wait for session/prompt, in seconds. Defaults to 300, capped at 3600. */
private Integer promptTimeoutSeconds;
/** UNKNOWN / OK / ERROR — last test result. */ /** UNKNOWN / OK / ERROR — last test result. */
private String lastStatus; private String lastStatus;

View File

@ -11,7 +11,6 @@ import vip.mate.acp.model.AcpEndpointEntity;
import vip.mate.exception.MateClawException; import vip.mate.exception.MateClawException;
import java.io.IOException; import java.io.IOException;
import java.time.Duration;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -47,11 +46,6 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AcpDelegationService { public class AcpDelegationService {
/** Hard ceiling on a single ACP delegation. Long enough for a
* multi-turn coding session, short enough that a hung agent can't
* permanently block an LLM tool call. */
private static final Duration PROMPT_TIMEOUT = Duration.ofMinutes(5);
private static final long INITIALIZE_TIMEOUT_MS = 15_000L; private static final long INITIALIZE_TIMEOUT_MS = 15_000L;
private static final long SESSION_NEW_TIMEOUT_MS = 10_000L; private static final long SESSION_NEW_TIMEOUT_MS = 10_000L;
@ -89,6 +83,7 @@ public class AcpDelegationService {
List<String> args = endpointService.parseArgs(endpoint); List<String> args = endpointService.parseArgs(endpoint);
Map<String, String> env = endpointService.parseEnv(endpoint); Map<String, String> env = endpointService.parseEnv(endpoint);
boolean trusted = !Boolean.FALSE.equals(endpoint.getTrusted()); boolean trusted = !Boolean.FALSE.equals(endpoint.getTrusted());
long promptTimeoutMillis = resolvePromptTimeoutMillis(endpoint);
// Always resolve cwd to a real directory: Zed's ACP Zod schema // Always resolve cwd to a real directory: Zed's ACP Zod schema
// marks cwd as a required string and rejects {@code undefined} // marks cwd as a required string and rejects {@code undefined}
// with -32602. See {@link AcpRuntimeSupport#resolveCwd}. // with -32602. See {@link AcpRuntimeSupport#resolveCwd}.
@ -124,7 +119,7 @@ public class AcpDelegationService {
ObjectNode promptParams = objectMapper.createObjectNode(); ObjectNode promptParams = objectMapper.createObjectNode();
promptParams.put("sessionId", sessionId); promptParams.put("sessionId", sessionId);
promptParams.set("prompt", buildPromptArray(userPrompt)); promptParams.set("prompt", buildPromptArray(userPrompt));
autoClose.sendRequest("session/prompt", promptParams, PROMPT_TIMEOUT.toMillis()); autoClose.sendRequest("session/prompt", promptParams, promptTimeoutMillis);
} catch (IOException | InterruptedException e) { } catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) Thread.currentThread().interrupt(); if (e instanceof InterruptedException) Thread.currentThread().interrupt();
log.warn("ACP delegation failed for endpoint '{}': {}", endpointName, e.getMessage()); log.warn("ACP delegation failed for endpoint '{}': {}", endpointName, e.getMessage());
@ -144,6 +139,12 @@ public class AcpDelegationService {
return accumulator.toString().trim(); return accumulator.toString().trim();
} }
static long resolvePromptTimeoutMillis(AcpEndpointEntity endpoint) {
int seconds = AcpEndpointService.normalizePromptTimeoutSeconds(
endpoint != null ? endpoint.getPromptTimeoutSeconds() : null);
return seconds * 1000L;
}
private void wireHandlers(AcpStdioClient client, StringBuilder buf, private void wireHandlers(AcpStdioClient client, StringBuilder buf,
boolean trusted, String endpointName) { boolean trusted, String endpointName) {
// Notifications carry session/update messages; agent_message_chunk // Notifications carry session/update messages; agent_message_chunk

View File

@ -36,6 +36,9 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AcpEndpointService { public class AcpEndpointService {
public static final int DEFAULT_PROMPT_TIMEOUT_SECONDS = 300;
public static final int MAX_PROMPT_TIMEOUT_SECONDS = 3600;
private final AcpEndpointMapper mapper; private final AcpEndpointMapper mapper;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
@ -91,6 +94,7 @@ public class AcpEndpointService {
if (input.getStdioBufferLimitBytes() == null || input.getStdioBufferLimitBytes() <= 0) { if (input.getStdioBufferLimitBytes() == null || input.getStdioBufferLimitBytes() <= 0) {
input.setStdioBufferLimitBytes(50L * 1024L * 1024L); input.setStdioBufferLimitBytes(50L * 1024L * 1024L);
} }
input.setPromptTimeoutSeconds(normalizePromptTimeoutSeconds(input.getPromptTimeoutSeconds()));
if (input.getWorkspaceId() == null) input.setWorkspaceId(1L); if (input.getWorkspaceId() == null) input.setWorkspaceId(1L);
mapper.insert(input); mapper.insert(input);
log.info("Created ACP endpoint: {}", input.getName()); log.info("Created ACP endpoint: {}", input.getName());
@ -118,6 +122,9 @@ public class AcpEndpointService {
if (patch.getStdioBufferLimitBytes() != null && patch.getStdioBufferLimitBytes() > 0) { if (patch.getStdioBufferLimitBytes() != null && patch.getStdioBufferLimitBytes() > 0) {
existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes()); existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes());
} }
if (patch.getPromptTimeoutSeconds() != null) {
existing.setPromptTimeoutSeconds(normalizePromptTimeoutSeconds(patch.getPromptTimeoutSeconds()));
}
mapper.updateById(existing); mapper.updateById(existing);
publish(existing, AcpEndpointChangedEvent.Type.UPDATED); publish(existing, AcpEndpointChangedEvent.Type.UPDATED);
return existing; return existing;
@ -180,6 +187,13 @@ public class AcpEndpointService {
} }
} }
public static int normalizePromptTimeoutSeconds(Integer seconds) {
if (seconds == null || seconds <= 0) {
return DEFAULT_PROMPT_TIMEOUT_SECONDS;
}
return Math.min(seconds, MAX_PROMPT_TIMEOUT_SECONDS);
}
private List<String> parseStringList(String json) { private List<String> parseStringList(String json) {
if (json == null || json.isBlank()) return Collections.emptyList(); if (json == null || json.isBlank()) return Collections.emptyList();
try { try {

View File

@ -0,0 +1,7 @@
-- Issue #608: make ACP session/prompt timeout configurable per endpoint.
ALTER TABLE mate_acp_endpoint
ADD COLUMN IF NOT EXISTS prompt_timeout_seconds INT NOT NULL DEFAULT 300;
UPDATE mate_acp_endpoint
SET prompt_timeout_seconds = 300
WHERE prompt_timeout_seconds IS NULL OR prompt_timeout_seconds <= 0;

View File

@ -0,0 +1,7 @@
-- Issue #608: make ACP session/prompt timeout configurable per endpoint.
ALTER TABLE mate_acp_endpoint
ADD COLUMN IF NOT EXISTS prompt_timeout_seconds INT NOT NULL DEFAULT 300;
UPDATE mate_acp_endpoint
SET prompt_timeout_seconds = 300
WHERE prompt_timeout_seconds IS NULL OR prompt_timeout_seconds <= 0;

View File

@ -0,0 +1,7 @@
-- Issue #608: make ACP session/prompt timeout configurable per endpoint.
ALTER TABLE mate_acp_endpoint
ADD COLUMN prompt_timeout_seconds INT NOT NULL DEFAULT 300;
UPDATE mate_acp_endpoint
SET prompt_timeout_seconds = 300
WHERE prompt_timeout_seconds IS NULL OR prompt_timeout_seconds <= 0;

View File

@ -198,7 +198,7 @@ Hints surface in the test panel and in the streamed error message your agent rec
- `initialize` handshake: 15s - `initialize` handshake: 15s
- `session/new`: 10s - `session/new`: 10s
- Whole `session/prompt` round-trip: 5 min - Whole `session/prompt` round-trip: 300s by default, configurable per endpoint up to 3600s
- Stdio buffer cap: 50 MiB per call (configurable on the row via `stdio_buffer_limit_bytes`) - Stdio buffer cap: 50 MiB per call (configurable on the row via `stdio_buffer_limit_bytes`)
--- ---
@ -215,6 +215,7 @@ Hints surface in the test panel and in the streamed error message your agent rec
| `args_json` | TEXT | NULL | CLI args (JSON array) | | `args_json` | TEXT | NULL | CLI args (JSON array) |
| `env_json` | TEXT | NULL | Env overrides (JSON object) | | `env_json` | TEXT | NULL | Env overrides (JSON object) |
| `tool_parse_mode` | VARCHAR(32) | `call_title` | `call_title` / `call_detail` / `update_detail` | | `tool_parse_mode` | VARCHAR(32) | `call_title` | `call_title` / `call_detail` / `update_detail` |
| `prompt_timeout_seconds` | INT | 300 | `session/prompt` call timeout, capped at 3600s |
| `builtin` | BOOLEAN | FALSE | Built-in rows are write-protected | | `builtin` | BOOLEAN | FALSE | Built-in rows are write-protected |
| `trusted` | BOOLEAN | TRUE | Auto-allow permission requests | | `trusted` | BOOLEAN | TRUE | Auto-allow permission requests |
| `enabled` | BOOLEAN | FALSE | Off until you opt in | | `enabled` | BOOLEAN | FALSE | Off until you opt in |

View File

@ -198,7 +198,7 @@ ACP 服务端可以在做敏感动作前(写文件、跑 shell 命令等)发
- `initialize` 握手15 秒 - `initialize` 握手15 秒
- `session/new`10 秒 - `session/new`10 秒
- 整个 `session/prompt` 往返:5 分钟 - 整个 `session/prompt` 往返:默认 300 秒,可按端点配置,最高 3600 秒
- stdio 缓冲上限:单次 50 MiB行级 `stdio_buffer_limit_bytes` 可改) - stdio 缓冲上限:单次 50 MiB行级 `stdio_buffer_limit_bytes` 可改)
--- ---
@ -215,6 +215,7 @@ ACP 服务端可以在做敏感动作前(写文件、跑 shell 命令等)发
| `args_json` | TEXT | NULL | CLI 参数JSON 数组) | | `args_json` | TEXT | NULL | CLI 参数JSON 数组) |
| `env_json` | TEXT | NULL | 环境变量覆盖JSON 对象) | | `env_json` | TEXT | NULL | 环境变量覆盖JSON 对象) |
| `tool_parse_mode` | VARCHAR(32) | `call_title` | `call_title` / `call_detail` / `update_detail` | | `tool_parse_mode` | VARCHAR(32) | `call_title` | `call_title` / `call_detail` / `update_detail` |
| `prompt_timeout_seconds` | INT | 300 | `session/prompt` 调用超时,最高 3600 秒 |
| `builtin` | BOOLEAN | FALSE | 内置行写保护 | | `builtin` | BOOLEAN | FALSE | 内置行写保护 |
| `trusted` | BOOLEAN | TRUE | 自动放行权限请求 | | `trusted` | BOOLEAN | TRUE | 自动放行权限请求 |
| `enabled` | BOOLEAN | FALSE | 默认关闭,按需打开 | | `enabled` | BOOLEAN | FALSE | 默认关闭,按需打开 |

View File

@ -0,0 +1,36 @@
package vip.mate.acp.service;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.acp.model.AcpEndpointEntity;
import static org.junit.jupiter.api.Assertions.assertEquals;
class AcpDelegationServiceTimeoutTest {
@Test
@DisplayName("ACP prompt timeout defaults to 5 minutes when endpoint is unset")
void defaultPromptTimeout() {
AcpEndpointEntity endpoint = new AcpEndpointEntity();
assertEquals(300_000L, AcpDelegationService.resolvePromptTimeoutMillis(endpoint));
}
@Test
@DisplayName("ACP prompt timeout uses the endpoint setting")
void endpointPromptTimeout() {
AcpEndpointEntity endpoint = new AcpEndpointEntity();
endpoint.setPromptTimeoutSeconds(900);
assertEquals(900_000L, AcpDelegationService.resolvePromptTimeoutMillis(endpoint));
}
@Test
@DisplayName("ACP prompt timeout is clamped to a one-hour hard ceiling")
void clampPromptTimeout() {
AcpEndpointEntity endpoint = new AcpEndpointEntity();
endpoint.setPromptTimeoutSeconds(7200);
assertEquals(3_600_000L, AcpDelegationService.resolvePromptTimeoutMillis(endpoint));
}
}

View File

@ -2177,6 +2177,8 @@ export default {
args: 'Args (JSON array)', args: 'Args (JSON array)',
env: 'Environment variables', env: 'Environment variables',
toolParseMode: 'Tool parse mode', toolParseMode: 'Tool parse mode',
promptTimeoutSeconds: 'Call timeout (seconds)',
promptTimeoutHint: 'Applies to session/prompt. Default 300 seconds, maximum 3600 seconds for long OpenCode tasks.',
enabled: 'Enabled', enabled: 'Enabled',
}, },
env: { env: {

View File

@ -2034,6 +2034,8 @@ export default {
args: '参数 (JSON 数组)', args: '参数 (JSON 数组)',
env: '环境变量', env: '环境变量',
toolParseMode: 'Tool 解析模式', toolParseMode: 'Tool 解析模式',
promptTimeoutSeconds: '调用超时(秒)',
promptTimeoutHint: '用于 session/prompt。默认 300 秒,复杂 OpenCode 任务可调高,最大 3600 秒。',
enabled: '启用', enabled: '启用',
}, },
env: { env: {

View File

@ -126,6 +126,18 @@
<option value="update_detail">update_detail</option> <option value="update_detail">update_detail</option>
</select> </select>
</div> </div>
<div class="form-group">
<label class="form-label">{{ t('acp.fields.promptTimeoutSeconds') }}</label>
<input
v-model.number="form.promptTimeoutSeconds"
class="form-input"
type="number"
min="1"
max="3600"
step="30"
/>
<p class="form-help">{{ t('acp.fields.promptTimeoutHint') }}</p>
</div>
<div class="form-group full-width"> <div class="form-group full-width">
<label class="form-label">{{ t('acp.fields.args') }}</label> <label class="form-label">{{ t('acp.fields.args') }}</label>
<input v-model="form.argsJson" class="form-input mono" placeholder='["-y","@zed-industries/codex-acp"]' /> <input v-model="form.argsJson" class="form-input mono" placeholder='["-y","@zed-industries/codex-acp"]' />
@ -247,6 +259,7 @@ interface AcpEndpoint {
lastError?: string lastError?: string
lastTestedAt?: string lastTestedAt?: string
stdioBufferLimitBytes?: number stdioBufferLimitBytes?: number
promptTimeoutSeconds?: number
} }
const { t } = useI18n() const { t } = useI18n()
@ -264,6 +277,7 @@ const defaultForm = (): any => ({
argsJson: '[]', argsJson: '[]',
envJson: '{}', envJson: '{}',
toolParseMode: 'call_title', toolParseMode: 'call_title',
promptTimeoutSeconds: 300,
enabled: false, enabled: false,
}) })
const form = reactive<any>(defaultForm()) const form = reactive<any>(defaultForm())
@ -459,6 +473,7 @@ function openEditModal(ep: AcpEndpoint) {
argsJson: ep.argsJson || '[]', argsJson: ep.argsJson || '[]',
envJson: ep.envJson || '{}', envJson: ep.envJson || '{}',
toolParseMode: ep.toolParseMode || 'call_title', toolParseMode: ep.toolParseMode || 'call_title',
promptTimeoutSeconds: ep.promptTimeoutSeconds || 300,
enabled: !!ep.enabled, enabled: !!ep.enabled,
}) })
// Always open in form mode so users see the structured editor first. // Always open in form mode so users see the structured editor first.
@ -616,6 +631,7 @@ td .status-error { background: none; color: var(--mc-text-tertiary); font-size:
.form-input { padding: 8px 10px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 13px; color: var(--mc-text-primary); outline: none; background: var(--mc-bg-sunken); } .form-input { padding: 8px 10px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 13px; color: var(--mc-text-primary); outline: none; background: var(--mc-bg-sunken); }
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.1); } .form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.1); }
.form-input.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } .form-input.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.form-help { margin: 0; font-size: 11px; line-height: 1.4; color: var(--mc-text-tertiary); }
.form-textarea { resize: vertical; } .form-textarea { resize: vertical; }
.toggle-inline { display: flex; align-items: center; gap: 8px; font-size: 13px; } .toggle-inline { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 22px; border-top: 1px solid var(--mc-border-light); } .modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 22px; border-top: 1px solid var(--mc-border-light); }