Skip to content

ACP: IDE-Brücke

源码版本v2026.6.11

Verantwortung

serveAcpGateway (serveAcpGateway:42-173) ist der stdio-Brückenprozess, den OpenClaw für IDEs bereitstellt: ein eigenständiges Node-Programm (gestartet über openclaw acp CLI), das über stdin/stdout das JSON-RPC des Agent Client Protocol spricht und die von der IDE gesendeten Methoden initialize/newSession/prompt/cancel/loadSession/listSessions/resumeSession/closeSession in WebSocket chat/run-Aufrufe des Gateways übersetzt und die vom Gateway gepushten Ereignisse (event) zurück in ACP-SessionUpdate an die IDE wandelt.

Seine zentrale Position ist „Übersetzer (translator) + Mittelsmann": Es betreibt selbst keine Agent-Inferenz, führt keine Werkzeuge direkt aus, speichert keine Langzeit-Memory; es richtet das ACP-Protokoll und das Gateway-Protokoll bidirektional aus. ACP wird von Editoren wie Zed übernommen; über serveAcpGateway kann OpenClaw von diesen IDEs als Standard-Agent-Server eingebunden werden, ohne dass die IDE das OpenClaw-interne Protokoll kennen muss.

Designmotivation

Warum die IDE nicht direkt an das WebSocket des Gateways lassen?

  1. stdio-freundlich: Der Standardweg für IDEs, einen Agent-Server zu starten, ist einen Subprocess zu forken und über stdin/stdout zu kommunizieren. Das vermeidet TCP-Socket-Lecks beim Schließen der IDE und erfordert von der IDE weder Port noch Token des Gateways — sie braucht nur den Befehl openclaw acp --gateway-url ... --token-file ....
  2. Protokollunterschiede: ACP nutzt JSON-RPC over ndjson mit Methodennamen wie initialize/prompt/cancel (Agent-Semantik); das Gateway nutzt WebSocket mit eigenen Ereignisframes (chat/agent/exec.approval.requested). Beide Seiten unterscheiden sich in Granularität, Feldnamen, Fähigkeitsdeklarationen — AcpGatewayAgent (AcpGatewayAgent:250) ist genau für diese Übersetzung zuständig.
  3. Reconnect-Semantik: ACP ist Request-Response — die IDE sendet prompt und wartet auf das Ergebnis; das Gateway ist ein Ereignisstrom, dessen WebSocket mittendrin abreißen und wieder verbinden kann. AcpGatewayAgent hält eine pendingPrompts-Map (pendingPrompts:258) und indiziert „laufende Prompts" nach sessionId; gehen während des Disconnects Ereignisse verloren, kann nach Reconnect reconcilePendingPrompts (handleGatewayReconnect:329-356) den Zustand wieder ausrichten.
  4. Session-Persistenz und Replay: Nach IDE-Neustart soll die letzte Sitzungshistorie sichtbar sein; ACP bietet loadSession/listSessions/resumeSession. AcpEventLedger (AcpEventLedger:49-73) schreibt jeden Prompt und jeden SessionUpdate in SQLite, sodass nach Gateway-Neustart replay möglich bleibt.

Schlüsseldateien

  • serveAcpGateway:42-173 — Eintritt: routet Logs nach stderr, baut GatewayClient, wartet auf hello, startet AgentSideConnection.
  • GatewayClient Optionen:83-115clientName: CLI, clientDisplayName: "ACP", caps: [TOOL_EVENTS] und vier Callbacks onEvent/onHelloOk/onConnectError/onClose.
  • AgentSideConnection:163-170 — Wickelt den ndjson-Stream mit dem ACP-SDK AgentSideConnection; Factory liefert AcpGatewayAgent.
  • normalizeAcpInitializeProtocolVersion:175-197 — Kompatibilitäts-Hack: SDK 0.22 validiert protocolVersion streng als uint16; manche Editoren übergeben MCP-Datumsstrings, hier wird umgeschrieben.
  • AcpGatewayAgent Klasse:250-307 — Implementiert das Agent-Interface des ACP-SDK; hält connection/gateway/sessionStore/sessionUpdates/pendingPrompts/approvalRelays/disconnectTimer.
  • handleGatewayEvent:356-368 — Ereignis-Dispatch: chat → handleChatEvent, exec.approval.requested → handleExecApprovalRequestEvent, agent → handleAgentEvent.
  • initialize:370-395 — Liefert agentCapabilities (loadSession/promptCapabilities/mcpCapabilities/sessionCapabilities).
  • newSession:397-427 — Generiert sessionId, löst sessionKey, startLedgerSession, push ersten SessionSnapshotUpdate.
  • loadSession:429-490 — Dreischichtiger ledger-replay-Fallback: exact-by-sessionId → listed-by-sessionKey → fallback.
  • prompt:664-730 — Setzt cwd-Präfix, MAX_PROMPT_BYTES-Schutz, extractAttachmentsFromPrompt, idempotencyKey: runId, setzt pendingPrompt.
  • AcpEventLedger:49-90 — ledger-Interface, SQLite-backed; default MAX_SESSIONS=200, MAX_EVENTS_PER_SESSION=5000, MAX_SERIALIZED_BYTES=16MB.
  • LEDGER_VERSION + Dateischloss:17-30 — Alte file-ledger existiert noch; beim Start migriert migrateFileAcpEventLedgerToSQLite sie nach SQLite und archiviert die Quelldatei.
  • event-mapper:1-60 — Bidirektionales Mapping zwischen ACP ContentBlock/ToolCallContent ↔ Gateway Text/Anhänge/Metadaten.
  • AcpSession + AcpServerOptions:33-48 — Session-Datenstruktur + CLI-Optionen (gatewayUrl/gatewayToken/defaultSessionKey/requireExistingSession/prefixCwd/provenanceMode/sessionCreateRateLimit).
  • AcpTranslatorSessionUpdates — Pusht SessionUpdate-Frames und zeichnet sie im ledger auf.

Datenfluss

Der Start von serveAcpGateway (serveAcpGateway:42) folgt strikt „erst gateway ready, dann ACP":

typescript
export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
  routeLogsToStderr();                              // stdout bleibt ndjson vorbehalten, Logs gehen nach 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; });
  // ... dann erst AgentSideConnection starten
}

Reihenfolge kritisch: Logs werden nach stderr geroutet (routeLogsToStderr:43) — stdout ist der ndjson-Kanal, jedes console.log würde den Protokollframe verschmutzen. Der GatewayClient muss zuerst erfolgreich hello sein, bevor ACP startet, sonst geht der erste Prompt der IDE an ein Gateway, das noch nicht握handelt hat. SIGINT/SIGTERM lösen beide shutdown aus, sodass beim Schließen der IDE auch gateway.stop() aufgerufen wird — keine offene WebSocket-Verbindung hängen bleiben.

prompt ist die typischste Stelle der bidirektionalen Übersetzung (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);  // alter run derselben session noch aktiv -> vorher canceln
  }
  const userText = extractTextFromPrompt(params.prompt, MAX_PROMPT_BYTES);  // CWE-400: blockweise explode protection
  const attachments = extractAttachmentsFromPrompt(params.prompt);
  const displayCwd = shortenHomePath(session.cwd);
  const message = prefixCwd
    ? `[Working directory: ${displayCwd}]\n\n${userText}`   // Agent über aktuelles Arbeitsverzeichnis informieren
    : userText;
  // Defense-in-depth: auch nach der Assemblierung (message enthält cwd-Präfix) Gesamtgröße prüfen
  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,                                // Gateway-seitiges Dedupe
    thinking: readString(params["_meta"], ["thinking", "thinkingLevel"]),
    deliver: readBool(params["_meta"], ["deliver"]),
    timeoutMs: readNonNegativeInteger(params["_meta"], ["timeoutMs"]),
  };
  // ... Promise zurückgeben; resolve wird durch handleChatEvent/handleAgentEvent ausgelöst
}

Details: prefixCwd ist default an und steckt cwd-Information lesbar in den Prompt-Text (statt als Metadatum) — so weiß der Agent auch ohne explizite _meta-Behandlung das Arbeitsverzeichnis; idempotencyKey = runId garantiert, dass ein IDE-Retransmit desselben Prompts das Gateway nicht zweimal laufen lässt; eine aktive Run in derselben Session wird zuerst gecancelt (ACP erlaubt nur einen laufenden Prompt pro Session). MAX_PROMPT_BYTES lehnt in extractTextFromPrompt blockweise zu große Inhalte ab; nach der Assemblierung folgt ein zweiter Check — Defense-in-Depth, damit eine nach Blockprüfung zusammengesetzte Nachricht nicht doch den Schwellwert überschreitet (CWE-400).

Rückfluss: GatewayClient.onEvent empfängt Gateway-Ereignisse → agent.handleGatewayEvent dispatcht an chat/approval/agent-Handler → Handler rufen sessionUpdates.send* und pushen SessionUpdate-Frames → sessionUpdates schreibt das update gleichzeitig ins AcpEventLedger (SQLite) → IDE empfängt update.

Grenzen und Fehler

  • stdout-Verschmutzungsschutz: routeLogsToStderr() ist die erste Anweisung (routeLogsToStderr:43). Jede Abhängigkeit, die über console.log ausgibt, muss nach stderr geroutet sein, sonst wird die Log-Zeile vom ACP-SDK als Protokollframe geparst und die Verbindung stürzt ab. Diese Falle lauert häufig in Drittanbieter-Plugins.
  • protocolVersion-Kompatibilitätshack: normalizeAcpInitializeProtocolVersion (normalizeAcpInitializeProtocolVersion:175) erzwingt nur das protocolVersion-Feld der initialize-Nachricht — ACP-SDK 0.22 lehnt vor der Schema-Validierung nicht-uint16-Werte ab, während manche Editoren (besonders MCP-Kompatibilitätschichten) Datumsstrings übergeben. Hier wird vor Eintritt in das SDK normalisiert, damit die IDE nicht schon beim Handshake vom SDK abgewiesen wird.
  • disconnectTimer und reconnect: handleGatewayDisconnect (handleGatewayDisconnect:339) startet einen disconnectTimer; während des Disconnect werden alle pendingPrompts weder resolved noch rejected — reconcilePendingPrompts filtert bei handleGatewayReconnect nach generation und wirft nur die Prompts neu, die noch warten und deren generation passt. Das verhindert das Disaster „Gateway 5 Sekunden weg, alle Prompts melden gleichzeitig Fehler".
  • Dreischichtiger ledger-replay: loadSession (loadSession:429) versucht in Reihenfolge: exact-by-sessionId → listed-by-sessionKey → fallback. Sind die ersten beiden nicht komplett (sessionId im ledger nicht vorhanden oder unvollständig — etwa aus der file-ledger-Ära oder während der SQLite-Migration abgeschnitten), wird ein neues readLedgerReplay mit sessionId + sessionKey gesucht. Jede Schicht, die komplett ist, kehrt zurück, sodass die IDE beim Wiederöffnen so viel Zustand wie möglich wiederherstellen kann.
  • ledger-Kapazitätsobergrenzen: DEFAULT_MAX_SESSIONS = 200, DEFAULT_MAX_EVENTS_PER_SESSION = 5_000, DEFAULT_MAX_SERIALIZED_BYTES = 16 MB (ledger Obergrenzen:18-20). Bei Überschreitung werden älteste Sessions verdrängt oder Ereignisströme abgeschnitten — das verhindert, dass ein langlaufendes OpenClaw die ledger-Tabelle sprengt. Die file-ledger hat zusätzlich withFileLock mit 8 mal exponentiellem Backoff (retries: 8, factor: 2, minTimeout: 50, maxTimeout: 5_000) und 15s-Stale-Detektion; nach SQLite-Migration werden diese Pfade nur noch für historische Daten genutzt.
  • sessionCreateRateLimiter: newSession/loadSession (bei unbekannter Session) rufen enforceSessionCreateRateLimit (enforceSessionCreateRateLimit:399); default ist ein Fixed-Window-Limiter. Verhindert, dass eine IDE mit Bug oder Skript den Session-Store des Gateways mit newSession zumacht.
  • MAX_PROMPT_BYTES doppelte Verteidigung: extractTextFromPrompt lehnt blockweise zu große Inhalte ab; nach der Assemblierung zur vollständigen message folgt ein Buffer.byteLength-Check. Zwei unabhängige Prüfungen — selbst vergisst die Blockprüfung, die Assemblierung fängt (CWE-400).
  • ACP und OpenClaw-Agent entkoppelt: AcpGatewayAgent implementiert nur das Agent-Interface des ACP-SDK (implements Agent:250); das gesamte „Denken" passiert in der Agent-Hauptschleife auf der Gateway-Backend-Seite. Stürzt der ACP-Prozess ab, geht kein Agent-Zustand verloren — session/run-Datensätze leben im Gateway; nach IDE-Reconnect kann loadSession replayen.

Zusammenfassung

ACP bindet OpenClaw in das Standard-Agent-Protokoll der IDE ein und übersetzt nur — stdio ndjson ↔ Gateway WebSocket, ACP-Methoden ↔ chat run, ACP SessionUpdate ↔ Gateway-Ereignisframes. serveAcpGateway ist der Prozesseintritt (erst GatewayClient bauen, hello abwarten, dann ACP starten); AcpGatewayAgent der Protokollübersetzer; AcpEventLedger die SQLite-Persistenzschicht für Session-Replay. Wie die Gateway-Seite chat/run-Anfragen behandelt, siehe Gateway-Kern; die Details der echten Agent-Hauptschleife siehe Agent-Hauptschleife; wie ACP-durchgereichte Werkzeugaufrufe ausgeführt werden, siehe Capabilities/Tools. Detached Prompts, die durch ACP ausgelöst werden, tauchen in Tasks als runtime: "acp" auf und genießen die einheitliche Cancel-/Delivery-/Replay-Semantik von Tasks.