host 权限 port is_admin + 四个运行时管理命令 #87

Merged
Yushu merged 2 commits from feature/80-admin-commands into main 2026-07-28 03:48:59 +00:00
Member

Closes #80

新增 host 权限回调 is_admin(chat_id, user_id) 作为地基,在其上建四个运行时管理命令。命令形状沿用两条同意命令已验证过的 on_command(..., rule=to_me(), priority=1, block=True);那两条是用户级授权(ADR-0027 拒绝与管理权限混用),本次不碰。

四个 grill 决策

  • /setmodel 走 host 回调 set_model(chat_id, name) -> bool core 压根不认识模型:LLMClient 只有 complete(),provider/model 由 host 构造 client 时私有持有,AnyLLMClientclient_kwargs(api_base/api_key)更是只有 host 知道 —— core 自己重造客户端会静默丢掉它们(ADR-0011 明说第三方兼容端点必需 api_base),而故障要等下一次真实对话才暴露。返回布尔而非 None,host 说不行就如实说不行;host 没接回调时说"不支持",不假装成功。也因此不在 core 里建模型注册表 —— ADR-0020 已把「Routing 层 + /setmodel 语义变更」整体搁置。
  • /settool 只覆盖 host 工具。 core 那批由能力位/config 门控,其中 send_message 承重 —— 能被 per-chat 关掉就等于给了条把机器人变哑的路。存"被关掉的集合"而不是"启用集合":后者会让 host 新注册的工具对所有既有 chat 静默不可见。
  • /reloadpromptArisePorts.persona 静态字段换成 get_persona(chat_id) 回调(ADR-0001 2026-07-11 更新拍过板、从未落地)。命令报 persona 指纹而不是"已重载":host 自己缓存却没失效是这条命令最常见的失败模式,指纹没变时如实说没变并把线索指回 host,比回"已生效"有用。
  • /diagnose 默认探数据库/向量库/embedding,主模型要 diagnose model 才探。 现在探模型是一笔游离在记账外的消耗(记账/熔断在 #81/#82),正撞 user story 6。DependencyVerdict 因此有 skipped 这个独立取值 —— "没探"渲染成"通过"这条命令就在撒谎。

顺带处理的越界问题

  • 删掉一对死配置 arise_llm_provider/arise_llm_model:全仓消费者为零,host 自己造 client 注入,谁改都不会生效、只会让部署者以为配好了。
  • /settool 补齐深挖委托路径subagent 原本用未过滤的 get_tools("background"),而委托恰恰是 host 工具消耗最重的一条 —— 命令承诺了它在那条路上没兑现的事。
  • 修了一条我在 #79 埋的时钟竞态测试(私聊追问依赖「反应式那轮真实耗时超过 50ms」)。

两轴 review 后修掉的(第二个 commit)

两轴独立撞上同一个生产级 bug

  • / 前缀下三个命令直接坏掉。 第一版自己按命令名切 event.get_plaintext(),而 NoneBot 不改写 event message、COMMAND_START 默认只有 "/" —— head"/" 非空,于是把整串当参数返回:settool 恒回用法说明、diagnose model 永不探模型、setmodel"/setmodel gpt-4o" 整串当模型名递给 host。所有单测都直调处理函数、不带前缀,一条都没抓到。 改用 CommandArg() DI,逻辑抽成 _run_* 保持可直测,并新增走真实派发的测试文件补上取参数那一层(变异验证确认能复现全部三个症状)。
  • get_persona(chat_id) 我读错了 ADR。 我以为 PRD 提的这个"既有回调"是笔误(代码里确实不存在),实际 ADR-0001 明文拍过板、只是从未落地,立论正是"host 内多用户可切换人设、每人设独立 model/provider 路由"。已按 ADR 补回 chat_idset_model 同理。Delta 压缩与反思两个跨 chat cycle 改收回调而不是 persona 对象 —— 它们本来就按 chat 循环,传对象等于拿某一个 chat 的人设去判所有 chat 的候选。
  • /diagnosestr(exc) 原样回进群里,而数据库异常常带 DSN(主机/库名/用户名,有时口令),同群非管理员都读得到。改成只回异常类名 + "详情见日志",完整异常进 logger.exception
  • 漏探 embedding provider:AC 说"模型/数据库/向量库",但 embedding 是独立 provider + 凭据,Qdrant 通不代表它通,而它挂了整条召回静默失效。
  • /setmodel 成功时提醒 arise_llm_multimodal_kinds 不会跟着变:那是绑在主模型身份上的静态声明,而 ADR-0028 明令拒绝运行时探测多模态能力 —— core 既不自动改也不许去问,不提醒就是两种方向的静默失真。
  • 权限闸不再在校验前调 to_conversation_input(async host 回调,可能做媒体解析 IO),新增同步的 IngressPort.sender_id_ofbool | None 三态换 Literal(同 GateVerdict 惯例);手抄的 context 元组换 get_args(RuntimeContext);消掉那个只被测试调用的死函数(改成生产共用的 visible_host_tools)。

验证

1232 测试全绿。11 处变异逐条验过(权限闸放行 / 闸放到写操作之后 / 工具名不校验 / 把 host 拒绝渲染成成功 / 没接回调也假装成功 / 一律报已生效 / 默认就探模型 / 第一个探针失败即中断 / 工具过滤失效 / 只摘 schema 不摘分派表 / persona 被缓存),各自只挂对应测试。

三处变异没杀掉测试、据此改强了测试:① persona 那条原本只跑一轮对话,而"core 缓存 persona"的实现第一轮读到的就已经是新值 —— 改成跑两轮;② /reloadprompt 的"第一次记录"那支因为模块级状态不重置而实质未测 —— 补 autouse 重置 + 真正锁它的断言;③ 建表覆盖那条(#79 加的)期望集合取自被测函数自身 —— 已在 #79 改成独立枚举。

Closes #80 新增 host 权限回调 `is_admin(chat_id, user_id)` 作为地基,在其上建四个运行时管理命令。命令形状沿用两条同意命令已验证过的 `on_command(..., rule=to_me(), priority=1, block=True)`;那两条是用户级授权(ADR-0027 拒绝与管理权限混用),本次不碰。 ## 四个 grill 决策 - **`/setmodel` 走 host 回调 `set_model(chat_id, name) -> bool`。** core 压根不认识模型:`LLMClient` 只有 `complete()`,provider/model 由 host 构造 client 时私有持有,`AnyLLMClient` 的 `client_kwargs`(api_base/api_key)更是只有 host 知道 —— core 自己重造客户端会静默丢掉它们(ADR-0011 明说第三方兼容端点必需 api_base),而故障要等下一次真实对话才暴露。返回布尔而非 None,host 说不行就如实说不行;host 没接回调时说"不支持",不假装成功。也因此**不在 core 里建模型注册表** —— ADR-0020 已把「Routing 层 + /setmodel 语义变更」整体搁置。 - **`/settool` 只覆盖 host 工具。** core 那批由能力位/config 门控,其中 `send_message` 承重 —— 能被 per-chat 关掉就等于给了条把机器人变哑的路。存"被关掉的集合"而不是"启用集合":后者会让 host 新注册的工具对所有既有 chat 静默不可见。 - **`/reloadprompt` 把 `ArisePorts.persona` 静态字段换成 `get_persona(chat_id)` 回调**(ADR-0001 2026-07-11 更新拍过板、从未落地)。命令报 persona **指纹**而不是"已重载":host 自己缓存却没失效是这条命令最常见的失败模式,指纹没变时如实说没变并把线索指回 host,比回"已生效"有用。 - **`/diagnose` 默认探数据库/向量库/embedding,主模型要 `diagnose model` 才探。** 现在探模型是一笔游离在记账外的消耗(记账/熔断在 #81/#82),正撞 user story 6。`DependencyVerdict` 因此有 `skipped` 这个独立取值 —— "没探"渲染成"通过"这条命令就在撒谎。 ## 顺带处理的越界问题 - **删掉一对死配置** `arise_llm_provider`/`arise_llm_model`:全仓消费者为零,host 自己造 client 注入,谁改都不会生效、只会让部署者以为配好了。 - **`/settool` 补齐深挖委托路径**:`subagent` 原本用未过滤的 `get_tools("background")`,而委托恰恰是 host 工具消耗最重的一条 —— 命令承诺了它在那条路上没兑现的事。 - 修了一条我在 #79 埋的时钟竞态测试(私聊追问依赖「反应式那轮真实耗时超过 50ms」)。 ## 两轴 review 后修掉的(第二个 commit) 两轴独立撞上同一个**生产级 bug**: - **`/` 前缀下三个命令直接坏掉。** 第一版自己按命令名切 `event.get_plaintext()`,而 NoneBot 不改写 event message、`COMMAND_START` 默认只有 `"/"` —— `head` 是 `"/"` 非空,于是把整串当参数返回:`settool` 恒回用法说明、`diagnose model` 永不探模型、`setmodel` 把 `"/setmodel gpt-4o"` 整串当模型名递给 host。**所有单测都直调处理函数、不带前缀,一条都没抓到。** 改用 `CommandArg()` DI,逻辑抽成 `_run_*` 保持可直测,并新增走真实派发的测试文件补上取参数那一层(变异验证确认能复现全部三个症状)。 - **`get_persona(chat_id)` 我读错了 ADR。** 我以为 PRD 提的这个"既有回调"是笔误(代码里确实不存在),实际 ADR-0001 明文拍过板、只是从未落地,立论正是"host 内多用户可切换人设、每人设独立 model/provider 路由"。已按 ADR 补回 `chat_id`,`set_model` 同理。Delta 压缩与反思两个**跨 chat** cycle 改收回调而不是 persona 对象 —— 它们本来就按 chat 循环,传对象等于拿某一个 chat 的人设去判所有 chat 的候选。 - **`/diagnose` 把 `str(exc)` 原样回进群里**,而数据库异常常带 DSN(主机/库名/用户名,有时口令),同群非管理员都读得到。改成只回异常类名 + "详情见日志",完整异常进 `logger.exception`。 - **漏探 embedding provider**:AC 说"模型/数据库/向量库",但 embedding 是独立 provider + 凭据,Qdrant 通不代表它通,而它挂了整条召回静默失效。 - **`/setmodel` 成功时提醒 `arise_llm_multimodal_kinds` 不会跟着变**:那是绑在主模型身份上的静态声明,而 ADR-0028 明令拒绝运行时探测多模态能力 —— core 既不自动改也不许去问,不提醒就是两种方向的静默失真。 - 权限闸不再在校验前调 `to_conversation_input`(async host 回调,可能做媒体解析 IO),新增同步的 `IngressPort.sender_id_of`;`bool | None` 三态换 `Literal`(同 `GateVerdict` 惯例);手抄的 context 元组换 `get_args(RuntimeContext)`;消掉那个只被测试调用的死函数(改成生产共用的 `visible_host_tools`)。 ## 验证 1232 测试全绿。11 处变异逐条验过(权限闸放行 / 闸放到写操作之后 / 工具名不校验 / 把 host 拒绝渲染成成功 / 没接回调也假装成功 / 一律报已生效 / 默认就探模型 / 第一个探针失败即中断 / 工具过滤失效 / 只摘 schema 不摘分派表 / persona 被缓存),各自只挂对应测试。 三处**变异没杀掉测试**、据此改强了测试:① persona 那条原本只跑一轮对话,而"core 缓存 persona"的实现第一轮读到的就已经是新值 —— 改成跑两轮;② `/reloadprompt` 的"第一次记录"那支因为模块级状态不重置而实质未测 —— 补 autouse 重置 + 真正锁它的断言;③ 建表覆盖那条(#79 加的)期望集合取自被测函数自身 —— 已在 #79 改成独立枚举。
`/setmodel`、`/settool`、`/reloadprompt`、`/diagnose`,全部经新增的 host 权限
回调 `is_admin(chat_id, user_id)` 校验。命令形状沿用两条同意命令已验证过的
`on_command(..., rule=to_me(), priority=1, block=True)`;那两条属于用户级授权
(ADR-0027 拒绝与管理权限混用),本次不碰。

四个开放决策(grill 后定的):

- `/setmodel` 走 host 回调 `set_model(name) -> bool`。core 压根不认识模型:
  `LLMClient` 协议只有 `complete()`,provider/model 由 host 构造 client 时私有
  持有。所以只把名字递过去,怎么重建 client、要不要持久化、名字合不合法全归 host。
  返回布尔而非 None,host 说不行就如实说不行;host 没接这个回调时说"不支持",
  不假装切换成功。
- `/settool` 只覆盖 host 工具。core 那批由能力位/config 门控,其中 send_message
  承重——能被 per-chat 关掉就等于给了一条把机器人变哑的路。存"被关掉的集合"而不是
  "启用集合":后者会让 host 新注册的工具对所有既有 chat 静默不可见。校验按全部触发
  路径的并集,否则会出现"工具明明存在、/settool 却说不认识"。
- `/reloadprompt` 把 `ArisePorts.persona` 静态字段换成 `get_persona()` 回调,
  契约钉死为"每次调用都重新确定当前 persona"。core 这边压根没有 persona 的来源,
  静态对象一旦注入就没有"重新读"的余地。命令报**指纹**而不是报"已重载":host 自己
  缓存却没失效是这条命令最常见的失败模式,指纹没变时如实说没变并把线索指回 host,
  比回一句"已生效"有用。
- `/diagnose` 默认只探数据库与向量库(都免费、只读);模型默认不探,要探显式写
  `diagnose model`。现在探模型等于一笔游离在记账之外的消耗(成本记账/熔断是
  #81/#82),正撞 PRD user story 6。`DependencyStatus.ok` 因此是三态——"没探"不能
  渲染成"通过",否则这个命令在撒谎。

顺带删掉一对死配置:`arise_llm_provider`/`arise_llm_model` 消费者是零(host 自己
造 client 注入),谁改都不会生效、只会让部署者以为自己配好了。config 里写清了主
模型归 host、以及兜底多模态候选为什么是例外。

顺带修一条自己在 #79 埋的时钟竞态:私聊追问那条测试依赖"反应式那一轮真实耗时超过
0.05s 窗口",机器快一点就落到长冷场分支。改成回拨时机覆盖,确定性到期。

11 处变异全部验过(权限闸放行/闸放到写操作之后/工具名不校验/把 host 拒绝渲染成
成功/没接回调也假装成功/一律报已生效/默认就探模型/第一个探针失败即中断/工具过滤
失效/只摘 schema 不摘分派表/persona 被缓存),各自只挂对应测试。

其中 persona 那条第一次做时**变异没杀掉测试**——原测试只跑一轮对话,而"core 缓存
persona"的实现第一轮读到的就已经是新值了。测试改成跑两轮(中间换 persona),再验
变异确实挂了。1225 测试全绿。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
两轴独立撞上同一个生产级 bug,另有一处是我把 ADR 决策读错了。

真 bug:`/` 前缀下三个命令直接坏掉
  第一版自己按命令名切 `event.get_plaintext()`,而 NoneBot 不改写 event message,
  `COMMAND_START` 默认只有 `"/"` —— `head` 是 `"/"` 非空,于是把整串当参数返回:
  settool 恒回用法说明、diagnose model 永不探模型、setmodel 把 "/setmodel gpt-4o"
  整串当模型名递给 host。全部单测都直调处理函数、不带前缀,一条都没抓到。
  改用 `CommandArg()` DI(参数由 NoneBot 自己切),处理逻辑抽成 `_run_*` 保持可
  直测;新增 tests/test_admin_commands_dispatch.py 走真实派发补上那一层,变异验证
  确认它能复现全部三个症状。

读错 ADR:`get_persona(chat_id)` 不是 PRD 笔误
  ADR-0001(2026-07-11 更新)明文拍过板,只是从未落地。我把"代码里不存在"当成
  "设计上不存在",据此丢掉了 chat_id。ADR 的立论正是"host 内多用户可切换人设、每
  人设独立 model/provider 路由"。改成 `get_persona(chat_id)`;`set_model` 同理带上
  chat_id(ADR 原话"`get_persona(chat_id)` + 既有 /setmodel 等已隐含 per-chat 可
  配置的模型选择")。Delta 压缩与反思两个跨 chat cycle 改收**回调**而不是 persona
  对象 —— 它们本来就按 chat 循环,传对象等于拿某一个 chat 的人设去判所有 chat。

其余修正:
- `/settool` 漏了深挖委托路径(subagent 用未过滤的 get_tools),命令承诺了它在那条
  路上没兑现的事;委托恰恰是 host 工具消耗最重的一条。共享 `visible_host_tools`
  过滤,顺带消掉那个只被测试调用的死函数。
- `/diagnose` 把 `str(exc)` 原样回进群里,而数据库异常常带 DSN(主机/库名/用户名,
  有时口令)。改成只回异常类名 + "详情见日志",完整异常进 logger.exception。
- 漏探 embedding provider:AC 说"模型/数据库/向量库",但 embedding 是独立 provider
  + 凭据,Qdrant 通不代表它通,而它挂了整条召回静默失效。
- `/setmodel` 成功时提醒 `arise_llm_multimodal_kinds` 不会跟着变(ADR-0028 明令拒绝
  运行时探测多模态能力,所以 core 既不自动改也不许去问)。
- 权限闸不再在校验前调 `to_conversation_input`(async host 回调,可能做媒体解析
  IO);新增同步的 `IngressPort.sender_id_of`。
- `bool | None` 三态换成 `Literal["up","down","skipped"]`(同 GateVerdict 等既有
  惯例);手抄的 context 元组换成 `get_args(RuntimeContext)`;ToolToggleModel 的
  docstring 与实现对齐;`_admin_gate` 返回值收成 chat_id。
- `_last_persona_fingerprint` 是模块级状态、测试间不重置,"第一次记录"那支实质
  未测:补 autouse 重置 + 一条真正锁它的断言(变异验证过)。

过程记录:这轮我用错了替换区间的下界(锚在"下一个定时任务"而不是被替换块自己的
结尾),一次吞掉 580 行工作代码,含 _build_runtime_loop / _debounced_flush / 追问 /
Drive Tick。因为 __init__.py 自上次提交后没有别的改动,从 HEAD 精确恢复了该文件,
并给替换脚本加了行数断言。另外 ruff format 对整目录又动了 141 个文件,越界的 6 个
已逐个核对为纯换行噪音并回退。

1232 测试全绿。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Yushu merged commit 4bd07a9775 into main 2026-07-28 03:48:59 +00:00
Yushu deleted branch feature/80-admin-commands 2026-07-28 03:49:01 +00:00
Sign in to join this conversation.
No description provided.