Files
multiAgentTry/backend/app/services/cli_invoker.py

280 lines
8.2 KiB
Python
Raw Normal View History

"""
CLI 调用器
通过子进程调用真实的 AI CLI 工具Claude Code / Kimi CLI / OpenCode
prompt 发送给 CLI 并捕获输出
支持的 CLI
- claude: Claude Code CLI使用 -p 参数发送单轮 prompt
- kimi: Kimi CLI使用 -p 参数发送单轮 prompt
- opencode: OpenCode CLI
"""
import asyncio
import logging
import os
import re
import time
import shutil
from typing import Optional, Tuple
from dataclasses import dataclass
logger = logging.getLogger(__name__)
# CLI 命令映射model 前缀 → (二进制名, 构造参数的函数)
CLI_REGISTRY = {
"claude": "claude",
"kimi": "kimi",
"opencode": "opencode",
}
@dataclass
class CLIResult:
"""CLI 调用结果"""
content: str
cli_name: str
exit_code: int
latency: float
success: bool
error: str = ""
def detect_available_clis() -> dict:
"""检测系统中可用的 CLI 工具"""
available = {}
for name, binary in CLI_REGISTRY.items():
path = shutil.which(binary)
if path:
available[name] = path
return available
def resolve_cli(model: str) -> Optional[str]:
"""
根据 agent model 字段判断应使用哪个 CLI
规则
- "claude" 开头 claude CLI
- "kimi" 开头 kimi CLI
- "opencode" 开头 opencode CLI
- 完全匹配 CLI 直接使用
"""
model_lower = model.lower().strip()
for prefix in CLI_REGISTRY:
if model_lower.startswith(prefix):
return prefix
if model_lower in CLI_REGISTRY:
return model_lower
return None
async def invoke_cli(
cli_name: str,
prompt: str,
timeout: int = 120,
max_tokens: int = 1024,
system_prompt: str = "",
) -> CLIResult:
"""
调用指定的 CLI 工具并返回结果
参数:
cli_name: CLI 名称claude / kimi / opencode
prompt: 要发送的 prompt
timeout: 超时秒数
max_tokens: 最大 token
"""
binary = CLI_REGISTRY.get(cli_name)
if not binary:
return CLIResult(
content="", cli_name=cli_name, exit_code=-1,
latency=0, success=False, error=f"未知 CLI: {cli_name}"
)
# 必须获取完整路径,否则 subprocess 在不同环境下可能找不到
full_path = shutil.which(binary)
if not full_path:
return CLIResult(
content="", cli_name=cli_name, exit_code=-1,
latency=0, success=False, error=f"CLI 未安装: {binary}"
)
cmd = _build_command(cli_name, prompt, max_tokens, full_path, system_prompt)
logger.info(f"调用 CLI [{cli_name}]: {full_path} (prompt 长度={len(prompt)})")
# Windows 下需要设置 PYTHONIOENCODING 解决 GBK 编码问题
env = dict(os.environ)
env["PYTHONIOENCODING"] = "utf-8"
start = time.time()
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
try:
# 立即关闭 stdin防止 CLI 阻塞等待输入
stdout, stderr = await asyncio.wait_for(
proc.communicate(input=b""), timeout=timeout
)
except asyncio.TimeoutError:
proc.kill()
await proc.communicate()
return CLIResult(
content="", cli_name=cli_name, exit_code=-1,
latency=time.time() - start, success=False,
error=f"CLI 超时 ({timeout}s)"
)
latency = time.time() - start
stdout_text = stdout.decode("utf-8", errors="replace").strip()
stderr_text = stderr.decode("utf-8", errors="replace").strip()
# 过滤掉 OpenCode 的 INFO 日志行和 kimi 的框线
stdout_text = _clean_output(cli_name, stdout_text)
if proc.returncode == 0 and stdout_text:
logger.info(f"CLI [{cli_name}] 完成: {latency:.1f}s, {len(stdout_text)} chars")
return CLIResult(
content=stdout_text,
cli_name=cli_name,
exit_code=0,
latency=round(latency, 2),
success=True,
)
else:
error_msg = stderr_text or f"退出码 {proc.returncode}"
logger.warning(f"CLI [{cli_name}] 失败: {error_msg}")
return CLIResult(
content=stdout_text or "",
cli_name=cli_name,
exit_code=proc.returncode or -1,
latency=round(latency, 2),
success=False,
error=error_msg,
)
except FileNotFoundError:
return CLIResult(
content="", cli_name=cli_name, exit_code=-1,
latency=0, success=False, error=f"找不到命令: {binary}"
)
except Exception as e:
return CLIResult(
content="", cli_name=cli_name, exit_code=-1,
latency=time.time() - start, success=False, error=str(e)
)
def _build_command(
cli_name: str, prompt: str, max_tokens: int, full_path: str, system_prompt: str = ""
) -> list:
"""
为不同 CLI 构造命令行参数
使用完整二进制路径确保跨环境兼容
"""
default_sys = (
"这是一个角色扮演讨论场景,不是编程任务。"
"请直接用中文回答,不要使用任何工具、不要读取文件、不要执行代码。"
"直接给出你作为角色的观点和建议2-3句话即可。"
)
sys_prompt = system_prompt or default_sys
if cli_name == "claude":
return [
full_path,
"-p", prompt,
"--output-format", "text",
"--system-prompt", sys_prompt,
]
elif cli_name == "kimi":
return [
full_path,
"-p", f"{sys_prompt}\n\n{prompt}",
]
elif cli_name == "opencode":
return [
full_path,
"run", f"{sys_prompt}\n\n{prompt}",
"--model", "opencode/minimax-m2.5-free",
]
else:
return [full_path, "-p", prompt]
def _clean_output(cli_name: str, text: str) -> str:
"""清理 CLI 输出中的框线、日志、prompt 回显等噪音"""
if cli_name == "kimi":
return _clean_kimi_output(text)
lines = text.splitlines()
cleaned = []
for line in lines:
if line.strip().startswith("INFO "):
continue
cleaned.append(line)
result = "\n".join(cleaned).strip()
return result if result else text.strip()
def _clean_kimi_output(text: str) -> str:
"""
Kimi CLI 输出格式
(prompt 回显)
思考过程...
实际回复内容
需要1) 移除框线和框内的 prompt 回显
2) 只保留最后一个 bullet 作为实际回复
"""
lines = text.splitlines()
# 找到框线结束位置(最后一个 └ 或 ╰ 行)
box_end = -1
for i, line in enumerate(lines):
stripped = line.strip()
if stripped and stripped[0] in "└╰" and all(
c in "└┘─╰╯ " for c in stripped
):
box_end = i
# 跳过框线区域
content_lines = lines[box_end + 1:] if box_end >= 0 else lines
# Kimi 用 • 输出思考过程和最终回复,最后一个 • 块通常是实际回复
bullets = []
current_bullet = []
for line in content_lines:
stripped = line.strip()
if not stripped:
if current_bullet:
current_bullet.append(line)
continue
if stripped.startswith("") or stripped.startswith("? "):
if current_bullet:
bullets.append("\n".join(current_bullet))
current_bullet = [stripped.lstrip("•? ").strip()]
elif current_bullet:
current_bullet.append(stripped)
if current_bullet:
bullets.append("\n".join(current_bullet))
if not bullets:
return text.strip()
# 最后一个 bullet 是实际回复
return bullets[-1].strip()