Skip to content

ACP: puente con el IDE

源码版本v2026.6.11

Responsabilidad

serveAcpGateway (serveAcpGateway:42-173) es el proceso puente stdio que OpenClaw ofrece al IDE: es un programa Node independiente (se arranca desde la CLI openclaw acp), habla JSON-RPC de Agent Client Protocol sobre stdin/stdout, y traduce los métodos que el IDE envía (initialize/newSession/prompt/cancel/loadSession/listSessions/resumeSession/closeSession, etc.) a llamadas WebSocket chat/run del Gateway, y los eventos (event) que el Gateway empuja se traducen de vuelta a SessionUpdate de ACP para el IDE.

Su posición clave es la de «traductor (translator) + intermediario»: no hace inferencia del agent, no ejecuta herramientas directamente, no almacena memoria a largo plazo; solo alinea el protocolo ACP y el protocolo del Gateway en ambos sentidos. ACP es un protocolo adoptado por editores como Zed, así que a través de serveAcpGateway OpenClaw puede exponerse a estos IDEs como un agent server estándar, sin que el IDE necesite conocer el protocolo interno de OpenClaw.

Motivación de diseño

¿Por qué no dejar que el IDE se conecte directamente al WebSocket del gateway?

  1. stdio-friendly: la forma estándar de un IDE para arrancar un agent server es forkear un subproceso y comunicarse por stdin/stdout. Esto evita leaks de TCP socket al cerrar el IDE y no exige que el IDE conozca puerto/token del gateway — solo necesita conocer el comando openclaw acp --gateway-url ... --token-file ....
  2. Diferencias de protocolo: ACP usa JSON-RPC sobre ndjson, con nombres de método initialize/prompt/cancel con semántica de agent; el Gateway usa WebSocket con frames de eventos propios (chat/agent/exec.approval.requested). La granularidad de eventos, los nombres de campos y las declaraciones de capacidad son completamente distintos en cada lado — AcpGatewayAgent (AcpGatewayAgent:250) es la clase que específicamente hace esta traducción.
  3. Semántica de reconexión: ACP es request-response, el IDE envía prompt y espera el resultado; pero el Gateway es un flujo de eventos, el WebSocket puede caerse y reconectarse a mitad. AcpGatewayAgent mantiene un Map pendingPrompts (pendingPrompts:258) indexa «prompts en curso» por sessionId, y si se pierden eventos durante la desconexión, reconcilePendingPrompts (handleGatewayReconnect:329-356) realinea el estado tras reconnect.
  4. Persistencia y replay de sesión: tras reiniciar el IDE, se quiere ver el historial de la sesión anterior; ACP tiene loadSession/listSessions/resumeSession. AcpEventLedger (AcpEventLedger:49-73) persiste cada prompt y SessionUpdate a SQLite, permitiendo replay aunque el gateway se reinicie.

Archivos clave

  • serveAcpGateway:42-173 — entrada: rutea logs a stderr, construye GatewayClient, espera hello, arranca AgentSideConnection.
  • GatewayClient options:83-115clientName: CLI, clientDisplayName: "ACP", caps: [TOOL_EVENTS], con cuatro callbacks onEvent/onHelloOk/onConnectError/onClose.
  • AgentSideConnection:163-170 — envuelve el ndjson stream con AgentSideConnection del ACP SDK, la factory devuelve AcpGatewayAgent.
  • normalizeAcpInitializeProtocolVersion:175-197 — hack de compatibilidad: el SDK 0.22 valida estrictamente que protocolVersion sea uint16, algunos editores mandan strings de fecha estilo MCP, aquí se sobreescribe.
  • AcpGatewayAgent class:250-307 — implementa la interfaz Agent del ACP SDK, mantiene connection/gateway/sessionStore/sessionUpdates/pendingPrompts/approvalRelays/disconnectTimer.
  • handleGatewayEvent:356-368 — dispatch de eventos: chat → handleChatEvent, exec.approval.requested → handleExecApprovalRequestEvent, agent → handleAgentEvent.
  • initialize:370-395 — devuelve agentCapabilities (loadSession/promptCapabilities/mcpCapabilities/sessionCapabilities).
  • newSession:397-427 — genera sessionId, resuelve sessionKey, startLedgerSession, empuja el primer SessionSnapshotUpdate.
  • loadSession:429-490 — tres capas de fallback en ledger replay: exact-by-sessionId → listed-by-sessionKey → fallback.
  • prompt:664-730 — monta el prefijo cwd, MAX_PROMPT_BYTES anti overflow, extractAttachmentsFromPrompt, idempotencyKey: runId, set del pendingPrompt.
  • AcpEventLedger:49-90 — interfaz del ledger, SQLite-backed, por defecto MAX_SESSIONS=200, MAX_EVENTS_PER_SESSION=5000, MAX_SERIALIZED_BYTES=16MB.
  • LEDGER_VERSION + file lock:17-30 — si aún existe un ledger de archivos, al arrancar migrateFileAcpEventLedgerToSQLite lo migra a SQLite y archiva el archivo origen.
  • event-mapper:1-60 — mapeo bidireccional entre ContentBlock/ToolCallContent de ACP y texto/adjuntos/metadata del Gateway.
  • AcpSession + AcpServerOptions:33-48 — estructura de datos de sesión + opciones CLI (gatewayUrl/gatewayToken/defaultSessionKey/requireExistingSession/prefixCwd/provenanceMode/sessionCreateRateLimit).
  • AcpTranslatorSessionUpdates — empuja frames SessionUpdate y los registra en el ledger.

Flujo de datos

El flujo de arranque de serveAcpGateway (serveAcpGateway:42) sigue estrictamente «primero gateway ready, luego abre ACP»:

typescript
export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
  routeLogsToStderr();                              // stdout queda para ndjson, logs van a stderr
  const cfg = getRuntimeConfig();
  const bootstrap = await resolveGatewayClientBootstrap({ ... });
  const gateway = new GatewayClient({
    url: bootstrap.url,
    token: bootstrap.auth.token,
    password: bootstrap.auth.password,
    clientName: GATEWAY_CLIENT_NAMES.CLI,
    clientDisplayName: "ACP",
    clientVersion: "acp",
    mode: GATEWAY_CLIENT_MODES.CLI,
    caps: [GATEWAY_CLIENT_CAPS.TOOL_EVENTS],
    onEvent: (evt) => { void agent?.handleGatewayEvent(evt); },
    onHelloOk: () => { resolveGatewayReady(); agent?.handleGatewayReconnect(); },
    onConnectError: (err) => { rejectGatewayReady(err); },
    onClose: (code, reason) => { /* reject or onClosed */ },
  });
  process.once("SIGINT", shutdown);
  process.once("SIGTERM", shutdown);
  const readiness = await startGatewayClientWhenEventLoopReady(gateway, { ... });
  // ...
  await gatewayReady.catch((err) => { shutdown(); throw err; });
  // ... recién aquí arranca AgentSideConnection
}

Orden clave: los logs se rutean a stderr (routeLogsToStderr:43) — stdout es el canal ndjson, cualquier console.log contamina el frame del protocolo. GatewayClient debe completar hello antes de abrir ACP, si no, el primer prompt que el IDE envíe iría a un gateway aún sin handshake. SIGINT/SIGTERM ambos disparan shutdown, garantizando que al cerrar el IDE se llame gateway.stop() y no queden WebSockets colgados.

prompt es el punto más típico de traducción bidireccional (prompt:664):

typescript
async prompt(params: PromptRequest): Promise<PromptResponse> {
  const session = this.sessionStore.getSession(params.sessionId);
  if (!session) throw new Error(`Session ${params.sessionId} not found`);
  if (session.abortController) {
    this.sessionStore.cancelActiveRun(params.sessionId);  // si el run anterior no terminó, se cancela primero
  }
  const userText = extractTextFromPrompt(params.prompt, MAX_PROMPT_BYTES);  // CWE-400: rechaza por bloques
  const attachments = extractAttachmentsFromPrompt(params.prompt);
  const displayCwd = shortenHomePath(session.cwd);
  const message = prefixCwd
    ? `[Working directory: ${displayCwd}]\n\n${userText}`   // deja que el agent sepa el directorio actual
    : userText;
  // Defense-in-depth: volver a chequear el tamaño total ya montado (message con prefijo cwd)
  if (Buffer.byteLength(message, "utf-8") > MAX_PROMPT_BYTES) {
    throw new Error(`Prompt exceeds maximum allowed size of ${MAX_PROMPT_BYTES} bytes`);
  }
  const abortController = new AbortController();
  const runId = randomUUID();
  this.sessionStore.setActiveRun(params.sessionId, runId, abortController);
  const requestParams = {
    sessionKey: session.sessionKey,
    message,
    attachments: attachments.length > 0 ? attachments : undefined,
    idempotencyKey: runId,                                // dedupe en Gateway
    thinking: readString(params["_meta"], ["thinking", "thinkingLevel"]),
    deliver: readBool(params["_meta"], ["deliver"]),
    timeoutMs: readNonNegativeInteger(params["_meta"], ["timeoutMs"]),
  };
  // ... devuelve una Promise cuyo resolve lo disparan handleChatEvent/handleAgentEvent
}

Varios detalles: prefixCwd está on por defecto, inserta la info de cwd en el texto del prompt de forma legible (no como metadata) — así el agent no necesita procesar _meta para conocer el directorio; idempotencyKey = runId garantiza que si el IDE reenvía el mismo prompt, el Gateway no lo ejecute dos veces; si hay un run activo en la misma sesión, se cancela primero (ACP permite un solo prompt en curso por sesión). MAX_PROMPT_BYTES se aplica dentro de extractTextFromPrompt por bloque para rechazar contenido enorme, y tras montar el mensaje se vuelve a chequear — defense-in-depth, previene que la concatenación de bloques supere el umbral (CWE-400).

Dirección de retorno de eventos: GatewayClient.onEvent recibe el evento del gateway → agent.handleGatewayEvent lo dispatcha a los handlers chat/approval/agent → el handler llama a sessionUpdates.send* para empujar un frame SessionUpdatesessionUpdates también escribe el update al AcpEventLedger (SQLite) → el IDE recibe el update.

Límites y fallos

  • Defensa contra contaminación de stdout: routeLogsToStderr() es la primera sentencia (routeLogsToStderr:43). Cualquier dependencia que use console.log debe rutearla a stderr, si no, una línea de log será parseada como frame por el ACP SDK y tirará la conexión. Este pozo es común en plugins de terceros.
  • Hack de compatibilidad de protocolocolVersion: normalizeAcpInitializeProtocolVersion (normalizeAcpInitializeProtocolVersion:175) normaliza forzadamente el campo protocolVersion del mensaje initialize — el ACP SDK 0.22 rechaza valores no uint16 antes de la validación del schema, y algunos editores (especialmente capas de compatibilidad MCP) mandan strings de fecha. Aquí se normaliza antes de que el frame entre al SDK, evitando que el IDE falle el handshake.
  • disconnectTimer y reconnect: handleGatewayDisconnect (handleGatewayDisconnect:339) arranca un disconnectTimer, durante el cual todos los pendingPrompt ni se resuelven ni se rechazan — reconcilePendingPrompts filtra por generation al reconectar, y solo reinyecta los prompts que aún esperan y cuya generation coincide. Evita el desastre de «gateway cae 5 segundos y todos los prompts sueltan error al IDE a la vez».
  • Tres capas de ledger replay: loadSession (loadSession:429) intenta en orden: exact-by-sessionId → listed-by-sessionKey → fallback. Si las dos primeras no están complete, significa que el sessionId no está en el ledger o está incompleto (podría ser una sesión vieja del era file-ledger, o truncada en migración a SQLite), y dispara un nuevo readLedgerReplay combinando sessionId + sessionKey. Si cualquiera de las capas completa, devuelve, garantizando que el IDE pueda recuperar estado en la reapertura.
  • Límites de capacidad del ledger: DEFAULT_MAX_SESSIONS = 200, DEFAULT_MAX_EVENTS_PER_SESSION = 5_000, DEFAULT_MAX_SERIALIZED_BYTES = 16 MB (ledger limits:18-20). Al superarse, se evicta la sesión más vieja o se trunca el flujo de eventos — evita que un OpenClaw corriendo a largo plazo explote la tabla del ledger. El file ledger adicionalmente usa withFileLock con 8 retries exponenciales (retries: 8, factor: 2, minTimeout: 50, maxTimeout: 5_000) y detección de stale a 15s; tras migrar a SQLite estas rutas solo se usan para datos históricos.
  • sessionCreateRateLimiter: newSession/loadSession (cuando la sesión es desconocida) llaman enforceSessionCreateRateLimit (enforceSessionCreateRateLimit:399), con rate limit fijo por ventana por defecto. Previene que un IDE con bug o un script bombardee el session store del Gateway con newSession.
  • Defensa en dos capas de MAX_PROMPT_BYTES: extractTextFromPrompt rechaza contenido enorme a nivel de bloque, y tras montar el message completo se vuelve a chequear con Buffer.byteLength. Dos chequeos independientes, si el nivel de bloque olvidara el límite, el montaje lo atrapa (CWE-400).
  • ACP desacoplado del agent de OpenClaw: AcpGatewayAgent solo implementa la interfaz Agent del ACP SDK (implements Agent:250), todo el «pensamiento» vive en el bucle principal del agent en el backend del gateway. Si el proceso ACP cae, no se pierde estado del agent — session/run viven en el gateway, y al reconectar el IDE loadSession puede replay.

Resumen

ACP integra OpenClaw en el protocolo estándar de agent del IDE, haciendo solo de traductor — stdio ndjson ↔ WebSocket del Gateway, métodos ACP ↔ chat run, SessionUpdate ACP ↔ frames de evento del gateway. serveAcpGateway es la entrada del proceso (construye GatewayClient primero, espera hello, luego abre ACP), AcpGatewayAgent es el traductor de protocolo, AcpEventLedger es la capa de persistencia SQLite para replay de sesión. Cómo el Gateway maneja las peticiones chat/run en Núcleo del gateway; los detalles del bucle principal del agent en Runner embebido; cómo se ejecutan las herramientas que ACP pasa a través en Herramientas. Los prompts detached disparados por ACP aparecen en Tasks con runtime: "acp", pudiendo aprovechar las semánticas unificadas de cancelación/delivery/replay del task.