深入 ACP 协议:将 Kiro CLI 封装为 REST API 的双通道架构实现

把一个只有 stdio 接口的 CLI 工具变成可编程的 REST API,核心难点不在 HTTP 层,而在于搞清楚底层协议的实际行为。

最近我在做 AI 编码工具的集成。Kiro CLI 是亚马逊云科技推出的终端 AI 编码工具,代码生成、分析、重构都支持。问题在于:它只有 stdio 接口,没有 HTTP 端口,没有 SDK。想在自动化流水线里用它,只能自己封装。

这篇文章记录了完整的封装过程。重点是 ACP 协议的通信机制、模型切换的限制分析,以及最终的双通道架构设计。

项目结构

最终只有两个文件,约 600 行 Python:

kiro-api/
├── acp_client.py   # ACP JSON-RPC 2.0 客户端(~300 行,纯 stdlib)
└── server.py       # FastAPI HTTP 服务(~280 行)

完整源码已在上文逐段展示,可直接复制使用。

ACP 协议详解

Kiro CLI 通过 kiro-cli acp 子命令暴露了 ACP(Agent Communication Protocol)协议。底层是 JSON-RPC 2.0 over stdio 的双向通信。

核心方法

方法 方向 语义
initialize Client → Kiro 协议握手,声明客户端能力
session/new Client → Kiro 创建编码会话,返回 session_id
session/prompt Client → Kiro 发送编码任务,阻塞等待结果
session/update Kiro → Client 流式推送文本块(通知,无 id)
session/request_permission Kiro → Client 敏感操作审批请求(有 id,需回复)
_kiro.dev/metadata Kiro → Client Credits 和上下文用量(通知)

协议中有两类消息需要区分:

  • 通知(Notification):无 id 字段,单向推送,不需要回复
  • 请求(Request):有 id 字段,需要回复

session/update 是通知,session/request_permission 是请求。搞混这两个会导致严重问题——权限请求不回复,进程就永远挂着。

请求-响应的异步特性

ACP 的 stdio 通信是异步的。你往 stdin 写一条请求之后,stdout 上不会直接给你响应。中间可能穿插多条 session/update 通知和 _kiro.dev/metadata 通知,最后才是你的响应。

这意味着:你不能简单地"写一行、读一行"。需要一个消息分发机制。

模型切换:8 种方式的系统性验证

我的需求是在 API 层支持动态指定模型。下面是系统性的验证过程:

# 尝试方式 结果 分析
1 session/new 加 model 参数 忽略 协议不识别该字段
2 session/setModel 方法 Method not found 协议未定义
3 session/configure 方法 Method not found 协议未定义
4 _kiro.dev/commands/execute/model 进程崩溃 反序列化错误
5 kiro-cli acp --model X unexpected argument CLI 不支持该参数
6 kiro-cli-chat acp --model X unexpected argument CLI 不支持
7 环境变量 KIRO_MODEL 忽略 不被识别
8 ~/.kiro/settings/cli.json 忽略 不影响运行时行为

结论:ACP v1.25 不支持运行时模型切换。 这是协议的设计限制。

替代方案:kiro-cli chat --model X --no-interactive。这个命令支持指定模型,但以一次性子进程运行,不支持多轮会话。

双通道架构

基于上述限制,设计了双通道架构:

HTTP 客户端(curl / Python / JS / 任意 HTTP 客户端)
        |
        | HTTP REST (port 8642)
        v
  FastAPI Server (server.py)
        |
   -----+--------------------
   |                        |
   | model=auto             | model=指定模型
   v                        v
 ACP 通道                  Chat 通道
 acp_client.py             server.py (subprocess)
 常驻进程                   一次性子进程
 JSON-RPC 2.0 over stdio   stdout 文本解析
 多轮会话支持               单次调用

详细对比:

维度 ACP 通道 Chat 通道
实现位置 acp_client.py server.py
通信协议 JSON-RPC 2.0 over stdio subprocess + 文本解析
进程模型 常驻进程 每次调用新建子进程
模型选择 不支持(固定 auto) 支持 --model
多轮会话 支持 不支持
冷启动 约 3 秒
稳定性 结构化协议,可靠 文本解析,较脆弱

设计原则:优先走 ACP 通道。只有在需要指定模型时才降级到 Chat 通道。

关键实现一:Event + Pending Map 同步机制

异步 stdio 转同步调用的核心代码:

class ACPClient:
    def __init__(self):
        self._pending = {}  # {req_id: (Event, [result, error])}
    
    def _send_request(self, method, params, timeout=60):
        req_id = self._next_id()
        event = threading.Event()
        self._pending[req_id] = (event, [None, None])
        
        msg = {"jsonrpc": "2.0", "id": req_id,
               "method": method, "params": params}
        self._proc.stdin.write(json.dumps(msg) + "\n")
        self._proc.stdin.flush()
        
        # 阻塞等待
        if not event.wait(timeout):
            raise TimeoutError(f"Request {method} timed out")
        
        _, holder = self._pending.pop(req_id)
        if holder[1]:  # error
            raise RPCError(holder[1])
        return holder[0]  # result

读线程负责分发:

def _read_loop(self):
    for line in self._proc.stdout:
        msg = json.loads(line)
        
        if "id" in msg and "result" in msg:
            # 响应:按 id 匹配并唤醒
            if msg["id"] in self._pending:
                event, holder = self._pending[msg["id"]]
                holder[0] = msg["result"]
                event.set()
        
        elif "method" in msg:
            # 通知或请求:按 method 分发
            self._handle_notification(msg)

关键实现二:流式文本收集

session/prompt 的最终响应只有结束标志:

{"jsonrpc": "2.0", "id": 3, "result": {"stopReason": "end_turn"}}

文本内容通过 session/update 通知逐块到达:

← {"method": "session/update", "params": {"content": {"type": "text", "text": "```python"}}}
← {"method": "session/update", "params": {"content": {"type": "text", "text": "\ndef fibonacci(n):"}}}
← ...
← {"jsonrpc": "2.0", "id": 3, "result": {"stopReason": "end_turn"}}

处理方式:

self._session_updates = {}  # {session_id: [text_chunk, ...]}

def _handle_session_update(self, params):
    sid = params.get("sessionId")
    content = params.get("content", {})
    if content.get("type") == "text":
        self._session_updates.setdefault(sid, []).append(content["text"])

def _get_collected_text(self, session_id):
    chunks = self._session_updates.pop(session_id, [])
    return "".join(chunks)

关键实现三:权限请求处理

权限请求是带 id 的同步 request,必须回复:

def _handle_notification(self, msg):
    method = msg.get("method")
    if method == "session/request_permission" and "id" in msg:
        # 必须回复!否则 Kiro 永远阻塞
        self._send_response(msg["id"], {"optionId": "allow_always"})
    elif method == "session/update":
        self._handle_session_update(msg.get("params", {}))

关键实现四:Chat 输出清洗

Chat 通道的 stdout 面向终端用户,混杂大量 UI 元素:

def _clean_chat_output(raw: str) -> str:
    import re
    # 1. 去 ANSI 转义码
    text = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', raw)
    
    # 2. 跳过 banner 和提示框
    lines = text.split('\n')
    content_start = None
    content_end = None
    
    for i, line in enumerate(lines):
        if line.strip().startswith('>'):
            content_start = i
            break
    
    for i in range(len(lines) - 1, -1, -1):
        if '▸' in lines[i] and 'Credits' in lines[i]:
            content_end = i
            break
    
    # 3. 提取有效内容
    if content_start is not None:
        end = content_end or len(lines)
        return '\n'.join(lines[content_start:end])
    return text

关键实现五:stderr 死锁防护

def _drain_stderr(self):
    """专门的线程排空 stderr,防止缓冲区满导致进程阻塞"""
    for line in self._proc.stderr:
        pass  # 读掉即可

这个问题在 stdio 双向通信中很常见。不处理 stderr,高频输出时整个进程会死锁。

REST API 接口

接口 方法 说明
/prompt POST 快捷调用,支持指定模型
/sessions POST 创建多轮会话
/sessions/{id}/prompt POST 会话内发送任务
/sessions GET 列出活跃会话
/sessions/{id} DELETE 删除会话
/models GET 列出可用模型
/ GET 健康检查

已知限制

限制 说明
无鉴权 仅限内网使用,不建议暴露公网
单 ACP 连接 高并发需排队处理
会话不持久化 服务重启后丢失
Chat 无上下文 每次新进程
输出清洗脆弱 依赖 CLI 输出格式

总结

类别 结论
协议限制 ACP v1.25 不支持运行时切换模型
架构决策 双通道:多轮走 ACP,指定模型走 Chat
关键发现 session/update 通知才是文本载体
关键发现 权限请求是同步阻塞 request
关键发现 Chat stdout 混杂 UI 元素,需清洗
关键发现 stdio 必须读写线程分离,stderr 必须排空

把 CLI 工具封装成 API,核心工作量在于理解协议的真实行为。官方文档没覆盖的部分,只能靠调试和抓包。

完整源码已在上文逐段展示,可直接复制使用。


本文基于亚马逊云科技官方博客整理。原文:将 Kiro CLI 封装为 REST API:双通道架构实践

posted @ 2026-04-08 10:50  亚马逊云开发者  阅读(23)  评论(0)    收藏  举报