콘텐츠로 이동

ACP 사용 가이드

이 가이드는 Continuum Router를 ACP(Agent Communication Protocol) 에이전트로 사용하는 방법을 설정부터 IDE 클라이언트 연결까지 다룹니다.

사전 요구 사항

  • Continuum Router 바이너리 (설치 참조)
  • 하나 이상의 설정된 LLM 백엔드 (OpenAI, Anthropic, Ollama 등)
  • ACP가 활성화된 설정 파일

설정

config.yaml에 acp 섹션을 추가합니다:

backends:
  - url: http://localhost:11434
    name: ollama
    models: ["llama3.2"]

acp:
  enabled: true

  transport:
    stdio:
      enabled: true

  capabilities:
    load_session: true
    mcp: true

  # session/prompt 요청이 "model"을 생략했을 때 사용됩니다. 위 백엔드가 실제로
  # 제공하는 모델이어야 하며, 기본 모델을 설정하지 않으면 "model"이 없는
  # 프롬프트는 -32602로 거부됩니다.
  default_model: "llama3.2"

  permissions:
    default_policy: ask_always
    auto_allow:
      - read
      - search
      - think
    always_ask:
      - edit
      - delete
      - execute

  sessions:
    max_concurrent: 10
    idle_timeout: "1h"
    storage: memory

전체 설정 레퍼런스는 ACP 아키텍처 페이지를 참조하세요.

ACP 모드로 라우터 시작하기

--mode stdio로 라우터를 실행하면 ACP 전송이 활성화됩니다:

continuum-router --config config.yaml --mode stdio

이 모드에서는 다음과 같이 동작합니다:

  • stdin/stdout은 JSON-RPC 2.0 메시지(NDJSON 형식) 전용으로 예약됩니다
  • stderr로 로그가 출력됩니다 (tracing)
  • HTTP 리스너를 시작하지 않으며, 라우터는 오직 stdio로만 통신합니다

Tip

HTTP API 게이트웨이는 여전히 별도로 실행할 수 있습니다. ACP stdio 모드는 HTTP 모드에 덧붙는 기능이 아니라 별개의 실행 모드입니다.

수동 테스트

stdin으로 JSON-RPC 메시지를 파이프하여 ACP 통신을 테스트할 수 있습니다.

1단계: 프로토콜 초기화

모든 ACP 세션은 initialize 핸드셰이크로 시작해야 합니다:

echo '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"readTextFile":true,"writeTextFile":true,"terminal":false},"clientInfo":{"name":"manual-test","version":"1.0.0"}},"id":1}' \
  | continuum-router --config config.yaml --mode stdio 2>/dev/null

예상 응답:

{"jsonrpc":"2.0","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"image":false,"audio":false,"embeddedContext":false,"mcp":true,"mcpCapabilities":{"acp":true}},"agentInfo":{"name":"Continuum Router","version":"2.0.0"},"authMethods":[]},"id":1}

2단계: 대화형 세션

완전한 대화형 세션에는 socat 같은 도구나 간단한 스크립트를 사용합니다:

# 라우터를 백그라운드로 시작하고 stdin/stdout을 캡처
continuum-router --config config.yaml --mode stdio 2>/dev/null &
ROUTER_PID=$!

# /proc 또는 명명된 파이프로 메시지 전송
# (실용적인 접근 방법은 아래 스크립트 예제 참조)

스크립트로 테스트하기

다음은 셸 스크립트를 사용한 전체 테스트 세션입니다:

#!/bin/bash
# acp-test.sh: ACP 메시지 시퀀스를 라우터로 전송

CONFIG="config.yaml"

{
  # 1. 초기화
  echo '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"readTextFile":true},"clientInfo":{"name":"test","version":"1.0"}},"id":1}'

  # 응답이 처리될 때까지 대기
  sleep 0.5

  # 2. 새 세션 생성
  echo '{"jsonrpc":"2.0","method":"session/new","params":{"mode":"code"},"id":2}'
  sleep 0.5

  # 3. 프롬프트 전송 (SESSION_ID를 2단계에서 받은 실제 세션 ID로 교체)
  # echo '{"jsonrpc":"2.0","method":"session/prompt","params":{"sessionId":"SESSION_ID","content":[{"type":"text","text":"Hello, what can you do?"}],"stream":false},"id":3}'
  # sleep 2

} | continuum-router --config "$CONFIG" --mode stdio 2>/dev/null

Python으로 테스트하기

양방향 통신을 더 세밀하게 제어하려면 다음과 같이 합니다:

#!/usr/bin/env python3
"""ACP 클라이언트 테스트 스크립트."""

import json
import subprocess
import sys
import threading

def read_responses(proc):
    """라우터의 응답을 읽어 출력합니다."""
    for line in proc.stdout:
        resp = json.loads(line)
        print(json.dumps(resp, indent=2))

def send(proc, method, params=None, msg_id=None):
    """JSON-RPC 메시지를 전송합니다."""
    msg = {"jsonrpc": "2.0", "method": method}
    if params:
        msg["params"] = params
    if msg_id is not None:
        msg["id"] = msg_id
    proc.stdin.write(json.dumps(msg) + "\n")
    proc.stdin.flush()

proc = subprocess.Popen(
    ["continuum-router", "--config", "config.yaml", "--mode", "stdio"],
    stdin=subprocess.PIPE,
    stdout=subprocess.PIPE,
    stderr=subprocess.DEVNULL,
    text=True,
)

# 백그라운드 스레드에서 응답 읽기
reader = threading.Thread(target=read_responses, args=(proc,), daemon=True)
reader.start()

# 1. 초기화
send(proc, "initialize", {
    "protocolVersion": 1,
    "clientCapabilities": {"readTextFile": True, "writeTextFile": True},
    "clientInfo": {"name": "python-test", "version": "1.0.0"},
}, msg_id=1)

import time; time.sleep(1)

# 2. 세션 생성
send(proc, "session/new", {"mode": "code"}, msg_id=2)
time.sleep(1)

# 3. stdin을 닫아 정상 종료 유도
proc.stdin.close()
proc.wait()

IDE 통합

ACP는 IDE 클라이언트가 라우터를 자식 프로세스로 스폰하고 stdio로 통신하도록 설계되었습니다. 일반적인 패턴은 다음과 같습니다:

IDE 프로세스
    │
    ├── 스폰: continuum-router --config config.yaml --mode stdio
    │           stdin  ← IDE가 보내는 JSON-RPC 요청
    │           stdout → IDE로 전달되는 JSON-RPC 응답
    │           stderr → 로그 (디버깅용으로 캡처 가능)
    │
    └── NDJSON으로 통신 (한 줄에 JSON 객체 하나)

통합 단계

  1. 프로세스 스폰: continuum-router --mode stdio --config <path>를 stdin/stdout이 파이프로 연결된 자식 프로세스로 시작합니다.

  2. initialize 전송: 첫 메시지는 반드시 initialize 요청이어야 합니다. 초기화가 완료될 때까지 다른 모든 메서드는 거부됩니다.

  3. 세션 생성: session/new를 모드(code, architect 등)와 함께 호출하여 sessionId를 받습니다.

  4. 프롬프트 전송: 세션 ID와 함께 session/prompt를 호출해 사용자 메시지를 보냅니다. 라우터는 session/update 알림으로 응답을 스트리밍해 돌려줍니다.

  5. 권한 처리: LLM이 도구 작업(파일 편집, 명령 실행)을 요청하면 라우터가 session/request_permission 알림을 보냅니다. IDE는 UI 프롬프트를 표시한 뒤 allow_once, allow_always, reject_once, reject_always 중 하나로 응답해야 합니다.

  6. 라이프사이클 관리: 진행 중인 작업을 중단하려면 session/cancel을, 모드를 바꾸려면 session/set_mode를 사용하고 정상 종료하려면 stdin을 닫습니다.

예제: Node.js에서 스폰하기

const { spawn } = require("child_process");
const readline = require("readline");

const router = spawn("continuum-router", [
  "--config", "config.yaml",
  "--mode", "stdio",
]);

// NDJSON 응답 파싱
const rl = readline.createInterface({ input: router.stdout });
rl.on("line", (line) => {
  const msg = JSON.parse(line);
  console.log("Received:", JSON.stringify(msg, null, 2));
});

// 디버깅을 위해 stderr 로그 출력
router.stderr.on("data", (data) => {
  console.error("[router log]", data.toString());
});

function send(method, params, id) {
  const msg = JSON.stringify({ jsonrpc: "2.0", method, params, id });
  router.stdin.write(msg + "\n");
}

// 초기화
send("initialize", {
  protocolVersion: 1,
  clientCapabilities: { readTextFile: true, writeTextFile: true },
  clientInfo: { name: "my-ide", version: "1.0.0" },
}, 1);

// initialize 응답을 받은 후 세션 생성...
setTimeout(() => {
  send("session/new", { mode: "code" }, 2);
}, 1000);

MCP 서버 터널링

MCP 서버가 설정되어 있다면, ACP 클라이언트는 별도의 MCP 연결을 관리하지 않고도 라우터를 통해 MCP 서버에 접근할 수 있습니다.

설정

설정에 MCP 서버를 추가합니다:

acp:
  enabled: true
  capabilities:
    mcp: true
  mcp:
    max_connections_per_session: 5
    allowed_servers: []  # 비어 있으면 모두 허용

mcp_servers:
  - id: filesystem
    command: npx
    args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
  - id: github
    command: npx
    args: ["-y", "@modelcontextprotocol/server-github"]
    env:
      GITHUB_TOKEN: "${GITHUB_TOKEN}"

사용법

초기화한 뒤 MCP 서버에 연결하고 메시지를 라우팅합니다:

// 연결
{"jsonrpc":"2.0","method":"mcp/connect","params":{"id":"filesystem"},"id":10}

// 도구 목록 조회
{"jsonrpc":"2.0","method":"mcp/message","params":{"connectionId":"<returned-uuid>","message":{"jsonrpc":"2.0","method":"tools/list","id":1}},"id":11}

// 연결 해제
{"jsonrpc":"2.0","method":"mcp/disconnect","params":{"connectionId":"<returned-uuid>"},"id":12}

타임아웃과 사용량 기록

스트리밍 session/prompt에는 HTTP 릴레이와 동일한 timeouts.request.streaming 시간 제한이 모델별로 적용됩니다.

  • first_byte: 요청 전송부터 첫 본문 청크까지의 대기 시간이며, 응답 헤더 대기를 포함합니다.
  • chunk_interval: 이후 청크 사이의 간격입니다.
  • total: 백엔드 요청 전체의 제한입니다. 비스트리밍 프롬프트에는 standard total만 적용됩니다.

first_byte, chunk_interval, 또는 total이 만료되면, 라우터는 업스트림 연결을 닫고 대화 기록을 변경하지 않은 채 프롬프트를 실패 처리합니다.

{"code":-32603,"message":"Backend stream timed out","data":{"timeout":"chunk_interval","timeout_ms":30000}}

백엔드 스트림이 finish_reason과 [DONE] 센티넬 중 어느 것도 보내지 않고 닫히면(빈 200 본문 포함), 라우터는 응답이 중간에 끊긴 것으로 판단하여 대화 기록을 변경하지 않고 프롬프트를 실패 처리합니다.

{"code":-32603,"message":"Backend stream ended before the reply finished"}

각 session/update 알림 전송은 chunk_interval로 제한되며 session/cancel과 경합합니다. 클라이언트가 전송 채널을 읽지 않아 정체되면, 라우터는 경고를 기록하고 해당 턴의 이후 알림 전송을 건너뛰며 응답 본문 누적은 끝까지 마칩니다.

백엔드에 도달한 모든 프롬프트는 라우터의 통계와 토큰 카운터에 한 건씩 기록되며, 백엔드 선택 실패(-32602 미제공 모델 또는 -32603 가용 백엔드 없음)는 시도를 집계하지 않고 not_dispatched 아래에 실패 행 하나를 기록합니다. 요청 전송 전에 취소되면 아무것도 기록되지 않습니다. ACP 세션에는 API 키가 없으므로 키별 집계는 남지 않습니다.

Note: --mode stdio는 HTTP 리스너와 control-plane 에이전트를 시작하지 않으므로, 해당 프로세스에서는 이 카운터를 수집할 수 없습니다. control_plane.enabled: true이면 라우터는 시작할 때 경고를 남기고, control-plane 사용량 레코드를 큐에 쌓지 않으며, Hub로도 전달하지 않습니다.

프롬프트 결과 outcome 집계
정상 종료 (finish_reason 또는 [DONE]) completed 성공
finish_reason: length length 성공
요청 전송 후 취소 client_cancelled 실패
first_byte 만료, 또는 첫 본문 청크 전 total 만료 timeout_first_byte 실패
chunk_interval 만료, 또는 본문 수신 중 total 만료 timeout_mid_stream 실패
2xx 외 상태 코드, 읽기 오류, 끊긴 스트림, 또는 백엔드 선택 실패 upstream_error 실패

스트리밍 프롬프트는 stream_options.include_usage를 요청합니다. 백엔드가 usage 블록을 보내지 않으면, 사용량 레코드에는 라우터의 입력 추정치와 token_source: none이 기록되고 로컬 토큰 카운터는 집계하지 않습니다.

관리자 엔드포인트

ACP 설정과 함께 HTTP 모드로 실행하면 Admin API가 ACP 상태 엔드포인트를 제공합니다:

엔드포인트 설명
GET /admin/acp/status ACP 서브시스템 상태와 기능
GET /admin/acp/sessions 활성 ACP 세션 목록
GET /admin/acp/agent.json 디스커버리용 에이전트 레지스트리 메타데이터
# ACP 상태 확인
curl http://localhost:8080/admin/acp/status

# 활성 세션 보기
curl http://localhost:8080/admin/acp/sessions

# 에이전트 메타데이터 가져오기
curl http://localhost:8080/admin/acp/agent.json

문제 해결

라우터가 즉시 종료되는 경우

설정 파일이 존재하고 YAML 문법이 올바른지 확인하세요. stderr 출력을 확인합니다:

continuum-router --config config.yaml --mode stdio 2>router.log
# 그런 다음 router.log에서 오류 확인

"Server not initialized" 오류 (-32002)

initialize 핸드셰이크가 가장 먼저 전송되는 메시지여야 합니다. 초기화가 완료될 때까지 다른 모든 메서드는 거부됩니다.

프롬프트에 응답이 없는 경우

  • 설정에서 acp.enabled가 true인지 확인합니다
  • 설정한 백엔드에 연결할 수 있는지 확인합니다
  • acp.default_model이 백엔드에서 사용 가능한 모델과 일치하는지 확인합니다
  • stderr 로그에서 백엔드 연결 오류를 살펴봅니다

-32602로 프롬프트가 거부되는 경우

라우터는 요청의 model 필드를 먼저 보고 없으면 acp.default_model을 사용합니다. -32602가 나오는 경우는 세 가지입니다.

  • "No model specified": 요청과 acp.default_model 모두 모델을 지정하지 않았습니다. session/prompt 파라미터에 "model"을 추가하거나 설정에 acp.default_model을 지정하세요.
  • "Model name exceeds maximum length of 256 characters": 요청이나 acp.default_model의 이름이 공백을 제거한 뒤에도 256바이트를 넘습니다. data.code는 model_too_long입니다. 오류는 이름을 다시 보여 주지 않고, 이 프롬프트는 통계 행을 남기지 않습니다.
  • "No backend serves model '<name>'": 이름은 정해졌지만 그 모델을 제공하는 백엔드가 없습니다. 오류의 data.source가 이름의 출처를 알려줍니다. request면 클라이언트가 없는 모델을 요청한 것이고 acp.default_model이면 설정이 없는 모델을 가리키는 것입니다. GET /v1/models와 이름을 대조하세요. 비교는 문자 그대로 이뤄지며 ACP 경로에서는 모델 별칭이 해석되지 않습니다.

internal: true 또는 enabled: false인 백엔드는 ACP 트래픽을 받지 않으므로, 그런 백엔드만 제공하는 모델은 제공되지 않는 것으로 보고됩니다.

-32603 "No backend is currently available"로 거부되는 경우

모델을 제공하는 백엔드는 있지만 지금 요청을 받을 수 있는 백엔드가 없습니다. 헬스 체크에 실패했거나 서킷 브레이커가 열린 상태입니다. GET /admin/health와 서킷 브레이커 상태를 확인하고 백엔드가 회복된 뒤 다시 시도하세요. ACP는 모델 폴백 체인을 적용하지 않으므로, 프롬프트가 조용히 다른 모델로 우회되지 않습니다.

권한 요청이 동작하지 않는 경우

도구 호출이 아무 안내 없이 거부된다면 권한 설정을 확인하세요:

acp:
  permissions:
    default_policy: ask_always  # 프로덕션에서는 "allow_all"을 사용하지 마세요
    auto_allow:
      - read
      - search
      - think

MCP 서버 연결에 실패하는 경우

  • MCP 서버 명령이 설치되어 있는지 확인합니다 (예: npx 사용 가능 여부)
  • 서버 ID가 설정과 일치하는지 확인합니다
  • server_spawn_timeout 설정을 확인합니다 (기본값 10초)

다음 단계