Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

### Changed

- 本机 agentic CLI 模式下 Agent 编排的自有步骤(路由 / 追问判读 / 评审总结)响应更快:每步不再加载用不到的 API 调用栈,降低每次响应的固定启动延迟,对话与自动评审整体更跟手。
- 配置面板改为左右分区布局:左侧分区导航(常规 / 连接 / AI / 关于)、右侧按分区归类展示配置项,替代此前单列平铺;分区结构为后续扩展(主题、编辑器风格、上下文窗口等)预留。
- **代码平台接入层基于领域设计重构**(行为不变):为后续平台接入与维护打基础。
- 按连接、PR 操作、评论、用户与媒体四个领域拆分为独立服务,职责更清晰、便于按领域独立维护与测试。
Expand Down
26 changes: 21 additions & 5 deletions apps/desktop/scripts/pragent-shim/meebox_pragent_shim/chat.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
"""编排器「独立 LLM 对话通道」的运行入口(见 docs/arch/06-agent.md §3 + packages/pr-agent-bridge)。

由嵌入式运行时以 `python -m meebox_pragent_shim.chat` 启动:复用 pr-agent **已被本 shim 补丁**的
`LiteLLMAIHandler.chat_completion`——provider 路由、CLI 模式(MEEBOX_CLI_MODE)、Anthropic 去
temperature、token usage 哨兵全部继承,无需在此重复实现。
由嵌入式运行时以 `python -m meebox_pragent_shim.chat` 启动,按 provider 分两条路:

- **CLI 模式**(MEEBOX_CLI_MODE 置位,本机 claude / codex):直接调 `cli.run_cli_chat`,**不 import
pr_agent / litellm**。CLI 路径的真实调用本就绕过 litellm(见 cli/install.py),此处避免每次 chat 子进程
为拿一个用不到的 LiteLLMAIHandler 而白白付整套 pr_agent + litellm import 开销——编排自有步骤(路由 /
judge / summary)每流程调多次,累计可观。

- **API 模式**(anthropic / openai / deepseek …):litellm 即 HTTP 客户端、无法绕开,复用 pr-agent
**已被本 shim 补丁**的 `LiteLLMAIHandler.chat_completion`——provider 路由、Anthropic 去 temperature、
提示缓存、token usage 哨兵全部继承,无需在此重复实现。

约定:stdin 收一段 JSON `{"system": ..., "user": ..., "temperature"?: ..., "max_output_tokens"?: ...}`,
回复正文写 stdout,token 用量经 `@@MEEBOX_USAGE@@` 哨兵打到 stderr(主进程与 pr-agent run 同一套累加,
见 ipc.ts)。max_output_tokens 封顶输出(轻量路由判读用),经 env 中转给 litellm_handler 补丁注入 litellm
max_tokens——仅嵌入式 litellm 路径生效,CLI provider 忽略。
max_tokens——仅嵌入式 litellm 路径生效,CLI provider 忽略(其算力档由 MEEBOX_CLI_REASONING 控制)
"""
import asyncio
import json
Expand All @@ -29,13 +36,22 @@ def _read_payload() -> dict:


async def _run(payload: dict) -> str:
# CLI 模式短路:直接调本机 CLI,绕过 litellm,且不 import pr_agent——省去整套 import 开销。
# model / temperature / max_output_tokens 在 CLI 路径用不到(命令与算力档由 spec + MEEBOX_CLI_*
# env 决定),忽略即可。
if os.environ.get("MEEBOX_CLI_MODE"):
from .cli.install import run_cli_chat

bin_name = (os.environ.get("MEEBOX_CLI_BIN") or "claude").strip() or "claude"
return await run_cli_chat(bin_name, payload["system"], payload["user"])

# 输出封顶:每次 chat 独立子进程,故置环境变量即「本次调用」级别——litellm_handler 补丁里
# 的 _get_completion 包装读它注入 litellm max_tokens(见 patches/litellm_handler)。
mot = payload.get("max_output_tokens")
if isinstance(mot, int) and mot > 0:
os.environ["MEEBOX_CHAT_MAX_TOKENS"] = str(mot)

# 惰性 import:触发 shim 注册的 post-import 补丁(CLI 模式替换 / _get_completion usage 包装)。
# 惰性 import:触发 shim 注册的 post-import 补丁(_get_completion usage 包装 / 提示缓存 / 去 temperature)。
from pr_agent.algo.ai_handlers.litellm_ai_handler import LiteLLMAIHandler
from pr_agent.config_loader import get_settings

Expand Down
165 changes: 91 additions & 74 deletions apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
"""CLI 模式:把 LiteLLMAIHandler.chat_completion 整体替换成「调本机 CLI 子进程」版本,
完全绕过 litellm / 直连 API。由 patches.litellm_handler 在 MEEBOX_CLI_MODE 置位时调用。"""
"""CLI 模式:调本机 CLI 子进程跑一轮对话,完全绕过 litellm / 直连 API。

两个入口共用同一份子进程逻辑 `run_cli_chat`:
- `_install_cli_chat_completion`:把 LiteLLMAIHandler.chat_completion 整体替换为调 CLI 的版本,
由 patches.litellm_handler 在 MEEBOX_CLI_MODE 置位时调用——服务 **pr-agent 工具 run**(/describe
/review /ask 经 `python -m pr_agent.cli`,其内部 LLM 调用必经 chat_completion)。
- `run_cli_chat`:编排 **chat 通道**(`python -m meebox_pragent_shim.chat`)在 CLI 模式下直接调它,
无需 import pr_agent / litellm(CLI 路径根本不用 litellm),省去每次 chat 子进程的整套 import 开销。
"""
import os
import sys

Expand All @@ -20,10 +27,12 @@ def _resolve_cli_exe(bin_name):
return exe, needs_cmd


def _install_cli_chat_completion(handler_cls, bin_name) -> None:
"""把 chat_completion 换成调本机 CLI 子进程的版本。pr-agent 只依赖 chat_completion 返回
(text, finish_reason) 这个稳定契约(base_ai_handler 定义),故本替换与 pr-agent 具体版本
无关,**不受版本守卫限制**(区别于依赖内部实现的其它 patch)。
async def run_cli_chat(bin_name, system, user) -> str:
"""调本机 CLI 子进程跑一轮 system+user 对话,返回回复正文(usage 经哨兵打 stderr)。

pr-agent 只依赖 chat_completion 返回 (text, finish_reason) 这个稳定契约(base_ai_handler 定义),
故 CLI 接管与 pr-agent 具体版本无关,**不受版本守卫限制**(区别于依赖内部实现的其它 patch)。本函数
自包含、不 import pr_agent / litellm,编排 chat 通道在 CLI 模式可直接调用以省去整套 import 开销。

各命令差异(argv flags / 输出解析 / 需剥离的计费 env)集中在 _CLI_SPECS,按命令名取用:
- prompt 经 **stdin** 喂入:review prompt 含完整 diff(数十 KB),走 argv 会撞命令行长度上限;
Expand All @@ -33,81 +42,89 @@ def _install_cli_chat_completion(handler_cls, bin_name) -> None:
完整文件;describe/review 不下发该 env、维持中性临时目录。净化在主进程侧做(清空仓库自带指令文件)。
- 子进程继承父 env(PATH / HOME / 代理变量),故能找到命令、复用其登录态、出站自动走代理。
- **凭据隔离**:剥掉对应计费 key(claude: ANTHROPIC_*;codex: OPENAI_API_KEY / CODEX_API_KEY),
让 CLI 使用其自身登录会话,而非环境里残留的 API key。模型与额度由该 CLI 账户与用户授权决定。"""
让 CLI 使用其自身登录会话,而非环境里残留的 API key。模型与额度由该 CLI 账户与用户授权决定。
"""
import asyncio
import tempfile

name = (bin_name or "").strip().lower()
spec = _CLI_SPECS.get(name)
exe, needs_cmd = _resolve_cli_exe(bin_name) if spec else (None, False)
# 命令前缀(cmd 包装 + exe);exe 解析失败为 None。flags 每次调用按 env 组装(低算力档)。
cmd_prefix = (["cmd", "/c", exe] if needs_cmd else [exe]) if (spec and exe) else None

def _build_argv():
flags = list(spec["flags"])
# 低算力档:仅 Agent 编排通道经 MEEBOX_CLI_REASONING=low/minimal 开启;把 low_effort_flags
# 插到尾部 `-`(stdin 占位)之前、保持 `-` 在末位;无尾部 `-` 则直接追加。
if os.environ.get("MEEBOX_CLI_REASONING", "").strip().lower() in ("low", "minimal"):
extra = list(spec.get("low_effort_flags") or [])
if extra:
flags = flags[:-1] + extra + ["-"] if flags and flags[-1] == "-" else flags + extra
return cmd_prefix + flags
if spec is None:
raise RuntimeError(
f"不支持的本地 CLI 命令 '{bin_name}'(当前已适配 claude / codex)。"
)
exe, needs_cmd = _resolve_cli_exe(bin_name)
# 命令前缀(cmd 包装 + exe);exe 解析失败为 None。
cmd_prefix = (["cmd", "/c", exe] if needs_cmd else [exe]) if exe else None
if cmd_prefix is None:
raise RuntimeError(
f"找不到本地 CLI 命令 '{bin_name}':请确认已安装、已登录,且 '{bin_name}' 在 PATH 中。"
)

# 低算力档:仅 Agent 编排通道经 MEEBOX_CLI_REASONING=low/minimal 开启;把 low_effort_flags
# 插到尾部 `-`(stdin 占位)之前、保持 `-` 在末位;无尾部 `-` 则直接追加。
flags = list(spec["flags"])
if os.environ.get("MEEBOX_CLI_REASONING", "").strip().lower() in ("low", "minimal"):
extra = list(spec.get("low_effort_flags") or [])
if extra:
flags = flags[:-1] + extra + ["-"] if flags and flags[-1] == "-" else flags + extra
argv = cmd_prefix + flags

# CLI 单轮无独立 system 槽:system+user 拼一段。先剥除缓存断点标记(仅 Anthropic litellm 路径用于
# 分块缓存;CLI 不缓存、标记不得进入 prompt)。
system = strip_cache_break(system) if system else system
prompt = f"{system}\n\n\n{user}" if system else user
# 基于 os.environ 拷贝再剔除计费 key——其余(PATH/HOME/代理变量等)原样保留。
child_env = {k: v for k, v in os.environ.items() if k not in spec["strip_env"]}
try:
proc = await asyncio.create_subprocess_exec(
*argv,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=(os.environ.get("MEEBOX_CLI_WORKDIR") or "").strip() or tempfile.gettempdir(),
env=child_env,
)
except Exception as exc: # noqa: BLE001
raise RuntimeError(f"启动 CLI '{bin_name}' 失败: {exc}") from exc
out, err = await proc.communicate(prompt.encode("utf-8"))
if proc.returncode != 0:
raise RuntimeError(
f"CLI '{bin_name}' 退出码 {proc.returncode}: "
f"{(err or b'').decode('utf-8', 'replace')[:500]}"
)
text, usage = spec["parser"]((out or b"").decode("utf-8", "replace"))
if usage:
# prompt_tokens ≈ 输入侧总规模,output_tokens ≈ completion(input/output_tokens 两家同名)。
# 缓存字段两家约定不同:
# - Anthropic(claude):input_tokens **不含**缓存,cache_read/创建需累加进总量;
# cache_read 用 cache_read_input_tokens。
# - OpenAI(codex):input_tokens **已含**缓存,cached_input_tokens 仅作命中量、不再计入总量。
prompt_tokens = usage.get("input_tokens")
for k in ("cache_read_input_tokens", "cache_creation_input_tokens"):
v = usage.get(k)
if isinstance(v, int):
prompt_tokens = (prompt_tokens or 0) + v
cache_read = usage.get("cache_read_input_tokens")
if not isinstance(cache_read, int):
cache_read = usage.get("cached_input_tokens") # codex/OpenAI 风格
turns = usage.get("num_turns")
_emit_usage_tokens(
prompt_tokens,
usage.get("output_tokens"),
cache_read_tokens=cache_read if isinstance(cache_read, int) else None,
turns=turns if isinstance(turns, int) else None,
)
return text


def _install_cli_chat_completion(handler_cls, bin_name) -> None:
"""把 chat_completion 换成调本机 CLI 子进程的版本(委托 run_cli_chat),服务 pr-agent 工具 run。
chat_completion 的 model / temperature / img_path 在 CLI 路径里用不到(命令与算力档由 spec + env 决定),
仅为满足 base_ai_handler 的方法签名而保留。"""

async def chat_completion(self, model, system, user, temperature=0.2, img_path=None):
if spec is None:
raise RuntimeError(
f"不支持的本地 CLI 命令 '{bin_name}'(当前已适配 claude / codex)。"
)
if cmd_prefix is None:
raise RuntimeError(
f"找不到本地 CLI 命令 '{bin_name}':请确认已安装、已登录,且 '{bin_name}' 在 PATH 中。"
)
argv = _build_argv()
# CLI 单轮无独立 system 槽:system+user 拼一段。先剥除缓存断点标记(仅 Anthropic litellm 路径用于
# 分块缓存;CLI 不缓存、标记不得进入 prompt)。
system = strip_cache_break(system) if system else system
prompt = f"{system}\n\n\n{user}" if system else user
# 基于 os.environ 拷贝再剔除计费 key——其余(PATH/HOME/代理变量等)原样保留。
child_env = {k: v for k, v in os.environ.items() if k not in spec["strip_env"]}
try:
proc = await asyncio.create_subprocess_exec(
*argv,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=(os.environ.get("MEEBOX_CLI_WORKDIR") or "").strip() or tempfile.gettempdir(),
env=child_env,
)
except Exception as exc: # noqa: BLE001
raise RuntimeError(f"启动 CLI '{bin_name}' 失败: {exc}") from exc
out, err = await proc.communicate(prompt.encode("utf-8"))
if proc.returncode != 0:
raise RuntimeError(
f"CLI '{bin_name}' 退出码 {proc.returncode}: "
f"{(err or b'').decode('utf-8', 'replace')[:500]}"
)
text, usage = spec["parser"]((out or b"").decode("utf-8", "replace"))
if usage:
# prompt_tokens ≈ 输入侧总规模,output_tokens ≈ completion(input/output_tokens 两家同名)。
# 缓存字段两家约定不同:
# - Anthropic(claude):input_tokens **不含**缓存,cache_read/创建需累加进总量;
# cache_read 用 cache_read_input_tokens。
# - OpenAI(codex):input_tokens **已含**缓存,cached_input_tokens 仅作命中量、不再计入总量。
prompt_tokens = usage.get("input_tokens")
for k in ("cache_read_input_tokens", "cache_creation_input_tokens"):
v = usage.get(k)
if isinstance(v, int):
prompt_tokens = (prompt_tokens or 0) + v
cache_read = usage.get("cache_read_input_tokens")
if not isinstance(cache_read, int):
cache_read = usage.get("cached_input_tokens") # codex/OpenAI 风格
turns = usage.get("num_turns")
_emit_usage_tokens(
prompt_tokens,
usage.get("output_tokens"),
cache_read_tokens=cache_read if isinstance(cache_read, int) else None,
turns=turns if isinstance(turns, int) else None,
)
text = await run_cli_chat(bin_name, system, user)
return text, "stop"

handler_cls.chat_completion = chat_completion
Expand Down
9 changes: 8 additions & 1 deletion docs/arch/04-pragent-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,14 @@ litellm**。
- **接入点**:env `MEEBOX_CLI_MODE=1` + `MEEBOX_CLI_BIN=claude`(由 `buildPragentEnv` 注入)→ shim 把
`LiteLLMAIHandler.chat_completion` 整体换成「起 `claude -p --output-format json` 子进程、prompt 走 stdin、
解析 JSON 的 `result` 文本 + `usage`」的版本,返回 `(text, "stop")`。**只依赖 `base_ai_handler` 的稳定契约,
不受版本守卫限制**(区别于其它依赖内部实现的补丁,放在版本守卫之前)。
不受版本守卫限制**(区别于其它依赖内部实现的补丁,放在版本守卫之前)。子进程调用逻辑抽在 `cli/install.py`
的 `run_cli_chat`,`_install_cli_chat_completion`(服务 pr-agent 工具 run)与编排 chat 通道共用它。
- **编排 chat 通道 CLI 短路**:上一条服务的是 **pr-agent 工具 run**(`/describe` `/review` `/ask` 经 `pr_agent.cli`,
必经 `chat_completion`)。**编排自有步骤**(路由 / judge / summary 经 `meebox_pragent_shim.chat`)则在 CLI 模式
**直接调 `run_cli_chat`、不 import pr_agent / litellm**——CLI 路径本就不用 litellm,无谓地拉起整套 pr_agent +
litellm import 会给每次 chat 子进程白增数百 ms~1s+ 启动开销,而编排一个流程要调多次。API 模式无此短路(litellm
即 HTTP 客户端、不可绕),仍复用被补丁的 `LiteLLMAIHandler` 以继承 provider 路由 / 去 temperature / 提示缓存 /
usage 哨兵。
- **prompt 走 stdin**:review prompt 含完整 diff(数十 KB),走 argv 会撞命令行长度上限;system/user 合并成
一段喂入(CLI 无独立 system 槽)。cwd 默认落到中性临时目录,避免吃到被评审仓库的 `CLAUDE.md`/`AGENTS.md`。
- **`/ask` 例外(取完整文件上下文)**:自由问答需读真实文件,仅对 `/ask` 由主进程下发 env `MEEBOX_CLI_WORKDIR`
Expand Down
5 changes: 3 additions & 2 deletions packages/pr-agent-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,9 @@ export class EmbeddedRuntimeBridge extends BaseBridge {
env?: Record<string, string>;
cwd?: string;
} {
// 跑随运行时打包的 chat helper:复用 pr-agent 已被 shim 补丁的 LiteLLMAIHandler
// (provider 路由 / CLI 模式 / 去 temperature / usage 哨兵全继承)。
// 跑随运行时打包的 chat helper:API 模式复用 pr-agent 已被 shim 补丁的 LiteLLMAIHandler
// (provider 路由 / 去 temperature / 提示缓存 / usage 哨兵全继承);CLI 模式(MEEBOX_CLI_MODE)
// 在 helper 内直接调本机 CLI、不 import pr_agent / litellm,省每次启动的 import 开销。
return {
cmd: this.pythonPath,
args: ['-m', 'meebox_pragent_shim.chat'],
Expand Down
Loading