codebee 0.1.64 → 0.1.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +520 -507
  2. package/LICENSE +21 -21
  3. package/README.md +494 -487
  4. package/app/core/aiflavor.py +197 -197
  5. package/app/core/artifacts.py +148 -148
  6. package/app/core/attachments.py +532 -532
  7. package/app/core/automation.py +758 -758
  8. package/app/core/backup.py +470 -470
  9. package/app/core/beekeeper.py +183 -183
  10. package/app/core/board.py +209 -209
  11. package/app/core/bookmeta.py +272 -217
  12. package/app/core/bookmeta_catalog.py +180 -180
  13. package/app/core/branching.py +137 -137
  14. package/app/core/builtin_agent.py +1234 -1191
  15. package/app/core/capability.py +144 -144
  16. package/app/core/catalog.py +23 -24
  17. package/app/core/chaptersafe.py +32 -32
  18. package/app/core/cleanup.py +303 -303
  19. package/app/core/compaction.py +201 -186
  20. package/app/core/covergen.py +356 -356
  21. package/app/core/diagnostics.py +114 -114
  22. package/app/core/dispatch.py +173 -173
  23. package/app/core/dispatch_log.py +113 -113
  24. package/app/core/env_scrub.py +85 -85
  25. package/app/core/error_codes.py +220 -65
  26. package/app/core/errorlog.py +137 -137
  27. package/app/core/evaluation.py +174 -0
  28. package/app/core/failure_notify.py +290 -0
  29. package/app/core/flows.py +355 -355
  30. package/app/core/goal_service.py +167 -167
  31. package/app/core/health.py +374 -342
  32. package/app/core/history.py +38 -38
  33. package/app/core/hooks.py +162 -156
  34. package/app/core/jobs.py +1034 -1022
  35. package/app/core/knowledge.py +6 -2
  36. package/app/core/manager.py +2115 -1996
  37. package/app/core/market.py +362 -362
  38. package/app/core/market_remote.py +66 -66
  39. package/app/core/mocks.py +64 -64
  40. package/app/core/modelhub.py +3517 -3332
  41. package/app/core/notify.py +106 -106
  42. package/app/core/paihang.py +109 -109
  43. package/app/core/paths.py +2 -2
  44. package/app/core/pipeline.py +4614 -4483
  45. package/app/core/planner.py +33 -31
  46. package/app/core/portguard.py +111 -111
  47. package/app/core/preview.py +293 -293
  48. package/app/core/publish/__init__.py +7 -7
  49. package/app/core/publish/auto.py +367 -367
  50. package/app/core/publish/browser.py +533 -533
  51. package/app/core/publish/fanqie.py +128 -128
  52. package/app/core/publish/flow.py +432 -406
  53. package/app/core/publish/flows-fanqie-calibrated.json +17 -9
  54. package/app/core/publish/flows-qimao-calibrated.json +6 -2
  55. package/app/core/publish/ledger.py +198 -198
  56. package/app/core/publish/manager.py +623 -590
  57. package/app/core/publish/qimao.py +141 -141
  58. package/app/core/publish/ws.py +139 -139
  59. package/app/core/redact.py +38 -38
  60. package/app/core/repeat_guard.py +129 -124
  61. package/app/core/router.py +96 -22
  62. package/app/core/runner.py +2050 -2019
  63. package/app/core/selfupdate.py +384 -384
  64. package/app/core/session_log.py +161 -161
  65. package/app/core/sessions.py +312 -312
  66. package/app/core/settings.py +149 -149
  67. package/app/core/settings_schema.py +262 -262
  68. package/app/core/share_page.py +114 -114
  69. package/app/core/skill_scan.py +114 -106
  70. package/app/core/skillpacks/fanqie-novel.md +80 -80
  71. package/app/core/skillpacks/market/character-bible.md +66 -66
  72. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  73. package/app/core/skillpacks/market/git-workflow.md +57 -57
  74. package/app/core/skillpacks/market/release-notes.md +72 -72
  75. package/app/core/skillpacks/market/weekly-report.md +71 -71
  76. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  77. package/app/core/skills.py +221 -72
  78. package/app/core/step_runner.py +132 -132
  79. package/app/core/store.py +23 -0
  80. package/app/core/task_compile.py +199 -199
  81. package/app/core/telemetry.py +292 -292
  82. package/app/core/tlsctx.py +64 -64
  83. package/app/core/token_meter.py +147 -147
  84. package/app/core/upstream.py +41 -0
  85. package/app/core/usage.py +114 -3
  86. package/app/core/volumes.py +355 -355
  87. package/app/core/wxdigest.py +858 -858
  88. package/app/core/zentao.py +2447 -2447
  89. package/app/main.py +2838 -2832
  90. package/app/pick_dialog.py +110 -110
  91. package/app/ui/app.js +13994 -13977
  92. package/app/ui/board.css +20 -41
  93. package/app/ui/board.html +162 -162
  94. package/app/ui/board.js +305 -305
  95. package/app/ui/i18n.js +2716 -2711
  96. package/app/ui/icons/bee.svg +79 -79
  97. package/app/ui/index.html +1 -1
  98. package/app/ui/manifest.json +16 -16
  99. package/app/ui/qrcode.js +2297 -2297
  100. package/app/ui/style.css +6107 -6125
  101. package/bin/tutti.js +147 -147
  102. package/package.json +46 -41
  103. package/tools/wx_reader.py +261 -261
  104. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  105. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  106. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  107. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  108. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  109. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  110. package/app/.playwright-cli/run-detail-after.png +0 -0
@@ -1,124 +1,129 @@
1
- # -*- coding: utf-8 -*-
2
- """重复 CLI 调用检测:防止编排者用同一份 prompt 反复调同一 CLI 死循环。
3
-
4
- 设计稿:docs/migration/01-defense-patterns.md §5C。
5
- 参考 dsh packages/guard/repeat-tool-reminder/src/index.ts:
6
- 监听 tools/post-execute,按 (tool, canonical_args) 跟踪,3/5/8 阈值注入 user-message 提醒。
7
-
8
- Tutti 的 "tool" 对应到 "step role","canonical_args" 对应到 prompt 模板指纹。
9
- 步骤级调用代价远高于普通工具调用,因此第 3 次提醒、第 5 次强制停止。
10
- """
11
- from __future__ import annotations
12
-
13
- import hashlib
14
- import logging
15
- import threading
16
- import time
17
-
18
- log = logging.getLogger(__name__)
19
-
20
- # 渐进阈值:第 N 次命中时给提醒;最后一次及以上时强制停止。
21
- DEFAULT_THRESHOLDS = (3, 5)
22
-
23
- REMINDER_TEMPLATE = (
24
- "⚠️ 提醒:上一步使用了几乎相同的输入(已连续 {count} 次调用 {role})。"
25
- "请换一种方法:读不同的文件、用更具体的指令、或明确询问用户澄清。"
26
- )
27
-
28
- STOP_TEMPLATE = (
29
- "已连续 {count} 次使用相同输入调用 {role},超过 {last_thr} 次阈值,"
30
- "强制停止本 step 以防止无限循环。请用户检查输入或调整 prompt 模板。"
31
- )
32
-
33
-
34
- def _prompt_fingerprint(prompt: str) -> str:
35
- """prompt 指纹:用前 200 字符 + 总长度(CLI prompt 头几行通常是模板部分)。
36
-
37
- 不做严格 canonical args 比对(CLI prompt 通常很长,且 dsh 也用 JSON.stringify);
38
- 指纹命中说明是同一个模板路径走过多次,已经够分辨。
39
-
40
- 用 SHA-256 取前 12 字符(96 bit):48 亿分之一碰撞概率对去重足够;不追求密码学强度。
41
- """
42
- head = (prompt or "")[:200]
43
- return hashlib.sha256(f"{head}|{len(prompt or '')}".encode("utf-8")).hexdigest()[:12]
44
-
45
-
46
- class RepeatGuard:
47
- """每 (run_id, step_role) 独立计数;key = prompt 指纹。
48
-
49
- 计数仅统计相同 key 连续出现;新 prompt 会重置。
50
- """
51
-
52
- def __init__(self, thresholds=DEFAULT_THRESHOLDS, *, max_history=50):
53
- self.thresholds = tuple(sorted(set(thresholds)))
54
- self.max_history = max_history
55
- self._lock = threading.Lock()
56
- # chain[(run_id, role)] = [(ts, fingerprint)]
57
- self._chains: dict = {}
58
-
59
- def check(self, run_id: str, role: str, prompt: str) -> dict:
60
- """检查并更新 (run_id, role) 的连续计数。
61
-
62
- Returns:
63
- {"count": int, "reminder": str | None, "should_stop": bool}
64
- """
65
- fp = _prompt_fingerprint(prompt)
66
- chain_key = (run_id, role)
67
- with self._lock:
68
- chain = self._chains.setdefault(chain_key, [])
69
- chain.append((time.time(), fp))
70
- # 仅统计相同 fp 连续出现
71
- count = 1
72
- for _, prev_fp in reversed(chain[:-1]):
73
- if prev_fp == fp:
74
- count += 1
75
- else:
76
- break
77
- # 截尾避免内存膨胀
78
- if len(chain) > self.max_history:
79
- self._chains[chain_key] = chain[-self.max_history:]
80
-
81
- reminder = None
82
- stop = False
83
- last_thr = self.thresholds[-1]
84
- if count >= last_thr:
85
- stop = True
86
- reminder = STOP_TEMPLATE.format(count=count, role=role, last_thr=last_thr)
87
- elif count in self.thresholds:
88
- reminder = REMINDER_TEMPLATE.format(count=count, role=role)
89
-
90
- if reminder:
91
- log.warning("repeat-guard %s/%s count=%d stop=%s reminder=%s",
92
- run_id, role, count, stop, reminder[:80])
93
- return {"count": count, "reminder": reminder, "should_stop": stop}
94
-
95
- def reset(self, run_id: str) -> int:
96
- """重置某 run_id 的所有 (run_id, *) 链。返回清除的链条数。"""
97
- with self._lock:
98
- cleared = 0
99
- for k in list(self._chains.keys()):
100
- if k[0] == run_id:
101
- del self._chains[k]
102
- cleared += 1
103
- return cleared
104
-
105
- def stats(self, run_id: str, role: str) -> dict:
106
- """读取 (run_id, role) 当前状态(不更新)。用于 UI 调试。"""
107
- chain_key = (run_id, role)
108
- with self._lock:
109
- chain = self._chains.get(chain_key, [])
110
- if not chain:
111
- return {"chain_len": 0, "last_fp": ""}
112
- # 当前连续计数
113
- last_fp = chain[-1][1]
114
- count = 1
115
- for _, fp in reversed(chain[:-1]):
116
- if fp == last_fp:
117
- count += 1
118
- else:
119
- break
120
- return {"chain_len": len(chain), "last_fp": last_fp, "count": count}
121
-
122
-
123
- # 模块级默认实例(pipeline 接线用;run_id 入键天然隔离多任务,测试无需替换)
124
- guard = RepeatGuard()
1
+ # -*- coding: utf-8 -*-
2
+ """重复 CLI 调用检测:防止编排者用同一份 prompt 反复调同一 CLI 死循环。
3
+
4
+ 设计稿:docs/migration/01-defense-patterns.md §5C。
5
+ 参考 dsh packages/guard/repeat-tool-reminder/src/index.ts:
6
+ 监听 tools/post-execute,按 (tool, canonical_args) 跟踪,3/5/8 阈值注入 user-message 提醒。
7
+
8
+ Tutti 的 "tool" 对应到 step role + 执行者身份,"canonical_args" 对应到 prompt
9
+ 模板指纹。不同 CLI 间的换将是有效的回退,不能共享同一个重复预算。步骤级调用
10
+ 代价远高于普通工具调用,因此第 3 次提醒、第 5 次强制停止。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import logging
16
+ import threading
17
+ import time
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ # 渐进阈值:第 N 次命中时给提醒;最后一次及以上时强制停止。
22
+ DEFAULT_THRESHOLDS = (3, 5)
23
+
24
+ REMINDER_TEMPLATE = (
25
+ "⚠️ 提醒:上一步使用了几乎相同的输入(已连续 {count} 次调用 {role})。"
26
+ "请换一种方法:读不同的文件、用更具体的指令、或明确询问用户澄清。"
27
+ )
28
+
29
+ STOP_TEMPLATE = (
30
+ "已连续 {count} 次使用相同输入调用 {role},超过 {last_thr} 次阈值,"
31
+ "强制停止本 step 以防止无限循环。请用户检查输入或调整 prompt 模板。"
32
+ )
33
+
34
+
35
+ def _prompt_fingerprint(prompt: str, identity: str = "") -> str:
36
+ """prompt 指纹:用前 200 字符 + 总长度(CLI prompt 头几行通常是模板部分)。
37
+
38
+ 不做严格 canonical args 比对(CLI prompt 通常很长,且 dsh 也用 JSON.stringify);
39
+ 指纹命中说明是同一个模板路径走过多次,已经够分辨。
40
+
41
+ 用 SHA-256 取前 12 字符(96 bit):48 亿分之一碰撞概率对去重足够;不追求密码学强度。
42
+ """
43
+ head = (prompt or "")[:200]
44
+ return hashlib.sha256(
45
+ f"{identity}\0{head}|{len(prompt or '')}".encode("utf-8")).hexdigest()[:12]
46
+
47
+
48
+ class RepeatGuard:
49
+ """每 (run_id, step_role) 的最近执行链独立计数;指纹包含执行者身份。
50
+
51
+ 计数仅统计相同 key 连续出现;新 prompt 会重置。
52
+ """
53
+
54
+ def __init__(self, thresholds=DEFAULT_THRESHOLDS, *, max_history=50):
55
+ self.thresholds = tuple(sorted(set(thresholds)))
56
+ self.max_history = max_history
57
+ self._lock = threading.Lock()
58
+ # chain[(run_id, role)] = [(ts, fingerprint)]
59
+ self._chains: dict = {}
60
+
61
+ def check(self, run_id: str, role: str, prompt: str, *, identity: str = "") -> dict:
62
+ """检查并更新 (run_id, role) 的连续计数。
63
+
64
+ identity 标识实际执行 CLI/agent。相同 prompt 换到其他执行者属于回退,
65
+ 会从新的连续链计数;同一执行者重复相同调用仍按阈值停止。
66
+
67
+ Returns:
68
+ {"count": int, "reminder": str | None, "should_stop": bool}
69
+ """
70
+ fp = _prompt_fingerprint(prompt, str(identity or ""))
71
+ chain_key = (run_id, role)
72
+ with self._lock:
73
+ chain = self._chains.setdefault(chain_key, [])
74
+ chain.append((time.time(), fp))
75
+ # 仅统计相同 fp 连续出现
76
+ count = 1
77
+ for _, prev_fp in reversed(chain[:-1]):
78
+ if prev_fp == fp:
79
+ count += 1
80
+ else:
81
+ break
82
+ # 截尾避免内存膨胀
83
+ if len(chain) > self.max_history:
84
+ self._chains[chain_key] = chain[-self.max_history:]
85
+
86
+ reminder = None
87
+ stop = False
88
+ last_thr = self.thresholds[-1]
89
+ if count >= last_thr:
90
+ stop = True
91
+ reminder = STOP_TEMPLATE.format(count=count, role=role, last_thr=last_thr)
92
+ elif count in self.thresholds:
93
+ reminder = REMINDER_TEMPLATE.format(count=count, role=role)
94
+
95
+ if reminder:
96
+ log.warning("repeat-guard %s/%s count=%d stop=%s reminder=%s",
97
+ run_id, role, count, stop, reminder[:80])
98
+ return {"count": count, "reminder": reminder, "should_stop": stop}
99
+
100
+ def reset(self, run_id: str) -> int:
101
+ """重置某 run_id 的所有 (run_id, *) 链。返回清除的链条数。"""
102
+ with self._lock:
103
+ cleared = 0
104
+ for k in list(self._chains.keys()):
105
+ if k[0] == run_id:
106
+ del self._chains[k]
107
+ cleared += 1
108
+ return cleared
109
+
110
+ def stats(self, run_id: str, role: str) -> dict:
111
+ """读取 (run_id, role) 当前状态(不更新)。用于 UI 调试。"""
112
+ chain_key = (run_id, role)
113
+ with self._lock:
114
+ chain = self._chains.get(chain_key, [])
115
+ if not chain:
116
+ return {"chain_len": 0, "last_fp": ""}
117
+ # 当前连续计数
118
+ last_fp = chain[-1][1]
119
+ count = 1
120
+ for _, fp in reversed(chain[:-1]):
121
+ if fp == last_fp:
122
+ count += 1
123
+ else:
124
+ break
125
+ return {"chain_len": len(chain), "last_fp": last_fp, "count": count}
126
+
127
+
128
+ # 模块级默认实例(pipeline 接线用;run_id 入键天然隔离多任务,测试无需替换)
129
+ guard = RepeatGuard()
@@ -2,10 +2,11 @@
2
2
  """智能路由:能力基线 × 历史胜率 × 角色约束 → 选智能体,并给出可解释的理由。"""
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import os
6
7
  import re
7
8
 
8
- from . import dispatch, history
9
+ from . import dispatch, history, upstream
9
10
 
10
11
  # 各类智能体的能力基线(0-100)。真实 CLI 里官方双雄最高。
11
12
  CAPABILITY = {
@@ -56,8 +57,9 @@ def _history_bonus(stats, agent_id, ttype):
56
57
  runs = s["runs"]
57
58
  win_rate = s["wins"] / runs
58
59
  # 胜率为主,经验为辅;败率要扣分且随样本量爬满(前 3 次线性加权,防一次
59
- # 偶发失败把智能体埋了)。0/3 全败 = -18:常挂的 CLI 必须排到无历史的新
60
- # 面孔之后(2026-09-17 前败率不扣分,0/3 还拿 +6 经验分,比没跑过还高)。
60
+ # 偶发失败把智能体埋了)。扣分项封顶 -24,0/3 全败净 -21(含 +3 经验分)、
61
+ # 0/6 净 -18:常挂的 CLI 必须排到无历史的新面孔之后(2026-09-17 前败率不
62
+ # 扣分,0/3 还拿 +6 经验分,比没跑过还高)。
61
63
  loss_penalty = 24.0 * (1.0 - win_rate) * min(1.0, runs / 3.0)
62
64
  return round(18.0 * win_rate + min(6.0, runs) - loss_penalty, 1)
63
65
 
@@ -84,6 +86,9 @@ def _online_bonus(agent, role, ttype):
84
86
  reason = (",在线 %d/%d 验收成功(%+.1f),P95 %.1fs(%+.1f),均价 $%.4f(%+.1f)"
85
87
  % (int(metrics.get("successes") or 0), success_samples,
86
88
  success_score, p95, latency_score, cost, cost_score))
89
+ # 标准要求回退层出现在候选理由里(成本与时延预算第 5 条):
90
+ # exact/task-role/task/global,让"分数来自哪层样本"可核对。
91
+ reason += ",样本层 %s" % (metrics.get("fallback") or "global")
87
92
  return total, reason
88
93
  except Exception:
89
94
  return 0.0, ""
@@ -153,30 +158,89 @@ _LOCAL_ENDPOINT_PROBES = (
153
158
  ("codex-cli", "~/.codex/config.toml", r'base_url\s*=\s*"([^"]+)"'),
154
159
  )
155
160
 
161
+ _PI_SETTINGS_PATH = "~/.pi/agent/settings.json"
162
+ _PI_MODELS_PATH = "~/.pi/agent/models.json"
163
+
164
+
165
+ def _pi_provider_hosts(selected_name):
166
+ """按选中名取 pi 供应商块的 host:port(先 models.json,再 settings.json 兜底)。
167
+
168
+ 老配置可能把 providers 直接写进 settings.json,两件都认;只认选中的那块。"""
169
+ hosts = set()
170
+ if not selected_name:
171
+ return hosts
172
+ for rel in (_PI_MODELS_PATH, _PI_SETTINGS_PATH):
173
+ try:
174
+ with open(os.path.expanduser(rel), "r", encoding="utf-8") as fh:
175
+ cfg = json.load(fh)
176
+ except (OSError, ValueError, TypeError):
177
+ continue
178
+ if not isinstance(cfg, dict):
179
+ continue
180
+ block = (cfg.get("providers") or {}).get(selected_name)
181
+ if not isinstance(block, dict):
182
+ continue
183
+ h = _host_of(block.get("baseUrl") or block.get("baseURL") or
184
+ block.get("base_url"))
185
+ if h:
186
+ hosts.add(h)
187
+ break # 命中即定:models.json 优先,别让另一件的旧块参与判断
188
+ return hosts
189
+
156
190
 
157
191
  def _host_of(url):
158
- """上游归一:只留 host:port。/api/anthropic 与 /v1 的路径差异不算换上游。"""
159
- m = re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://([^/?#]+)", str(url or "").strip())
160
- return m.group(1).lower() if m else ""
192
+ """上游归一:路径、认证信息和默认端口不影响同一上游身份。"""
193
+ return upstream.normalize_upstream(url)
161
194
 
162
195
 
163
- def agent_upstreams(agent_id):
164
- """候选实际会用到的上游集合(host:port 归一)。显式链 > CLI 本机配置
165
- 端点;两者都拿不到返回空集 = 未知。"""
196
+ def agent_upstreams(agent_id, difficulty="default", task_type="", role=""):
197
+ """候选实际会用到的上游集合(host:port 归一)。显式链优先;空绑定按
198
+ 运行期自动推荐链判断,再回落到 CLI 本机配置;都拿不到返回空集 = 未知。"""
166
199
  ups = set()
167
200
  try:
168
201
  from . import modelhub
169
202
  pref = modelhub._binding_for(agent_id)
170
- chain = modelhub._binding_chain(pref)
171
203
  provs = {p.get("id"): p for p in modelhub.providers()}
172
- if chain:
173
- for item in chain:
204
+
205
+ def _add_chain(chain):
206
+ for item in chain or ():
174
207
  pid = (item.get("provider_id") or "").strip()
175
- h = _host_of((provs.get(pid) or {}).get("base_url"))
208
+ provider = item.get("provider") or provs.get(pid) or {}
209
+ h = _host_of(provider.get("base_url"))
176
210
  if h:
177
211
  ups.add(h)
212
+
213
+ configured = bool(modelhub._binding_chain(pref) or pref.get("provider_id"))
214
+ # 保留原始显式链中的 endpoint,即使某条已禁用/失效;同一个死上游仍
215
+ # 不值得在另一个 CLI 上立刻再撞一次。
216
+ if configured:
217
+ chain = modelhub._binding_chain(pref)
218
+ _add_chain(chain)
219
+ resolved = modelhub.resolve_binding(agent_id, difficulty) or {}
220
+ _add_chain(resolved.get("call_chain"))
221
+ else:
222
+ # bind_agent 在空绑定时会自动推荐供应商链。换将选路必须观察同一
223
+ # 生效链,否则“空绑定”的多个 CLI 可能被排成不同候选却共用一个 403 网关。
224
+ recommended = modelhub.recommend_binding(
225
+ agent_id, difficulty, task_type=task_type, role=role) or {}
226
+ _add_chain(recommended.get("call_chain"))
178
227
  except Exception:
179
228
  pass
229
+ if not ups:
230
+ if agent_id == "pi":
231
+ # Pi 的选择位在 settings.json(defaultProvider),而 endpoint 在**同目录
232
+ # models.json** 的 providers.<选中名>.baseUrl(pi 的 getModelsPath(),
233
+ # CodeBee 托管与用户手工配置都落在那儿)——不能只扫所有 baseUrl,
234
+ # 那会把未选中的 provider 也误算成当前上游。
235
+ try:
236
+ with open(os.path.expanduser(_PI_SETTINGS_PATH), "r",
237
+ encoding="utf-8") as fh:
238
+ settings = json.load(fh)
239
+ selected_name = str((settings.get("defaultProvider")
240
+ if isinstance(settings, dict) else "") or "")
241
+ ups.update(_pi_provider_hosts(selected_name))
242
+ except (OSError, ValueError, TypeError):
243
+ pass
180
244
  if not ups:
181
245
  for aid, path, pat in _LOCAL_ENDPOINT_PROBES:
182
246
  if aid != agent_id:
@@ -195,18 +259,27 @@ def agent_upstreams(agent_id):
195
259
 
196
260
 
197
261
  def pick_switch_candidate(agents, role, ttype, stats, exclude=(),
198
- dead_upstreams=()):
262
+ dead_upstreams=(), difficulty="default",
263
+ blocked_upstreams=()):
199
264
  """换将选将。配额类死亡背景下:与死者同上游的候选依次让位,直到找到
200
265
  异上游/上游未知的候选(2026-09-22 实案:kimi 与 claude 同骑智谱,0.1 分
201
266
  之差把异上游 qwencode 压在下面,换将=换壳不换命)。异上游耗尽后同上游
202
- 候选捡回分数最高者——聊胜于无。"""
267
+ 候选捡回分数最高者——聊胜于无。403 明确拒绝的上游始终阻断,不捡回重试。"""
203
268
  exclude = set(exclude)
269
+ blocked_sets = [set(d) for d in (blocked_upstreams or ()) if d]
204
270
  best, reason = pick(agents, role, ttype, stats, exclude=exclude)
205
- if best is None or not dead_upstreams:
271
+ if best is None or (not dead_upstreams and not blocked_sets):
206
272
  return best, reason
207
273
  deferred = []
208
274
  while best is not None:
209
- ups = agent_upstreams(best.get("id"))
275
+ ups = agent_upstreams(best.get("id"), difficulty=difficulty,
276
+ task_type=ttype, role=role)
277
+ if ups and any(ups & blocked for blocked in blocked_sets):
278
+ # 明确的 403 权限拒绝:同一上游没有换模型/换 CLI 再试的价值,
279
+ # 即使异上游候选耗尽也不把它捡回来(与可换账号的配额错误不同)。
280
+ exclude.add(best.get("id"))
281
+ best, reason = pick(agents, role, ttype, stats, exclude=exclude)
282
+ continue
210
283
  if not (ups and any(ups & dead for dead in dead_upstreams)):
211
284
  break # 异上游或上游未知:就用它
212
285
  deferred.append((best, reason))
@@ -265,14 +338,14 @@ def route_plan(agents, role, task_spec, stats=None, exclude=(), selected=None,
265
338
 
266
339
 
267
340
  def pick_reviewer(agents, impl, ttype, stats=None, exclude=(),
268
- dead_upstreams=()):
341
+ dead_upstreams=(), difficulty="default"):
269
342
  """评审者:跨厂商是硬规则(Codeband 的对抗式配对)——同族评审有同款盲区,
270
343
  评审者必须来自与实现者不同的 kind;跨族池为空才回退同厂商并如实备注,
271
344
  绝不把回退伪装成跨厂商。exclude 用于剔除运行时已知死候选(实现步走查证伪
272
- 的 quota/限流死链),与 pick / pick_switch_candidate 的 exclude 同语义。
345
+ 的 quota/403 死链),与 pick / pick_switch_candidate 的 exclude 同语义。
273
346
  dead_upstreams 进一步剔除「与死者同上游」的候选——配额通常按网关/账号烧刻,
274
- 同上游 = 同配额桶,评审者又是单点,撞死链会白烧一轮并误报「评审器故障」,
275
- 故宁可少一个候选也不选已知-同上游的。上游未知不剔除(宁白试不误杀)。"""
347
+ 同上游 = 同配额桶,403 权限拒绝也不能换壳重试。评审者又是单点,撞死链会
348
+ 白烧一轮并误报「评审器故障」,故宁可少一个候选也不选已知同上游的。"""
276
349
  stats = stats or history.agent_stats()
277
350
  excluded = set(exclude)
278
351
  dead_sets = [set(d) for d in (dead_upstreams or ()) if d]
@@ -282,7 +355,8 @@ def pick_reviewer(agents, impl, ttype, stats=None, exclude=(),
282
355
  return False
283
356
  if not dead_sets:
284
357
  return True
285
- ups = agent_upstreams(a.get("id"))
358
+ ups = agent_upstreams(a.get("id"), difficulty=difficulty,
359
+ task_type=ttype, role="review")
286
360
  return not (ups and any(ups & d for d in dead_sets))
287
361
 
288
362
  impl_kind = impl.get("kind")