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
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,64 @@ ACP bridge 额外暴露了 ZCode 新版协议方法,供编辑器/脚本调用
- `--prompt` 创建的会话可被 `session/list` 看到、被 `--resume` 或 ACP resume 恢复
- 反之亦然

## 凭证与限流(自动化集成必读)

### Model ID 格式(canonical)

**canonical model id = `~/.zcode/v2/config.json` 里 `models` 的 key 原样**(如 `GLM-5.2`),**不加 provider 前缀**。`shared/credentials.py`、MCP server、ACP bridge 三处统一用原始 id。实测 `zai/GLM-5.2` 也兼容,但非 canonical,本项目不使用。

### 凭证注入:显式环境变量优先

三个组件注入凭证的合并顺序为 `{**config_creds, **os.environ}`——**已显式设置的环境变量覆盖 config 读出的值**。便于不改 config 临时调试/覆盖:

```bash
# 临时用另一个模型跑 ACP bridge(覆盖 config 的 GLM-5.2)
ZCODE_MODEL=GLM-5-Turbo ./packages/acp-bridge/zcode-acp-bridge

# 临时覆盖 MCP server 的 baseURL
ZCODE_BASE_URL=https://api.z.ai/api/anthropic ./packages/mcp-server/zcode-mcp-server
```

诊断"实际会注入哪些凭证"(不改 config、apiKey 脱敏):
```bash
./packages/agent-help/zcode-agent-help --print-injected-env
# 输出每个 key 的来源(config vs env)+ 最终值(apiKey 脱敏)
```

### ZCODE_BASE_URL 残留自动检测(切换过 plan 的用户)

**背景**:ZCode App 切换过订阅 plan 的用户,旧 plan 的 baseURL 会残留在子进程环境(即使该 plan 已失效)。例如 `ZCODE_BASE_URL=https://zcode.z.ai`(start-plan 残留)与 config 当前 enabled 的 `zai-coding-plan`(`api.z.ai`)不一致,会导致 headless 调用打到错误端点(404)。

**自动自愈**:bridge 启动时检测——若 env 的 `ZCODE_BASE_URL` 指向 config 里**另一个 provider** 的官方 endpoint(host 匹配),判定为 App 注入的残留 → **自动用 config enabled provider 的值** + stderr 告警。用户自建/代理 endpoint(不在 config 任何 provider 里)则正常尊重 env(issue #3 调试场景不受影响)。

```bash
# 用 --print-injected-env 看是否检测到残留 (标 🚫)
./packages/agent-help/zcode-agent-help --print-injected-env
```

### 限流与重试(MCP server)

`zcode_review` 调用 headless zcode 做 PR 审查时,多个 gate 并发可能触发 provider 限流。MCP server 做了三层防护(issue #3):

| 机制 | 行为 | 配置 |
|------|------|------|
| **进程级文件锁** | 多个 MCP client 并发调用时,串行化 headless review,防并发触发限流 | `ZCODE_BRIDGE_REVIEW_LOCK=0` 关闭 |
| **provider 错误解析** | 识别 429 / 1302 / `Too Many Requests` / `请求过于频繁` / `retry-after`,区分限流/配额/其他 | — |
| **有限重试 + 退避** | 仅对**限流**错误重试(配额/Unauthorized 不重试),退避用 retry-after 或指数退避(`2^n+1`) | `ZCODE_BRIDGE_MAX_RETRIES`(默认 3) |

注意:zcode 内部已有自己的指数退避重试(`_retryWithExponentialBackoff`),MCP 层的重试是补充,默认保守(max 3)。

### 聚焦审查 prompt 建议

为降低限流风险,自动化审查时建议:把 diff/证据作为 `--attach` 附件,prompt 写明"不要 spawn 子代理、不要探索文件系统、只基于附件推理"。

## 版本兼容性

本项目兼容以下 ZCode 版本,**对旧版完全向后兼容**:

| ZCode CLI 版本 | 支持情况 | ACP bridge 流式 | 扩展方法 |
|:--------------:|:--------:|:---------------:|:--------:|
| **0.15.0+**(App 3.2.0) | ✅ 完整 | **真流式**(事件驱动) | ✅ 全部(含 workspace/*、setThoughtLevel 等) |
| **0.15.0+**(App 3.2.0+) | ✅ 完整 | **真流式**(事件驱动) | ✅ 全部(含 workspace/*、setThoughtLevel 等) |
| **0.14.8**(App 3.1.4) | ✅ 完整 | **真流式**(事件驱动) | ✅ fork/rewind/goal/compact/steer |
| **0.14.5 ~ 0.14.7** | ✅ 兼容 | 伪流式(自动降级轮询) | ❌(旧版协议未实现) |
| **< 0.14.5** | ⚠️ 未测 | — | — |
Expand Down
93 changes: 87 additions & 6 deletions packages/acp-bridge/zcode-acp-bridge
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,52 @@ def load_zcode_credentials():
return {}


def _merge_env_with_creds(creds):
"""合并 config 凭证与环境变量: 显式非空 env 优先, 空串视为未设置;
且自动检测/自愈 App 注入的残留 baseURL (issue #3)。

源自 shared/credentials.py.merge_env_with_creds (单文件可独立运行故内嵌)。
残留检测: env 的 ZCODE_BASE_URL 若是 config 里"另一个 provider"的官方 endpoint
(App 切换 plan 后的残留), 自愈用 config 值; 用户自建 endpoint 则尊重 env。
"""
merged = {**os.environ, **creds}
for k in ("ZCODE_MODEL", "ZCODE_BASE_URL", "ANTHROPIC_API_KEY"):
v = os.environ.get(k)
if v: # 仅非空才覆盖
merged[k] = v
# 残留检测
env_bu = os.environ.get("ZCODE_BASE_URL", "")
config_bu = creds.get("ZCODE_BASE_URL", "")
if env_bu and config_bu and env_bu != config_bu:
all_hosts = set()
try:
from urllib.parse import urlparse as _up
with open(ZCODE_CREDS_PATH) as _f:
_cfg = json.load(_f)
for _p in _cfg.get("provider", {}).values():
_bu = _p.get("options", {}).get("baseURL", "")
if _bu:
try:
_h = _up(_bu)
if _h.hostname:
all_hosts.add(f"{_h.scheme}://{_h.netloc}" if _h.scheme else _h.netloc)
except Exception:
pass
_eh = _up(env_bu)
env_host = f"{_eh.scheme}://{_eh.netloc}" if (_eh.scheme and _eh.hostname) else None
_ch = _up(config_bu)
config_host = f"{_ch.scheme}://{_ch.netloc}" if (_ch.scheme and _ch.hostname) else None
except Exception:
env_host = config_host = None
# env host 等于某 config provider host, 但不等于 enabled 那个 → 残留
if env_host and config_host and env_host != config_host and env_host in all_hosts:
merged["ZCODE_BASE_URL"] = config_bu
log(f"⚠ ZCODE_BASE_URL 残留检测: env='{env_bu}' 是 config 里另一个 provider "
f"的 endpoint, 与 enabled provider (config='{config_bu}') 不一致。已自动用 "
f"config 值 (旧 plan 残留)。")
return merged


# ============================================================
# ZCode app-server 的 client 封装
# ============================================================
Expand All @@ -114,7 +160,9 @@ class ZCodeBackend:
"""

def __init__(self):
env = {**os.environ, **load_zcode_credentials()}
# 显式非空环境变量优先 (issue #3 子项2): ZCODE_MODEL=xxx 临时覆盖生效,
# 但空串 env 视为未设置 (保留 config 值, 避免误清空)。
env = _merge_env_with_creds(load_zcode_credentials())
self.proc = subprocess.Popen(
[ZCODE_BIN, "app-server", "--stdio"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
Expand Down Expand Up @@ -1631,7 +1679,8 @@ class ACPBridge:
"""
translator = EventTranslator()
t0 = time.time()
emitted_text = False # 本轮是否已通过事件发过文本 (用于决定兜底)
emitted_text = False # 本轮是否已通过事件发过文本 (决定兜底回复发不发)
emitted_output = False # 本轮是否已发过任何实质输出 (text/tool/patch, 决定失败检测)
last_event_time = time.time()
last_stall_check = 0 # 上次停滞检查的时间戳 (可重复检查)

Expand Down Expand Up @@ -1663,15 +1712,25 @@ class ACPBridge:
# 停滞收尾: 不调 differ.diff() (differ 不知道事件路径已发过什么,
# 会重复发 TextDelta/tool 事件)。只做兜底: 若事件路径未发文本,
# 用 _fetch_last_reply 取最后回复。
stall_reply = None
if not emitted_text:
reply_text = self._fetch_last_reply(zcode_sid, differ)
if reply_text:
stall_reply = self._fetch_last_reply(zcode_sid, differ)
if stall_reply:
self._send_acp_notification("session/update", {
"sessionId": acp_sid,
"update": {"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": reply_text},
"content": {"type": "text", "text": stall_reply},
"messageId": chunk_msg_id},
})
# 失败检测 (issue #3 子项3d / Codex P1#3): 停滞收尾时若事件路径既没收到
# turn.failed (否则已正常返回), 也无任何实质输出 (text/tool/patch),
# 疑似 turn 失败 (如 turn.completed 事件丢失 + provider 错误)。显式告警而非静默成功。
# 用 emitted_output (含 tool/patch) 而非 emitted_text, 避免纯工具 turn 误判。
if not emitted_output and not stall_reply:
log(" ⚠ 停滞收尾: turn 完成但事件路径无输出, 疑似失败")
return self._error(msg_id, -32603,
"zcode turn 完成但无输出 (停滞收尾疑似失败; "
"可能 turn 事件丢失或 provider 错误)")
return {"jsonrpc": "2.0", "id": msg_id,
"result": {"stopReason": "end_turn"}}
continue
Expand All @@ -1681,9 +1740,13 @@ class ACPBridge:
# 翻译事件 → ACP 事件 dict 列表
acp_events = translator.translate(event)
for ev in acp_events:
# 标记: 是否已通过 model.streaming 发过文本
# 标记: 是否已通过 model.streaming 发过文本 (决定兜底回复)
if ev.get("kind") == "TextDelta":
emitted_text = True
# 标记: 是否已发过任何实质输出 text/tool/patch (决定失败检测, Codex P1#3:
# 纯工具 turn 有 ToolCallNew 但无 TextDelta, 失败检测必须认 tool 才不误判)
if ev.get("kind") in ("TextDelta", "ToolCallNew", "FilesChanged"):
emitted_output = True
self._dispatch_event(ev, acp_sid, chunk_msg_id)

# turn 结束判定
Expand Down Expand Up @@ -1777,8 +1840,26 @@ class ACPBridge:
# turn 完成: 取一次完整 snapshot, 发本轮新增的 tool/text/reasoning/plan 事件
cur_snapshot = self._build_snapshot(zcode_sid, projection)
events = differ.diff(None, cur_snapshot)
has_output = False
for ev in events:
# 标记是否本轮产出了实质内容 (text/tool/patch)。PlanUpdate/UsageDelta
# 是辅助信息 (todos/usage 状态), 不是 turn 的实质产出, 不计入。
if ev.get("kind") in ("TextDelta", "ToolCallNew", "FilesChanged"):
has_output = True
self._dispatch_event(ev, acp_sid, chunk_msg_id)
# 失败检测 (issue #3 子项3d, 修复 PR#2 遗留):
# 轮询模式无法收到 turn.failed 事件 payload (zcode 只在事件里给 resultType,
# projection/messages 无失败标志, 实测确认)。但若 turn "完成"却完全无有效输出
# (只有 step-start 半成品), 强烈暗示 turn 失败 (如 provider 错误)。
# 此时返回明确错误而非静默 end_turn, 把"吞掉失败"变成"显式告警"。
if not has_output:
reply_text = self._fetch_last_reply(zcode_sid, differ)
if not reply_text:
del self.pending_turns[msg_id]
log(" ⚠ turn 完成但无任何输出 (无 text/tool/patch), 疑似 turn 失败")
return self._error(msg_id, -32603,
"zcode turn 完成但无输出 (轮询模式疑似失败; "
"可能 provider 错误, 见 stderr 日志)")
turn_completed = True
break

Expand Down
112 changes: 111 additions & 1 deletion packages/agent-help/zcode-agent-help
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ zcode-agent-help — ZCode 开放生态能力说明书
zcode-agent-help # 完整能力清单 (JSON)
zcode-agent-help --pretty # 人类可读的格式化输出
zcode-agent-help --section <name> # 只看某一节
zcode-agent-help --print-injected-env # 诊断: 会注入哪些凭证 (脱敏, issue #3)
zcode-agent-help --help # 本帮助

可用 section:
Expand All @@ -28,13 +29,118 @@ from pathlib import Path

ZCODE_BIN = os.environ.get("ZCODE_BIN", "zcode")

# 模型 ID 的 canonical 格式: config 里 models 的 key 原样 (如 GLM-5.2), 不加 provider 前缀。
# 实测 "zai/GLM-5.2" 也兼容, 但与 shared/credentials.py 权威实现不一致, 故统一用原始 id。
ZCODE_CREDS_PATH = Path.home() / ".zcode" / "v2" / "config.json"


def _load_creds_internal(config_path=None):
"""内嵌的凭证读取 (源自 shared/credentials.py, 单文件可独立运行故内嵌)。

返回从 config 读出的凭证 dict (不含 env 覆盖); 读取失败返回 {}。
与 shared/credentials.py.load_zcode_credentials 行为一致 (原始 model id)。
"""
path = config_path or ZCODE_CREDS_PATH
try:
with open(path) as f:
cfg = json.load(f)
for _pid, p in cfg.get("provider", {}).items():
if p.get("enabled"):
opts = p.get("options", {})
models = p.get("models", {})
model_id = next(iter(models)) if models else "GLM-5.2"
return {
"ZCODE_MODEL": model_id,
"ZCODE_BASE_URL": opts.get("baseURL", ""),
"ANTHROPIC_API_KEY": opts.get("apiKey", ""),
}
except (FileNotFoundError, json.JSONDecodeError, KeyError):
return {}
except Exception:
return {}
return {}


def _mask_key(key):
"""脱敏 apiKey: 只留前 4 后 4。"""
if not key or len(key) <= 12:
return "(短或空)" if not key else f"{key[:2]}...{key[-2:]}"
return f"{key[:4]}...{key[-4:]}"


def print_injected_env():
"""打印"如果现在注入凭证, 会用哪些值"(脱敏), 用于不改 config 诊断 model/baseURL 来源。

显示每个 key 的来源 (config vs 显式 env) + 最终值 (apiKey 脱敏),
体现"显式 env 优先"的合并语义 (issue #3 子项2)。
"""
creds = _load_creds_internal()
if not creds:
print(f"❌ 未从 {ZCODE_CREDS_PATH} 读取到凭证 (config 无 enabled provider 或文件缺失)")
return 0
# 读 config 里所有 provider 的 baseURL (残留检测用, host 集合)
all_config_hosts = set()
try:
from urllib.parse import urlparse as _up
with open(ZCODE_CREDS_PATH) as _f:
_cfg = json.load(_f)
for _p in _cfg.get("provider", {}).values():
_bu = _p.get("options", {}).get("baseURL", "")
if _bu:
try:
_h = _up(_bu)
if _h.hostname:
all_config_hosts.add(f"{_h.scheme}://{_h.netloc}" if _h.scheme else _h.netloc)
except Exception:
pass
except Exception:
pass

def _host(u):
try:
h = _up(u)
return f"{h.scheme}://{h.netloc}" if (h.scheme and h.hostname) else None
except Exception:
return None

keys = ["ZCODE_MODEL", "ZCODE_BASE_URL", "ANTHROPIC_API_KEY"]
print("=== 凭证注入诊断 (显式非空 env 优先于 config; 空串视为未设置) ===")
print(f"config 源: {ZCODE_CREDS_PATH}")
for k in keys:
config_val = creds.get(k, "")
env_val = os.environ.get(k)
if env_val: # 非空显式 env 才覆盖 (空串视为未设置, 与 _merge_env_with_creds 一致)
shown = _mask_key(env_val) if k == "ANTHROPIC_API_KEY" else env_val
print(f" {k}:")
print(f" config → {('脱敏 ' + _mask_key(config_val)) if k == 'ANTHROPIC_API_KEY' and config_val else (config_val or '(空)')}")
# ZCODE_BASE_URL 特殊: 检测 App 注入的残留官方 endpoint (host 匹配)
if k == "ZCODE_BASE_URL" and config_val and env_val != config_val:
_eh = _host(env_val)
_ch = _host(config_val)
if _eh and _ch and _eh != _ch and _eh in all_config_hosts:
print(f" env → {shown} 🚫 残留! 是 config 里另一个 provider 的 endpoint")
print(f" → bridge 会自动用 config 值: {config_val}")
print(" (App 切换 plan 后的残留; 在 config 切回该 provider 或用自建 endpoint 可消除)")
else:
print(f" env → {shown} ⚠ 使用此值 (env 覆盖, 自定义 endpoint)")
else:
print(f" env → {shown} ⚠ 使用此值 (env 覆盖)")
elif env_val == "":
# 空串 env: 视为未设置, 仍用 config
shown = _mask_key(config_val) if k == "ANTHROPIC_API_KEY" else config_val
print(f" {k}: {shown} (来自 config; env 为空串, 视为未设置)")
else:
shown = _mask_key(config_val) if k == "ANTHROPIC_API_KEY" else config_val
print(f" {k}: {shown} (来自 config)")
return 0


# ============================================================
# 静态能力描述 (基于实测)
# ============================================================
OVERVIEW = {
"tool": "zcode",
"version_described": "0.15.0 (App 3.2.0)",
"version_described": "0.15.0 (App 3.2.1)",
"what_it_is": (
"ZCode 是智谱(Z.AI)的 Agentic Coding CLI, 由 GLM 系列模型驱动。"
"它能自主完成代码编写/审查/调试/分析等任务, 可调用工具(读写文件、"
Expand Down Expand Up @@ -488,6 +594,10 @@ def main():
print(__doc__)
return 0

# 诊断: 打印"会注入哪些凭证"(脱敏), 不改 config 即可排查 model/baseURL 来源
if "--print-injected-env" in args:
return print_injected_env()

if "--section" in args:
idx = args.index("--section")
if idx + 1 < len(args):
Expand Down
Loading
Loading