Conversation
|
@chen-ran Please complete the following information using the template:
Editing the description triggers another check; format feedback does not block or cancel code CI. |
|
三条全部复现属实,已在 P1 — 激活契约到不了主模型:确认。 契约现在同时进入两种表示。fragment 用 顺带修了一处评审没提到、但同源的顺序错误: P2a — 配置解析失败绕过门禁:确认,按你的区分实现。bot 级 override 是操作者记录在案的决定,此时任何解析失败一律 fail closed 并落一条 P2b — 不完整判定被接受:确认,也核对了你引用的 Cahciua 行为属实( 测试补强:新增 2 条过真实 context applier 的端到端测试、4 条判定解析用例(reason 缺失 / null / 非字符串、should_act 为 null)、2 条 P2a 分支测试;fail-closed 不变量的畸形输入表从 11 项扩到 15 项。之前那条
|
acc1c7b to
1c401a8
Compare
|
三条全部复现属实,已在 P1 强制工具调用不跨 provider — 确认核对了锁定版本 SDK 的四个适配器:
已按建议改用 补了你要求的 provider 请求测试(httptest 捕获各适配器真实发出的请求体)。第一版写错了:测试自己拼装请求,对旧写法照样全绿,只证明了 SDK 能正确转换某个常量,证明不了门禁发的就是它。改成把选项抽进 P1 固定 16K 裁判预算 — 确认
裁判改用自己的 回归测试直接用你描述的形状(5 份各约 4K 摘要 + 一条 @bot),并先断言前置条件:主模型 admission 在同一输入上确实溢出,否则这条测试就不再守任何东西。 P2 标题模型回退 — 确认能力问题,配置问题想听你的
但「明确区分 关闭 / 继承 / 指定模型」这一半我没有单方面做。 我的倾向是加
|
native runtime 的 discuss 会话此前没有任何发言判定:群里每来一条消息都会跑 一次完整 primary——全工具、全上下文、可能带 reasoning——然后靠 mode_discuss.md 里那句 "prefer silence" 让模型自己决定闭嘴。该不该说和说什么由同一次调用混在 一起决定,且无论结果如何都已付出全额成本。inbound 的 shouldTriggerAssistantResponse 规则门对 discuss 会话不生效:分发块在该检查之前就 return 了。 引入一个外部裁判:判定模型只拿到 decide 一个工具,强制 tool choice,输出 send / no_action 两值,判定通过才唤醒 primary。门禁位置在 sync compaction 之前——不唤醒模型的这一轮不需要压缩上下文,所以沉默的群只花一次廉价判定, 而不是一次 summarizer 加一次 primary。 模型解析:bots.discuss_probe_model_id 优先(该列自 0001 起就存在,但此前没有 任何查询选取它),回退到 owner 的 title_model_id。两个槽位要的是同一类模型 ——便宜、快、自身不需要工具——所以已经配了标题模型的部署无需再做一次选择; bot 级设置仍然优先,因为群聊策略是 per-bot 的,而标题模型是账号级偏好。 fail closed 是门禁的全部意义:模型缺失、provider 不可达、上下文溢出、判定 缺失/不可解析/值不认识,一律落到不发言。唯一例外是从未配置门禁,此时返回 Ran=false,行为与本次改动前逐字节一致。 私聊不走门禁。inbound 的默认分派是 group → discuss、DM → chat,而 chat 模式 下模型的文本直接投递给用户(mode_chat.md:Do not use messaging tools for ordinary text replies)。私聊里没有 send 工具要求,也不存在「该不该插话」的 问题——每条消息按构造就是对 Bot 说的。门禁只为群聊而存在。 激活契约作为最后一条 user message 追加在会话尾部,而不是写进 system prompt。 要求必须紧贴生成点,否则长历史会把它挤出模型的工作注意力。 判定落 bot_discuss_probe_decisions(迁移 0153)。outcome 区分 act / no_action / missing / malformed / error,是为了让「Bot 突然不说话了」能被诊断成判断、解析 失败还是故障,而不是全部塌缩成 no_action。 顺带的 DB 优化:buildBaseRunConfig 本来就读过 bot settings,RunConfig.Bot 也 已装好 BotInfo,此前这两样都会被重复查询。现在 settings 随 ResolveRunConfigResult 返回,BotInfo 直接复用;并从 resolveTitleModel 拆出 resolveBotOwnerUserID, bot 已配门禁模型时不必再读 owner 的 account profile。每条群聊消息的额外查询 从 4 次降到 1-2 次,私聊 0 次。 前端放在 Advanced 标签页,单行 ModelSelect,autosave 无保存按钮。候选列表要求 模型支持 tool-call:不支持的模型永远无法返回判定,门禁会在每次唤醒 fail closed, 等于给用户提供一个静音开关。 未包含:primary 的强制 tool_choice 与「结束无发送则强制补发」。native 的 RunConfig 没有 tool choice 字段,要改 Stream() 循环,另起一份工作;当前靠尾部 激活契约在提示层保证发言。 外部 runtime(ACP / Claude Code / Codex)维持原有的 DiscussAddressed 门,未接入 probe:门禁的另一半是交给 primary 的发言契约,托管的外部 agent 不从我们这里接受 契约,只给它加判定等于买了一个约束不住的判断。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
评审复现的三个问题:
1. 激活契约实际到不了主模型。turn_discuss.go 把契约追加进 runConfig.Messages,
但 ContextSourceFrags 仍由未含契约的 admitted 构造。生产装的
contextview.ProviderRunConfigApplier 在存在 fragments 时走 fragsFirst 分支,
用 frags 渲染结果整体覆盖 cfg.Messages(provider_run_config.go: `cfg.Messages
= payload.Messages`),刚追加的契约随即消失。结果是 probe 放行后 primary 仍
按「可以保持沉默」运行——付了判定的钱,却没换来它授权的那条指令。
契约现在同时进入两种表示:appendDiscussActivation 返回消息与 fragment 两份,
fragment 用 TrustSystem(说话的是 runtime,不是会话参与者)和 OverflowKeep
(预算压力下裁掉它,等于丢弃唤醒这一轮的全部理由)。
连带修正一处本次引入的顺序错误:图片注入与 injectDiscussImages 都向后找
最后一条 user 消息,而激活契约正是最后一条 user 消息,先追加会把新附件
贴到指令上而不是承载它的聊天消息。现在图片先注入,契约最后追加。
新增的回归测试走完整 StartTurn 再过真实 applier,断言契约出现在最终 provider
请求且位于末尾;另一条断言图片落在聊天消息而非契约上。原先只测 helper 是否
把消息追加进切片——测的是我造的接缝,不是真正要保证的行为。
2. 配置解析失败会绕过门禁。owner/account 查询出错时返回零值,Ran=false,调用方
当成「未配置门禁」继续跑完整 primary。现在区分两种情况:bot 级 override 是
操作者记录在案的决定,此时任何解析失败一律 fail closed 并落一条
outcome=error 的判定;没有 override 时失败的只是「能否回退」的查询,保持
未配置状态——否则一个没有 owner 的 Bot 会被永久静音,而那是常驻状态而非抖动。
3. 不完整的判定会被接受。reason 解码成普通字符串,未检查字段存在性,
{"should_act":"send"} 与 reason:null 都会得到 outcome=act,既违反自己的
工具 schema,也与 Cahciua 的 `typeof parsed.reason === 'string'` 不一致。
两个字段改为指针解码:缺失与显式 null 都可与空串区分,类型错误直接
unmarshal 失败。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
三处都会让 Bot 永久静音——fail-closed 的设计把每个配置或兼容性问题
都转成了「不发言」,而其中两处没有自愈路径。
1. 强制工具调用的写法不跨 provider。原本用 Chat Completions 的嵌套
{"type":"function","function":{"name":...}}。核对 SDK 各适配器:
Anthropic 的 convertToolChoice 能识别嵌套形式;Completions 原样透传
且格式正确;但 Responses 适配器同样原样透传,而该 API 要的是顶层
name;Gemini 的 convertTools 只处理 toolChoice.(string),map 直接丢弃,
toolConfig 为 nil——裁判根本没被强制,可以用散文回答。前者请求不合契约,
后者落到 missing,两者都把门禁焊死。
改用 "required"。decide 是该请求上唯一的工具,意图等价,而这个拼写
四个适配器都能正确转换(Anthropic→any,Gemini→ANY,两个 OpenAI 方言
原样合法)。
新增 provider 请求测试:用 httptest 捕获各适配器真正发出的请求体。
为此把请求选项抽成 discussProbeGenerateOptions,生产与测试共用同一个
构造——先前那版测试自己拼装请求,只能证明 SDK 会正确转换某个常量,
证明不了门禁发的就是它,对旧写法照样通过。
2. 固定 16K 裁判预算会让长会话持续静默。原本复用主模型的
admitDiscussMessages,它会 pin 住每一条压缩摘要(主模型不能丢历史)。
但摘要是按主模型窗口生成的,长会话上摘要总量本身就超过任何裁判级预算,
admission 于是每次都返回 ProtectedOverflow。而门禁拒绝后 pumpDiscuss
直接 return,同步压缩不会执行,摘要永远不会变小——包括直接 @ 也无法恢复。
裁判改用自己的 admitDiscussProbeMessages:取能放下的最新一段连续后缀,
不 pin 摘要。判断「现在该不该开口」需要的是会话尾部,不是线程史;
去掉 pin 是消除这一整类溢出,而不是处理它。始终保留最新一条,窗口不会为空。
预算也不再是常数:按裁判模型自身窗口反推,扣除输出保留与 prompt/工具
定义开销,并设下限。小窗口裁判配大窗口主模型时,原先会构造出它必须
拒绝的请求。
3. 自动回退的标题模型可能不支持工具调用。IsValidTitleModel 只校验
type=chat,前端门禁选择器的 tool-call 过滤保护不到继承路径。一个纯文本
标题模型会静默接管群聊门禁,然后每次唤醒都返回不了判定。
新增 discussProbeModelCanJudge 运行时校验:解析出的裁判模型不支持工具
调用时关闭门禁(而不是把门焊死),并记录告警。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
这张表是从上游 Cahciua 的 probe_responses_v2 照搬来的,没有在本仓语境下 重新论证。复查后它站不住: - ListDiscussProbeDecisions 全仓零调用。为「审计轨迹」写了查询,却没有 任何读取方——没有 API、没有 UI、没有诊断入口。 - 没有保留策略。开了门禁的活跃群里每条消息插一行,无限增长。 - 它复制了一条已经存在的日志。除 token 计数外的每一列,原有的 "discuss probe: decision" 结构化日志都已经带着。 - token 记账另有其家。token_usage 从 bot_history_messages.usage 聚合, 这张表的 token 列既不进那个视图也无人查询。 代价是 216 行迁移/查询/生成代码、0001_init 追加 36 行(含 RLS 与多租户 接线),以及热路径上 8 个持久化调用点,换来一份没有读取方、不会过期、 且与日志重复的数据。 当初写的理由是「让『Bot 突然不说话了』能被诊断成判断、解析失败还是故障」。 目标成立,但表不是必要手段:把 outcome 放进日志同样能达成,而且立刻可用, 不需要迁移、不占热路径写入。真正的动机只是上游有这张表——那是 Cahciua 「一切持久化以支持重放」的架构后果,不是本功能的需求。 改为让日志真正承担这个职责:新增 reportDiscussProbe,每条退出路径发同一组 字段(bot_id / session_id / model_id / cause / outcome / gate_ran / activated) 并返回原判定,使「每一次返回同时就是一次记录」。cause 区分分支,gate_ran 区分「门禁把它关住了」与「根本没有门禁」——这正是操作者最容易混淆、而 原先各打各的 warn 无法回答的那个问题。私聊不记录,否则每条私信一行会淹没 真正重要的记录。 配套测试断言字段集在各路径一致、两种静默可区分、门禁关停以 WARN 可见、 私聊零记录。 bots.discuss_probe_model_id 是本 PR 之前就存在的列,保持不动。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
b372dcd to
4cd1f7f
Compare
自我修正:移除
|
A1 裁剪拆开工具交换。admitDiscussProbeMessages 只按大小取后缀,没有角色 感知:较大的 assistant 工具调用被裁掉后,较小的 tool result 仍可能留在 窗口开头。那不是任何 provider 接受的独立历史,请求会以协议错误告终,而 不是返回判定——门禁于是因为与判断无关的原因让 Bot 沉默。 镜像 turn.AdmitContextEntries 既有的同名处理:窗口起点向前推过任何其调用 已不在窗口内的 tool response。测试补了混合 assistant/tool/user 的边界, 包括连续 tool result 与大小写变体——原先只测纯 user 消息,结构上看不到 这个问题。 A2 预算不是硬上限。原实现把输入预算向上钳制到 floor,4097 窗口因此索要 2048 输入 + 2048 输出 + 1024 开销 = 5120,每次唤醒都被拒;最新消息又被 无条件保留,8192 模型可选入约 20000 token。 改为真正的预留:输入、输出与 prompt/工具 schema 开销必须同时装进裁判自身 窗口;装不下最小请求时返回 ok=false 并报 model_window_too_small,而不是把 已知超限的请求交给 provider 试错。未声明窗口按 8192 保守假设(与 titleInputFallbackWindow 同源)。超长的最新消息改为头尾截断;带 RawContent 的结构化载荷不能安全裁剪,报无有效窗口而非发出畸形请求。 原先的 TestDiscussProbeContextBudget 把「tiny window clamps to the floor」 断言成了预期行为——测试把缺陷固化成了契约。改为断言算术性质:任何窗口下 输入+输出+开销都不得超过窗口。 A3 显式配置的不兼容模型绕过门禁。能力校验失败时无论配置来源都返回 Ran=false。继承来的标题模型这样处理是既定政策,但显式配置的门禁在模型能力 变化后也被直接关闭——与「配置读不出时 fail closed」自相矛盾。现在按来源 区分:显式配置 fail closed 并报 configured_model_cannot_call_tools;继承 保持 ungated。 A4 异常关停从默认日志消失。分级原本按 Ran 判断,于是「模型不支持工具调用」 这种无 error 的关停落到 DEBUG,在默认 INFO 部署中等于没有记录——而这恰是 Bot 停止说话时要找的那条。改为按 cause 分级:只有 not_configured 用 DEBUG, 其余关停一律 WARN。 测试 handler 的 Enabled 原本恒为 true,结构上就不可能发现级别过滤问题。改为 携带真实阈值,并新增按 cause 逐项断言级别的表,使新增分支无法再静默继承 DEBUG。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
A1–A4 全部复现属实,已在 A1 裁剪拆开工具交换复现: 仓库里本来就有正解: A2 预算不是硬上限复现数字与你给的一致: 改为真正的预留,三者必须同时装进裁判窗口;装不下最小请求时返回 修复后: 原 A3 显式配置的不兼容模型绕过门禁属实,而且与我上一轮刚修的「配置读不出时 fail closed」自相矛盾。现按来源区分:显式配置 fail closed 报 A4 异常关停从默认日志消失属实。分级原本按 你对测试 handler 的批评是这轮最关键的一条: B 组与 C 组B1–B4 我也逐条在 这些我不在本 PR 做:都是下游集成工作,在 OSS 里预留半成品接缝反而更糟。B2 倒是提醒了一件事——我改 C 组多数是产品政策(三态默认、是否硬保证发送、成本实测、失败重试),不是「是否存在」能回答的,已按你的要求把这些未完成项和不保证写进 PR 描述,不再让描述听起来比实现更完整。C7 已修,描述里关于表和迁移的内容只剩一条注明「已移除」的历史说明。
一点自评:这三轮你找出的问题里,有 5 个是我的测试本可以捕获却没有——测 helper 而非真实路径、用永远 Enabled 的 handler、把 bug 断言成预期、只用同质数据。这不是漏写测试,是测试写法系统性地偏向验证我自己的实现假设。 |
问题
native runtime 的 discuss 会话此前没有任何发言判定。群里每来一条消息都会跑一次完整 primary——全工具、全上下文、可能带 reasoning——然后靠
mode_discuss.md里那句 "prefer silence unless intervention is useful" 让模型自己决定闭嘴。也就是说:该不该说和说什么由同一次调用混在一起决定,而且无论结果如何都已经付出了全额成本。一个 Bot 该发言比例 5% 的活跃群,95% 的调用花在让模型决定不说话上。
shouldTriggerAssistantResponse(@提及 / 回复 / 私聊)那道规则门对 discuss 不生效——inbound/channel.go的 discuss 分发块在该检查之前就return了,注释也写明「The discuss driver autonomously decides whether to call the LLM」。方案
引入一个外部裁判(probe)。判定模型只拿到
decide一个工具,强制 tool choice,输出send/no_action两值;判定通过才唤醒 primary。门禁位置在 sync compaction 之前——不唤醒模型的这一轮不需要压缩上下文,所以沉默的群只花一次廉价判定,而不是一次 summarizer 加一次 primary。
模型解析:
bots.discuss_probe_model_id优先,回退到 owner 的title_model_id。回退到标题模型是有意为之:两个槽位要的是同一类模型——便宜、快、自身不需要工具——所以已经配了标题模型的部署无需再做一次选择。bot 级设置仍然优先,因为群聊策略是 per-bot 的,而标题模型是账号级偏好。
fail closed 是门禁的全部意义:模型缺失、provider 不可达、上下文溢出、判定缺失 / 不可解析 / 值不认识,一律落到不发言。唯一例外是从未配置门禁,此时返回
Ran=false,行为与本 PR 之前逐字节一致。私聊不走门禁。 inbound 的默认分派是 group → discuss、DM → chat,而 chat 模式下模型的文本直接投递给用户(
mode_chat.md:Do not use messaging tools for ordinary text replies)。私聊里没有send工具要求,也不存在「该不该插话」的问题——每条消息按构造就是对 Bot 说的。门禁只为群聊而存在。激活契约作为最后一条 user message 追加在会话尾部,而不是写进 system prompt。要求必须紧贴生成点,否则长历史会把它挤出模型的工作注意力。
诊断记录走结构化日志,不落库。每条退出路径发同一组字段(
bot_id/session_id/model_id/cause/outcome/gate_ran/activated):cause区分分支,gate_ran区分「门禁把它关住了」与「根本没有门禁」。只有not_configured用 DEBUG,其余关停一律 WARN,确保默认 INFO 部署看得见。顺带的 DB 优化
buildBaseRunConfig本来就读过 bot settings,RunConfig.Bot也已装好BotInfo,此前这两样都会被重复查询。现在 settings 随ResolveRunConfigResult返回、BotInfo直接复用;并从resolveTitleModel拆出resolveBotOwnerUserID,bot 已配门禁模型时不必再读 owner 的 account profile。每条群聊消息的额外查询:
前端
放在 Bot → Advanced 标签页底部,单行
ModelSelect,autosave 无保存按钮(沿用bot-compaction.vue的契约)。候选列表要求模型支持tool-call:不支持的模型永远无法返回判定,门禁会在每次唤醒 fail closed,等于给用户提供一个静音开关。en / zh / ja 三语齐全。本 PR 未包含
tool_choice与「结束无发送则强制补发」。native 的RunConfig没有 tool choice 字段,要改Stream()循环,另起一份工作。当前靠尾部激活契约在提示层保证发言。DiscussAddressed门,未接入 probe。门禁的另一半是交给 primary 的发言契约,托管的外部 agent 不从我们这里接受契约,只给它加判定等于买了一个约束不住的判断。验证
go build ./.../go test ./internal/.../golangci-lint run ./...全绿filterDiscussProbeModels的 tool-call 要求与 provider 过滤0001_init全量应用,确认本 PR 未新增任何表;改动过的GetSettingsByBotID/UpsertBotSettings/DeleteSettingsByBotID对真实 schemaPREPARE通过已知的既有问题(非本 PR 引入)
pnpm --filter @memohai/web typecheck在origin/main上已有 65 个文件报错;已对比 baseline,本 PR 未新增任何报错文件。scripts/check-ui-contract.mjs报的 2 处违规都在apps/web/src/pages/home/components/tool-call-diff-panel.vue,同为既有。shellcheck未安装(mise shim 无版本)在internal/workspacedeps/catalog失败,与本 PR 无关;该包在正常环境下通过。本 PR 未包含(已知,非遗漏)
tool_choice与「结束无发送则补发」。native 的RunConfig没有 tool choice 字段,需改Stream()循环。当前「至少发一条」只有提示词约束,没有运行时保证,应按尽力契约理解。DiscussAddressed门,未接 probe。buildBaseRunConfig签名冲突、probe 调用的费用归属。不是零冲突自动同步。🤖 Generated with Claude Code